[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.go]\nindent_style = tab\n\n[*.{js,ts,tsx,json,yml,yaml}]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".gitignore",
    "content": "# --- Global OS Files ---\n.DS_Store\nThumbs.db\n\n# --- Global Editor Files ---\n.idea/\n.vscode/\n*.swp\n*.swo\n\n# --- Secrets (Safety Net) ---\n# We ignore these here just in case someone accidentally\n# creates an .env file in the root.\n.env\n.env.*\n# But allow .env.example and example.env files (templates for users)\n!.env.example\n!**/.env.example\n!**/example.env\n*.pem\n*.key\n\n# --- Claude Code ---\n# Ignore .claude/ contents but allow CLAUDE.md files\n.claude/*\n!.claude/CLAUDE.md\n**/.claude/*\n!**/.claude/CLAUDE.md\n\n# --- Logs ---\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# --- Docker ---\n# If you mount volumes locally\n.docker/\npostgres_data/\nredis_data/\n\n# --- Additional Safety Nets ---\n# These provide defense-in-depth, even though subdirectory\n# .gitignore files may already cover these patterns\ntmp/\ntemp/\n\n# Additional IDE files\n*.sublime-*\n.vscode-test/\n\n# ==============================================================================\n# PROJECT-SPECIFIC IGNORES\n# ==============================================================================\n\n# --- Go Backend (go-b2b-starter/) ---\n# Go Environment Files\ngo-b2b-starter/.env\ngo-b2b-starter/.env.*\ngo-b2b-starter/app.env\n\n# Binaries and Build Artifacts\ngo-b2b-starter/bin/\ngo-b2b-starter/dist/\ngo-b2b-starter/*.exe\ngo-b2b-starter/*.exe~\ngo-b2b-starter/*.dll\ngo-b2b-starter/*.so\ngo-b2b-starter/*.dylib\ngo-b2b-starter/*.test\ngo-b2b-starter/main\ngo-b2b-starter/src/main/main\ngo-b2b-starter/src/bin/main\n\n# Go Temporary Files\ngo-b2b-starter/tmp/\ngo-b2b-starter/temp/\n\n# Go Test Coverage\ngo-b2b-starter/coverage.out\ngo-b2b-starter/coverage.html\ngo-b2b-starter/coverage.txt\ngo-b2b-starter/coverage/\n\n# Go Vendor\ngo-b2b-starter/vendor/\n\n# --- Next.js Frontend (next_b2b_starter/) ---\n# Next.js Environment Files\nnext_b2b_starter/.env.local\nnext_b2b_starter/.env.production\nnext_b2b_starter/.env.development\nnext_b2b_starter/.env\n\n# Dependencies\nnext_b2b_starter/node_modules/\n\n# Next.js Build Output\nnext_b2b_starter/.next/\nnext_b2b_starter/out/\nnext_b2b_starter/build/\n\n# TypeScript Build Info\nnext_b2b_starter/*.tsbuildinfo\nnext_b2b_starter/.turbo/\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nGuidance for Claude Code when working with this monorepo.\n\n## Monorepo Structure\n\nProduction SaaS Starter - Enterprise-grade B2B SaaS boilerplate with Next.js 16 frontend and Go backend.\n\n```\nproduction-saas-starter/\n├── go-b2b-starter/        # Go backend (API, auth, billing, AI/RAG)\n├── next_b2b_starter/      # Next.js frontend (React 19, TypeScript)\n├── setup.sh               # One-line setup script\n├── DEVELOPMENT.md         # Development workflow guide\n└── README.md              # Project overview\n```\n\n## Quick Navigation\n\n**Working on Backend?**\n- See: `go-b2b-starter/.claude/CLAUDE.md`\n- Key commands: `make server`, `make dev`, `make sqlc`, `make test`\n- Tech: Go, Gin, PostgreSQL, SQLC, Stytch, Polar.sh\n\n**Working on Frontend?**\n- See: `next_b2b_starter/.claude/CLAUDE.md`\n- Key commands: `pnpm dev`, `pnpm build`, `pnpm lint`\n- Tech: Next.js 16, React 19, TypeScript, Tailwind, shadcn/ui, TanStack Query\n\n## Getting Started\n\n```bash\n# One-line setup\nchmod +x setup.sh && ./setup.sh\n\n# Or manual start:\n\n# Backend\ncd go-b2b-starter\nmake run-deps          # Start PostgreSQL\nmake migrateup         # Apply migrations\nmake dev               # Run with hot reload\n\n# Frontend\ncd next_b2b_starter\npnpm install\npnpm dev               # Start Next.js dev server\n```\n\n## Architecture Overview\n\n| Component | Technology | Purpose |\n|-----------|------------|---------|\n| **Authentication** | Stytch B2B | Magic links, RBAC, multi-tenant |\n| **Billing** | Polar.sh | Subscriptions, webhooks, usage metering |\n| **Database** | PostgreSQL + SQLC | Type-safe queries, pgvector |\n| **Backend** | Go + Gin | Clean Architecture, DI (uber-go/dig) |\n| **Frontend** | Next.js 16 + React 19 | Server Actions, TanStack Query |\n| **Styling** | Tailwind + shadcn/ui | Consistent design system |\n\n## Key Patterns\n\n### Backend (Go)\n- **Clean Architecture**: domain -> app -> infra layers\n- **Repository Pattern**: Domain interfaces implemented by SQLC-backed repos\n- **Event Bus**: Loose coupling between modules\n- **RBAC Middleware**: Permission-based route protection\n\n### Frontend (Next.js)\n- **Server Actions**: For mutations with auth/permission guards\n- **TanStack Query**: Server state management with caching\n- **Repository Pattern**: API client abstractions\n- **Type Safety**: Strict TypeScript throughout\n\n## Documentation\n\n- `DEVELOPMENT.md` - Development workflow and setup\n- `SETUP.md` - Initial project configuration\n- `go-b2b-starter/docs/` - Backend documentation\n- `next_b2b_starter/docs/` - Frontend documentation\n"
  },
  {
    "path": "Caddyfile",
    "content": "# Caddyfile - Production reverse proxy configuration\n#\n# Routes traffic between Next.js frontend and Go backend:\n# - Next.js internal API routes stay in frontend container\n# - Go backend API routes go to backend container\n# - Everything else (pages, assets) goes to frontend\n#\n# Usage:\n#   Set DOMAIN environment variable or it defaults to localhost\n#   For production: DOMAIN=yourdomain.com docker compose up\n\n{$DOMAIN:localhost} {\n\t# =========================================================================\n\t# Next.js internal API routes (must go to frontend)\n\t# These are handled by Next.js API routes, not the Go backend\n\t# =========================================================================\n\n\t# Auth session management (handled by Next.js)\n\thandle /api/auth/session/* {\n\t\treverse_proxy frontend:3000\n\t}\n\n\t# Billing webhook (handled by Next.js for Polar.sh)\n\thandle /api/billing/webhook {\n\t\treverse_proxy frontend:3000\n\t}\n\n\t# Next.js health check\n\thandle /api/health {\n\t\treverse_proxy frontend:3000\n\t}\n\n\t# =========================================================================\n\t# Go Backend API routes\n\t# All other /api/* routes go to the Go backend\n\t# =========================================================================\n\thandle /api/* {\n\t\treverse_proxy backend:8080\n\t}\n\n\t# Backend health check (optional, for container orchestration)\n\thandle /health {\n\t\treverse_proxy backend:8080\n\t}\n\n\t# =========================================================================\n\t# Frontend - Everything else (pages, assets, etc.)\n\t# =========================================================================\n\thandle {\n\t\treverse_proxy frontend:3000\n\t}\n}\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "# 🛠️ Local Development Setup\n\nComplete guide to running the Production SaaS Starter Kit locally.\n\n---\n\n## 📋 Prerequisites\n\nInstall these tools before starting:\n\n| Tool | Version | Purpose |\n|------|---------|---------|\n| **Docker Desktop** | Latest | Runs PostgreSQL + Redis containers |\n| **Go** | 1.25+ | Backend server |\n| **Node.js** | 20+ | Frontend build |\n| **pnpm** | 9+ | Frontend package manager |\n\n> **Note:** You do NOT need to install PostgreSQL or Redis directly — Docker handles them.\n\n---\n\n### 🐳 Install Docker Desktop\n\nDocker runs PostgreSQL and Redis in containers so you don't need to install them directly.\n\n**macOS:**\n```bash\n# Option 1: Homebrew\nbrew install --cask docker\n\n# Option 2: Direct download\n# https://www.docker.com/products/docker-desktop/\n```\n\n**Windows:**\n1. Download from https://www.docker.com/products/docker-desktop/\n2. Run installer\n3. Enable WSL 2 when prompted\n\n**Linux (Ubuntu/Debian):**\n```bash\n# Add Docker's official GPG key\nsudo apt-get update\nsudo apt-get install ca-certificates curl\nsudo install -m 0755 -d /etc/apt/keyrings\nsudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc\nsudo chmod a+r /etc/apt/keyrings/docker.asc\n\n# Add the repository\necho \\\n  \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \\\n  $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" | \\\n  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null\n\n# Install Docker\nsudo apt-get update\nsudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n\n# Add your user to docker group (logout/login required)\nsudo usermod -aG docker $USER\n```\n\nAfter installing, **open Docker Desktop** and wait for it to start before proceeding.\n\n---\n\n### 🐹 Install Go\n\nThe backend requires Go 1.25 or higher.\n\n**macOS:**\n```bash\n# Option 1: Homebrew (recommended)\nbrew install go\n\n# Option 2: Direct download\n# https://go.dev/dl/\n```\n\n**Windows:**\n1. Download from https://go.dev/dl/\n2. Run the MSI installer\n3. Restart terminal after install\n\n**Linux:**\n```bash\n# Download latest (check https://go.dev/dl/ for current version)\nwget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz\n\n# Remove old version and extract new\nsudo rm -rf /usr/local/go\nsudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz\n\n# Add to PATH (add to ~/.bashrc or ~/.zshrc)\nexport PATH=$PATH:/usr/local/go/bin\n```\n\n---\n\n### 📦 Install Node.js\n\nThe frontend requires Node.js 20 or higher. We recommend using **nvm** (Node Version Manager) to manage Node versions.\n\n**macOS/Linux — Install nvm:**\n```bash\n# Install nvm\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash\n\n# Restart terminal, then install Node 20\nnvm install 20\nnvm use 20\nnvm alias default 20\n```\n\n**Windows — Install nvm-windows:**\n1. Download from https://github.com/coreybutler/nvm-windows/releases\n2. Run the installer\n3. Open new terminal:\n```bash\nnvm install 20\nnvm use 20\n```\n\n**Alternative — Direct install (without nvm):**\n```bash\n# macOS\nbrew install node@20\n\n# Or download from https://nodejs.org/\n```\n\n---\n\n### 📦 Install pnpm\n\npnpm is our package manager for the frontend (faster than npm, saves disk space).\n\n```bash\n# After Node is installed\nnpm install -g pnpm\n```\n\nOr use Corepack (built into Node 16.13+):\n```bash\ncorepack enable\ncorepack prepare pnpm@latest --activate\n```\n\n---\n\n### ✅ Verify Installation\n\nRun these commands to confirm everything is installed correctly:\n\n```bash\ndocker --version    # Docker version 24+\ngo version          # go1.25+\nnode --version      # v20+\npnpm --version      # 9+\n```\n\nAll four should return version numbers. If any command fails, revisit the install steps above.\n\n---\n\n## 📁 Project Structure\n\n```\nproduction-saas-starter/\n├── go-b2b-starter/       # Go backend\n├── next_b2b_starter/     # Next.js frontend\n├── deps/                 # Docker Compose files\n└── setup.sh              # Automated setup script\n```\n\n---\n\n## 🚀 First-Time Setup\n\n### 1. Clone the Repository\n\n```bash\ngit clone <repo-url>\ncd production-saas-starter\n```\n\n### 2. Start Backend Services\n\n```bash\ncd go-b2b-starter\n\n# Start PostgreSQL + Redis containers\nmake run-deps\n\n# Wait for containers to be healthy, then run migrations\nmake migrateup\n```\n\n### 3. Configure Frontend Environment\n\n```bash\ncd next_b2b_starter\n\n# Copy environment template\ncp .env.example .env.local\n\n# Edit .env.local and fill in:\n# - STYTCH_PROJECT_ID\n# - STYTCH_SECRET\n# - NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN\n# - POLAR_ACCESS_TOKEN (if using billing)\n```\n\n### 4. Install Frontend Dependencies\n\n```bash\npnpm install\n```\n\n### 5. Start Development Servers\n\n**Terminal 1 — Backend:**\n```bash\ncd go-b2b-starter\nmake dev\n```\n\n**Terminal 2 — Frontend:**\n```bash\ncd next_b2b_starter\npnpm dev\n```\n\n### 6. Access the App\n\n| Service | URL |\n|---------|-----|\n| Frontend | http://localhost:3000 |\n| Backend API | http://localhost:8080 |\n| API Docs (Swagger) | http://localhost:8080/swagger/index.html |\n\n---\n\n## 📅 Daily Workflow\n\n### Starting Your Day\n\n```bash\n# Terminal 1: Start database containers (if not running)\ncd go-b2b-starter\nmake run-deps\n\n# Terminal 2: Start backend with hot reload\ncd go-b2b-starter\nmake dev\n\n# Terminal 3: Start frontend\ncd next_b2b_starter\npnpm dev\n```\n\n### Ending Your Day\n\n```bash\n# Stop the Go server: Ctrl+C in Terminal 2\n# Stop the Next.js server: Ctrl+C in Terminal 3\n\n# Optional: Stop database containers (data persists)\ncd go-b2b-starter\nmake stop-deps\n```\n\n---\n\n## 📋 Commands Cheat Sheet\n\n### Backend (Go)\n\nRun from `go-b2b-starter/`:\n\n| Command | Description |\n|---------|-------------|\n| `make run-deps` | Start PostgreSQL + Redis containers |\n| `make stop-deps` | Stop and remove containers |\n| `make dev` | Start server with hot reload (Air) |\n| `make server` | Start server without hot reload |\n| `make migrateup` | Apply database migrations |\n| `make migratedown` | Rollback migrations |\n| `make create-migration MIGRATION_NAME=add_users` | Create new migration |\n| `make sqlc` | Generate Go code from SQL queries |\n| `make test` | Run tests |\n| `make swagger` | Regenerate Swagger docs |\n| `make build` | Build production binary |\n\n### Frontend (Next.js)\n\nRun from `next_b2b_starter/`:\n\n| Command | Description |\n|---------|-------------|\n| `pnpm dev` | Start development server |\n| `pnpm build` | Build for production |\n| `pnpm start` | Start production server |\n| `pnpm lint` | Run ESLint |\n\n---\n\n## 🔌 Service Ports\n\n| Service | Port | Notes |\n|---------|------|-------|\n| Next.js Frontend | 3000 | Turbopack hot reload |\n| Go Backend | 8080 | Air hot reload |\n| PostgreSQL | 5432 | pgvector enabled |\n| Redis | 6379 | For caching/sessions |\n\n---\n\n## 🗄️ Database Access\n\n### Connect via psql\n\n```bash\npsql -h localhost -p 5432 -U user -d mydatabase\n# Password: password\n```\n\n### Connect via GUI (TablePlus, DBeaver, etc.)\n\n| Field | Value |\n|-------|-------|\n| Host | localhost |\n| Port | 5432 |\n| User | user |\n| Password | password |\n| Database | mydatabase |\n\n### Database Credentials\n\nDefined in `go-b2b-starter/deps/docker-compose.yml`:\n\n```yaml\nPOSTGRES_DB: mydatabase\nPOSTGRES_USER: user\nPOSTGRES_PASSWORD: password\n```\n\n---\n\n## 🔐 Environment Variables\n\n### Backend\n\nThe backend uses Docker environment variables defined in `deps/docker-compose.yml`. No `.env` file needed for local dev.\n\n### Frontend\n\nRequired variables in `.env.local`:\n\n```bash\n# App URLs\nAPP_BASE_URL=http://localhost:3000\nNEXT_PUBLIC_APP_BASE_URL=http://localhost:3000\n\n# Stytch B2B Auth (required)\nSTYTCH_PROJECT_ID=project-test-xxx\nSTYTCH_SECRET=secret-test-xxx\nSTYTCH_PROJECT_ENV=test\nNEXT_PUBLIC_STYTCH_PROJECT_ENV=test\nNEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=public-token-test-xxx\n\n# Polar Billing (optional for dev)\nPOLAR_ACCESS_TOKEN=\nPOLAR_WEBHOOK_SECRET=\n```\n\nGet Stytch credentials from: https://stytch.com/dashboard\n\n---\n\n## 🔧 Troubleshooting\n\n### Docker containers won't start\n\n```bash\n# Check if Docker is running\ndocker info\n\n# Check container status\ndocker ps -a\n\n# View container logs\ndocker compose -f deps/docker-compose.yml logs postgres\ndocker compose -f deps/docker-compose.yml logs redis\n\n# Nuclear option: remove everything and start fresh\nmake stop-deps\ndocker volume rm deps_postgres_data deps_redis_data\nmake run-deps\n```\n\n### Port already in use\n\n```bash\n# Find what's using the port\nlsof -i :5432  # PostgreSQL\nlsof -i :8080  # Backend\nlsof -i :3000  # Frontend\n\n# Kill the process\nkill -9 <PID>\n```\n\n### Migration errors\n\n```bash\n# Check migration status\ndocker compose -f deps/docker-compose.yml run --rm cli migrate -path ./internal/db/postgres/sqlc/migrations -database \"postgresql://user:password@postgres:5432/mydatabase?sslmode=disable\" version\n\n# Force to specific version if stuck\ndocker compose -f deps/docker-compose.yml run --rm cli migrate -path ./internal/db/postgres/sqlc/migrations -database \"postgresql://user:password@postgres:5432/mydatabase?sslmode=disable\" force <VERSION>\n```\n\n### SQLC generation fails\n\n```bash\n# Make sure containers are running\nmake run-deps\n\n# Check sqlc.yaml configuration\ncat internal/db/postgres/sqlc/sqlc.yaml\n\n# Run sqlc with verbose output\ndocker compose -f deps/docker-compose.yml run --rm -w /workspace/internal/db/postgres/sqlc cli sqlc generate\n```\n\n### Hot reload not working (Backend)\n\nAir watches for file changes. If it's not working:\n\n```bash\n# Check Air is installed in container\ndocker compose -f deps/docker-compose.yml run --rm cli which air\n\n# Restart with fresh build\nmake stop-deps\nmake run-deps\nmake dev\n```\n\n### Hot reload not working (Frontend)\n\n```bash\n# Clear Next.js cache\nrm -rf .next\n\n# Restart\npnpm dev\n```\n\n### Can't connect to backend from frontend\n\n1. Check backend is running: http://localhost:8080/health\n2. Check CORS settings in backend\n3. Verify API URL in frontend matches backend port\n\n---\n\n## 💡 Tips\n\n### VS Code Extensions\n\nRecommended for this project:\n\n- **Go** — Go language support\n- **ESLint** — JavaScript/TypeScript linting\n- **Tailwind CSS IntelliSense** — Tailwind autocomplete\n- **Docker** — Docker file support\n- **PostgreSQL** — SQL syntax highlighting\n\n### Multiple Terminals\n\nUse a terminal multiplexer or VS Code's integrated terminals:\n\n```\n┌─────────────────────────────────────────┐\n│ Terminal 1: make run-deps (background)  │\n├─────────────────────────────────────────┤\n│ Terminal 2: make dev (backend)          │\n├─────────────────────────────────────────┤\n│ Terminal 3: pnpm dev (frontend)         │\n└─────────────────────────────────────────┘\n```\n\n### Fast Iteration Cycle\n\n1. Make code changes\n2. Save file (hot reload triggers)\n3. Test in browser\n4. Repeat\n\nNo manual restart needed thanks to Air (Go) and Turbopack (Next.js).\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Mohammed Salim\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# ⭐ Production SaaS Starter Kit\n\nThe Enterprise-Grade SaaS boilerplate for serious founders. Built with **Next.js 16** and **Go 1.25**.\n\n[![Go Report Card](https://goreportcard.com/badge/github.com/moasq/production-saas-starter)](https://goreportcard.com/report/github.com/moasq/production-saas-starter)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)\n\n![Dashboard Preview](docs/dashboard.png)\n\n## 🛠️ Built With\n\n### Frontend Stack\n\n- **[Next.js 16](https://nextjs.org)** (v16.0.10)\n  Modern React framework with App Router and API routes.\n- **[React 19](https://react.dev)** (v19.2.3)\n  Latest React with improved performance and concurrent features.\n- **[TypeScript](https://www.typescriptlang.org)** (v5.7.3)\n  Type-safe JavaScript for enhanced developer experience.\n- **[Tailwind CSS](https://tailwindcss.com)** (v3.4.17)\n  Utility-first CSS framework for rapid UI development.\n- **[shadcn/ui](https://ui.shadcn.com)** + **Radix UI**\n  Accessible component library with 29+ pre-built components.\n- **[TanStack Query](https://tanstack.com/query)** (v5.90.5)\n  Powerful data fetching and state management.\n- **[Zustand](https://zustand-demo.pmnd.rs)** (v5.0.8)\n  Lightweight state management for UI state.\n- **[react-hook-form](https://react-hook-form.com)** + **[Zod](https://zod.dev)**\n  Type-safe forms with schema validation.\n- **[Stytch](https://stytch.com)**\n  Enterprise authentication with magic links, OAuth, and SSO.\n- **[Polar.sh](https://polar.sh)**\n  Billing integration and subscription management.\n- **[Recharts](https://recharts.org)**\n  Composable charting library for data visualization.\n\n### Backend Stack\n\n- **[Go 1.25](https://go.dev)**\n  High-performance, concurrent backend with excellent tooling.\n- **[Gin](https://gin-gonic.com)**\n  Fast HTTP web framework with middleware support.\n- **[PostgreSQL](https://www.postgresql.org)** with **[pgvector](https://github.com/pgvector/pgvector)**\n  Reliable relational database with vector similarity search.\n- **[SQLC](https://sqlc.dev)**\n  Type-safe SQL compiler for Go (no ORM).\n- **[Stytch B2B](https://stytch.com)**\n  Enterprise authentication, SSO, and RBAC.\n- **[Polar.sh](https://polar.sh)**\n  Merchant of Record for subscriptions, invoicing, and global tax compliance.\n- **[OpenAI API](https://openai.com)**\n  LLM integration with RAG pipeline and vector embeddings.\n- **[Mistral AI](https://mistral.ai)**\n  OCR service for document data extraction.\n- **[Cloudflare R2](https://www.cloudflare.com/products/r2/)**\n  Object storage for file management.\n- **[Docker](https://www.docker.com)** + **Docker Compose**\n  Containerization for consistent environments.\n\n## 🥇 Features\n\n- **Authentication**: Sign in with Magic Link, Google OAuth, and Enterprise SSO.\n- **Multi-Tenancy**: Built-in Organization support with strict data isolation.\n- **Roles & Permissions**: Granular RBAC system with 3 roles (Member, Manager, Admin) and 7 permission types.\n- **Billing & Subscriptions**: Complete integration with Polar.sh for SaaS pricing models.\n- **AI & RAG**: Ready-to-use vector embeddings pipeline for AI features.\n- **OCR Service**: Extract structured data from valid documents instantly.\n- **Team Management**: Invite members, manage roles, and update settings.\n- **Responsive Design**: Mobile-first UI built with Tailwind CSS and shadcn/ui.\n- **Type Safety**: End-to-end type safety from database (SQLC) to frontend (TypeScript).\n\n## ➡️ Coming Soon\n\n- **Audit Logs**: Complete audit logging system for tracking user activities.\n- **Webhooks UI**: Customer-facing webhook configuration.\n- **Advanced Analytics**: Built-in charts and usage tracking.\n\n## ✨ Getting Started\n\nPlease follow these simple steps to get a local copy up and running.\n\n### Prerequisites\n\n- **Docker** & **Docker Compose**\n- **Go 1.25+**\n- **Node.js 20+** & **pnpm**\n\n### The One-Line Setup\n\nRun this command to configure your keys and start the infrastructure:\n\n```bash\nchmod +x setup.sh && ./setup.sh\n```\n\n**Manual Start:**\n\n1.  **Backend:** `cd go-b2b-starter && make dev`\n2.  **Frontend:** `cd next_b2b_starter && pnpm dev`\n3.  **Visit:** [http://localhost:3000](http://localhost:3000)\n\n> [!IMPORTANT]\n> See **[SETUP.md](./SETUP.md)** for quick setup or **[DEVELOPMENT.md](./DEVELOPMENT.md)** for comprehensive guidance including multi-platform prerequisites, troubleshooting, and daily workflow tips.\n\n## 🛡️ License\n\n[MIT License](./LICENSE)\n\n## 👯 Consulting & Services\n\nAlthough this kit is self-service, I help ambitious founders move faster.\n\n**I can help you with:**\n1.  **Managed Config:** I sets up your AWS/GCP production environment so you never touch DevOps.\n2.  **Custom Features:** Need SAML SSO or complex RAG flows? I'll build them directly into your repo.\n3.  **Code Audits:** Migrating from Node/Python? I'll review your architecture for scale.\n\n**[m.salim@apflowhq.com](mailto:m.salim@apflowhq.com)** • [**@foundmod**](https://x.com/foundmod)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nUse this section to tell people about which versions of your project are\ncurrently being supported with security updates.\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 1.0.x   | :white_check_mark: |\n| < 1.0   | :x:                |\n\n## Reporting a Vulnerability\n\nWe take the security of this starter kit seriously. If you find a vulnerability, please **DO NOT** open a public issue.\n\n\n"
  },
  {
    "path": "SETUP.md",
    "content": "# 🛠️ Setup Guide\n\n> **Looking for detailed setup?** See **[DEVELOPMENT.md](./DEVELOPMENT.md)** for comprehensive guidance including multi-platform prerequisites, troubleshooting, database access, and daily workflow tips.\n\nThis document covers the manual steps to verify your environment if `setup.sh` is not sufficient.\n\n## 1. Environment Variables\n\nThe kit comes with example files. You need to copy them to the \"live\" filenames.\n\n### Backend (`go-b2b-starter`)\n```bash\ncp go-b2b-starter/example.env go-b2b-starter/app.env\n```\nOpen `app.env` and fill in the keys:\n*   `DB_SOURCE`: Your Postgres connection string.\n*   `STYTCH_PROJECT_ID`: From Stytch Dashboard.\n*   `POLAR_ACCESS_TOKEN`: From Polar.sh.\n\n### Frontend (`next_b2b_starter`)\n```bash\ncp next_b2b_starter/.env.example next_b2b_starter/.env.local\n```\nUpdate `.env.local` with your public API keys.\n\n## 2. Docker Dependencies\n\nIf you prefer running dependencies manually (without `setup.sh`):\n\n```bash\ncd go-b2b-starter\ndocker compose -f deps/docker-compose.yml up -d postgres redis\n```\n\n## 3. Database Migrations\n\nOnce Docker is running, you must apply the schema:\n\n```bash\ncd go-b2b-starter\nmake migrateup\n```\n\n## 4. Troubleshooting\nIf the backend fails to start, verify that Redis is reachable on port `6379`.\n"
  },
  {
    "path": "docker-compose.production.yml",
    "content": "# Production Docker Compose\n# Copy to docker-compose.yml and configure with your .env file\n#\n# Required environment variables in .env:\n# - POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB\n# - STYTCH_PROJECT_ID, STYTCH_SECRET, STYTCH_PROJECT_ENV\n# - NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN, NEXT_PUBLIC_STYTCH_PROJECT_ID\n# - NEXT_PUBLIC_APP_BASE_URL\n# - POLAR_ACCESS_TOKEN (if using billing)\n\nservices:\n  # =============================================================================\n  # Reverse Proxy - Routes traffic between frontend and backend\n  # =============================================================================\n  caddy:\n    image: caddy:2-alpine\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    volumes:\n      - ./Caddyfile:/etc/caddy/Caddyfile:ro\n      - caddy_data:/data\n      - caddy_config:/config\n    environment:\n      - DOMAIN=${DOMAIN:-localhost}\n    depends_on:\n      - frontend\n      - backend\n    restart: unless-stopped\n\n  # =============================================================================\n  # Frontend - Next.js application\n  # =============================================================================\n  frontend:\n    build:\n      context: ./next_b2b_starter\n      dockerfile: Dockerfile\n      args:\n        - NEXT_PUBLIC_APP_BASE_URL=${NEXT_PUBLIC_APP_BASE_URL}\n        - NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-/api}\n        - NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=${NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN}\n        - NEXT_PUBLIC_STYTCH_PROJECT_ID=${NEXT_PUBLIC_STYTCH_PROJECT_ID}\n        - NEXT_PUBLIC_STYTCH_PROJECT_ENV=${NEXT_PUBLIC_STYTCH_PROJECT_ENV:-test}\n        - NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN=${NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN:-}\n        - NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN=${NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN:-}\n    environment:\n      # Runtime environment variables for Server Actions\n      - API_BASE_URL_INTERNAL=http://backend:8080/api\n      - STYTCH_PROJECT_ID=${STYTCH_PROJECT_ID}\n      - STYTCH_SECRET=${STYTCH_SECRET}\n      - STYTCH_PROJECT_ENV=${STYTCH_PROJECT_ENV:-test}\n    expose:\n      - \"3000\"\n    depends_on:\n      - backend\n    restart: unless-stopped\n\n  # =============================================================================\n  # Backend - Go API server\n  # =============================================================================\n  backend:\n    build:\n      context: ./go-b2b-starter\n      dockerfile: Dockerfile\n    environment:\n      - POSTGRES_HOST=postgres\n      - POSTGRES_PORT=5432\n      - POSTGRES_USER=${POSTGRES_USER}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}\n      - POSTGRES_DB=${POSTGRES_DB}\n      - REDIS_HOST=redis\n      - REDIS_PORT=6379\n      - STYTCH_PROJECT_ID=${STYTCH_PROJECT_ID}\n      - STYTCH_SECRET=${STYTCH_SECRET}\n      - STYTCH_PROJECT_ENV=${STYTCH_PROJECT_ENV:-test}\n      - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-}\n    expose:\n      - \"8080\"\n    depends_on:\n      postgres:\n        condition: service_healthy\n      redis:\n        condition: service_started\n    restart: unless-stopped\n\n  # =============================================================================\n  # Database - PostgreSQL with pgvector\n  # =============================================================================\n  postgres:\n    image: pgvector/pgvector:pg17\n    environment:\n      - POSTGRES_DB=${POSTGRES_DB}\n      - POSTGRES_USER=${POSTGRES_USER}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n    restart: unless-stopped\n\n  # =============================================================================\n  # Cache - Redis\n  # =============================================================================\n  redis:\n    image: redis:alpine\n    volumes:\n      - redis_data:/data\n    restart: unless-stopped\n\nvolumes:\n  postgres_data:\n  redis_data:\n  caddy_data:\n  caddy_config:\n"
  },
  {
    "path": "go-b2b-starter/.air.toml",
    "content": "root = \".\"\ntestdata_dir = \"testdata\"\ntmp_dir = \"tmp\"\n\n[build]\n  args_bin = []\n  bin = \"./tmp/main\"\n  cmd = \"go build -o ./tmp/main ./cmd/api/main.go\"\n  delay = 1000\n  exclude_dir = [\"tmp\", \"vendor\", \"testdata\", \"frontend-starter\", \"next_b2b_starter\", \"deps\", \"src\"]\n  exclude_file = []\n  exclude_regex = [\"_test.go\"]\n  exclude_unchanged = false\n  follow_symlink = false\n  full_bin = \"\"\n  include_dir = []\n  include_ext = [\"go\", \"tpl\", \"tmpl\", \"html\"]\n  include_file = []\n  kill_delay = \"0s\"\n  log = \"build-errors.log\"\n  poll = false\n  poll_interval = 0\n  rerun = false\n  rerun_delay = 500\n  send_interrupt = false\n  stop_on_error = false\n\n[color]\n  app = \"\"\n  build = \"yellow\"\n  main = \"magenta\"\n  runner = \"green\"\n  watcher = \"cyan\"\n\n[log]\n  main_only = false\n  time = false\n\n[misc]\n  clean_on_exit = true\n\n[screen]\n  clear_on_rebuild = false\n  keep_scroll = true\n"
  },
  {
    "path": "go-b2b-starter/.claude/CLAUDE.md",
    "content": "# CLAUDE.md\n\nAI instructions for working with this Go B2B SaaS Starter Kit codebase.\n\n## Project Overview\n\nGo B2B SaaS Starter Kit - Invoice-to-pay lifecycle automation with AI-powered data extraction, duplicate detection, and payment optimization via Polar.sh integration.\n\n**Architecture**: Modular monolith following Clean Architecture with clear module boundaries.\n\n**Layers**:\n- `internal/modules/*/` - Feature modules (domain/app/infra layers per module)\n- `internal/platform/` - Cross-cutting concerns (logger, server, etc.)\n- `internal/db/` - Database layer (SQLC adapters, DI registration)\n- `internal/bootstrap/` - Application initialization\n- `pkg/` - Shared utility packages (httperr, response)\n- `cmd/api/` - Application entry point\n\n**Core Patterns**:\n- Clean Architecture (domain → app → infra)\n- Dependency Injection (uber-go/dig)\n- Repository Pattern (domain interfaces → store adapters)\n- Adapter Pattern (limit SQLC exposure)\n\n## Commands\n\n```bash\n# Development\nmake server          # Run dev server\nmake build          # Build binary\nmake deps           # Update Go dependencies\n\n# Database\nmake run-deps       # Start PostgreSQL in Docker\nmake migrateup      # Apply migrations\nmake migratedown    # Rollback migrations\nmake sqlc           # Generate Go code from SQL\n\n# Testing\nmake test           # Run tests with coverage\n\n# Docker\nmake run-stack      # Start full stack\nmake restart-app    # Restart app container\n```\n\n## Database Layer (internal/db/)\n\n**Structure**:\n```\ninternal/db/\n├── adapters/           # Legacy adapter interfaces (being phased out)\n├── postgres/\n│   ├── sqlc/\n│   │   ├── migrations/  # SQL migration files\n│   │   ├── query/       # SQL queries with SQLC annotations\n│   │   └── gen/        # Generated code (DO NOT EDIT)\n│   ├── adapter_impl/   # Legacy adapter implementations\n│   └── postgres.go     # DB connection and pooling\n└── inject.go           # DI registration for all domain repositories\n```\n\n**Key Principle**: Domain interfaces (defined in `internal/modules/*/domain/`) are implemented by repositories in `internal/modules/*/infra/repositories/`. The `internal/db/inject.go` registers these implementations in the DI container.\n\n**SQLC Workflow**:\n```\ninternal/db/postgres/sqlc/\n├── migrations/     # SQL migration files\n├── query/          # SQL queries with SQLC annotations\n└── gen/           # Generated code (DO NOT EDIT)\n```\n\n### Adding Database Operations\n\n**1. Define domain interface** in your module (`internal/modules/{module}/domain/repository.go`):\n```go\npackage domain\n\ntype UserRepository interface {\n    GetByID(ctx context.Context, orgID, userID int32) (*User, error)\n    Create(ctx context.Context, user *User) (*User, error)\n    Update(ctx context.Context, user *User) (*User, error)\n    Delete(ctx context.Context, orgID, userID int32) error\n}\n```\n\n**2. Write SQL query** (`internal/db/postgres/sqlc/query/{domain}.sql`):\n```sql\n-- name: GetUserByID :one\nSELECT * FROM users WHERE organization_id = $1 AND id = $2;\n\n-- name: CreateUser :one\nINSERT INTO users (organization_id, email, full_name)\nVALUES ($1, $2, $3)\nRETURNING *;\n```\n\n**3. Generate SQLC code**:\n```bash\nmake sqlc\n```\n\n**4. Implement repository** (`internal/modules/{module}/infra/repositories/{domain}_repository.go`):\n```go\npackage repositories\n\nimport (\n    \"github.com/moasq/go-b2b-starter/internal/modules/{module}/domain\"\n    sqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\ntype userRepository struct {\n    store sqlc.Store\n}\n\nfunc NewUserRepository(store sqlc.Store) domain.UserRepository {\n    return &userRepository{store: store}\n}\n\nfunc (r *userRepository) GetByID(ctx context.Context, orgID, userID int32) (*domain.User, error) {\n    dbUser, err := r.store.GetUserByID(ctx, sqlc.GetUserByIDParams{\n        OrganizationID: orgID,\n        ID: userID,\n    })\n    if err != nil {\n        return nil, err\n    }\n\n    // Map SQLC type to domain type\n    return &domain.User{\n        ID:             dbUser.ID,\n        OrganizationID: dbUser.OrganizationID,\n        Email:          dbUser.Email,\n        FullName:       dbUser.FullName,\n    }, nil\n}\n```\n\n**5. Register in DI** (`internal/db/inject.go`):\n```go\nimport (\n    userDomain \"github.com/moasq/go-b2b-starter/internal/modules/users/domain\"\n    userRepos \"github.com/moasq/go-b2b-starter/internal/modules/users/infra/repositories\"\n)\n\n// In registerDomainStores function:\nif err := container.Provide(func(sqlcStore sqlc.Store) userDomain.UserRepository {\n    return userRepos.NewUserRepository(sqlcStore)\n}); err != nil {\n    return fmt.Errorf(\"failed to provide user repository: %w\", err)\n}\n```\n\n**Why This Architecture**:\n- Domain defines interfaces (Dependency Inversion Principle)\n- Infra implements these interfaces using SQLC\n- SQLC types never leak out of the infra layer\n- Easy to mock for testing (depend on interface, not implementation)\n- Clear separation of concerns\n\n**Error Handling** (`core/errors.go`):\n- `ErrNoRows`, `ErrTxClosed`, `ErrPoolClosed`, `ErrInvalidConnection`, `ErrTimeout`\n- Helpers: `IsNoRowsError()`, `IsConstraintError()`, `IsTimeoutError()`\n\n## Authentication (internal/modules/auth/)\n\n**Provider-agnostic auth with Stytch integration**. Type-safe middleware for JWT verification, RBAC, and multi-tenant org context.\n\n**Core Types**:\n- `Identity` - User info from auth provider (email, roles, permissions)\n- `RequestContext` - Resolved database IDs (OrganizationID, AccountID)\n- `Permission` - Format `\"resource:action\"` (e.g., `\"invoice:create\"`)\n\n**Middleware Setup**:\n```go\nauthMiddleware := auth.NewMiddleware(authProvider, orgResolver, accResolver, nil)\n\n// Apply middleware\nrouter.Use(authMiddleware.RequireAuth())          // Verify JWT\nrouter.Use(authMiddleware.RequireOrganization())  // Resolve org/account IDs\n```\n\n**Route Protection**:\n```go\n// Permission-based\nrouter.POST(\"/invoices\",\n    auth.RequirePermissionFunc(\"invoice\", \"create\"),\n    handler.CreateInvoice)\n\n// Role-based\nrouter.DELETE(\"/orgs/:id\",\n    authMiddleware.RequireRole(auth.RoleAdmin),\n    handler.DeleteOrg)\n```\n\n**Handler Context Access**:\n```go\nfunc (h *Handler) MyHandler(c *gin.Context) {\n    reqCtx := auth.GetRequestContext(c)\n    orgID := reqCtx.OrganizationID      // int32\n    accountID := reqCtx.AccountID       // int32\n    email := reqCtx.Identity.Email      // string\n\n    // Or use convenience functions\n    orgID := auth.GetOrganizationID(c)  // Safe: returns 0 if not set\n    accountID := auth.GetAccountID(c)   // Safe: returns 0 if not set\n}\n```\n\n**Common Permissions** (`permissions.go`):\n```go\nauth.PermInvoiceCreate    // \"invoice:create\"\nauth.PermInvoiceView      // \"invoice:view\"\nauth.PermInvoiceDelete    // \"invoice:delete\"\nauth.PermOrgView          // \"org:view\"\nauth.PermOrgManage        // \"org:manage\"\n```\n\n**Configuration** (environment variables):\n```env\nSTYTCH_PROJECT_ID=project-test-xxx-xxx    # Required\nSTYTCH_SECRET=secret-test-xxx             # Required\nSTYTCH_ENV=test                           # Optional: \"test\" or \"live\"\n```\n\n**See**: `internal/modules/auth/README.md` for detailed usage patterns and examples.\n\n## File Manager (internal/modules/files/)\n\n**Dual architecture**: Cloudflare R2 (object storage) + PostgreSQL (searchable metadata).\n\n**Components**:\n- `FileRepository` - Combined operations (upload, download, delete, search)\n- `R2Repository` - R2 object storage operations\n- `FileMetadataRepository` - Database metadata operations\n- `FileService` - Business logic with validation\n\n**Upload with Entity Linking**:\n```go\nreq := &domain.FileUploadRequest{\n    Filename:    \"invoice_001.pdf\",\n    ContentType: \"application/pdf\",\n    Context:     file_manager.ContextInvoice,\n}\n\nfile := &domain.FileAsset{\n    EntityType: \"invoice\",\n    EntityID:   invoiceID,\n}\n\nuploadedFile, err := fileService.UploadFile(ctx, req, fileReader)\n```\n\n**Search Operations**:\n```go\nfiles, err := fileRepo.GetByEntity(ctx, \"invoice\", invoiceID)\ndocuments, err := fileRepo.GetByCategory(ctx, file_manager.CategoryDocument, 10, 0)\nreceipts, err := fileRepo.GetByContext(ctx, file_manager.ContextReceipt, 20, 0)\n```\n\n**Atomic Transactions**:\n1. Save metadata to DB (get ID)\n2. Upload file to R2 (using DB ID in key)\n3. Update metadata with storage path\n4. Automatic rollback on failure\n\n## Go Coding Standards\n\n### Core Rules\n\n**Use `any` instead of `interface{}`** (Go 1.18+):\n```go\n// ✅ Good\nfunc ProcessData(data any) error\ntype Request struct { Metadata map[string]any }\n\n// ❌ Bad\nfunc ProcessData(data interface{}) error\n```\n\n**Error Wrapping**:\n```go\n// ✅ Good\nif err := repo.Create(ctx, invoice); err != nil {\n    return fmt.Errorf(\"failed to create invoice %d: %w\", invoice.ID, err)\n}\n```\n\n**Context First**:\n```go\n// ✅ Good\nfunc (s *service) ProcessInvoice(ctx context.Context, invoiceID int32) error\n```\n\n**Naming**:\n- Packages: lowercase, single word (`invoice`, not `invoice_mgmt`)\n- Interfaces: noun/adjective + \"er\" (`Repository`, `Handler`)\n- Structs: PascalCase (`InvoiceService`, `PaymentRequest`)\n- Methods: PascalCase verbs (`CreateInvoice`, `ValidateData`)\n\n### Struct Organization\n```go\ntype Invoice struct {\n    // Identifiers first\n    ID            int32  `json:\"id\" db:\"id\"`\n    InvoiceNumber string `json:\"invoice_number\"`\n\n    // Core business data\n    Amount        decimal.Decimal `json:\"amount\"`\n    DueDate       time.Time       `json:\"due_date\"`\n\n    // References\n    VendorID      int32 `json:\"vendor_id\"`\n\n    // Timestamps last\n    CreatedAt     time.Time `json:\"created_at\"`\n    UpdatedAt     time.Time `json:\"updated_at\"`\n}\n```\n\n### Dependency Injection\n```go\n// ✅ Constructor returns interface\nfunc NewInvoiceService(\n    repo domain.InvoiceRepository,\n    logger logger.Logger,\n) domain.InvoiceService {\n    return &invoiceService{repo: repo, logger: logger}\n}\n\n// ✅ Register in DI\ncontainer.Provide(NewInvoiceService)\n```\n\n### Event-Driven Patterns\n```go\n// ✅ Events are past tense, include all data\ntype InvoiceCreatedEvent struct {\n    BaseEvent\n    InvoiceID int32           `json:\"invoice_id\"`\n    Amount    decimal.Decimal `json:\"amount\"`\n    CreatedAt time.Time       `json:\"created_at\"`\n}\n```\n\n### Testing\n```go\n// ✅ Table-driven tests\nfunc TestInvoiceValidation(t *testing.T) {\n    tests := []struct {\n        name    string\n        invoice *Invoice\n        wantErr bool\n    }{\n        {\"valid\", &Invoice{Amount: decimal.NewFromInt(100)}, false},\n        {\"negative\", &Invoice{Amount: decimal.NewFromInt(-100)}, true},\n    }\n\n    for _, tt := range tests {\n        t.Run(tt.name, func(t *testing.T) {\n            err := tt.invoice.Validate()\n            if (err != nil) != tt.wantErr {\n                t.Errorf(\"got error = %v, want %v\", err, tt.wantErr)\n            }\n        })\n    }\n}\n```\n\n## API Development Pattern\n\n**ALWAYS follow this pattern** for consistency and Clean Architecture compliance.\n\n### Implementation Steps\n\n**1. Database Layer** (if new data needed):\n- Add SQLC queries: `internal/db/postgres/sqlc/query/{domain}.sql`\n- Run `make sqlc`\n- Define repository interface in `internal/modules/{module}/domain/repository.go`\n- Implement repository in `internal/modules/{module}/infra/repositories/{domain}_repository.go`\n- Register in DI: `internal/db/inject.go`\n\n**2. Domain Layer** (`internal/modules/{module}/domain/`):\n- Create entities with business types\n- Define repository interfaces\n- Add validation methods\n\n**3. Infrastructure** (`internal/modules/{module}/infra/repositories/`):\n- Implement repository interfaces using SQLC\n- Map SQLC types ↔ domain types (never expose SQLC outside infra)\n- Handle transactions\n\n**4. Application** (`internal/modules/{module}/app/services/`):\n- Define request/response DTOs\n- Add service interface\n- Implement business logic\n\n**5. API Layer** (`internal/modules/{module}/`):\n- Add handler with validation (`handler.go`)\n- Add Swagger annotations (see Swagger Best Practices below)\n- Register routes (`routes.go`)\n- Wire dependencies in module initialization\n\n### Required Handler Pattern\n```go\nfunc (h *Handler) OperationName(c *gin.Context) {\n    // 1. Extract path params\n    var entityID int32\n    if _, err := fmt.Sscanf(c.Param(\"id\"), \"%d\", &entityID); err != nil {\n        c.JSON(400, httperr.NewHTTPError(400, \"invalid_id\", \"Invalid ID\"))\n        return\n    }\n\n    // 2. Get auth context\n    reqCtx := auth.GetRequestContext(c)\n    if reqCtx == nil {\n        c.JSON(401, httperr.NewHTTPError(401, \"unauthorized\", \"Auth required\"))\n        return\n    }\n\n    // 3. Bind request (if needed)\n    var req models.RequestDto\n    if err := c.ShouldBindJSON(&req); err != nil {\n        c.JSON(400, httperr.NewHTTPError(400, \"invalid_request\", err.Error()))\n        return\n    }\n\n    // 4. Call service\n    response, err := h.service.Operation(c.Request.Context(), reqCtx.OrganizationID, req)\n    if err != nil {\n        c.JSON(500, httperr.NewHTTPError(500, \"operation_failed\", err.Error()))\n        return\n    }\n\n    // 5. Return response\n    c.JSON(200, response)\n}\n```\n\n### Swagger Best Practices\n\n**CRITICAL**: Always use local type references in swagger annotations. Never use full package paths.\n\n**✅ Correct**:\n```go\n// @Success 200 {object} domain.User \"User details\"\n// @Success 201 {object} services.CreateUserResponse \"Created user\"\n// @Failure 400 {object} httperr.HTTPError \"Bad request\"\n// @Param request body services.CreateUserRequest true \"User data\"\n```\n\n**❌ Wrong**:\n```go\n// @Success 200 {object} github_com_moasq_go-b2b-starter_internal_modules_users_domain.User\n// @Failure 400 {object} errors.HTTPError  // Wrong package name\n```\n\n**Common Patterns**:\n```go\n// Handler in internal/modules/users/handler.go\nimport (\n    \"github.com/moasq/go-b2b-starter/internal/modules/users/domain\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/users/app/services\"\n    \"github.com/moasq/go-b2b-starter/pkg/httperr\"\n)\n\n// @Summary Create user\n// @Description Creates a new user in the organization\n// @Tags users\n// @Accept json\n// @Produce json\n// @Param request body services.CreateUserRequest true \"User data\"\n// @Success 201 {object} domain.User \"Created user\"\n// @Failure 400 {object} httperr.HTTPError \"Invalid request\"\n// @Failure 500 {object} httperr.HTTPError \"Internal error\"\n// @Router /api/users [post]\nfunc (h *Handler) CreateUser(c *gin.Context) {\n    // Implementation\n}\n```\n\n**Docker Compose Best Practices**:\n\nWhen using docker-compose for CLI tools, use `${PWD}` for volume mounts:\n```yaml\ncli:\n  volumes:\n    - ${PWD}:/workspace  # ✅ Correct - uses current directory\n    # - ../:/workspace   # ❌ Wrong - relative paths don't work consistently\n  working_dir: /workspace\n```\n\n### Required Service Pattern\n```go\nfunc (s *service) Operation(ctx context.Context, orgID int32, req *Request) (*Response, error) {\n    // 1. Validate\n    if err := req.Validate(); err != nil {\n        return nil, err\n    }\n\n    // 2. Execute business logic\n    result, err := s.repo.Operation(ctx, orgID, req)\n    if err != nil {\n        return nil, fmt.Errorf(\"operation failed: %w\", err)\n    }\n\n    // 3. Return\n    return result, nil\n}\n```\n\n### Required Middleware Pattern\n```go\n// In routes.go\napiGroup := router.Group(\"/{domain}\")\napiGroup.Use(\n    authMiddleware.RequireAuth(),\n    authMiddleware.RequireOrganization(),\n)\n{\n    apiGroup.POST(\"/path\",\n        auth.RequirePermissionFunc(\"{resource}\", \"{action}\"),\n        h.HandlerMethod)\n}\n```\n\n### Mandatory Requirements\n- **Context**: First parameter in all I/O operations\n- **Error Wrapping**: `fmt.Errorf(\"context: %w\", err)`\n- **Validation**: At both entity and service levels\n- **Logging**: Structured logging for operations\n- **Middleware**: Auth, org context, permissions\n- **Swagger**: Complete API documentation\n- **Transactions**: Atomic operations where needed\n\n### Testing Requirements\n- Unit tests with mocked dependencies\n- Integration tests with database\n- Validation tests for all error scenarios\n- Permission tests for access control\n\n## Dependency Management\n\n**Rule**: Use interface abstractions when dependencies aren't ready.\n\n```go\n// ✅ Good - Depend on interface\ntype OCRService interface {\n    ExtractData(ctx context.Context, fileID int32) (map[string]any, error)\n}\n\n// ✅ Good - Event-driven integration\nfunc NewInvoiceService(eventBus eventbus.EventBus) InvoiceService {\n    // Publish events; other modules subscribe when ready\n}\n\n// ❌ Bad - Don't inject concrete types that don't exist\nfunc NewInvoiceService(ocrService *OCRServiceImpl) InvoiceService {\n    // Fails if OCRServiceImpl doesn't exist\n}\n```\n\n## Project Structure\n\n```\ngo-b2b-starter/\n├── cmd/api/                    # Application entry point\n├── internal/\n│   ├── bootstrap/             # App initialization and wiring\n│   ├── db/                    # Database layer (SQLC, DI registration)\n│   │   ├── postgres/sqlc/     # SQLC queries and generated code\n│   │   ├── adapters/          # Legacy adapters (being phased out)\n│   │   └── inject.go          # Repository DI registration\n│   ├── modules/               # Feature modules\n│   │   ├── {module}/\n│   │   │   ├── domain/        # Entities, interfaces, validation\n│   │   │   ├── app/services/  # Business logic (use cases)\n│   │   │   ├── infra/         # Repository implementations\n│   │   │   ├── handler.go     # HTTP handlers\n│   │   │   ├── routes.go      # Route definitions\n│   │   │   └── module.go      # Module DI setup\n│   │   ├── auth/              # Authentication & RBAC\n│   │   ├── billing/           # Polar.sh subscriptions\n│   │   ├── organizations/     # Multi-tenant org management\n│   │   ├── documents/         # PDF document management\n│   │   ├── cognitive/         # RAG and embeddings\n│   │   ├── files/             # File storage (R2 + metadata)\n│   │   └── paywall/           # Subscription access gating\n│   └── platform/              # Cross-cutting concerns\n│       ├── logger/            # Structured logging\n│       ├── server/            # HTTP server\n│       ├── eventbus/          # Event pub-sub\n│       ├── llm/               # LLM client\n│       ├── ocr/               # OCR service\n│       ├── redis/             # Redis client\n│       └── stytch/            # Stytch B2B client\n└── pkg/                       # Public shared utilities\n    ├── httperr/               # HTTP error responses\n    ├── pagination/            # Pagination helpers\n    ├── response/              # Standard API responses\n    └── slugify/               # Slug generation utilities\n```\n\n## Billing & Paywall (internal/modules/billing/)\n\n**Polar.sh integration** with hybrid sync for subscriptions.\n\n**Sync Strategy**:\n1. **Webhooks** (primary) - Real-time updates from Polar.sh\n2. **Active Verification** - Poll after checkout redirect\n3. **Lazy Guarding** - Verify with API if local data suggests expired\n\n**Paywall Middleware**:\n```go\n// Require active subscription\npremiumGroup := router.Group(\"/premium\")\npremiumGroup.Use(\n    resolver.Get(\"auth\"),\n    resolver.Get(\"org_context\"),\n    resolver.Get(\"paywall\"),  // RequireActiveSubscription\n)\n\n// Optional subscription info\npublicGroup.Use(resolver.Get(\"paywall_optional\"))\n```\n\n**Quota Management**:\n```go\n// Check and consume quota\nstatus, err := billingService.ConsumeInvoiceQuota(ctx, orgID)\nif err == domain.ErrInsufficientQuota {\n    // Handle quota exhausted\n}\n```\n\n## Event Bus (internal/platform/eventbus/)\n\n**In-memory event bus** for loose coupling between modules.\n\n**Define Event**:\n```go\ntype DocumentUploadedEvent struct {\n    eventbus.BaseEvent\n    DocumentID     int32 `json:\"document_id\"`\n    OrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc NewDocumentUploadedEvent(docID, orgID int32) *DocumentUploadedEvent {\n    return &DocumentUploadedEvent{\n        BaseEvent:      eventbus.NewBaseEvent(\"document.uploaded\"),\n        DocumentID:     docID,\n        OrganizationID: orgID,\n    }\n}\n```\n\n**Publish**:\n```go\nevent := NewDocumentUploadedEvent(doc.ID, orgID)\neventBus.Publish(ctx, event)  // Fire-and-forget\n```\n\n**Subscribe**:\n```go\neventBus.Subscribe(\"document.uploaded\", func(ctx context.Context, event eventbus.Event) error {\n    docEvent := event.(*DocumentUploadedEvent)\n    return embeddingService.GenerateForDocument(ctx, docEvent.DocumentID)\n})\n```\n\n## Module Initialization Order\n\n**File**: `internal/bootstrap/bootstrap.go`\n\nOrder matters due to dependencies:\n\n```go\n// Phase 1: Infrastructure (no dependencies)\nlogger.Inject(container)\nserver.Inject(container)\ndb.Inject(container)           // Registers all domain repositories\n\n// Phase 2: Platform Services\nredis.Inject(container)\nllm.Inject(container)\nocr.Inject(container)\npolar.Inject(container)\neventbus.Inject(container)\n\n// Phase 3: Module Dependencies (order critical!)\nfiles.SetupDependencies(container)     // File storage\nauth.SetupDependencies(container)      // Auth, RBAC, resolvers\norganizations.RegisterDependencies(container)\nbilling.Configure(container)\ncognitive.RegisterDependencies(container)\ndocuments.RegisterDependencies(container)\n\n// Phase 4: Event Subscriptions\ncognitive.SetupEventSubscriptions(container)\n\n// Phase 5: HTTP Server Setup\nserver.SetupMiddleware(container)\n```\n\n## Named Middlewares\n\nAccess middleware by name in routes:\n\n```go\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver server.MiddlewareResolver) {\n    group := router.Group(\"/api\")\n    group.Use(\n        resolver.Get(\"auth\"),          // RequireAuth\n        resolver.Get(\"org_context\"),   // RequireOrganization\n        resolver.Get(\"paywall\"),       // RequireActiveSubscription\n        resolver.Get(\"paywall_optional\"), // OptionalSubscriptionStatus\n        resolver.Get(\"subscription\"),  // Deprecated alias\n    )\n}\n```\n\n## Configuration\n\nEnvironment-based configuration using `app.env` and `example.env`. Docker Compose for local dependencies.\n\n## Documentation\n\nComprehensive documentation available in `docs/`:\n\n- `docs/README.md` - Overview and quick start\n- `docs/architecture.md` - Clean Architecture patterns\n- `docs/database.md` - SQLC workflow and migrations\n- `docs/authentication.md` - Auth, RBAC, Stytch integration\n- `docs/billing.md` - Polar.sh and paywall\n- `docs/file-manager.md` - R2 file storage\n- `docs/event-bus.md` - Event-driven patterns\n- `docs/api-development.md` - Step-by-step API guide\n- `docs/modules/` - Module-specific documentation\n"
  },
  {
    "path": "go-b2b-starter/.dockerignore",
    "content": "# Environment and configuration files\napp.env\n.env\n.air.toml\nexample.env\n\n# Version control\n.git\n.gitignore\n.gitlab-ci.yml\n\n# IDE and editor files\n.idea\n.vscode\n.claude\n\n# Project files\nMakefile\nREADME.md\n\n# Directories\ndocs/\ndeps/\ndeployment/\nscripts/\ntmp/\nbin/\nstorage/\n.scannerwork/\ncoverage/\n/src/pkg/db/postgres/seed\n/src/pkg/db/postgres/sqlc/migrations\n/src/pkg/db/postgres/sqlc/query\n/src/pkg/db/postgres/sqlc.yml\n\n# Build artifacts\n*.log\n*.out\n*.env\n*.sql\n\n# Docker files\nDockerfile\ndocker-compose.yml"
  },
  {
    "path": "go-b2b-starter/.gitignore",
    "content": "/api\n"
  },
  {
    "path": "go-b2b-starter/.gitlab-ci.yml",
    "content": "stages:\n  - test\n  - build\n\nrun-tests:\n  stage: test\n  image: golang:1.22.4\n  script:\n    - mkdir -p coverage\n    - bash scripts/run_tests_with_coverage.sh\n  artifacts:\n    paths:\n      - coverage/\n    reports:\n      coverage_report:\n        coverage_format: cobertura\n        path: coverage/coverage.xml\n  coverage: '/total:\\s+\\(statements\\)\\s+(\\d+\\.\\d+)%/'\n\nbuild-image:\n  stage: build\n  image: docker:latest\n  services:\n    - docker:dind\n  script:\n    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY\n    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .\n    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA\n    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest\n    - docker push $CI_REGISTRY_IMAGE:latest\n  rules:\n    - if: $CI_COMMIT_BRANCH == \"main\"\n      when: on_success\n"
  },
  {
    "path": "go-b2b-starter/Dockerfile",
    "content": "# Builder stage\nFROM golang:1.25-alpine3.20 AS builder\n\nWORKDIR /app\n\n# Install build dependencies\nRUN apk add --no-cache git\n\n# Copy go mod and sum files first for better caching\nCOPY go.mod go.sum ./\nRUN go mod download\n\n# Copy the source code\nCOPY . .\n\n# Build the application with additional flags for production\nRUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=\"-s -w\" -o /main ./cmd/api/main.go\n\n# Final stage - using Alpine for smaller image with necessary system files\nFROM alpine:3.20\n\n# Install necessary packages and clean up\nRUN apk add --no-cache ca-certificates tzdata && \\\n    rm -rf /var/cache/apk/*\n\n# Create non-root user\nRUN addgroup -S appgroup && adduser -S appuser -G appgroup\n\nWORKDIR /app\n\n# Copy only the binary from builder\nCOPY --from=builder /main /app/main\n\n# Set proper permissions\nRUN chown -R appuser:appgroup /app && \\\n    chmod +x /app/main\n\n# Use non-root user\nUSER appuser\n\n# Image metadata\nLABEL org.opencontainers.image.title=\"B2B SaaS Starter Backend\" \\\n      org.opencontainers.image.description=\"Go backend for B2B SaaS Starter\" \\\n      org.opencontainers.image.vendor=\"B2B SaaS Starter\" \\\n      org.opencontainers.image.version=\"1.0.0\" \\\n      org.opencontainers.image.source=\"https://github.com/yourusername/b2b-saas-starter\"\n\n# Expose the port your app runs on\nEXPOSE 8080\n\n# Command to run the application\nENTRYPOINT [\"/app/main\"]"
  },
  {
    "path": "go-b2b-starter/Makefile",
    "content": "COMPOSE_FILE := deps/docker-compose.yml\nMIGRATION_PATH ?= schema/migration\nPOSTGRES_HOST ?= localhost\nPOSTGRES_PORT ?= 5432\nPOSTGRES_DB ?= mydatabase\nPOSTGRES_USER ?= user\nPOSTGRES_PASSWORD ?= password\nCONTAINER_NAME ?= deps-postgis-1\nMIGRATION_NAME ?= init_schema\nMIGRATION_DIR ?= ./internal/db/postgres/sqlc/migrations\nSQLC_DIR ?= internal/db/postgres/sqlc\n\n# Start the necessary docker containers\nrun-deps:\n\tdocker compose -f $(COMPOSE_FILE) up --build -d\n\n# Stop and remove docker containers\nstop-deps:\n\tdocker compose -f $(COMPOSE_FILE) down -v\n\n# Create a new database migration file\ncreate-migration:\n\t@docker compose -f $(COMPOSE_FILE) run --rm cli migrate create -ext sql -dir $(MIGRATION_DIR) -seq $(MIGRATION_NAME)\n\t@echo \"Migration created in $(MIGRATION_DIR) with name $(MIGRATION_NAME)\"\n\n# Apply all up migrations\n# Apply all up migrations\nmigrateup:\n\t@docker compose -f $(COMPOSE_FILE) run --rm cli migrate -path $(MIGRATION_DIR) -database \"postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable\" -verbose up\n\n# Apply all down migrations\nmigratedown:\n\t@docker compose -f $(COMPOSE_FILE) run --rm cli migrate -path $(MIGRATION_DIR) -database \"postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable\" -verbose down\n\nsqlc:\n\t@docker compose -f $(COMPOSE_FILE) run --rm -w /workspace/$(SQLC_DIR) cli sqlc generate\n\n# Create a new module\ncreate-module:\n\tbash scripts/create_module.sh $(type) $(name)\n\tif [ \"$(db)\" = \"postgres\" ]; then bash scripts/setup_db.sh $(type) $(name); fi\n\n# Run the server\nserver:\n\tgo run ./cmd/api/main.go\n\n# build the app\nbuild:\n\tgo build -o bin/api ./cmd/api/main.go\n\n# install dependencies\ndeps:\n\tgo mod tidy\n\n# swagger\nswagger:\n\t@docker compose -f $(COMPOSE_FILE) run --rm cli swag init -g cmd/api/main.go -d . --parseDependency --parseInternal -o internal/docs/gen\n\n# Run the server with Air (Live Reload)\ndev:\n\t@docker compose -f $(COMPOSE_FILE) run --rm -T --service-ports cli air \n\nreload-profile:\n\t@source ~/.bashrc\n\ntest:\n\t@bash scripts/run_tests_with_coverage.sh\n\n# Clear RBAC and JWKS caches from Redis\nclear-rbac-cache:\n\t@echo \"Clearing RBAC and JWKS Redis caches...\"\n\t@redis-cli DEL \"stytch:rbac:policy\" || echo \"  ✗ Failed to clear stytch:rbac:policy (may not exist)\"\n\t@redis-cli DEL \"stytch:jwks:cache\" || echo \"  ✗ Failed to clear stytch:jwks:cache (may not exist)\"\n\t@echo \"✓ Cache clearing attempted (caches will auto-expire if not manually cleared)\"\n\n\n.PHONY: \\\n    build \\\n    clear-rbac-cache \\\n    create-migration \\\n    create-module \\\n    create-seed-country \\\n    deps \\\n    generate-seed-file \\\n    generate-changed-seed-file \\\n\tgenerate-migrations-file \\\n\tgenerate-down-migrations-file \\\n    migratedown \\\n    migrateup \\\n    push-to-do \\\n    reload-profile \\\n    run-deps \\\n    seed-db \\\n    server \\\n    sonar-scanner \\\n    sqlc \\\n    swagger \\\n    test\n"
  },
  {
    "path": "go-b2b-starter/README.md",
    "content": "# Go B2B Starter Backend\n\nProfessional Modular Monolith backend for B2B SaaS using idiomatic Go project layout.\n\n## ⚡️ Quick Start\n\n```bash\n# 1. Start dependencies (Postgres, Redis)\ncd deps && docker-compose up -d postgres redis\n\n# 2. Copy environment config\ncp example.env app.env\n\n# 3. Run migrations\nmake migrateup\n\n# 4. Start server with live reload\nmake dev\n```\n\n## 🏗 Project Layout (Go Standard 2026)\n\n```\ngo-b2b-starter/\n├── cmd/\n│   └── api/              # Application entry point\n│       └── main.go\n│\n├── internal/             # Private application code\n│   ├── bootstrap/        # App initialization & DI wiring\n│   ├── api/              # API route registration\n│   │\n│   ├── auth/             # Authentication & RBAC\n│   ├── billing/          # Subscription & billing\n│   ├── organizations/    # Multi-tenant org management\n│   ├── documents/        # PDF document handling\n│   ├── cognitive/        # AI/RAG chat features\n│   │\n│   ├── db/               # Database connections & SQLC\n│   ├── server/           # HTTP server & middleware\n│   ├── redis/            # Redis client\n│   └── stytch/           # Stytch B2B auth adapter\n│\n├── pkg/                  # Public reusable packages\n│   ├── httperr/          # HTTP error types\n│   ├── pagination/       # Pagination helpers\n│   ├── response/         # API response helpers\n│   └── slugify/          # String utilities\n│\n├── deps/                 # Docker Compose for dependencies\n├── docs/                 # Documentation\n└── go.mod                # Single module (consolidated)\n```\n\n## 📚 Documentation\n\n- **[Architecture Guide](./docs/01-architecture.md)** - Understand the layers\n- **[Adding a Feature](./docs/02-adding-a-module.md)** - How to create new features\n- **[API & Auth](./docs/03-api-and-auth.md)** - Security and Request flow\n\n## 🛠 Key Commands\n\n| Command | Description |\n|---------|-------------|\n| `make dev` | Start server with Air (Live Reload) |\n| `make server` | Run server directly |\n| `make build` | Build binary to `bin/api` |\n| `make migrateup` | Apply DB migrations |\n| `make sqlc` | Generate type-safe DB code |\n| `make swagger` | Generate Swagger docs |\n\n## 🔧 Module Structure\n\nEach feature module in `internal/` follows **Clean Architecture**:\n\n```\ninternal/billing/\n├── cmd/              # Module initialization (DI)\n│   └── init.go\n├── app/              # Application layer (use cases)\n│   └── services/\n├── domain/           # Core business logic & interfaces\n├── infra/            # External integrations\n│   └── repositories/\n├── handler.go        # HTTP handlers\n├── routes.go         # Route registration\n└── provider.go       # Dependency injection\n```\n\n## 🚀 API Endpoints\n\nThe server exposes these API groups:\n\n- `/api/auth/*` - Authentication & member management\n- `/api/organizations/*` - Organization CRUD\n- `/api/accounts/*` - Account management\n- `/api/rbac/*` - Role & permission discovery\n- `/api/subscriptions/*` - Billing status\n- `/api/example_documents/*` - PDF upload/management\n- `/api/example_cognitive/*` - AI chat sessions\n- `/swagger/*` - API documentation\n- `/health` - Health check\n"
  },
  {
    "path": "go-b2b-starter/cmd/api/main.go",
    "content": "// Package main provides the entry point for the B2B SaaS Starter\n//\n//\t@title\t\t\tB2B SaaS Starter API\n//\t@version\t\t1.0\n//\t@description\tThis is the API server for B2B SaaS Starter.\n//\t@termsOfService\thttp://swagger.io/terms/\n//\n//\t@contact.name\tAPI Support\n//\t@contact.url\thttp://www.swagger.io/support\n//\t@contact.email\tsupport@swagger.io\n//\n//\t@license.name\tApache 2.0\n//\t@license.url\thttp://www.apache.org/licenses/LICENSE-2.0.html\n//\n//\t@host\t\tlocalhost:8080\n//\t@BasePath\t/api\n//\n//\t@securityDefinitions.basic\tBasicAuth\n//\n//\t@externalDocs.description\tOpenAPI\n//\t@externalDocs.url\t\t\thttps://swagger.io/resources/open-api/\npackage main\n\nimport \"github.com/moasq/go-b2b-starter/internal/bootstrap\"\n\nfunc main() {\n\tbootstrap.Execute()\n}\n"
  },
  {
    "path": "go-b2b-starter/deps/Dockerfile",
    "content": "FROM golang:1.25.5-alpine\n\nWORKDIR /workspace\n\n# Install system dependencies\nRUN apk add --no-cache git make bash curl\n\n# Install Go tools\nRUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest && \\\n    go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \\\n    go install github.com/swaggo/swag/cmd/swag@latest && \\\n    go install github.com/air-verse/air@latest\n\nCMD [\"bash\"]\n"
  },
  {
    "path": "go-b2b-starter/deps/docker-compose.yml",
    "content": "services:\n  postgres:\n    image: pgvector/pgvector:pg17\n    environment:\n      POSTGRES_DB: mydatabase\n      POSTGRES_USER: user\n      POSTGRES_PASSWORD: password\n    ports:\n      - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U user -d mydatabase\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n\n  redis:\n    image: redis:alpine\n    platform: linux/arm64\n    ports:\n      - \"6379:6379\"\n    volumes:\n      - redis_data:/data\n\n  cli:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    image: go-b2b-starter-cli\n    volumes:\n      - ${PWD}:/workspace\n    working_dir: /workspace\n    environment:\n      - POSTGRES_HOST=postgres\n      - POSTGRES_PORT=5432\n      - POSTGRES_USER=user\n      - POSTGRES_PASSWORD=password\n      - POSTGRES_DB=mydatabase\n      - REDIS_HOST=redis\n      - REDIS_PORT=6379\n    ports:\n      - \"8080:8080\"\n    depends_on:\n      postgres:\n        condition: service_healthy\n\n\nvolumes:\n  postgres_data:\n  redis_data:\n"
  },
  {
    "path": "go-b2b-starter/docs/README.md",
    "content": "# Go B2B SaaS Starter Kit\n\nA production-ready Go backend for B2B SaaS applications with multi-tenant architecture, authentication, billing, and file management.\n\n## Quick Start\n\n```bash\nmake run-deps      # Start PostgreSQL & Redis\nmake migrateup     # Run migrations\nmake dev           # Start dev server with hot reload\n```\n\n## Documentation\n\n### Core Systems\n- **[Architecture](./architecture.md)** - Clean Architecture, dependency injection, module patterns\n- **[Database](./database.md)** - SQLC workflow, migrations, store adapters\n- **[Authentication](./authentication.md)** - Stytch integration, RBAC, middleware\n- **[Billing](./billing.md)** - Polar.sh integration, subscriptions, paywall\n\n### Infrastructure\n- **[File Manager](./file-manager.md)** - R2 storage and file operations\n- **[Event Bus](./event-bus.md)** - Event-driven architecture patterns\n- **[API Development](./api-development.md)** - Guide to building new endpoints\n\n## Project Structure\n\nThe codebase follows Clean Architecture with three main layers:\n\n**API Layer** (`internal/`) - HTTP handlers and routes\n**Application Layer** (`internal/`) - Business logic organized by modules\n**Shared Layer** (`internal/`) - Reusable infrastructure packages\n\nEach application module contains:\n- `domain/` - Entities, interfaces, business rules\n- `app/` - Services (use cases)\n- `infra/` - Repository implementations\n- `module.go` - Dependency injection setup\n\n## Common Commands\n\n```bash\n# Development\nmake dev                # Run dev server with hot reload (Air)\nmake server             # Run server without hot reload\nmake build              # Build production binary\n\n# Dependencies\nmake run-deps           # Start PostgreSQL & Redis in Docker\nmake stop-deps          # Stop and remove Docker containers\n\n# Database\nmake migrateup          # Apply all migrations\nmake migratedown        # Rollback migrations\nmake sqlc               # Generate code from SQL\nmake create-migration   # Create new migration file\n\n# Code Generation\nmake swagger            # Generate Swagger docs\n\n# Testing\nmake test               # Run tests with coverage\n\n# Utilities\nmake clear-rbac-cache   # Clear RBAC and JWKS caches from Redis\n```\n\n## Tech Stack\n\n- **Language**: Go 1.25+\n- **HTTP**: Gin framework\n- **Database**: PostgreSQL with SQLC\n- **Auth**: Stytch B2B\n- **Payments**: Polar.sh\n- **Storage**: Cloudflare R2\n- **DI**: uber-go/dig\n\n## Environment Setup\n\nCopy `example.env` to `app.env` and configure:\n\n```env\n# Database\nDATABASE_HOST=localhost\nDATABASE_NAME=b2b_starter\n\n# Authentication\nSTYTCH_PROJECT_ID=your-project-id\nSTYTCH_SECRET=your-secret\n\n# Billing\nPOLAR_ACCESS_TOKEN=your-token\nPOLAR_WEBHOOK_SECRET=your-secret\n\n# File Storage\nR2_ACCOUNT_ID=your-account\nR2_ACCESS_KEY_ID=your-key\nR2_SECRET_ACCESS_KEY=your-secret\nR2_BUCKET_NAME=files\n```\n\nSee `example.env` for all configuration options.\n\n## Getting Started\n\n1. **Understand the architecture**: Read [Architecture](./architecture.md)\n2. **Set up the database**: Follow [Database](./database.md)\n3. **Configure authentication**: See [Authentication](./authentication.md)\n4. **Build your first API**: Follow [API Development](./api-development.md)\n"
  },
  {
    "path": "go-b2b-starter/docs/adding-a-module.md",
    "content": "# Adding a New Module\n\nThis guide shows how to create a new feature module following Clean Architecture and the idiomatic Go project layout.\n\n## Modules vs Platform Decision\n\nBefore creating a new module, determine whether it should be a **feature module** or a **platform component**.\n\n### Create a Module (`internal/modules/`) when:\n- Implementing a business domain feature\n- Has domain entities with business rules\n- Exposes API endpoints\n- Contains use cases and workflows\n- **Examples**: billing, documents, organizations, invoices, products\n\n### Create a Platform Component (`internal/platform/`) when:\n- Infrastructure or cross-cutting concern\n- Used by multiple modules\n- No business logic (pure infrastructure)\n- Provides technical capability\n- **Examples**: logger, eventbus, redis, http server\n\n### Decision Tree\n\n```\nIs it a business domain feature?\n├─ Yes → Create in internal/modules/{name}/\n└─ No → Is it used by multiple modules?\n    ├─ Yes → Create in internal/platform/{name}/\n    └─ No → Should it be part of an existing module?\n```\n\n## Module Location\n\nAll feature modules live in `internal/modules/` which enforces Go's import boundary:\n\n```\ninternal/\n├── modules/               # Feature modules (business domains)\n│   ├── auth/             # Authentication & RBAC\n│   ├── billing/          # Subscription & billing\n│   ├── organizations/    # Multi-tenant organizations\n│   ├── documents/        # Document management\n│   ├── cognitive/        # AI/RAG features\n│   ├── files/            # File storage\n│   ├── paywall/          # Subscription middleware\n│   └── products/         # ← Your new module here\n│\n├── platform/             # Cross-cutting infrastructure\n│   ├── server/           # HTTP server\n│   ├── eventbus/         # Event pub/sub\n│   ├── logger/           # Structured logging\n│   ├── redis/            # Redis client\n│   └── ...\n│\n├── db/                   # Database layer\n└── bootstrap/            # App initialization\n```\n\n## Module Structure\n\nEach module follows **Clean Architecture** with these layers:\n\n```\ninternal/modules/products/\n├── cmd/                      # Module initialization (DI wiring)\n│   └── init.go\n│\n├── app/                      # Application Layer (Use Cases)\n│   └── services/\n│       └── product_service.go\n│\n├── domain/                   # Domain Layer (Core Business Logic)\n│   ├── entity.go             # Data structures\n│   └── repository.go         # Interface definitions\n│\n├── infra/                    # Infrastructure Layer (External)\n│   └── repositories/\n│       └── product_repository.go\n│\n├── handler.go                # HTTP handlers (Delivery Layer)\n├── routes.go                 # Route registration\n└── module.go                 # Dependency injection setup\n```\n\n## Step-by-Step Guide\n\n### 1. Define the Entity (`domain/entity.go`)\n\nStart with your core business objects:\n\n```go\npackage domain\n\nimport \"time\"\n\ntype Product struct {\n    ID             int32     `json:\"id\"`\n    Name           string    `json:\"name\"`\n    Description    string    `json:\"description\"`\n    Price          float64   `json:\"price\"`\n    OrganizationID int32     `json:\"organization_id\"`\n    CreatedAt      time.Time `json:\"created_at\"`\n    UpdatedAt      time.Time `json:\"updated_at\"`\n}\n\n// Validate validates the product data\nfunc (p *Product) Validate() error {\n    if p.Name == \"\" {\n        return ErrInvalidProductName\n    }\n    if p.Price < 0 {\n        return ErrInvalidPrice\n    }\n    return nil\n}\n```\n\n### 2. Define the Repository Interface (`domain/repository.go`)\n\nDefine what operations your module needs:\n\n```go\npackage domain\n\nimport \"context\"\n\ntype ProductRepository interface {\n    Create(ctx context.Context, p *Product) (*Product, error)\n    GetByID(ctx context.Context, orgID, id int32) (*Product, error)\n    ListByOrganization(ctx context.Context, orgID int32, limit, offset int32) ([]*Product, error)\n    Update(ctx context.Context, p *Product) error\n    Delete(ctx context.Context, orgID, id int32) error\n}\n```\n\n**Key Points:**\n- Interface uses **domain types**, not database types\n- Defined in the domain layer (where it's used)\n- Independent of implementation details\n\n### 3. Implement the Repository (`infra/repositories/product_repository.go`)\n\nImplement the interface using SQLC:\n\n```go\npackage repositories\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/domain\"\n    sqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\ntype productRepository struct {\n    store sqlc.Store\n}\n\nfunc NewProductRepository(store sqlc.Store) domain.ProductRepository {\n    return &productRepository{store: store}\n}\n\nfunc (r *productRepository) Create(ctx context.Context, p *domain.Product) (*domain.Product, error) {\n    dbProduct, err := r.store.CreateProduct(ctx, sqlc.CreateProductParams{\n        Name:           p.Name,\n        Description:    p.Description,\n        Price:          p.Price,\n        OrganizationID: p.OrganizationID,\n    })\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to create product: %w\", err)\n    }\n\n    // Map SQLC type to domain type\n    return &domain.Product{\n        ID:             dbProduct.ID,\n        Name:           dbProduct.Name,\n        Description:    dbProduct.Description,\n        Price:          dbProduct.Price,\n        OrganizationID: dbProduct.OrganizationID,\n        CreatedAt:      dbProduct.CreatedAt,\n        UpdatedAt:      dbProduct.UpdatedAt,\n    }, nil\n}\n\nfunc (r *productRepository) GetByID(ctx context.Context, orgID, id int32) (*domain.Product, error) {\n    dbProduct, err := r.store.GetProductByID(ctx, sqlc.GetProductByIDParams{\n        OrganizationID: orgID,\n        ID:             id,\n    })\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to get product: %w\", err)\n    }\n\n    return &domain.Product{\n        ID:             dbProduct.ID,\n        Name:           dbProduct.Name,\n        Description:    dbProduct.Description,\n        Price:          dbProduct.Price,\n        OrganizationID: dbProduct.OrganizationID,\n        CreatedAt:      dbProduct.CreatedAt,\n        UpdatedAt:      dbProduct.UpdatedAt,\n    }, nil\n}\n\n// ... implement other methods\n```\n\n### 4. Create the Service (`app/services/product_service.go`)\n\nBusiness logic lives here:\n\n```go\npackage services\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/domain\"\n)\n\ntype ProductService interface {\n    Create(ctx context.Context, orgID int32, req *CreateProductRequest) (*domain.Product, error)\n    GetByID(ctx context.Context, orgID, id int32) (*domain.Product, error)\n    ListByOrganization(ctx context.Context, orgID int32, limit, offset int32) ([]*domain.Product, error)\n}\n\ntype productService struct {\n    repo domain.ProductRepository\n}\n\nfunc NewProductService(repo domain.ProductRepository) ProductService {\n    return &productService{repo: repo}\n}\n\ntype CreateProductRequest struct {\n    Name        string  `json:\"name\" binding:\"required\"`\n    Description string  `json:\"description\"`\n    Price       float64 `json:\"price\" binding:\"required,min=0\"`\n}\n\nfunc (s *productService) Create(ctx context.Context, orgID int32, req *CreateProductRequest) (*domain.Product, error) {\n    product := &domain.Product{\n        Name:           req.Name,\n        Description:    req.Description,\n        Price:          req.Price,\n        OrganizationID: orgID,\n    }\n\n    // Validate\n    if err := product.Validate(); err != nil {\n        return nil, err\n    }\n\n    // Create\n    created, err := s.repo.Create(ctx, product)\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to create product: %w\", err)\n    }\n\n    return created, nil\n}\n```\n\n### 5. Create the Handler (`handler.go`)\n\nHTTP request handling:\n\n```go\npackage products\n\nimport (\n    \"fmt\"\n    \"net/http\"\n\n    \"github.com/gin-gonic/gin\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/app/services\"\n    \"github.com/moasq/go-b2b-starter/pkg/httperr\"\n)\n\ntype Handler struct {\n    service services.ProductService\n}\n\nfunc NewHandler(service services.ProductService) *Handler {\n    return &Handler{service: service}\n}\n\n// @Summary Create product\n// @Description Creates a new product in the organization\n// @Tags products\n// @Accept json\n// @Produce json\n// @Param request body services.CreateProductRequest true \"Product data\"\n// @Success 201 {object} domain.Product \"Created product\"\n// @Failure 400 {object} httperr.HTTPError \"Invalid request\"\n// @Failure 500 {object} httperr.HTTPError \"Internal error\"\n// @Router /api/products [post]\nfunc (h *Handler) Create(c *gin.Context) {\n    reqCtx := auth.GetRequestContext(c)\n    if reqCtx == nil {\n        c.JSON(http.StatusUnauthorized, httperr.NewHTTPError(\n            http.StatusUnauthorized,\n            \"unauthorized\",\n            \"Authentication required\",\n        ))\n        return\n    }\n\n    var req services.CreateProductRequest\n    if err := c.ShouldBindJSON(&req); err != nil {\n        c.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n            http.StatusBadRequest,\n            \"invalid_request\",\n            err.Error(),\n        ))\n        return\n    }\n\n    product, err := h.service.Create(c.Request.Context(), reqCtx.OrganizationID, &req)\n    if err != nil {\n        c.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n            http.StatusInternalServerError,\n            \"creation_failed\",\n            err.Error(),\n        ))\n        return\n    }\n\n    c.JSON(http.StatusCreated, product)\n}\n\n// @Summary Get product\n// @Description Gets a product by ID\n// @Tags products\n// @Produce json\n// @Param id path int true \"Product ID\"\n// @Success 200 {object} domain.Product \"Product details\"\n// @Failure 400 {object} httperr.HTTPError \"Invalid ID\"\n// @Failure 404 {object} httperr.HTTPError \"Product not found\"\n// @Router /api/products/{id} [get]\nfunc (h *Handler) GetByID(c *gin.Context) {\n    reqCtx := auth.GetRequestContext(c)\n    if reqCtx == nil {\n        c.JSON(http.StatusUnauthorized, httperr.NewHTTPError(\n            http.StatusUnauthorized,\n            \"unauthorized\",\n            \"Authentication required\",\n        ))\n        return\n    }\n\n    var productID int32\n    if _, err := fmt.Sscanf(c.Param(\"id\"), \"%d\", &productID); err != nil {\n        c.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n            http.StatusBadRequest,\n            \"invalid_id\",\n            \"Product ID must be a number\",\n        ))\n        return\n    }\n\n    product, err := h.service.GetByID(c.Request.Context(), reqCtx.OrganizationID, productID)\n    if err != nil {\n        c.JSON(http.StatusNotFound, httperr.NewHTTPError(\n            http.StatusNotFound,\n            \"not_found\",\n            err.Error(),\n        ))\n        return\n    }\n\n    c.JSON(http.StatusOK, product)\n}\n```\n\n### 6. Define Routes (`routes.go`)\n\nRegister your endpoints:\n\n```go\npackage products\n\nimport (\n    \"github.com/gin-gonic/gin\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n    \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\ntype Routes struct {\n    handler *Handler\n}\n\nfunc NewRoutes(handler *Handler) *Routes {\n    return &Routes{handler: handler}\n}\n\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver domain.MiddlewareResolver) {\n    products := router.Group(\"/products\")\n    products.Use(\n        resolver.Get(\"auth\"),         // RequireAuth\n        resolver.Get(\"org_context\"),  // RequireOrganization\n    )\n    {\n        products.POST(\"\", r.handler.Create)\n        products.GET(\"/:id\", r.handler.GetByID)\n        products.GET(\"\", r.handler.List)\n        products.PUT(\"/:id\", r.handler.Update)\n        products.DELETE(\"/:id\", r.handler.Delete)\n    }\n}\n```\n\n### 7. Wire Dependencies (`cmd/init.go`)\n\nSet up dependency injection:\n\n```go\npackage cmd\n\nimport (\n    \"fmt\"\n\n    \"go.uber.org/dig\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/products\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/app/services\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/domain\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/infra/repositories\"\n    sqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\nfunc RegisterDependencies(container *dig.Container) error {\n    // Repository - registered in internal/db/inject.go\n    // (See step 8 below)\n\n    // Service\n    if err := container.Provide(services.NewProductService); err != nil {\n        return fmt.Errorf(\"failed to provide product service: %w\", err)\n    }\n\n    // Handler\n    if err := container.Provide(products.NewHandler); err != nil {\n        return fmt.Errorf(\"failed to provide product handler: %w\", err)\n    }\n\n    // Routes\n    if err := container.Provide(products.NewRoutes); err != nil {\n        return fmt.Errorf(\"failed to provide product routes: %w\", err)\n    }\n\n    return nil\n}\n```\n\n### 8. Register Repository in Database Layer\n\n**IMPORTANT**: Repositories are registered in `internal/db/inject.go`, not in the module's `cmd/init.go`.\n\nAdd to `internal/db/inject.go`:\n\n```go\nimport (\n    productDomain \"github.com/moasq/go-b2b-starter/internal/modules/products/domain\"\n    productRepos \"github.com/moasq/go-b2b-starter/internal/modules/products/infra/repositories\"\n)\n\n// In registerDomainStores function:\nif err := container.Provide(func(sqlcStore sqlc.Store) productDomain.ProductRepository {\n    return productRepos.NewProductRepository(sqlcStore)\n}); err != nil {\n    return fmt.Errorf(\"failed to provide product repository: %w\", err)\n}\n```\n\n### 9. Register in Bootstrap\n\nAdd your module to `internal/bootstrap/init_mods.go`:\n\n```go\nimport productsCmd \"github.com/moasq/go-b2b-starter/internal/modules/products/cmd\"\n\nfunc InitMods(container *dig.Container) error {\n    // ... existing modules ...\n\n    // Products module\n    if err := productsCmd.RegisterDependencies(container); err != nil {\n        return fmt.Errorf(\"failed to register products dependencies: %w\", err)\n    }\n\n    return nil\n}\n```\n\n### 10. Register Routes in API\n\nRoutes are auto-registered via DI. Ensure your module's Routes struct is provided in step 7.\n\nThe `internal/api/provider.go` will automatically discover and register all route groups.\n\n## Database Setup\n\n### 1. Create Migration\n\nCreate a migration for your new table:\n\n```bash\ncd internal/db/postgres/sqlc/migrations\n# Create files manually with next sequence number\n```\n\n**Up migration** (`000015_create_products.up.sql`):\n\n```sql\nCREATE TABLE app.products (\n    id SERIAL PRIMARY KEY,\n    name VARCHAR(255) NOT NULL,\n    description TEXT,\n    price DECIMAL(10, 2) NOT NULL,\n    organization_id INTEGER NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n    CONSTRAINT fk_organization\n        FOREIGN KEY (organization_id)\n        REFERENCES app.organizations(id)\n        ON DELETE CASCADE\n);\n\nCREATE INDEX idx_products_organization_id ON app.products(organization_id);\nCREATE INDEX idx_products_name ON app.products(name);\n```\n\n**Down migration** (`000015_create_products.down.sql`):\n\n```sql\nDROP TABLE IF EXISTS app.products;\n```\n\n### 2. Create SQLC Queries\n\nCreate `internal/db/postgres/sqlc/query/products.sql`:\n\n```sql\n-- name: CreateProduct :one\nINSERT INTO products (name, description, price, organization_id)\nVALUES ($1, $2, $3, $4)\nRETURNING *;\n\n-- name: GetProductByID :one\nSELECT * FROM products\nWHERE organization_id = $1 AND id = $2;\n\n-- name: ListProductsByOrganization :many\nSELECT * FROM products\nWHERE organization_id = $1\nORDER BY created_at DESC\nLIMIT $2 OFFSET $3;\n\n-- name: UpdateProduct :one\nUPDATE products\nSET name = $2, description = $3, price = $4, updated_at = NOW()\nWHERE organization_id = $1 AND id = $5\nRETURNING *;\n\n-- name: DeleteProduct :exec\nDELETE FROM products\nWHERE organization_id = $1 AND id = $2;\n```\n\n### 3. Run Migrations and Generate Code\n\n```bash\nmake migrateup  # Apply migrations\nmake sqlc       # Generate type-safe Go code\n```\n\n## Testing\n\n### Unit Test Example\n\n```go\npackage services_test\n\nimport (\n    \"context\"\n    \"testing\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/domain\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/products/app/services\"\n)\n\ntype mockProductRepository struct {\n    createFunc func(ctx context.Context, p *domain.Product) (*domain.Product, error)\n}\n\nfunc (m *mockProductRepository) Create(ctx context.Context, p *domain.Product) (*domain.Product, error) {\n    return m.createFunc(ctx, p)\n}\n\nfunc TestProductService_Create(t *testing.T) {\n    mockRepo := &mockProductRepository{\n        createFunc: func(ctx context.Context, p *domain.Product) (*domain.Product, error) {\n            p.ID = 1\n            return p, nil\n        },\n    }\n\n    service := services.NewProductService(mockRepo)\n\n    req := &services.CreateProductRequest{\n        Name:  \"Test Product\",\n        Price: 99.99,\n    }\n\n    product, err := service.Create(context.Background(), 1, req)\n    if err != nil {\n        t.Fatalf(\"expected no error, got %v\", err)\n    }\n\n    if product.ID != 1 {\n        t.Errorf(\"expected product ID 1, got %d\", product.ID)\n    }\n}\n```\n\n## Best Practices\n\n1. **Domain Layer is Pure**: No external dependencies in `domain/`\n2. **Interfaces in Domain**: Repository interfaces defined where they're used\n3. **Services Return Domain Types**: Not database types\n4. **Handlers Are Thin**: Validation, auth, delegate to service\n5. **Context First**: Always pass `context.Context` as first parameter\n6. **Use `httperr.HTTPError`**: For consistent API error responses\n7. **Register Repositories Centrally**: In `internal/db/inject.go`, not module init\n8. **Map Database Types**: Convert SQLC types to domain types in repositories\n\n## Common Pitfalls\n\n### Import Cycles\n\n```go\n// ❌ Bad - Creates import cycle\n// internal/modules/products/domain/entity.go\nimport \"github.com/moasq/go-b2b-starter/internal/modules/products/app/services\"\n\n// ✅ Good - Domain has no dependencies\n// internal/modules/products/domain/entity.go\npackage domain\n```\n\n### Wrong Repository Registration\n\n```go\n// ❌ Bad - Registering repository in module init\n// internal/modules/products/cmd/init.go\ncontainer.Provide(repositories.NewProductRepository)\n\n// ✅ Good - Register in database layer\n// internal/db/inject.go\ncontainer.Provide(func(sqlcStore sqlc.Store) productDomain.ProductRepository {\n    return productRepos.NewProductRepository(sqlcStore)\n})\n```\n\n### Exposing SQLC Types\n\n```go\n// ❌ Bad - Service returns SQLC types\nfunc (s *service) GetProduct(ctx context.Context, id int32) (*sqlc.Product, error)\n\n// ✅ Good - Service returns domain types\nfunc (s *service) GetProduct(ctx context.Context, id int32) (*domain.Product, error)\n```\n\n## File Checklist\n\nAfter creating a new module, you should have:\n\n- [ ] `domain/entity.go` - Domain entities\n- [ ] `domain/repository.go` - Repository interfaces\n- [ ] `infra/repositories/{entity}_repository.go` - Repository implementation\n- [ ] `app/services/{entity}_service.go` - Service interface and implementation\n- [ ] `handler.go` - HTTP handlers with Swagger docs\n- [ ] `routes.go` - Route registration\n- [ ] `cmd/init.go` - DI setup for service, handler, routes\n- [ ] SQL queries in `internal/db/postgres/sqlc/query/{entity}.sql`\n- [ ] Repository registration in `internal/db/inject.go`\n- [ ] Module registration in `internal/bootstrap/init_mods.go`\n- [ ] Database migrations in `internal/db/postgres/sqlc/migrations/`\n\n## Next Steps\n\n- **Architecture Details**: See [Architecture Guide](./architecture.md)\n- **Database Operations**: See [Database Guide](./database.md)\n- **API Development**: See [API Development Guide](./api-development.md)\n"
  },
  {
    "path": "go-b2b-starter/docs/api-development.md",
    "content": "# API Development Guide\n\nStep-by-step guide to building new API endpoints following Clean Architecture patterns.\n\n## Overview\n\nBuilding an API endpoint involves these layers:\n\n1. **Domain** - Entity and repository interface\n2. **Infrastructure** - Repository implementation\n3. **Application** - Service with business logic\n4. **API** - HTTP handler and routes\n\n## Step 1: Database Layer\n\n### Create Migration\n\nAdd migration files in `internal/db/postgres/sqlc/migrations/`:\n\n```sql\n-- 000015_create_resources.up.sql\nCREATE TABLE app.resources (\n    id SERIAL PRIMARY KEY,\n    organization_id INT NOT NULL,\n    name VARCHAR(255) NOT NULL,\n    status VARCHAR(50) NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_resources_org ON app.resources(organization_id);\n```\n\n### Write SQL Queries\n\nIn `internal/db/postgres/sqlc/query/resources.sql`:\n\n```sql\n-- name: GetResourceByID :one\nSELECT * FROM app.resources WHERE id = $1;\n\n-- name: CreateResource :one\nINSERT INTO app.resources (organization_id, name, status)\nVALUES ($1, $2, $3)\nRETURNING *;\n\n-- name: ListResources :many\nSELECT * FROM app.resources\nWHERE organization_id = $1\nORDER BY created_at DESC;\n```\n\n### Generate Code\n\n```bash\nmake sqlc\n```\n\n### Create Store Interface\n\nIn `internal/db/adapters/resource_store.go`:\n\n```go\ntype ResourceStore interface {\n    GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error)\n    CreateResource(ctx context.Context, arg sqlc.CreateResourceParams) (sqlc.Resource, error)\n    ListResources(ctx context.Context, orgID int32) ([]sqlc.Resource, error)\n}\n```\n\n### Implement Adapter\n\nIn `internal/db/postgres/adapter_impl/resource_store.go`:\n\n```go\ntype resourceStore struct {\n    store sqlc.Store\n}\n\nfunc NewResourceStore(store sqlc.Store) adapters.ResourceStore {\n    return &resourceStore{store: store}\n}\n\nfunc (s *resourceStore) GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) {\n    return s.store.GetResourceByID(ctx, id)\n}\n```\n\n### Register in DI\n\nIn `internal/db/inject.go`:\n\n```go\ncontainer.Provide(func(sqlcStore sqlc.Store) adapters.ResourceStore {\n    return adapter_impl.NewResourceStore(sqlcStore)\n})\n```\n\n## Step 2: Domain Layer\n\n### Create Entity\n\nIn `internal/resources/domain/entity.go`:\n\n```go\ntype Resource struct {\n    ID             int32\n    OrganizationID int32\n    Name           string\n    Status         string\n    CreatedAt      time.Time\n    UpdatedAt      time.Time\n}\n\nfunc (r *Resource) Validate() error {\n    if r.Name == \"\" {\n        return ErrResourceNameRequired\n    }\n    return nil\n}\n```\n\n### Define Repository Interface\n\nIn `internal/resources/domain/repository.go`:\n\n```go\ntype ResourceRepository interface {\n    Create(ctx context.Context, resource *Resource) (*Resource, error)\n    GetByID(ctx context.Context, id int32) (*Resource, error)\n    List(ctx context.Context, orgID int32) ([]*Resource, error)\n}\n```\n\n## Step 3: Infrastructure Layer\n\n### Implement Repository\n\nIn `internal/resources/infra/repositories/resource_repository.go`:\n\n```go\ntype resourceRepository struct {\n    store adapters.ResourceStore\n}\n\nfunc NewResourceRepository(store adapters.ResourceStore) domain.ResourceRepository {\n    return &resourceRepository{store: store}\n}\n\nfunc (r *resourceRepository) Create(ctx context.Context, resource *domain.Resource) (*domain.Resource, error) {\n    params := sqlc.CreateResourceParams{\n        OrganizationID: resource.OrganizationID,\n        Name:           resource.Name,\n        Status:         resource.Status,\n    }\n\n    dbResource, err := r.store.CreateResource(ctx, params)\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to create resource: %w\", err)\n    }\n\n    return toDomainResource(dbResource), nil\n}\n```\n\n## Step 4: Application Layer\n\n### Define Service Interface\n\nIn `internal/resources/app/services/resource_service_interface.go`:\n\n```go\ntype ResourceService interface {\n    CreateResource(ctx context.Context, orgID int32, req *CreateResourceRequest) (*domain.Resource, error)\n    GetResource(ctx context.Context, id int32) (*domain.Resource, error)\n    ListResources(ctx context.Context, orgID int32) ([]*domain.Resource, error)\n}\n```\n\n### Implement Service\n\nIn `internal/resources/app/services/resource_service.go`:\n\n```go\ntype resourceService struct {\n    repo domain.ResourceRepository\n}\n\nfunc NewResourceService(repo domain.ResourceRepository) ResourceService {\n    return &resourceService{repo: repo}\n}\n\nfunc (s *resourceService) CreateResource(\n    ctx context.Context,\n    orgID int32,\n    req *CreateResourceRequest,\n) (*domain.Resource, error) {\n    // Validate request\n    if err := req.Validate(); err != nil {\n        return nil, err\n    }\n\n    // Create entity\n    resource := &domain.Resource{\n        OrganizationID: orgID,\n        Name:           req.Name,\n        Status:         \"active\",\n    }\n\n    // Persist\n    return s.repo.Create(ctx, resource)\n}\n```\n\n## Step 5: API Layer\n\n### Create Handler\n\nIn `internal/resources/handler.go`:\n\n```go\ntype Handler struct {\n    service services.ResourceService\n}\n\nfunc NewHandler(service services.ResourceService) *Handler {\n    return &Handler{service: service}\n}\n\nfunc (h *Handler) CreateResource(c *gin.Context) {\n    // Get auth context\n    reqCtx := auth.GetRequestContext(c)\n    if reqCtx == nil {\n        c.JSON(401, gin.H{\"error\": \"unauthorized\"})\n        return\n    }\n\n    // Parse request\n    var req services.CreateResourceRequest\n    if err := c.ShouldBindJSON(&req); err != nil {\n        c.JSON(400, gin.H{\"error\": \"invalid request\"})\n        return\n    }\n\n    // Call service\n    resource, err := h.service.CreateResource(c.Request.Context(), reqCtx.OrganizationID, &req)\n    if err != nil {\n        c.JSON(500, gin.H{\"error\": \"failed to create resource\"})\n        return\n    }\n\n    c.JSON(201, resource)\n}\n```\n\n### Register Routes\n\nIn `internal/resources/routes.go`:\n\n```go\ntype Routes struct {\n    handler        *Handler\n    authMiddleware *auth.Middleware\n}\n\nfunc NewRoutes(handler *Handler, authMiddleware *auth.Middleware) *Routes {\n    return &Routes{handler: handler, authMiddleware: authMiddleware}\n}\n\nfunc (r *Routes) Register(router *gin.Engine) {\n    apiGroup := router.Group(\"/api/resources\")\n    apiGroup.Use(r.authMiddleware.RequireAuth())\n    apiGroup.Use(r.authMiddleware.RequireOrganization())\n    {\n        apiGroup.POST(\"\",\n            auth.RequirePermissionFunc(\"resource\", \"create\"),\n            r.handler.CreateResource)\n\n        apiGroup.GET(\"/:id\", r.handler.GetResource)\n        apiGroup.GET(\"\", r.handler.ListResources)\n    }\n}\n```\n\n## Step 6: Module Registration\n\n### Create Module\n\nIn `internal/resources/module.go`:\n\n```go\ntype Module struct {\n    container *dig.Container\n}\n\nfunc NewModule(container *dig.Container) *Module {\n    return &Module{container: container}\n}\n\nfunc (m *Module) RegisterDependencies() error {\n    // Repository\n    if err := m.container.Provide(func(store adapters.ResourceStore) domain.ResourceRepository {\n        return repositories.NewResourceRepository(store)\n    }); err != nil {\n        return err\n    }\n\n    // Service\n    if err := m.container.Provide(func(repo domain.ResourceRepository) services.ResourceService {\n        return services.NewResourceService(repo)\n    }); err != nil {\n        return err\n    }\n\n    return nil\n}\n```\n\n### Initialize Module\n\nIn `internal/resources/cmd/init.go`:\n\n```go\nfunc Init(container *dig.Container) error {\n    module := NewModule(container)\n    return module.RegisterDependencies()\n}\n```\n\n### Register API\n\nIn `internal/resources/provider.go`:\n\n```go\nfunc RegisterDependencies(container *dig.Container) error {\n    // Register handler\n    if err := container.Provide(func(service services.ResourceService) *Handler {\n        return NewHandler(service)\n    }); err != nil {\n        return err\n    }\n\n    // Register routes\n    if err := container.Provide(func(\n        handler *Handler,\n        authMiddleware *auth.Middleware,\n    ) *Routes {\n        return NewRoutes(handler, authMiddleware)\n    }); err != nil {\n        return err\n    }\n\n    return nil\n}\n```\n\n## Quick Reference\n\n### File Structure\n\n```\ninternal/resources/\n├── domain/\n│   ├── entity.go\n│   ├── repository.go\n│   └── errors.go\n├── app/services/\n│   ├── resource_service_interface.go\n│   └── resource_service.go\n├── infra/repositories/\n│   └── resource_repository.go\n├── cmd/init.go\n└── module.go\n\ninternal/resources/\n├── handler.go\n├── routes.go\n└── provider.go\n```\n\n### Common Response Codes\n\n- `200` - Success\n- `201` - Created\n- `400` - Bad Request\n- `401` - Unauthorized\n- `403` - Forbidden\n- `404` - Not Found\n- `500` - Internal Server Error\n\n## Next Steps\n\n- **Add tests**: Unit tests for service, integration tests for repository\n- **Add Swagger docs**: Document API with Swagger annotations\n- **Add validation**: Request/response validation\n- **Add events**: Publish domain events for cross-module communication\n"
  },
  {
    "path": "go-b2b-starter/docs/architecture.md",
    "content": "# Backend Architecture\n\nThe backend is a **Modular Monolith** using **idiomatic Go project layout** with Clear separation between business features and infrastructure.\n\n## High-Level Structure\n\n```\ngo-b2b-starter/\n├── cmd/                  # Application entry points\n│   └── api/\n│       └── main.go       # Main entry point\n│\n├── internal/             # Private application code (import boundary)\n│   ├── modules/          # Feature modules (business domains)\n│   ├── platform/         # Cross-cutting infrastructure\n│   ├── db/               # Database layer (SQLC, DI registration)\n│   ├── bootstrap/        # Application initialization\n│   └── api/              # API route registration\n│\n├── pkg/                  # Public reusable packages\n│   ├── httperr/          # HTTP error types\n│   ├── pagination/       # Pagination helpers\n│   └── response/         # API response utilities\n│\n└── go.mod                # Single consolidated module\n```\n\n## Architectural Layers\n\n### Feature Modules (`internal/modules/`)\n\nBusiness domain modules following **Clean Architecture**. Each module represents a distinct business capability:\n\n```\ninternal/modules/\n├── auth/             # Authentication & RBAC\n├── billing/          # Polar.sh subscriptions & quota management\n├── organizations/    # Multi-tenant organization management\n├── documents/        # PDF document processing\n├── cognitive/        # RAG (Retrieval-Augmented Generation) & embeddings\n├── files/            # File storage (R2 + metadata)\n└── paywall/          # Subscription middleware\n```\n\n**Characteristics of Feature Modules:**\n- Business domain logic\n- Domain entities with business rules\n- API endpoints\n- Use cases and workflows\n- Follow Clean Architecture layers (domain → app → infra)\n\n### Platform Services (`internal/platform/`)\n\nCross-cutting infrastructure components used by multiple modules:\n\n```\ninternal/platform/\n├── server/           # HTTP server & middleware\n├── eventbus/         # Event pub/sub system\n├── logger/           # Structured logging\n├── redis/            # Redis cache client\n├── stytch/           # Stytch auth provider client\n├── polar/            # Polar.sh billing provider client\n├── llm/              # LLM integration (OpenAI)\n└── ocr/              # OCR service (Mistral)\n```\n\n**Characteristics of Platform Components:**\n- Infrastructure concerns\n- Used by multiple modules\n- No business logic\n- Provide technical capabilities\n\n### Database Layer (`internal/db/`)\n\nCentralized database layer using SQLC:\n\n```\ninternal/db/\n├── postgres/\n│   ├── sqlc/\n│   │   ├── migrations/    # SQL migration files\n│   │   ├── query/         # SQL queries with SQLC annotations\n│   │   └── gen/          # Generated Go code\n│   └── postgres.go       # DB connection and pooling\n├── inject.go             # DI registration for all repositories\n└── core/\n    └── errors.go         # Database error types\n```\n\n**Key Responsibilities:**\n- SQLC code generation\n- Repository DI registration\n- Database connection management\n- Transaction support\n\n## Module Structure (Clean Architecture)\n\nEach feature module in `internal/modules/` follows **Clean Architecture**:\n\n```mermaid\ngraph TD\n    A[Handler] --> B[Service]\n    B --> C[Repository Interface]\n    C --> D[Repository Implementation]\n    D --> E[SQLC Store]\n    E --> F[PostgreSQL]\n```\n\n### Layer Details\n\n```\ninternal/modules/billing/\n├── cmd/                  # Module initialization (DI wiring)\n│   └── init.go\n│\n├── app/                  # Application Layer (Use Cases)\n│   └── services/\n│       └── billing_service.go\n│\n├── domain/               # Domain Layer (Core Business Logic)\n│   ├── entity.go         # Data structures\n│   ├── repository.go     # Interface definitions\n│   ├── errors.go         # Domain errors\n│   └── events/           # Domain events\n│\n├── infra/                # Infrastructure Layer (External)\n│   ├── repositories/     # Repository implementations\n│   │   └── subscription_repository.go\n│   └── polar/           # Polar.sh adapter\n│       └── polar_adapter.go\n│\n├── handler.go            # HTTP handlers (Delivery Layer)\n├── routes.go             # Route registration\n└── module.go             # Dependency injection setup\n```\n\n## Key Principles\n\n### 1. `internal/` Boundary\n\nCode in `internal/` cannot be imported by external packages. This enforces encapsulation and prevents unintended dependencies.\n\n### 2. Dependency Rule (Clean Architecture)\n\n```mermaid\ngraph LR\n    A[Domain] --> B[Application]\n    B --> C[Infrastructure]\n    C --> D[Delivery/Handler]\n```\n\n**Direction of dependencies:**\n- Domain → Nothing (pure business logic)\n- Application → Domain (uses domain interfaces)\n- Infrastructure → Domain (implements domain interfaces)\n- Handlers → Application (calls services)\n\n**Key Point**: Inner layers never depend on outer layers. Infrastructure implements interfaces defined in domain.\n\n### 3. Feature-Based Organization\n\nModules are organized by **business feature** (billing, auth, organizations), not by technical layer (controllers, services, models).\n\nThis promotes:\n- High cohesion within features\n- Low coupling between features\n- Easy to understand and navigate\n- Clear ownership and boundaries\n\n### 4. Single `go.mod`\n\nOne module for the entire project eliminates workspace complexity and simplifies dependency management.\n\n## Request Flow\n\n```mermaid\nsequenceDiagram\n    participant Client\n    participant Middleware\n    participant Handler\n    participant Service\n    participant Repository\n    participant Database\n\n    Client->>Middleware: HTTP Request\n    Middleware->>Middleware: Auth & Validation\n    Middleware->>Handler: Authenticated Request\n    Handler->>Handler: Parse & Validate\n    Handler->>Service: Call Use Case\n    Service->>Service: Business Logic\n    Service->>Repository: Get/Save Data (Interface)\n    Repository->>Database: SQL Query (SQLC)\n    Database-->>Repository: Result\n    Repository-->>Service: Domain Entity\n    Service-->>Handler: DTO Response\n    Handler-->>Client: JSON Response\n```\n\n### Flow Breakdown\n\n1. **Client Request**: HTTP request arrives at server\n2. **Middleware**: Auth, logging, rate limiting, CORS\n3. **Handler**: Parse request, extract context (org ID, user ID)\n4. **Service**: Execute business logic, orchestrate operations\n5. **Repository**: Data access using domain interfaces\n6. **Database**: SQLC-generated type-safe queries\n\n## Initialization Flow\n\n```mermaid\ngraph TD\n    A[cmd/api/main.go] --> B[bootstrap.Execute]\n    B --> C[InitMods]\n    C --> D[Infrastructure Layer]\n    C --> E[Platform Services]\n    C --> F[Feature Modules]\n    C --> G[API Routes]\n\n    D --> D1[Logger]\n    D --> D2[Server]\n    D --> D3[Database]\n\n    E --> E1[Redis]\n    E --> E2[EventBus]\n    E --> E3[Stytch]\n    E --> E4[Polar]\n\n    F --> F1[Auth Module]\n    F --> F2[Billing Module]\n    F --> F3[Organizations Module]\n    F --> F4[Documents Module]\n\n    G --> G1[Register Routes]\n    G --> G2[Setup Middleware]\n```\n\n### Initialization Order (Critical)\n\n```\ncmd/api/main.go\n    └── bootstrap.Execute()\n        ├── 1. Infrastructure (no dependencies)\n        │   ├── logger.Inject()\n        │   ├── server.Inject()\n        │   └── db.Inject()              # Registers all domain repositories\n        │\n        ├── 2. Platform Services\n        │   ├── redis.Inject()\n        │   ├── llm.Inject()\n        │   ├── ocr.Inject()\n        │   ├── polar.Inject()\n        │   └── eventbus.Inject()\n        │\n        ├── 3. Feature Modules (order matters!)\n        │   ├── files.SetupDependencies()\n        │   ├── auth.SetupDependencies()\n        │   ├── organizations.RegisterDependencies()\n        │   ├── billing.Configure()\n        │   ├── cognitive.RegisterDependencies()\n        │   └── documents.RegisterDependencies()\n        │\n        ├── 4. Event Subscriptions\n        │   └── cognitive.SetupEventSubscriptions()\n        │\n        └── 5. HTTP Server Setup\n            ├── server.SetupMiddleware()\n            └── api.RegisterRoutes()\n```\n\n**Why Order Matters:**\n- `db.Inject()` must run early (registers all repositories)\n- `auth` must be before modules that need auth middleware\n- `files` must be before `documents` (documents depend on files)\n- Event subscriptions must be after all modules are loaded\n\n## Dependency Injection\n\nThe project uses **uber-go/dig** for dependency injection:\n\n```go\n// 1. Define interface in domain\npackage domain\ntype ProductRepository interface {\n    Create(ctx context.Context, p *Product) (*Product, error)\n}\n\n// 2. Implement in infrastructure\npackage repositories\nfunc NewProductRepository(store sqlc.Store) domain.ProductRepository {\n    return &productRepository{store: store}\n}\n\n// 3. Register in DI (internal/db/inject.go)\ncontainer.Provide(func(sqlcStore sqlc.Store) productDomain.ProductRepository {\n    return productRepos.NewProductRepository(sqlcStore)\n})\n\n// 4. Inject into service\npackage services\nfunc NewProductService(repo domain.ProductRepository) ProductService {\n    return &productService{repo: repo}\n}\n```\n\n**Benefits:**\n- Automatic dependency resolution\n- Easy testing (inject mocks)\n- Clear dependency graph\n- Compile-time safety\n\n## Modules vs Platform Decision\n\n```mermaid\ngraph TD\n    A{Is it a business domain feature?}\n    A -->|Yes| B[Create in internal/modules/]\n    A -->|No| C{Used by multiple modules?}\n    C -->|Yes| D[Create in internal/platform/]\n    C -->|No| E{Part of existing module?}\n    E -->|Yes| F[Add to that module]\n    E -->|No| D\n```\n\n### Examples\n\n| Component | Location | Reason |\n|-----------|----------|--------|\n| Product Catalog | `modules/products/` | Business domain feature |\n| Invoice Processing | `modules/invoices/` | Business domain feature |\n| Subscription Management | `modules/billing/` | Business domain feature |\n| Event Bus | `platform/eventbus/` | Used by all modules |\n| Logging | `platform/logger/` | Cross-cutting infrastructure |\n| Stytch Client | `platform/stytch/` | Auth provider client |\n| Auth Module | `modules/auth/` | Business auth logic using Stytch |\n\n## Communication Between Modules\n\nModules communicate through:\n\n### 1. Event Bus (Loosely Coupled)\n\n```go\n// Module A publishes event\neventBus.Publish(ctx, events.NewDocumentUploadedEvent(docID, orgID))\n\n// Module B subscribes\neventBus.Subscribe(\"document.uploaded\", func(ctx context.Context, event Event) error {\n    docEvent := event.(*DocumentUploadedEvent)\n    return embeddingService.GenerateForDocument(ctx, docEvent.DocumentID)\n})\n```\n\n**Use When:**\n- Asynchronous operations\n- One-to-many communication\n- Loose coupling desired\n\n### 2. Direct Service Injection (Tightly Coupled)\n\n```go\n// Service A uses Service B\nfunc NewInvoiceService(\n    repo domain.InvoiceRepository,\n    billingService billing.BillingService,  // Direct dependency\n) InvoiceService {\n    return &invoiceService{\n        repo: repo,\n        billing: billingService,\n    }\n}\n```\n\n**Use When:**\n- Synchronous operations\n- One-to-one communication\n- Strong dependency relationship\n\n### 3. Shared Platform Components\n\n```go\n// Multiple modules use platform logger\nfunc NewDocumentService(\n    repo domain.DocumentRepository,\n    logger logger.Logger,  // Platform component\n) DocumentService {\n    return &documentService{repo: repo, logger: logger}\n}\n```\n\n## Multi-Tenancy\n\nEvery module supports multi-tenancy through **Organization ID**:\n\n```go\n// Organization context in middleware\ntype RequestContext struct {\n    OrganizationID int32  // From JWT token\n    AccountID      int32  // User account ID\n    Identity       *Identity\n}\n\n// Used in handlers\nreqCtx := auth.GetRequestContext(c)\nproducts, err := service.ListByOrganization(ctx, reqCtx.OrganizationID)\n\n// Enforced in repository\nSELECT * FROM products WHERE organization_id = $1 AND id = $2\n```\n\n**Benefits:**\n- Data isolation per organization\n- Single database for all tenants\n- Efficient resource usage\n- Simplified deployment\n\n## File Structure Summary\n\n| Layer | Path | Purpose |\n|-------|------|---------|\n| Entry point | `cmd/api/main.go` | Application startup |\n| Feature modules | `internal/modules/*/` | Business domains |\n| Platform services | `internal/platform/*/` | Infrastructure |\n| Database layer | `internal/db/` | SQLC, migrations, DI |\n| Bootstrap | `internal/bootstrap/` | Initialization |\n| API routes | `internal/api/` | Route registration |\n| Shared utilities | `pkg/*/` | Public packages |\n\n## Next Steps\n\n- **Adding a Module**: See [Adding a Module Guide](./adding-a-module.md)\n- **Database Operations**: See [Database Guide](./database.md)\n- **API Development**: See [API Development Guide](./api-development.md)\n- **Authentication**: See [Authentication Guide](./authentication.md)\n- **Event Bus**: See [Event Bus Guide](./event-bus.md)\n"
  },
  {
    "path": "go-b2b-starter/docs/authentication.md",
    "content": "# Authentication Guide\n\nThe authentication system uses Stytch B2B for identity management with JWT verification, RBAC, and multi-tenant organization context.\n\n## Architecture\n\n**Provider**: Stytch B2B handles user authentication and sessions\n**Middleware**: Verifies JWTs and resolves organization/account context\n**RBAC**: Role-based access control with permissions\n**Resolvers**: Bridge auth provider IDs to database IDs\n\n## JWT Verification\n\nThe system uses a two-tier verification strategy:\n\n**1. Fast Path** - Verify JWT locally using cached public keys\n**2. API Fallback** - Call Stytch API if local verification fails\n\nThis approach balances security with performance.\n\n### Configuration\n\n```env\nSTYTCH_PROJECT_ID=project-test-xxx\nSTYTCH_SECRET=secret-test-xxx\nSTYTCH_ENV=test  # or \"live\"\n```\n\n## Middleware\n\nThree middleware functions protect routes:\n\n### RequireAuth\n\nVerifies JWT and extracts identity.\n\n```go\nrouter.Use(authMiddleware.RequireAuth())\n```\n\n**What it does:**\n- Verifies JWT from `Authorization: Bearer {token}` header\n- Extracts user identity (email, roles, permissions)\n- Stores `auth.Identity` in request context\n- Returns 401 if auth fails\n\n### RequireOrganization\n\nResolves organization and account IDs from auth provider.\n\n```go\nrouter.Use(authMiddleware.RequireOrganization())\n```\n\n**What it does:**\n- Gets organization ID from Stytch → resolves to database ID\n- Gets user email → resolves to account ID\n- Stores `auth.RequestContext` with IDs\n- Returns 401 if resolution fails\n\n**Note:** Always use after `RequireAuth()`.\n\n### RequirePermission\n\nChecks user has specific permission.\n\n```go\nrouter.POST(\"/resources\",\n    auth.RequirePermissionFunc(\"resource\", \"create\"),\n    handler.CreateResource)\n```\n\n**What it does:**\n- Checks if user has permission (e.g., `\"resource:create\"`)\n- Returns 403 if permission missing\n\n**Note:** Use after `RequireOrganization()`.\n\n## Using Context in Handlers\n\nAccess authentication info from request context:\n\n```go\nfunc (h *Handler) MyHandler(c *gin.Context) {\n    // Get full context\n    reqCtx := auth.GetRequestContext(c)\n    orgID := reqCtx.OrganizationID    // int32\n    accountID := reqCtx.AccountID      // int32\n    email := reqCtx.Identity.Email     // string\n\n    // Or use convenience functions\n    orgID := auth.GetOrganizationID(c)\n    accountID := auth.GetAccountID(c)\n}\n```\n\n## RBAC System\n\n### Roles\n\nDefined in `internal/auth/roles.go`:\n\n- `RoleAdmin` - Full system access\n- `RoleManager` - Organization management\n- `RoleMember` - Standard user access\n\n### Permissions\n\nFormat: `\"{resource}:{action}\"`\n\n**Common permissions:**\n- `resource:view` - Read access\n- `resource:create` - Create new items\n- `resource:update` - Modify existing items\n- `resource:delete` - Delete items\n- `org:manage` - Organization administration\n\nDefined in `internal/auth/permissions.go`.\n\n### Permission Checks\n\n```go\n// In middleware (route-level)\nrouter.POST(\"/resources\",\n    auth.RequirePermissionFunc(\"resource\", \"create\"),\n    handler.CreateResource)\n\n// In code (programmatic)\nif !auth.HasPermission(identity, \"resource:delete\") {\n    return errors.New(\"permission denied\")\n}\n```\n\n## Resolver Pattern\n\nResolvers convert auth provider IDs to database IDs.\n\n### Why Needed?\n\n- Stytch uses string UUIDs for organizations\n- Database uses int32 for primary keys\n- Auth package can't depend on domain modules (circular dependency)\n\n### How It Works\n\n**1. Auth package defines interfaces:**\n\n```go\ntype OrganizationResolver interface {\n    ResolveByProviderID(ctx context.Context, providerID string) (int32, error)\n}\n```\n\n**2. Domain modules implement via adapters:**\n\n```go\ntype orgResolverAdapter struct {\n    repo domain.OrganizationRepository\n}\n\nfunc (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, id string) (int32, error) {\n    org, err := a.repo.GetByStytchID(ctx, id)\n    if err != nil {\n        return 0, err\n    }\n    return org.ID, nil\n}\n```\n\n**3. Wired in initialization:**\n\nResolvers registered in `internal/bootstrap/init_mods.go` after organization module loads.\n\n## Route Protection Patterns\n\n### Public Route (No Auth)\n\n```go\nrouter.GET(\"/health\", handler.Health)\n```\n\n### Authenticated Route\n\n```go\napiGroup := router.Group(\"/api\")\napiGroup.Use(authMiddleware.RequireAuth())\napiGroup.Use(authMiddleware.RequireOrganization())\n{\n    apiGroup.GET(\"/profile\", handler.GetProfile)\n}\n```\n\n### Permission-Protected Route\n\n```go\napiGroup.POST(\"/resources\",\n    auth.RequirePermissionFunc(\"resource\", \"create\"),\n    handler.CreateResource)\n\napiGroup.DELETE(\"/resources/:id\",\n    auth.RequirePermissionFunc(\"resource\", \"delete\"),\n    handler.DeleteResource)\n```\n\n### Role-Protected Route\n\n```go\nadminGroup := router.Group(\"/admin\")\nadminGroup.Use(authMiddleware.RequireRole(auth.RoleAdmin))\n{\n    adminGroup.GET(\"/users\", handler.ListUsers)\n}\n```\n\n## Adding New Permissions\n\n**1. Define permission constant** in `internal/auth/permissions.go`:\n\n```go\nconst PermResourceView = Permission(\"resource:view\")\nconst PermResourceCreate = Permission(\"resource:create\")\n```\n\n**2. Assign to roles** in `internal/auth/rbac.go`:\n\n```go\n{\n    RoleMember: {\n        PermResourceView,\n        // ... other permissions\n    },\n    RoleManager: {\n        PermResourceView,\n        PermResourceCreate,\n        // ... other permissions\n    },\n}\n```\n\n**3. Protect routes**:\n\n```go\nrouter.POST(\"/resources\",\n    auth.RequirePermissionFunc(\"resource\", \"create\"),\n    handler.CreateResource)\n```\n\n## Common Patterns\n\n### Check Organization Ownership\n\n```go\nfunc (h *Handler) GetResource(c *gin.Context) {\n    orgID := auth.GetOrganizationID(c)\n    resourceID := parseID(c.Param(\"id\"))\n\n    resource, err := h.service.GetResource(c.Request.Context(), resourceID)\n    if err != nil {\n        c.JSON(500, gin.H{\"error\": \"failed to get resource\"})\n        return\n    }\n\n    // Verify resource belongs to user's organization\n    if resource.OrganizationID != orgID {\n        c.JSON(403, gin.H{\"error\": \"access denied\"})\n        return\n    }\n\n    c.JSON(200, resource)\n}\n```\n\n### Optional Authentication\n\n```go\nfunc (h *Handler) PublicResource(c *gin.Context) {\n    // Try to get org ID (may be 0 if not authenticated)\n    orgID := auth.GetOrganizationID(c)\n\n    if orgID != 0 {\n        // User is authenticated, show personalized data\n    } else {\n        // User is not authenticated, show public data\n    }\n}\n```\n\n## File Locations\n\n| Component | Path |\n|-----------|------|\n| Auth provider interface | `internal/auth/auth.go` |\n| Middleware | `internal/auth/middleware.go` |\n| Context helpers | `internal/auth/context.go` |\n| RBAC definitions | `internal/auth/rbac.go` |\n| Roles | `internal/auth/roles.go` |\n| Permissions | `internal/auth/permissions.go` |\n| Resolvers | `internal/auth/resolvers.go` |\n| Stytch adapter | `internal/auth/adapters/stytch/` |\n\n## Next Steps\n\n- **Database operations**: See [Database Guide](./database.md)\n- **Building APIs**: See [API Development Guide](./api-development.md)\n- **Stytch documentation**: https://stytch.com/docs/b2b\n"
  },
  {
    "path": "go-b2b-starter/docs/billing.md",
    "content": "# Billing Guide\n\nThe billing system integrates with Polar.sh for subscription management, usage-based billing, and payment processing with a hybrid sync strategy.\n\n## Architecture\n\n**Polar.sh**: Payment provider for subscriptions and metering\n**Hybrid Sync**: Webhooks + on-demand fetching\n**Paywall Middleware**: Protects routes based on subscription status\n**Quota Tracking**: Usage-based billing with meters\n\n## Core Concepts\n\n### Subscriptions\n\nManaged in Polar.sh, synced to local database.\n\n**Subscription states:**\n- `active` - Valid subscription\n- `incomplete` - Payment pending\n- `cancelled` - Subscription cancelled\n- `unpaid` - Payment failed\n\n### Quota Tracking\n\nTrack usage for metered billing.\n\n**How it works:**\n1. User performs action (API call, file upload, etc.)\n2. System increments local quota counter\n3. Periodically sync usage to Polar meters\n4. Polar charges based on usage\n\n### Billing Status\n\nRepresents organization's billing state:\n\n- **Subscription**: Active subscription details\n- **Quota Usage**: Current usage vs limits\n- **Payment Status**: Last payment result\n- **Metering**: Usage meters for billing\n\n## Hybrid Sync Strategy\n\nCombines webhooks with on-demand fetching for reliability.\n\n### Webhook Path (Real-time)\n\n```\nPolar Event → Webhook → Update Database\n```\n\n**Handles:**\n- Subscription created/updated/cancelled\n- Payment succeeded/failed\n- Customer created/updated\n\n### Lazy Guarding (On-demand)\n\n```\nAPI Request → Check Subscription → Fetch if stale → Update Database\n```\n\n**When used:**\n- Webhook delivery failed\n- Data drift detected\n- Initial subscription fetch\n\n**Benefits:**\n- Self-healing system\n- No critical webhook dependency\n- Always up-to-date data\n\n## Paywall Middleware\n\nProtects routes based on subscription requirements.\n\n### Basic Usage\n\n```go\nrouter.POST(\"/premium-feature\",\n    paywallMiddleware.RequireActiveSubscription(),\n    handler.PremiumFeature)\n```\n\n### Quota-Based Protection\n\n```go\nrouter.POST(\"/api-call\",\n    paywallMiddleware.RequireQuota(\"api_calls\", 1),\n    handler.APICall)\n```\n\n**What it does:**\n1. Checks organization has active subscription\n2. Verifies quota available\n3. Increments usage counter\n4. Returns 402 (Payment Required) if quota exceeded\n\n### Feature-Based Protection\n\n```go\nrouter.POST(\"/advanced-feature\",\n    paywallMiddleware.RequireFeature(\"advanced_analytics\"),\n    handler.AdvancedFeature)\n```\n\nChecks if subscription plan includes specific feature.\n\n## Webhook Processing\n\nPolar sends webhooks for billing events.\n\n### Webhook Handler\n\nLocated in `internal/billing/polar_handler.go`.\n\n**Events handled:**\n- `subscription.created`\n- `subscription.updated`\n- `subscription.canceled`\n- `checkout.created`\n- `checkout.updated`\n\n### Verification\n\nWebhooks are verified using Polar webhook secret:\n\n```env\nPOLAR_WEBHOOK_SECRET=whsec_xxx\n```\n\nInvalid signatures are rejected.\n\n## Usage Tracking\n\nTrack resource usage for billing.\n\n### Recording Usage\n\n```go\nfunc (s *service) ProcessAction(ctx context.Context, orgID int32) error {\n    // Perform action\n    result, err := s.doAction(ctx)\n    if err != nil {\n        return err\n    }\n\n    // Record usage\n    err = s.billingService.IncrementQuota(ctx, orgID, \"actions\", 1)\n    if err != nil {\n        // Log error but don't fail the operation\n        log.Error(\"failed to record usage\", zap.Error(err))\n    }\n\n    return nil\n}\n```\n\n### Meter Ingestion\n\nUsage synced to Polar periodically:\n\n1. Accumulate usage locally\n2. Batch send to Polar meters API\n3. Polar charges based on metered usage\n\nConfigured in `internal/billing/app/services/metering_service.go`.\n\n## Configuration\n\n```env\n# Polar.sh\nPOLAR_ACCESS_TOKEN=polar_xxx\nPOLAR_WEBHOOK_SECRET=whsec_xxx\nPOLAR_ORGANIZATION_ID=org_xxx\n```\n\n## Common Patterns\n\n### Check Subscription Status\n\n```go\nfunc (h *Handler) GetFeature(c *gin.Context) {\n    orgID := auth.GetOrganizationID(c)\n\n    status, err := h.billingService.GetBillingStatus(ctx, orgID)\n    if err != nil {\n        c.JSON(500, gin.H{\"error\": \"failed to get billing status\"})\n        return\n    }\n\n    if status.Subscription == nil || !status.Subscription.IsActive() {\n        c.JSON(402, gin.H{\"error\": \"active subscription required\"})\n        return\n    }\n\n    // Proceed with feature\n}\n```\n\n### Track Usage\n\n```go\nfunc (s *service) ProcessFile(ctx context.Context, orgID int32, file *File) error {\n    // Process file\n    err := s.processor.Process(file)\n    if err != nil {\n        return err\n    }\n\n    // Record usage\n    s.billingService.IncrementQuota(ctx, orgID, \"files_processed\", 1)\n\n    return nil\n}\n```\n\n### Handle Payment Failures\n\n```go\nfunc (h *WebhookHandler) HandlePaymentFailed(ctx context.Context, event *Event) error {\n    // Update subscription status\n    err := h.billingService.UpdateSubscriptionStatus(ctx, event.SubscriptionID, \"unpaid\")\n    if err != nil {\n        return err\n    }\n\n    // Notify organization\n    h.notificationService.SendPaymentFailure(ctx, event.OrganizationID)\n\n    return nil\n}\n```\n\n## File Locations\n\n| Component | Path |\n|-----------|------|\n| Billing domain | `internal/billing/domain/` |\n| Billing service | `internal/billing/app/services/` |\n| Polar adapter | `internal/billing/infra/adapters/polar/` |\n| Paywall middleware | `internal/paywall/` |\n| Polar client | `internal/polar/` |\n| Webhook handlers | `internal/billing/` |\n\n## Next Steps\n\n- **API protection**: Use paywall middleware in routes\n- **Usage tracking**: Implement quota consumption\n- **Polar documentation**: https://docs.polar.sh/\n"
  },
  {
    "path": "go-b2b-starter/docs/database.md",
    "content": "# Database Guide\n\nThe database layer uses PostgreSQL with SQLC for type-safe SQL operations. Domain modules define repository interfaces in their `domain/` layer, which are implemented by repositories in the `infra/` layer using SQLC.\n\n## Architecture\n\nThe database layer follows the **Repository Pattern**:\n\n**1. Domain Interfaces** (`internal/modules/{module}/domain/repository.go`) - Repository contracts defined by the domain\n**2. Repository Implementations** (`internal/modules/{module}/infra/repositories/`) - Implement interfaces using SQLC\n**3. SQLC Generated Code** (`internal/db/postgres/sqlc/gen/`) - Auto-generated type-safe queries\n**4. DI Registration** (`internal/db/inject.go`) - Wire repositories to domain interfaces\n\n### Why This Pattern?\n\n- **Dependency Inversion** - Domain defines what it needs, infrastructure provides it\n- **SQLC Isolation** - SQLC types never leak out of the `infra/` layer\n- **Easy Testing** - Mock domain interfaces, not SQLC\n- **Clean Boundaries** - Domain stays pure, no database knowledge\n- **Type Safety** - SQLC generates type-safe Go code from SQL\n\n### Legacy Note\n\n> **Note**: Earlier versions used an `adapters/` and `adapter_impl/` pattern. This has been phased out in favor of the simpler repository pattern where domain interfaces are implemented directly by repository classes.\n\n## SQLC Workflow\n\n### 1. Write SQL Query\n\nCreate queries in `internal/db/postgres/sqlc/query/{domain}.sql`:\n\n```sql\n-- name: GetDocumentByID :one\nSELECT * FROM documents\nWHERE organization_id = $1 AND id = $2;\n\n-- name: CreateDocument :one\nINSERT INTO documents (organization_id, title, file_path, status)\nVALUES ($1, $2, $3, $4)\nRETURNING *;\n\n-- name: ListDocuments :many\nSELECT * FROM documents\nWHERE organization_id = $1\nORDER BY created_at DESC\nLIMIT $2 OFFSET $3;\n```\n\n**SQLC Annotations:**\n- `:one` - Returns single row\n- `:many` - Returns slice of rows\n- `:exec` - Returns error only (no data)\n\n### 2. Generate Code\n\n```bash\nmake sqlc\n```\n\nGenerates Go code in `internal/db/postgres/sqlc/gen/`.\n\n**Never edit generated files** - they are regenerated on every run.\n\n### 3. Define Repository Interface in Domain\n\nDefine interface in `internal/modules/documents/domain/repository.go`:\n\n```go\npackage domain\n\nimport \"context\"\n\ntype DocumentRepository interface {\n    GetByID(ctx context.Context, orgID, docID int32) (*Document, error)\n    Create(ctx context.Context, doc *Document) (*Document, error)\n    List(ctx context.Context, orgID int32, limit, offset int32) ([]*Document, error)\n    Update(ctx context.Context, doc *Document) error\n    Delete(ctx context.Context, orgID, docID int32) error\n}\n```\n\n**Key Points:**\n- Interface uses **domain types** (`*Document`), not SQLC types\n- Defined where it's used (in the domain layer)\n- Independent of implementation details\n\n### 4. Implement Repository\n\nCreate repository in `internal/modules/documents/infra/repositories/document_repository.go`:\n\n```go\npackage repositories\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n    sqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\ntype documentRepository struct {\n    store sqlc.Store\n}\n\nfunc NewDocumentRepository(store sqlc.Store) domain.DocumentRepository {\n    return &documentRepository{store: store}\n}\n\nfunc (r *documentRepository) GetByID(ctx context.Context, orgID, docID int32) (*domain.Document, error) {\n    // Call SQLC-generated method\n    dbDoc, err := r.store.GetDocumentByID(ctx, sqlc.GetDocumentByIDParams{\n        OrganizationID: orgID,\n        ID:             docID,\n    })\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to get document: %w\", err)\n    }\n\n    // Map SQLC type to domain type\n    return &domain.Document{\n        ID:             dbDoc.ID,\n        OrganizationID: dbDoc.OrganizationID,\n        Title:          dbDoc.Title,\n        FilePath:       dbDoc.FilePath,\n        Status:         dbDoc.Status,\n        CreatedAt:      dbDoc.CreatedAt,\n        UpdatedAt:      dbDoc.UpdatedAt,\n    }, nil\n}\n\nfunc (r *documentRepository) Create(ctx context.Context, doc *domain.Document) (*domain.Document, error) {\n    dbDoc, err := r.store.CreateDocument(ctx, sqlc.CreateDocumentParams{\n        OrganizationID: doc.OrganizationID,\n        Title:          doc.Title,\n        FilePath:       doc.FilePath,\n        Status:         doc.Status,\n    })\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to create document: %w\", err)\n    }\n\n    // Map back to domain\n    return &domain.Document{\n        ID:             dbDoc.ID,\n        OrganizationID: dbDoc.OrganizationID,\n        Title:          dbDoc.Title,\n        FilePath:       dbDoc.FilePath,\n        Status:         dbDoc.Status,\n        CreatedAt:      dbDoc.CreatedAt,\n        UpdatedAt:      dbDoc.UpdatedAt,\n    }, nil\n}\n```\n\n**Why Map Types?**\n- SQLC types (`sqlc.Document`) are generated and may change\n- Domain types (`domain.Document`) are stable and business-focused\n- Mapping keeps SQLC isolated in the infrastructure layer\n\n### 5. Register in DI\n\nAdd to `internal/db/inject.go`:\n\n```go\nimport (\n    documentDomain \"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n    documentRepos \"github.com/moasq/go-b2b-starter/internal/modules/documents/infra/repositories\"\n)\n\n// In registerDomainStores function:\nif err := container.Provide(func(sqlcStore sqlc.Store) documentDomain.DocumentRepository {\n    return documentRepos.NewDocumentRepository(sqlcStore)\n}); err != nil {\n    return fmt.Errorf(\"failed to provide document repository: %w\", err)\n}\n```\n\n**Why Centralize DI?**\n- All repository registrations in one place\n- Easy to see all database dependencies\n- Consistent pattern across modules\n\n## Database Migrations\n\n### File Structure\n\nMigrations live in `internal/db/postgres/sqlc/migrations/`:\n\n```\n000001_create_schema.up.sql\n000001_create_schema.down.sql\n000002_add_indexes.up.sql\n000002_add_indexes.down.sql\n```\n\n### Naming Convention\n\nFormat: `{6-digit-number}_{description}.{up|down}.sql`\n\n- `.up.sql` - Apply the migration\n- `.down.sql` - Rollback the migration\n\n### Example Migration\n\n**Up migration** (`000005_create_documents.up.sql`):\n\n```sql\nCREATE SCHEMA IF NOT EXISTS app;\n\nCREATE TABLE app.documents (\n    id SERIAL PRIMARY KEY,\n    organization_id INTEGER NOT NULL,\n    title VARCHAR(255) NOT NULL,\n    file_path VARCHAR(512) NOT NULL,\n    status VARCHAR(50) NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n    CONSTRAINT fk_organization\n        FOREIGN KEY (organization_id)\n        REFERENCES app.organizations(id)\n        ON DELETE CASCADE\n);\n\nCREATE INDEX idx_documents_org_id ON app.documents(organization_id);\nCREATE INDEX idx_documents_status ON app.documents(status);\n```\n\n**Down migration** (`000005_create_documents.down.sql`):\n\n```sql\nDROP TABLE IF EXISTS app.documents;\n```\n\n### Running Migrations\n\n```bash\nmake migrateup      # Apply all pending migrations\nmake migratedown    # Rollback last migration\n```\n\n## Type Conversions\n\nPostgreSQL types need conversion to Go types.\n\n### Nullable Fields\n\nSQLC uses `pgtype` for nullable fields:\n\n```go\n// Convert pgtype.Text to string\nstr := postgres.StringFromPgText(dbRecord.NullableField)\n\n// Convert string to pgtype.Text\npgText := postgres.ToPgText(str)\n\n// Convert pgtype.Int4 to int32\nnum := postgres.Int32FromPgInt4(dbRecord.NullableInt)\n```\n\nHelper functions in `internal/db/postgres/types_transform.go`.\n\n### JSONB Fields\n\n```go\n// Convert map to JSONB\njsonbData := postgres.ToJSONB(map[string]any{\"key\": \"value\"})\n\n// Convert JSONB to map\ndata := postgres.JSONBToMap(dbRecord.Metadata)\n```\n\n## Error Handling\n\nThe database layer provides specific error types in `internal/db/core/errors.go`:\n\n**Common Errors:**\n- `ErrNoRows` - Query returned no results\n- `ErrTxClosed` - Transaction already committed/rolled back\n- `ErrTimeout` - Operation exceeded timeout\n- `ErrPoolClosed` - Connection pool is closed\n\n**Helper Functions:**\n\n```go\nif core.IsNoRowsError(err) {\n    return domain.ErrDocumentNotFound\n}\n\nif core.IsConstraintError(err, \"unique_title\") {\n    return domain.ErrDocumentAlreadyExists\n}\n\nif core.IsTimeoutError(err) {\n    return domain.ErrDatabaseTimeout\n}\n```\n\n## Transactions\n\nUse transactions for multi-step operations that must be atomic.\n\n### Basic Transaction\n\n```go\nfunc (r *repository) CreateWithRelation(ctx context.Context, doc *domain.Document) error {\n    return r.db.WithTx(ctx, func(tx core.Transaction) error {\n        // Step 1: Create document\n        created, err := tx.CreateDocument(ctx, params)\n        if err != nil {\n            return err\n        }\n\n        // Step 2: Create embeddings\n        _, err = tx.CreateEmbedding(ctx, embeddingParams)\n        if err != nil {\n            return err // Transaction auto-rolls back on error\n        }\n\n        return nil // Transaction commits on success\n    })\n}\n```\n\n### Transaction Options\n\n```go\n// Read-only transaction\nerr := r.db.WithTxOptions(ctx, &sql.TxOptions{ReadOnly: true}, func(tx core.Transaction) error {\n    // Read operations only\n})\n\n// Custom isolation level\nerr := r.db.WithTxOptions(ctx, &sql.TxOptions{\n    Isolation: sql.LevelSerializable,\n}, func(tx core.Transaction) error {\n    // Operations\n})\n```\n\n## Best Practices\n\n### Always Use Context\n\n```go\n// ✅ Good\nfunc (r *repository) GetDocument(ctx context.Context, id int32) (*Document, error)\n\n// ❌ Bad\nfunc (r *repository) GetDocument(id int32) (*Document, error)\n```\n\n### Handle Errors Appropriately\n\n```go\n// ✅ Convert database errors to domain errors\ndoc, err := r.store.GetDocumentByID(ctx, params)\nif err != nil {\n    if core.IsNoRowsError(err) {\n        return nil, domain.ErrDocumentNotFound\n    }\n    return nil, fmt.Errorf(\"failed to get document: %w\", err)\n}\n```\n\n### Use Prepared Statements\n\nSQLC automatically creates prepared statements. Never concatenate SQL strings.\n\n```go\n// ✅ Good (SQLC handles this)\nSELECT * FROM documents WHERE title = $1\n\n// ❌ Bad (SQL injection risk)\nquery := fmt.Sprintf(\"SELECT * FROM documents WHERE title = '%s'\", title)\n```\n\n### Indexes for Performance\n\nAdd indexes for commonly queried fields:\n\n```sql\n-- Foreign keys\nCREATE INDEX idx_documents_org_id ON documents(organization_id);\n\n-- Status fields\nCREATE INDEX idx_documents_status ON documents(status);\n\n-- Timestamps for sorting\nCREATE INDEX idx_documents_created_at ON documents(created_at DESC);\n\n-- Composite indexes for multi-column queries\nCREATE INDEX idx_documents_org_status ON documents(organization_id, status);\n```\n\n### Map SQLC Types to Domain Types\n\nAlways convert SQLC types to domain types in the repository layer:\n\n```go\n// ✅ Good - Repository returns domain types\nfunc (r *repository) GetDocument(ctx context.Context, id int32) (*domain.Document, error) {\n    dbDoc, err := r.store.GetDocumentByID(ctx, id)\n    if err != nil {\n        return nil, err\n    }\n\n    // Map SQLC type to domain type\n    return &domain.Document{\n        ID:    dbDoc.ID,\n        Title: dbDoc.Title,\n        // ... other fields\n    }, nil\n}\n\n// ❌ Bad - Service receives SQLC types\nfunc (s *service) GetDocument(ctx context.Context, id int32) (*sqlc.Document, error)\n```\n\n## Complete Example: Adding a New Entity\n\nLet's add a `Comment` entity to the documents module:\n\n### 1. Write Migration\n\n`internal/db/postgres/sqlc/migrations/000010_create_comments.up.sql`:\n```sql\nCREATE TABLE app.comments (\n    id SERIAL PRIMARY KEY,\n    document_id INTEGER NOT NULL,\n    author_id INTEGER NOT NULL,\n    content TEXT NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n    CONSTRAINT fk_document\n        FOREIGN KEY (document_id)\n        REFERENCES app.documents(id)\n        ON DELETE CASCADE\n);\n\nCREATE INDEX idx_comments_document_id ON app.comments(document_id);\n```\n\n### 2. Write SQLC Queries\n\n`internal/db/postgres/sqlc/query/comments.sql`:\n```sql\n-- name: CreateComment :one\nINSERT INTO comments (document_id, author_id, content)\nVALUES ($1, $2, $3)\nRETURNING *;\n\n-- name: ListCommentsByDocument :many\nSELECT * FROM comments\nWHERE document_id = $1\nORDER BY created_at ASC;\n```\n\n### 3. Generate SQLC Code\n\n```bash\nmake migrateup\nmake sqlc\n```\n\n### 4. Define Domain Interface\n\n`internal/modules/documents/domain/repository.go`:\n```go\ntype CommentRepository interface {\n    Create(ctx context.Context, comment *Comment) (*Comment, error)\n    ListByDocument(ctx context.Context, docID int32) ([]*Comment, error)\n}\n```\n\n### 5. Implement Repository\n\n`internal/modules/documents/infra/repositories/comment_repository.go`:\n```go\npackage repositories\n\nimport (\n    \"context\"\n    \"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n    sqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\ntype commentRepository struct {\n    store sqlc.Store\n}\n\nfunc NewCommentRepository(store sqlc.Store) domain.CommentRepository {\n    return &commentRepository{store: store}\n}\n\nfunc (r *commentRepository) Create(ctx context.Context, comment *domain.Comment) (*domain.Comment, error) {\n    dbComment, err := r.store.CreateComment(ctx, sqlc.CreateCommentParams{\n        DocumentID: comment.DocumentID,\n        AuthorID:   comment.AuthorID,\n        Content:    comment.Content,\n    })\n    if err != nil {\n        return nil, err\n    }\n\n    return &domain.Comment{\n        ID:         dbComment.ID,\n        DocumentID: dbComment.DocumentID,\n        AuthorID:   dbComment.AuthorID,\n        Content:    dbComment.Content,\n        CreatedAt:  dbComment.CreatedAt,\n    }, nil\n}\n```\n\n### 6. Register in DI\n\n`internal/db/inject.go`:\n```go\nif err := container.Provide(func(sqlcStore sqlc.Store) documentDomain.CommentRepository {\n    return documentRepos.NewCommentRepository(sqlcStore)\n}); err != nil {\n    return fmt.Errorf(\"failed to provide comment repository: %w\", err)\n}\n```\n\n## File Locations\n\n| Component | Path |\n|-----------|------|\n| Domain interfaces | `internal/modules/{module}/domain/repository.go` |\n| Repository implementations | `internal/modules/{module}/infra/repositories/` |\n| SQL queries | `internal/db/postgres/sqlc/query/` |\n| Migrations | `internal/db/postgres/sqlc/migrations/` |\n| Generated code | `internal/db/postgres/sqlc/gen/` |\n| Type helpers | `internal/db/postgres/types_transform.go` |\n| Error types | `internal/db/core/errors.go` |\n| DI registration | `internal/db/inject.go` |\n\n## Next Steps\n\n- **Using repositories in services**: See [Architecture Guide](./architecture.md)\n- **Building APIs**: See [API Development Guide](./api-development.md)\n- **Adding a new module**: See [Adding a Module Guide](./02-adding-a-module.md)\n- **SQLC documentation**: https://docs.sqlc.dev/\n"
  },
  {
    "path": "go-b2b-starter/docs/event-bus.md",
    "content": "# Event Bus Guide\n\nThe event bus enables event-driven architecture for loose coupling between modules using an in-memory publish-subscribe pattern.\n\n## Architecture\n\n**In-memory event bus** - Simple, fast, synchronous\n**Publisher-subscriber pattern** - Decouple event producers from consumers\n**Type-safe events** - Events are Go structs implementing Event interface\n\n## Core Concepts\n\n### Events\n\nEvents represent things that have happened in the system.\n\n**Naming**: Past tense (ResourceCreated, ResourceUpdated, ResourceDeleted)\n\n```go\ntype ResourceCreatedEvent struct {\n    BaseEvent\n    ResourceID int32           `json:\"resource_id\"`\n    Name       string          `json:\"name\"`\n    CreatedBy  int32           `json:\"created_by\"`\n    CreatedAt  time.Time       `json:\"created_at\"`\n}\n```\n\n### Event Interface\n\nAll events implement the Event interface:\n\n```go\ntype Event interface {\n    EventName() string\n    EventID() string\n    OccurredAt() time.Time\n}\n```\n\n### BaseEvent\n\nProvides common event fields:\n\n```go\ntype BaseEvent struct {\n    ID         string    `json:\"id\"`\n    Name       string    `json:\"name\"`\n    Timestamp  time.Time `json:\"timestamp\"`\n}\n```\n\n## Publishing Events\n\nEmit events when something happens:\n\n```go\nfunc (s *service) CreateResource(ctx context.Context, req *Request) (*Resource, error) {\n    // Create resource\n    resource, err := s.repo.Create(ctx, req)\n    if err != nil {\n        return nil, err\n    }\n\n    // Publish event\n    event := &ResourceCreatedEvent{\n        ResourceID: resource.ID,\n        Name:       resource.Name,\n        CreatedBy:  req.UserID,\n        CreatedAt:  resource.CreatedAt,\n    }\n    s.eventBus.Publish(ctx, event)\n\n    return resource, nil\n}\n```\n\n**Note**: Publish is fire-and-forget. Failures don't block the operation.\n\n## Subscribing to Events\n\nListen for events and react:\n\n```go\nfunc (l *ResourceListener) Init(eventBus eventbus.EventBus) {\n    // Subscribe to events\n    eventBus.Subscribe(\"resource.created\", l.HandleResourceCreated)\n    eventBus.Subscribe(\"resource.updated\", l.HandleResourceUpdated)\n}\n\nfunc (l *ResourceListener) HandleResourceCreated(ctx context.Context, event eventbus.Event) error {\n    resourceEvent := event.(*ResourceCreatedEvent)\n\n    // React to event\n    log.Info(\"Resource created\", zap.Int32(\"id\", resourceEvent.ResourceID))\n\n    // Trigger other actions\n    return l.notificationService.NotifyResourceCreated(ctx, resourceEvent.ResourceID)\n}\n```\n\n## Event Flow\n\n```\nService → Publish Event → Event Bus → Notify Subscribers → Execute Handlers\n```\n\n**Synchronous**: Subscribers execute in the same request context\n**Ordered**: Subscribers execute in registration order\n**Error handling**: Subscriber errors are logged but don't fail the operation\n\n## Common Patterns\n\n### Cross-Module Communication\n\nModule A publishes events, Module B subscribes:\n\n```go\n// Module A (Resources)\nfunc (s *resourceService) Delete(ctx context.Context, id int32) error {\n    err := s.repo.Delete(ctx, id)\n    if err != nil {\n        return err\n    }\n\n    s.eventBus.Publish(ctx, &ResourceDeletedEvent{ResourceID: id})\n    return nil\n}\n\n// Module B (Analytics)\nfunc (l *analyticsListener) HandleResourceDeleted(ctx context.Context, event eventbus.Event) error {\n    evt := event.(*ResourceDeletedEvent)\n    return l.analyticsService.RecordDeletion(ctx, evt.ResourceID)\n}\n```\n\n### Audit Logging\n\nSubscribe to all events for audit trail:\n\n```go\nfunc (l *auditListener) Init(eventBus eventbus.EventBus) {\n    eventBus.Subscribe(\"*.created\", l.HandleCreated)\n    eventBus.Subscribe(\"*.updated\", l.HandleUpdated)\n    eventBus.Subscribe(\"*.deleted\", l.HandleDeleted)\n}\n\nfunc (l *auditListener) HandleCreated(ctx context.Context, event eventbus.Event) error {\n    return l.auditService.Log(ctx, \"created\", event)\n}\n```\n\n### Async Processing\n\nTrigger background jobs from events:\n\n```go\nfunc (l *processingListener) HandleFileUploaded(ctx context.Context, event eventbus.Event) error {\n    evt := event.(*FileUploadedEvent)\n\n    // Queue async job\n    return l.jobQueue.Enqueue(ctx, &ProcessFileJob{\n        FileID: evt.FileID,\n    })\n}\n```\n\n## Registration\n\nRegister listeners during module initialization:\n\n```go\n// internal/resources/cmd/init.go\nfunc Init(container *dig.Container) error {\n    return container.Invoke(func(\n        eventBus eventbus.EventBus,\n        listener *listeners.ResourceListener,\n    ) {\n        listener.Init(eventBus)\n    })\n}\n```\n\n## File Locations\n\n| Component | Path |\n|-----------|------|\n| Event bus interface | `internal/eventbus/eventbus.go` |\n| Event interface | `internal/eventbus/event.go` |\n| Base event | `internal/eventbus/base_event.go` |\n| Implementation | `internal/eventbus/memory_eventbus.go` |\n| Domain events | `internal/*/domain/events/` |\n| Event listeners | `internal/*/domain/listeners/` |\n\n## Next Steps\n\n- **Define events**: Create event structs in `domain/events/`\n- **Implement listeners**: Handle events in `domain/listeners/`\n- **Publish events**: Emit events in service layer\n"
  },
  {
    "path": "go-b2b-starter/docs/file-manager.md",
    "content": "# File Manager Guide\n\nThe file manager provides file storage using Cloudflare R2 (object storage) with PostgreSQL for searchable metadata.\n\n## Architecture\n\n**Dual-layer design:**\n\n**R2 Storage** - Stores actual file content\n**PostgreSQL** - Stores searchable metadata\n\nThis separation enables fast querying while leveraging object storage scalability.\n\n## Components\n\n**FileRepository**: Combined operations (upload, download, delete, search)\n**R2Repository**: R2 object storage operations\n**FileMetadataRepository**: Database metadata operations\n**FileService**: Business logic with validation\n\n## File Upload\n\n### Basic Upload\n\n```go\nreq := &domain.FileUploadRequest{\n    Filename:    \"document.pdf\",\n    ContentType: \"application/pdf\",\n    Context:     file_manager.ContextDocument,\n}\n\nfile, err := fileService.UploadFile(ctx, req, fileReader)\n```\n\n### Upload with Entity Linking\n\nLink files to domain entities (like resources, users, etc.):\n\n```go\nreq := &domain.FileUploadRequest{\n    Filename:    \"profile.jpg\",\n    ContentType: \"image/jpeg\",\n    Context:     file_manager.ContextProfile,\n}\n\nfile := &domain.FileAsset{\n    EntityType: \"user\",\n    EntityID:   userID,\n}\n\nuploadedFile, err := fileService.UploadFile(ctx, req, fileReader)\n```\n\n### Upload Flow\n\n1. Validate file (size, type, magic bytes)\n2. Save metadata to database (get ID)\n3. Upload content to R2 (using database ID in key)\n4. Update metadata with storage path\n5. Rollback on failure (atomic operation)\n\n## File Download\n\n### Get Presigned URL\n\nGenerate temporary download link:\n\n```go\nurl, err := fileService.GetPresignedURL(ctx, fileID, 15*time.Minute)\n```\n\nReturns a time-limited URL for direct download from R2.\n\n### Download File Content\n\n```go\ncontent, err := fileService.DownloadFile(ctx, fileID)\n```\n\nReturns `io.ReadCloser` with file content.\n\n## File Search\n\n### By Entity\n\nGet all files for a specific entity:\n\n```go\nfiles, err := fileRepo.GetByEntity(ctx, \"resource\", resourceID)\n```\n\n### By Category\n\nFind files by category:\n\n```go\ndocuments, err := fileRepo.GetByCategory(ctx, file_manager.CategoryDocument, 10, 0)\n```\n\n### By Context\n\nSearch by context type:\n\n```go\nprofiles, err := fileRepo.GetByContext(ctx, file_manager.ContextProfile, 20, 0)\n```\n\n## File Validation\n\nAutomatic validation on upload:\n\n**Magic byte verification** - Validates file type matches content\n**Size limits** - Configurable max file size\n**Content type** - Ensures valid MIME type\n\nConfigure in `FileService` initialization.\n\n## Contexts and Categories\n\n### Predefined Contexts\n\n- `ContextDocument` - General documents\n- `ContextProfile` - Profile images\n- `ContextAttachment` - Email/message attachments\n- `ContextThumbnail` - Image thumbnails\n\n### Categories\n\n- `CategoryDocument` - PDFs, docs\n- `CategoryImage` - Images\n- `CategoryVideo` - Videos\n- `CategoryArchive` - ZIP, TAR files\n\nDefined in `internal/files/domain/constants.go`.\n\n## Configuration\n\n```env\n# Cloudflare R2\nR2_ACCOUNT_ID=your-account-id\nR2_ACCESS_KEY_ID=your-access-key\nR2_SECRET_ACCESS_KEY=your-secret-key\nR2_BUCKET_NAME=files\nR2_REGION=auto  # Usually \"auto\" for R2\n```\n\n## Common Patterns\n\n### Upload User Avatar\n\n```go\nfunc (s *service) UpdateAvatar(ctx context.Context, userID int32, avatar io.Reader) error {\n    req := &domain.FileUploadRequest{\n        Filename:    fmt.Sprintf(\"avatar_%d.jpg\", userID),\n        ContentType: \"image/jpeg\",\n        Context:     file_manager.ContextProfile,\n    }\n\n    file, err := s.fileService.UploadFile(ctx, req, avatar)\n    if err != nil {\n        return err\n    }\n\n    // Link to user\n    return s.userRepo.UpdateAvatar(ctx, userID, file.ID)\n}\n```\n\n### Get Entity Files\n\n```go\nfunc (h *Handler) GetResourceFiles(c *gin.Context) {\n    resourceID := parseID(c.Param(\"id\"))\n\n    files, err := h.fileRepo.GetByEntity(c.Request.Context(), \"resource\", resourceID)\n    if err != nil {\n        c.JSON(500, gin.H{\"error\": \"failed to get files\"})\n        return\n    }\n\n    c.JSON(200, files)\n}\n```\n\n### Delete File\n\n```go\nfunc (s *service) DeleteResource(ctx context.Context, resourceID int32) error {\n    // Get associated files\n    files, err := s.fileRepo.GetByEntity(ctx, \"resource\", resourceID)\n    if err != nil {\n        return err\n    }\n\n    // Delete files\n    for _, file := range files {\n        err = s.fileService.DeleteFile(ctx, file.ID)\n        if err != nil {\n            return err\n        }\n    }\n\n    // Delete resource\n    return s.resourceRepo.Delete(ctx, resourceID)\n}\n```\n\n## File Locations\n\n| Component | Path |\n|-----------|------|\n| Domain entities | `internal/files/domain/` |\n| File service | `internal/files/internal/app/` |\n| R2 repository | `internal/files/internal/infra/r2/` |\n| Metadata repository | `internal/files/internal/infra/metadata/` |\n| Constants | `internal/files/domain/constants.go` |\n\n## Next Steps\n\n- **Upload files**: Integrate file upload in your features\n- **Link entities**: Associate files with domain objects\n- **R2 documentation**: https://developers.cloudflare.com/r2/\n"
  },
  {
    "path": "go-b2b-starter/example.env",
    "content": "# Go B2B SaaS Starter Kit - Environment Configuration Template\n# Copy this file to app.env and fill in your actual values\n\n# Environment\nENV=DEV\nALLOW_SELF_APPROVAL=true\n\n# Server\nSERVER_ADDRESS=:8080\nRATE_LIMIT_PER_SECOND=100\nMAX_REQUEST_SIZE=10485760\n\n# Security Settings\nTLS_CERT_PATH=/path/to/cert.pem\nTLS_KEY_PATH=/path/to/key.pem\nTRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16\nALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001\n\n# Redis Configuration\nREDIS_HOST=localhost\nREDIS_PORT=6379\nREDIS_PASSWORD=\nREDIS_DB=0\n\n# Postgres Configuration\nPOSTGRES_HOST=localhost\nPOSTGRES_PORT=5432\nPOSTGRES_DB=mydatabase\nPOSTGRES_USER=user\nPOSTGRES_PASSWORD=password\nDB_SSL_MODE=disable\nMIGRATION_URL=src/pkg/db/postgres/sqlc/migrations\nSEED_URL=src/pkg/db/postgres/seed\n\n# Auth Configuration\nACCESS_TOKEN_DURATION=3h\nREFRESH_TOKEN_DURATION=72h\nTOKEN_SYMMETRIC_KEY=REPLACE_WITH_YOUR_32_CHAR_SECRET_KEY\nSESSION_ENCRYPTION_KEY=REPLACE_WITH_YOUR_32_CHAR_SESSION_KEY\nPASSWORD_HASH_COST=12\nMAX_LOGIN_ATTEMPTS=5\nLOCKOUT_DURATION=15m\nJWT_ISSUER=go-b2b-starter\n\n# === Stytch B2B configuration ===\nSTYTCH_PROJECT_ID=project-test-REPLACE_WITH_YOUR_STYTCH_PROJECT_ID\nSTYTCH_SECRET=secret-test-REPLACE_WITH_YOUR_STYTCH_SECRET\nSTYTCH_ENV=test\nSTYTCH_SESSION_DURATION_MINUTES=1440\nSTYTCH_INVITE_REDIRECT_URL=http://localhost:3000/authenticate\nSTYTCH_LOGIN_REDIRECT_URL=http://localhost:3000/authenticate\nSTYTCH_OWNER_ROLE_SLUG=owner\nSTYTCH_DISABLE_SESSION_VERIFICATION=false\n\n# Cloudflare R2 Configuration\nR2_ACCOUNT_ID=REPLACE_WITH_YOUR_R2_ACCOUNT_ID\nR2_ACCESS_KEY_ID=REPLACE_WITH_YOUR_R2_ACCESS_KEY\nR2_SECRET_ACCESS_KEY=REPLACE_WITH_YOUR_R2_SECRET_KEY\nR2_BUCKET=uploads\nR2_REGION=auto\nS3_API=https://REPLACE_WITH_YOUR_R2_ACCOUNT_ID.r2.cloudflarestorage.com\n\n# OpenAI Configuration\nOPENAI_API_KEY=sk-proj-REPLACE_WITH_YOUR_OPENAI_API_KEY\nOPENAI_MODEL=gpt-4o-mini\nOPENAI_MAX_TOKENS=500\nOPENAI_TEMPERATURE=0.0\nLLM_TIMEOUT_SEC=30\nLLM_MAX_RETRIES=1\nLLM_FALLBACK_ENABLED=true\n\n# Mistral Configuration\nMISTRAL_API_KEY=REPLACE_WITH_YOUR_MISTRAL_API_KEY\nOCR_DEBUG_MODE=true\n\n# Polar Configuration\nPOLAR_ACCESS_TOKEN=polar_oat_REPLACE_WITH_YOUR_POLAR_ACCESS_TOKEN\nPOLAR_BASE_URL=https://sandbox-api.polar.sh\nPOLAR_DEBUG=true\nWEBHOOK_SECRET=polar_whs_REPLACE_WITH_YOUR_WEBHOOK_SECRET\nNEXT_PUBLIC_POLAR_PRODUCT_ID=REPLACE_WITH_YOUR_PRODUCT_ID\nNEXT_PUBLIC_POLAR_BUSINESS_PRODUCT_ID=REPLACE_WITH_YOUR_BUSINESS_PRODUCT_ID\n"
  },
  {
    "path": "go-b2b-starter/go.mod",
    "content": "module github.com/moasq/go-b2b-starter\n\ngo 1.25\n\nrequire (\n\tgithub.com/KyleBanks/depth v1.2.1\n\tgithub.com/MicahParks/keyfunc/v2 v2.0.1\n\tgithub.com/aws/aws-sdk-go-v2 v1.24.0\n\tgithub.com/aws/aws-sdk-go-v2/config v1.26.1\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.16.12\n\tgithub.com/aws/aws-sdk-go-v2/service/s3 v1.47.5\n\tgithub.com/aws/smithy-go v1.19.0\n\tgithub.com/gabriel-vasile/mimetype v1.4.10\n\tgithub.com/gin-contrib/cors v1.7.2\n\tgithub.com/gin-gonic/gin v1.10.1\n\tgithub.com/go-openapi/jsonpointer v0.19.5\n\tgithub.com/go-openapi/jsonreference v0.20.0\n\tgithub.com/go-openapi/spec v0.20.6\n\tgithub.com/go-openapi/swag v0.19.15\n\tgithub.com/go-playground/validator/v10 v10.23.0\n\tgithub.com/golang-jwt/jwt/v5 v5.3.0\n\tgithub.com/golang-migrate/migrate/v4 v4.17.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/jackc/pgx/v5 v5.7.2\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/pgvector/pgvector-go v0.3.0\n\tgithub.com/prometheus/client_golang v1.20.5\n\tgithub.com/redis/go-redis/v9 v9.7.0\n\tgithub.com/rs/zerolog v1.33.0\n\tgithub.com/shopspring/decimal v1.4.0\n\tgithub.com/spf13/viper v1.19.0\n\tgithub.com/stytchauth/stytch-go/v16 v16.40.0\n\tgithub.com/swaggo/files v1.0.1\n\tgithub.com/swaggo/gin-swagger v1.6.0\n\tgithub.com/swaggo/swag v1.16.3\n\tgithub.com/twpayne/go-geom v1.6.1\n\tgo.uber.org/dig v1.19.0\n\tgo.uber.org/zap v1.27.0\n\tgolang.org/x/time v0.8.0\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n)\n\nrequire (\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/bytedance/sonic v1.12.5 // indirect\n\tgithub.com/bytedance/sonic/loader v0.2.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.4 // indirect\n\tgithub.com/cloudwego/iasm v0.2.0 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/fsnotify/fsnotify v1.7.0 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/hashicorp/hcl v1.0.0 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/compress v1.17.11 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.9 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/magiconair/properties v1.8.7 // indirect\n\tgithub.com/mailru/easyjson v0.7.6 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mitchellh/mapstructure v1.5.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.3 // indirect\n\tgithub.com/prometheus/client_model v0.6.1 // indirect\n\tgithub.com/prometheus/common v0.55.0 // indirect\n\tgithub.com/prometheus/procfs v0.15.1 // indirect\n\tgithub.com/sagikazarmark/locafero v0.4.0 // indirect\n\tgithub.com/sagikazarmark/slog-shim v0.1.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.0 // indirect\n\tgithub.com/spf13/afero v1.11.0 // indirect\n\tgithub.com/spf13/cast v1.6.0 // indirect\n\tgithub.com/spf13/pflag v1.0.5 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.12 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgolang.org/x/arch v0.12.0 // indirect\n\tgolang.org/x/crypto v0.36.0 // indirect\n\tgolang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect\n\tgolang.org/x/net v0.37.0 // indirect\n\tgolang.org/x/sync v0.12.0 // indirect\n\tgolang.org/x/sys v0.31.0 // indirect\n\tgolang.org/x/text v0.23.0 // indirect\n\tgolang.org/x/tools v0.31.0 // indirect\n\tgoogle.golang.org/protobuf v1.35.2 // indirect\n\tgopkg.in/ini.v1 v1.67.0 // indirect\n\tgopkg.in/yaml.v2 v2.4.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go-b2b-starter/go.sum",
    "content": "entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=\nentgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=\ngithub.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=\ngithub.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=\ngithub.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=\ngithub.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=\ngithub.com/MicahParks/keyfunc/v2 v2.0.1 h1:6FrNNvG/20gEKkjxV+5anrkq0VOF666G2zUn8lk8dgk=\ngithub.com/MicahParks/keyfunc/v2 v2.0.1/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=\ngithub.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=\ngithub.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=\ngithub.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=\ngithub.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=\ngithub.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=\ngithub.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=\ngithub.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o=\ngithub.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=\ngithub.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=\ngithub.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w=\ngithub.com/bytedance/sonic v1.12.5/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=\ngithub.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=\ngithub.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=\ngithub.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=\ngithub.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=\ngithub.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=\ngithub.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=\ngithub.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=\ngithub.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=\ngithub.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=\ngithub.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=\ngithub.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=\ngithub.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=\ngithub.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=\ngithub.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=\ngithub.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=\ngithub.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=\ngithub.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=\ngithub.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=\ngithub.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=\ngithub.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=\ngithub.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=\ngithub.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=\ngithub.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=\ngithub.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=\ngithub.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=\ngithub.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=\ngithub.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=\ngithub.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=\ngithub.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=\ngithub.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=\ngithub.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=\ngithub.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=\ngithub.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=\ngithub.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=\ngithub.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=\ngithub.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=\ngithub.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=\ngithub.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=\ngithub.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=\ngithub.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=\ngithub.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=\ngithub.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=\ngithub.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=\ngithub.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=\ngithub.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=\ngithub.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=\ngithub.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=\ngithub.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=\ngithub.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=\ngithub.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=\ngithub.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=\ngithub.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=\ngithub.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=\ngithub.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=\ngithub.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=\ngithub.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=\ngithub.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=\ngithub.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=\ngithub.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=\ngithub.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=\ngithub.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=\ngithub.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=\ngithub.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=\ngithub.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=\ngithub.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=\ngithub.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/stytchauth/stytch-go/v16 v16.40.0 h1:xT9QyPtWi4j6rJPhkROfGCDzDeVBqvS2KQge1dv8rfs=\ngithub.com/stytchauth/stytch-go/v16 v16.40.0/go.mod h1:b2Dj63HNogYxAwJz7l9S7aJ8k3xyFYrMOtkzdTme+tk=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=\ngithub.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=\ngithub.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=\ngithub.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=\ngithub.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=\ngithub.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=\ngithub.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=\ngithub.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=\ngithub.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=\ngithub.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=\ngithub.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0=\ngithub.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk=\ngithub.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc=\ngithub.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w=\ngithub.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=\ngithub.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=\ngithub.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=\ngithub.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=\ngithub.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=\ngithub.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=\ngithub.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=\ngithub.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=\ngithub.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=\ngo.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=\ngo.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=\ngo.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=\ngo.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngolang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=\ngolang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=\ngolang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=\ngolang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=\ngolang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=\ngolang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=\ngolang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=\ngolang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=\ngolang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=\ngolang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=\ngolang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=\ngolang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=\ngoogle.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=\ngorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=\ngorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=\ngorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=\nmellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=\nmellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\n"
  },
  {
    "path": "go-b2b-starter/internal/api/provider.go",
    "content": "package api\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations\"\n\tserver \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\n// moduleRoutes holds handlers for all API modules\n// 1. OrganizationRoutes - Handles organization, account, and member management routes (includes /auth routes)\n// 2. RbacRoutes - Handles RBAC role and permission routes\n// 3. BillingHandler - Handles billing status and subscription routes (uses billing module)\n// 4. DocumentsRoutes - Handles PDF document upload and management routes\n// 5. CognitiveRoutes - Handles AI/RAG chat and document search routes\ntype moduleRoutes struct {\n\tOrganizationRoutes  *organizations.Routes\n\tRbacRoutes          *auth.Routes\n\tSubscriptionHandler *billing.Handler\n\tDocumentsRoutes     *documents.Routes\n\tCognitiveRoutes     *cognitive.Routes\n}\n\n// Init sets up all module dependencies and registers API routes\nfunc Init(container *dig.Container) error {\n\tif err := setupDependencies(container); err != nil {\n\t\treturn err\n\t}\n\n\tif err := registerAPI(container); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// registerAPI registers all module handlers and routes\nfunc registerAPI(container *dig.Container) error {\n\tif err := container.Provide(func(\n\t\torganizationRoutes *organizations.Routes,\n\t\trbacRoutes *auth.Routes,\n\t\tsubscriptionHandler *billing.Handler,\n\t\tdocumentsRoutes *documents.Routes,\n\t\tcognitiveRoutes *cognitive.Routes,\n\t) *moduleRoutes {\n\t\treturn &moduleRoutes{\n\t\t\tOrganizationRoutes:  organizationRoutes,\n\t\t\tRbacRoutes:          rbacRoutes,\n\t\t\tSubscriptionHandler: subscriptionHandler,\n\t\t\tDocumentsRoutes:     documentsRoutes,\n\t\t\tCognitiveRoutes:     cognitiveRoutes,\n\t\t}\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn container.Invoke(func(\n\t\tsrv server.Server,\n\t\tmodules *moduleRoutes,\n\t) {\n\t\t// Register each module's routes\n\t\tsrv.RegisterRoutes(modules.OrganizationRoutes.Routes, server.ApiPrefix)\n\t\tsrv.RegisterRoutes(modules.RbacRoutes.Routes, server.ApiPrefix)\n\t\tsrv.RegisterRoutes(modules.SubscriptionHandler.Routes, server.ApiPrefix)\n\t\tsrv.RegisterRoutes(modules.DocumentsRoutes.Routes, server.ApiPrefix)\n\t\tsrv.RegisterRoutes(modules.CognitiveRoutes.Routes, server.ApiPrefix)\n\t})\n}\n\n// setupDependencies initializes all module dependencies\nfunc setupDependencies(container *dig.Container) error {\n\tif err := organizations.NewProvider(container).RegisterDependencies(); err != nil {\n\t\treturn err\n\t}\n\n\t// Initialize RBAC API (role and permission discovery)\n\tif err := auth.NewProvider(container).RegisterDependencies(); err != nil {\n\t\treturn err\n\t}\n\n\t// Initialize billing API (subscription and billing status)\n\tif err := billing.RegisterHandlers(container); err != nil {\n\t\treturn err\n\t}\n\n\t// Initialize documents API (PDF upload and management)\n\tif err := documents.NewProvider(container).RegisterDependencies(); err != nil {\n\t\treturn err\n\t}\n\n\t// Initialize cognitive API (AI/RAG chat and document search)\n\tif err := cognitive.NewProvider(container).RegisterDependencies(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/bootstrap/init_mods.go",
    "content": "package bootstrap\n\nimport (\n\t\"context\"\n\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/api\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\tauthCmd \"github.com/moasq/go-b2b-starter/internal/modules/auth/cmd\"\n\tbilling \"github.com/moasq/go-b2b-starter/internal/modules/billing/cmd\"\n\tcognitive \"github.com/moasq/go-b2b-starter/internal/modules/cognitive/cmd\"\n\tdb \"github.com/moasq/go-b2b-starter/internal/db/cmd\"\n\tdocs \"github.com/moasq/go-b2b-starter/internal/docs/cmd\"\n\tdocuments \"github.com/moasq/go-b2b-starter/internal/modules/documents/cmd\"\n\teventbus \"github.com/moasq/go-b2b-starter/internal/platform/eventbus/cmd\"\n\tfiles \"github.com/moasq/go-b2b-starter/internal/modules/files/cmd\"\n\tllm \"github.com/moasq/go-b2b-starter/internal/platform/llm/cmd\"\n\tlogger \"github.com/moasq/go-b2b-starter/internal/platform/logger/cmd\"\n\tocr \"github.com/moasq/go-b2b-starter/internal/platform/ocr/cmd\"\n\torgDomain \"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\torganizations \"github.com/moasq/go-b2b-starter/internal/modules/organizations/cmd\"\n\tpaywall \"github.com/moasq/go-b2b-starter/internal/modules/paywall/cmd\"\n\tpolar \"github.com/moasq/go-b2b-starter/internal/platform/polar/cmd\"\n\tredisCmd \"github.com/moasq/go-b2b-starter/internal/platform/redis/cmd\"\n\tserver \"github.com/moasq/go-b2b-starter/internal/platform/server/cmd\"\n\tstytchCmd \"github.com/moasq/go-b2b-starter/internal/platform/stytch/cmd\"\n)\n\n// orgLookupAdapter adapts orgDomain.OrganizationRepository to auth.OrganizationLookup\ntype orgLookupAdapter struct {\n\trepo orgDomain.OrganizationRepository\n}\n\nfunc (a *orgLookupAdapter) GetByStytchID(ctx context.Context, stytchOrgID string) (auth.OrganizationEntity, error) {\n\treturn a.repo.GetByStytchID(ctx, stytchOrgID)\n}\n\n// accLookupAdapter adapts orgDomain.AccountRepository to auth.AccountLookup\ntype accLookupAdapter struct {\n\trepo orgDomain.AccountRepository\n}\n\nfunc (a *accLookupAdapter) GetByEmail(ctx context.Context, orgID int32, email string) (auth.AccountEntity, error) {\n\treturn a.repo.GetByEmail(ctx, orgID, email)\n}\n\nfunc InitMods(container *dig.Container) {\n\n\t// pkg\n\tserver.Init(container)\n\tlogger.Init(container)\n\tdb.Init(container)\n\tfiles.Init(container)\n\tif err := eventbus.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\tif err := llm.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Polar package must be initialized before payment module (payment depends on Polar client)\n\tif err := polar.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Redis must be initialized before auth (Stytch repositories rely on Redis-backed clients upstream)\n\tif err := redisCmd.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Stytch client package must be initialized before app/auth (for organization/member management)\n\t// This provides: stytch.Config, stytch.Client, stytch.RBACPolicyService\n\tif err := stytchCmd.ProvideStytchDependencies(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Auth package (pkg/auth) must be initialized before app/auth\n\t// This provides: auth.AuthProvider (authentication/authorization)\n\tif err := authCmd.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// docs\n\tdocs.Init(container)\n\n\t// app\n\tif err := organizations.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Register auth resolvers (bridges organizations domain to auth package)\n\tif err := auth.ProvideResolvers(container,\n\t\tfunc(repo orgDomain.OrganizationRepository) auth.OrganizationResolver {\n\t\t\treturn auth.NewOrganizationResolver(&orgLookupAdapter{repo: repo})\n\t\t},\n\t\tfunc(repo orgDomain.AccountRepository) auth.AccountResolver {\n\t\t\treturn auth.NewAccountResolver(&accLookupAdapter{repo: repo})\n\t\t},\n\t); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Initialize auth middleware (requires resolvers to be registered)\n\tif err := authCmd.InitMiddleware(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Register auth middleware as named middlewares for use in routes\n\tif err := auth.RegisterNamedMiddlewares(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Billing module (subscription lifecycle, quotas, webhooks)\n\tif err := billing.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Paywall middleware (access gating based on subscription status)\n\tif err := paywall.SetupMiddleware(container); err != nil {\n\t\tpanic(err)\n\t}\n\tif err := paywall.RegisterNamedMiddlewares(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// OCR service (Mistral API for document text extraction)\n\t// Must be initialized before documents module (documents depends on OCR)\n\tif err := ocr.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Documents module (PDF upload and text extraction)\n\tif err := documents.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Cognitive module (AI/RAG with embeddings and vector search)\n\t// Note: This also wires the event listener for DocumentUploaded events\n\tif err := cognitive.Init(container); err != nil {\n\t\tpanic(err)\n\t}\n\n\t// api\n\tapi.Init(container)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/bootstrap/root.go",
    "content": "package bootstrap\n\nimport (\n\t\"log\"\n\n\t\"github.com/joho/godotenv\"\n\t\"go.uber.org/dig\"\n\n\tserver \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\nfunc Execute() {\n\tif err := godotenv.Load(\"app.env\"); err != nil {\n\t\tlog.Printf(\"Warning: Error loading app.env file: %v\", err)\n\t}\n\n\tcontainer := dig.New()\n\n\tInitMods(container)\n\n\tvar srv server.Server\n\n\tif err := container.Invoke(func(s server.Server) {\n\t\tsrv = s\n\t}); err != nil {\n\t\tpanic(err)\n\t}\n\n\tsrv.Start()\n\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/README.md",
    "content": "# Database Layer Guide\n\nThis guide shows you how to work with the database layer using our adapter pattern. It's designed to be simple and practical.\n\n## What's the Adapter Pattern?\n\nWe keep database code separate from business logic:\n- **`adapters/`** - Interface definitions (what operations are available)\n- **`postgres/adapter_impl/`** - Actual database code (how PostgreSQL does it)\n\nYour business modules only need to know about `adapters/`. They never touch PostgreSQL directly.\n\n## The Workflow\n\nHere's the typical flow when you need to add something to the database:\n\n### 1. Create a Database Migration\n\nUse the Makefile to create migration files:\n\n```bash\nmake create-migration MIGRATION_NAME=add_users_table\n```\n\nThis creates two files in `postgres/sqlc/migrations/`:\n- `000XXX_add_users_table.up.sql` (creates your changes)\n- `000XXX_add_users_table.down.sql` (removes your changes)\n\nWrite your SQL in these files. Keep it simple.\n\n### 2. Apply Your Migration\n\nRun the migration to update your database:\n\n```bash\nmake migrateup\n```\n\nIf something goes wrong, you can rollback:\n\n```bash\nmake migratedown\n```\n\n### 3. Write SQL Queries\n\nCreate a file in `postgres/sqlc/query/` with your queries. Use SQLC's comments to tell it what to generate:\n\n```sql\n-- name: GetUserByID :one\nSELECT * FROM users WHERE id = $1;\n\n-- name: CreateUser :one\nINSERT INTO users (email, name) VALUES ($1, $2) RETURNING *;\n\n-- name: UpdateUserBalance :exec\nUPDATE users SET balance = balance + $1 WHERE id = $2;\n```\n\nThe comment annotations tell SQLC what kind of method to create (`:one` returns single row, `:many` returns multiple, `:exec` runs without returning).\n\n### 4. Generate Go Code\n\nLet SQLC generate type-safe Go code from your queries:\n\n```bash\nmake sqlc\n```\n\nThis creates Go methods in `postgres/sqlc/gen/` that you can use safely without writing SQL in Go code.\n\n### 5. Create an Adapter Interface\n\nDefine what operations your business logic needs in `adapters/user_adapter.go`:\n\n```go\npackage adapters\n\nimport (\n    \"context\"\n    db \"github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen\"\n)\n\ntype UserAdapter interface {\n    GetUserByID(ctx context.Context, id int32) (db.User, error)\n    CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error)\n\n    // For transactions - aggregate multiple operations\n    TransferMoney(ctx context.Context, fromUserID, toUserID int32, amount int32) error\n}\n```\n\n### 6. Implement the Adapter\n\nCreate the actual implementation in `postgres/adapter_impl/user_adapter.go`:\n\n```go\npackage adapterimpl\n\nimport (\n    \"context\"\n    \"fmt\"\n    \"github.com/moasq/go-b2b-starter/pkg/db/adapters\"\n    sqlc \"github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen\"\n)\n\ntype userAdapter struct {\n    store sqlc.Store\n}\n\nfunc NewUserAdapter(store sqlc.Store) adapters.UserAdapter {\n    return &userAdapter{store: store}\n}\n\nfunc (a *userAdapter) GetUserByID(ctx context.Context, id int32) (sqlc.User, error) {\n    return a.store.GetUserByID(ctx, id)\n}\n\nfunc (a *userAdapter) CreateUser(ctx context.Context, arg sqlc.CreateUserParams) (sqlc.User, error) {\n    return a.store.CreateUser(ctx, arg)\n}\n\n// TransferMoney - transaction example aggregating multiple queries\nfunc (a *userAdapter) TransferMoney(ctx context.Context, fromUserID, toUserID int32, amount int32) error {\n    // Start transaction using the store's ExecTx method\n    return a.store.ExecTx(ctx, func(q sqlc.Querier) error {\n        // All queries run within this transaction\n\n        // 1. Deduct from sender\n        err := q.UpdateUserBalance(ctx, sqlc.UpdateUserBalanceParams{\n            ID:     fromUserID,\n            Amount: -amount,\n        })\n        if err != nil {\n            return fmt.Errorf(\"failed to deduct balance: %w\", err)\n        }\n\n        // 2. Add to receiver\n        err = q.UpdateUserBalance(ctx, sqlc.UpdateUserBalanceParams{\n            ID:     toUserID,\n            Amount: amount,\n        })\n        if err != nil {\n            return fmt.Errorf(\"failed to add balance: %w\", err)\n        }\n\n        // 3. Verify sender has enough balance\n        sender, err := q.GetUserByID(ctx, fromUserID)\n        if err != nil {\n            return fmt.Errorf(\"failed to get sender: %w\", err)\n        }\n        if sender.Balance < 0 {\n            return fmt.Errorf(\"insufficient balance\")\n        }\n\n        // If any query fails, transaction auto-rollbacks\n        // If all succeed, transaction auto-commits\n        return nil\n    })\n}\n```\n\n### 7. Register in Dependency Injection\n\nAdd your adapter to `inject.go` so it's available everywhere:\n\n```go\nif err := container.Provide(func(sqlcStore sqlc.Store) adapters.UserAdapter {\n    return adapterImpl.NewUserAdapter(sqlcStore)\n}); err != nil {\n    return fmt.Errorf(\"failed to provide user adapter: %w\", err)\n}\n```\n\n### 8. Use in Your Module\n\nNow your business modules can request the adapter through dependency injection:\n\n```go\ntype UserService struct {\n    userAdapter adapters.UserAdapter\n}\n\nfunc NewUserService(userAdapter adapters.UserAdapter) *UserService {\n    return &UserService{userAdapter: userAdapter}\n}\n\nfunc (s *UserService) GetUser(ctx context.Context, id int32) (*User, error) {\n    user, err := s.userAdapter.GetUserByID(ctx, id)\n    if err != nil {\n        return nil, err\n    }\n    // ... your business logic here\n}\n\nfunc (s *UserService) TransferFunds(ctx context.Context, fromID, toID int32, amount int32) error {\n    // The adapter handles the transaction internally\n    return s.userAdapter.TransferMoney(ctx, fromID, toID, amount)\n}\n```\n\n## Working with Transactions\n\nWhen you need multiple queries to succeed or fail together, add a transaction method to your adapter:\n\n**Key Points:**\n- Your module only depends on the adapter interface (never touches the database pool)\n- The adapter implementation handles transaction logic using `store.ExecTx()`\n- All queries inside `ExecTx()` run in a transaction\n- If any query fails, everything rolls back automatically\n- If all succeed, everything commits automatically\n\n## Common Commands\n\n```bash\nmake create-migration MIGRATION_NAME=your_change  # Create migration files\nmake migrateup                                    # Apply migrations\nmake migratedown                                  # Rollback last migration\nmake sqlc                                         # Generate Go code from SQL\nmake build                                        # Verify everything compiles\n```\n\n## Why This Pattern?\n\n- **Clean separation** - modules only know about adapters, not databases\n- **Easy to test** - mock the adapter interface, no database needed\n- **Type safety** - SQLC catches SQL errors at compile time\n- **Transaction safety** - adapters encapsulate transaction logic\n- **Single dependency** - modules only inject adapters\n\nThat's it! The pattern keeps your business logic clean and focused.\n"
  },
  {
    "path": "go-b2b-starter/internal/db/adapters/cognitive_store.go",
    "content": "package adapters\n\nimport (\n\t\"context\"\n\n\tdb \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/pgvector/pgvector-go\"\n)\n\n// EmbeddingStore provides database operations for document embeddings\ntype EmbeddingStore interface {\n\tCreateDocumentEmbedding(ctx context.Context, arg db.CreateDocumentEmbeddingParams) (db.CognitiveDocumentEmbedding, error)\n\tGetDocumentEmbeddingByID(ctx context.Context, arg db.GetDocumentEmbeddingByIDParams) (db.CognitiveDocumentEmbedding, error)\n\tGetDocumentEmbeddingsByDocumentID(ctx context.Context, arg db.GetDocumentEmbeddingsByDocumentIDParams) ([]db.CognitiveDocumentEmbedding, error)\n\tSearchSimilarDocuments(ctx context.Context, arg db.SearchSimilarDocumentsParams) ([]db.SearchSimilarDocumentsRow, error)\n\tDeleteDocumentEmbeddings(ctx context.Context, arg db.DeleteDocumentEmbeddingsParams) error\n\tCountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error)\n}\n\n// ChatStore provides database operations for chat sessions and messages\ntype ChatStore interface {\n\t// Sessions\n\tCreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.CognitiveChatSession, error)\n\tGetChatSessionByID(ctx context.Context, arg db.GetChatSessionByIDParams) (db.CognitiveChatSession, error)\n\tListChatSessionsByAccount(ctx context.Context, arg db.ListChatSessionsByAccountParams) ([]db.CognitiveChatSession, error)\n\tUpdateChatSessionTitle(ctx context.Context, arg db.UpdateChatSessionTitleParams) (db.CognitiveChatSession, error)\n\tDeleteChatSession(ctx context.Context, arg db.DeleteChatSessionParams) error\n\n\t// Messages\n\tCreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.CognitiveChatMessage, error)\n\tGetChatMessagesBySession(ctx context.Context, sessionID int32) ([]db.CognitiveChatMessage, error)\n\tGetRecentChatMessages(ctx context.Context, arg db.GetRecentChatMessagesParams) ([]db.CognitiveChatMessage, error)\n\tCountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error)\n\tDeleteChatMessage(ctx context.Context, id int32) error\n}\n\n// VectorHelper provides utilities for working with pgvector\ntype VectorHelper interface {\n\tToVector(embedding []float64) pgvector.Vector\n\tFromVector(v pgvector.Vector) []float64\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/adapters/document_store.go",
    "content": "package adapters\n\nimport (\n\t\"context\"\n\n\tdb \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// DocumentStore provides database operations for documents\ntype DocumentStore interface {\n\tCreateDocument(ctx context.Context, arg db.CreateDocumentParams) (db.DocumentsDocument, error)\n\tGetDocumentByID(ctx context.Context, arg db.GetDocumentByIDParams) (db.DocumentsDocument, error)\n\tGetDocumentByFileAssetID(ctx context.Context, arg db.GetDocumentByFileAssetIDParams) (db.DocumentsDocument, error)\n\tListDocumentsByOrganization(ctx context.Context, arg db.ListDocumentsByOrganizationParams) ([]db.DocumentsDocument, error)\n\tListDocumentsByStatus(ctx context.Context, arg db.ListDocumentsByStatusParams) ([]db.DocumentsDocument, error)\n\tUpdateDocumentStatus(ctx context.Context, arg db.UpdateDocumentStatusParams) (db.DocumentsDocument, error)\n\tUpdateDocumentExtractedText(ctx context.Context, arg db.UpdateDocumentExtractedTextParams) (db.DocumentsDocument, error)\n\tUpdateDocument(ctx context.Context, arg db.UpdateDocumentParams) (db.DocumentsDocument, error)\n\tDeleteDocument(ctx context.Context, arg db.DeleteDocumentParams) error\n\tCountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error)\n\tCountDocumentsByStatus(ctx context.Context, arg db.CountDocumentsByStatusParams) (int64, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/adapters/file_asset_store.go",
    "content": "package adapters\n\nimport (\n\t\"context\"\n\t\n\tdb \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// FileAssetStore defines the interface for file asset database operations\n// It exposes only file asset-related methods and returns SQLC types directly\ntype FileAssetStore interface {\n\t// Basic file asset operations - using SQLC method signatures\n\tCreateFileAsset(ctx context.Context, arg db.CreateFileAssetParams) (db.FileManagerFileAsset, error)\n\tGetFileAssetByID(ctx context.Context, id int32) (db.FileManagerFileAsset, error)\n\tDeleteFileAsset(ctx context.Context, id int32) error\n\tGetFileAssetsByEntity(ctx context.Context, arg db.GetFileAssetsByEntityParams) ([]db.FileManagerFileAsset, error)\n\tGetFileAssetsByEntityAndPurpose(ctx context.Context, arg db.GetFileAssetsByEntityAndPurposeParams) ([]db.FileManagerFileAsset, error)\n\t\n\t// Category and context-based operations\n\tGetFileAssetsByCategory(ctx context.Context, categoryName string) ([]db.GetFileAssetsByCategoryRow, error)\n\tGetFileAssetsByContext(ctx context.Context, contextName string) ([]db.GetFileAssetsByContextRow, error)\n\t\n\t// Update operations\n\tUpdateFileAsset(ctx context.Context, arg db.UpdateFileAssetParams) error\n\t\n\t// Search and lookup operations\n\tGetFileAssetByStoragePath(ctx context.Context, storagePath string) (db.FileManagerFileAsset, error)\n\tListFileAssets(ctx context.Context, arg db.ListFileAssetsParams) ([]db.ListFileAssetsRow, error)\n\t\n\t// Lookup tables operations\n\tGetFileCategories(ctx context.Context) ([]db.FileManagerFileCategory, error)\n\tGetFileContexts(ctx context.Context) ([]db.FileManagerFileContext, error)\n}"
  },
  {
    "path": "go-b2b-starter/internal/db/adapters/organization_store.go",
    "content": "package adapters\n\nimport (\n\t\"context\"\n\n\tdb \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\n// OrganizationStore provides database operations for organizations\ntype OrganizationStore interface {\n\tCreateOrganization(ctx context.Context, arg db.CreateOrganizationParams) (db.OrganizationsOrganization, error)\n\tGetOrganizationByID(ctx context.Context, id int32) (db.OrganizationsOrganization, error)\n\tGetOrganizationBySlug(ctx context.Context, slug string) (db.OrganizationsOrganization, error)\n\tGetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (db.OrganizationsOrganization, error)\n\tGetOrganizationByUserEmail(ctx context.Context, email string) (db.OrganizationsOrganization, error)\n\tUpdateOrganization(ctx context.Context, arg db.UpdateOrganizationParams) (db.OrganizationsOrganization, error)\n\tUpdateOrganizationStytchInfo(ctx context.Context, arg db.UpdateOrganizationStytchInfoParams) (db.OrganizationsOrganization, error)\n\tListOrganizations(ctx context.Context, arg db.ListOrganizationsParams) ([]db.OrganizationsOrganization, error)\n\tDeleteOrganization(ctx context.Context, id int32) error\n\tGetOrganizationStats(ctx context.Context, id int32) (db.GetOrganizationStatsRow, error)\n}\n\n// AccountStore provides database operations for accounts\ntype AccountStore interface {\n\tCreateAccount(ctx context.Context, arg db.CreateAccountParams) (db.OrganizationsAccount, error)\n\tGetAccountByID(ctx context.Context, arg db.GetAccountByIDParams) (db.OrganizationsAccount, error)\n\tGetAccountByEmail(ctx context.Context, arg db.GetAccountByEmailParams) (db.OrganizationsAccount, error)\n\tListAccountsByOrganization(ctx context.Context, organizationID int32) ([]db.OrganizationsAccount, error)\n\tUpdateAccount(ctx context.Context, arg db.UpdateAccountParams) (db.OrganizationsAccount, error)\n\tUpdateAccountStytchInfo(ctx context.Context, arg db.UpdateAccountStytchInfoParams) (db.OrganizationsAccount, error)\n\tUpdateAccountLastLogin(ctx context.Context, arg db.UpdateAccountLastLoginParams) (db.OrganizationsAccount, error)\n\tDeleteAccount(ctx context.Context, arg db.DeleteAccountParams) error\n\tGetAccountOrganization(ctx context.Context, id int32) (db.OrganizationsOrganization, error)\n\tCheckAccountPermission(ctx context.Context, arg db.CheckAccountPermissionParams) (db.CheckAccountPermissionRow, error)\n\tGetAccountStats(ctx context.Context, id int32) (db.GetAccountStatsRow, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/adapters/subscription_store.go",
    "content": "package adapters\n\nimport (\n\t\"context\"\n\n\tdb \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// SubscriptionStore provides database operations for subscription billing\ntype SubscriptionStore interface {\n\t// Subscription operations\n\tGetSubscriptionByOrgID(ctx context.Context, organizationID int32) (db.SubscriptionBillingSubscription, error)\n\tGetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (db.SubscriptionBillingSubscription, error)\n\tUpsertSubscription(ctx context.Context, arg db.UpsertSubscriptionParams) (db.SubscriptionBillingSubscription, error)\n\tDeleteSubscription(ctx context.Context, organizationID int32) error\n\tListActiveSubscriptions(ctx context.Context) ([]db.SubscriptionBillingSubscription, error)\n\n\t// Quota operations\n\tGetQuotaByOrgID(ctx context.Context, organizationID int32) (db.SubscriptionBillingQuotaTracking, error)\n\tUpsertQuota(ctx context.Context, arg db.UpsertQuotaParams) (db.SubscriptionBillingQuotaTracking, error)\n\tDecrementInvoiceCount(ctx context.Context, organizationID int32) (db.SubscriptionBillingQuotaTracking, error)\n\tResetQuotaForPeriod(ctx context.Context, arg db.ResetQuotaForPeriodParams) (db.SubscriptionBillingQuotaTracking, error)\n\n\t// Combined operations\n\tGetQuotaStatus(ctx context.Context, organizationID int32) (db.GetQuotaStatusRow, error)\n\tListQuotasNearLimit(ctx context.Context, threshold int32) ([]db.ListQuotasNearLimitRow, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n)\n\nfunc Init(container *dig.Container) {\n\tProvideDependencies(container)\n\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/cmd/providers.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/moasq/go-b2b-starter/internal/db\"\n\t\"go.uber.org/dig\"\n)\n\n// ProvideDependencies registers all database dependencies using the centralized inject\nfunc ProvideDependencies(container *dig.Container) error {\n\t// Use the centralized inject function with default options\n\treturn db.Inject(container)\n}\n\n// ProvideDependenciesWithOptions registers database dependencies with custom options\nfunc ProvideDependenciesWithOptions(container *dig.Container, opts db.InjectOptions) error {\n\treturn db.InjectWithOptions(container, opts)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/core/connection.go",
    "content": "package core\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\n// Connection represents a database connection interface\ntype Connection interface {\n\t// Execute runs a query that doesn't return rows\n\tExecute(ctx context.Context, query string, args ...any) error\n\n\t// Query executes a query that returns rows\n\tQuery(ctx context.Context, query string, args ...any) (Rows, error)\n\n\t// QueryRow executes a query that returns a single row\n\tQueryRow(ctx context.Context, query string, args ...any) Row\n\n\t// BeginTx starts a new transaction\n\tBeginTx(ctx context.Context) (Transaction, error)\n\n\t// Ping verifies the connection to the database is still alive\n\tPing(ctx context.Context) error\n\n\t// Close closes the database connection\n\tClose() error\n}\n\n// Pool represents a connection pool interface\ntype Pool interface {\n\tConnection\n\n\t// Stats returns connection pool statistics\n\tStats() PoolStats\n\n\t// SetMaxConnections sets the maximum number of connections in the pool\n\tSetMaxConnections(n int)\n\n\t// SetMaxConnectionLifetime sets the maximum lifetime of a connection\n\tSetMaxConnectionLifetime(d time.Duration)\n\n\t// SetMaxConnectionIdleTime sets the maximum idle time of a connection\n\tSetMaxConnectionIdleTime(d time.Duration)\n}\n\n// PoolStats contains connection pool statistics\ntype PoolStats struct {\n\tTotalConnections    int\n\tIdleConnections     int\n\tAcquiredConnections int\n\tMaxConnections      int\n}\n\n// Rows represents the result of a query\ntype Rows interface {\n\t// Next prepares the next row for reading\n\tNext() bool\n\n\t// Scan reads the values from the current row\n\tScan(dest ...any) error\n\n\t// Close closes the rows\n\tClose() error\n\n\t// Err returns any error that occurred during iteration\n\tErr() error\n}\n\n// Row represents a single row result\ntype Row interface {\n\t// Scan reads the values from the row\n\tScan(dest ...any) error\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/core/errors.go",
    "content": "package core\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\n// Common database errors\nvar (\n\t// ErrNoRows is returned when a query returns no rows\n\tErrNoRows = errors.New(\"no rows in result set\")\n\t\n\t// ErrTxClosed is returned when an operation is attempted on a closed transaction\n\tErrTxClosed = errors.New(\"transaction has already been committed or rolled back\")\n\t\n\t// ErrPoolClosed is returned when an operation is attempted on a closed pool\n\tErrPoolClosed = errors.New(\"connection pool is closed\")\n\t\n\t// ErrInvalidConnection is returned when the connection is invalid\n\tErrInvalidConnection = errors.New(\"invalid database connection\")\n\t\n\t// ErrTimeout is returned when a database operation times out\n\tErrTimeout = errors.New(\"database operation timed out\")\n)\n\n// ErrTxRollbackFailed is returned when a transaction rollback fails\ntype ErrTxRollbackFailed struct {\n\tOriginalErr error\n\tRollbackErr error\n}\n\nfunc (e ErrTxRollbackFailed) Error() string {\n\treturn fmt.Sprintf(\"transaction rollback failed: %v (original error: %v)\", e.RollbackErr, e.OriginalErr)\n}\n\nfunc (e ErrTxRollbackFailed) Unwrap() error {\n\treturn e.OriginalErr\n}\n\n// ErrTxCommitFailed is returned when a transaction commit fails\ntype ErrTxCommitFailed struct {\n\tErr error\n}\n\nfunc (e ErrTxCommitFailed) Error() string {\n\treturn fmt.Sprintf(\"transaction commit failed: %v\", e.Err)\n}\n\nfunc (e ErrTxCommitFailed) Unwrap() error {\n\treturn e.Err\n}\n\n// ErrConstraintViolation represents a database constraint violation\ntype ErrConstraintViolation struct {\n\tConstraint string\n\tMessage    string\n}\n\nfunc (e ErrConstraintViolation) Error() string {\n\treturn fmt.Sprintf(\"constraint violation '%s': %s\", e.Constraint, e.Message)\n}\n\n// IsNoRowsError checks if an error is a no rows error\nfunc IsNoRowsError(err error) bool {\n\treturn errors.Is(err, ErrNoRows)\n}\n\n// IsConstraintError checks if an error is a constraint violation\nfunc IsConstraintError(err error) bool {\n\tvar constraintErr ErrConstraintViolation\n\treturn errors.As(err, &constraintErr)\n}\n\n// IsTimeoutError checks if an error is a timeout error\nfunc IsTimeoutError(err error) bool {\n\treturn errors.Is(err, ErrTimeout)\n}"
  },
  {
    "path": "go-b2b-starter/internal/db/core/transaction.go",
    "content": "package core\n\nimport \"context\"\n\n// Transaction represents a database transaction\ntype Transaction interface {\n\tConnection\n\t\n\t// Commit commits the transaction\n\tCommit(ctx context.Context) error\n\t\n\t// Rollback rolls back the transaction\n\tRollback(ctx context.Context) error\n}\n\n// TxFunc represents a function that runs within a transaction\ntype TxFunc func(ctx context.Context, tx Transaction) error\n\n// WithTransaction executes a function within a transaction\n// It automatically handles commit/rollback based on the function's return value\nfunc WithTransaction(ctx context.Context, pool Pool, fn TxFunc) error {\n\ttx, err := pool.BeginTx(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\t\n\tdefer func() {\n\t\tif p := recover(); p != nil {\n\t\t\t_ = tx.Rollback(ctx)\n\t\t\tpanic(p)\n\t\t}\n\t}()\n\t\n\tif err := fn(ctx, tx); err != nil {\n\t\tif rbErr := tx.Rollback(ctx); rbErr != nil {\n\t\t\treturn ErrTxRollbackFailed{\n\t\t\t\tOriginalErr: err,\n\t\t\t\tRollbackErr: rbErr,\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\t\n\tif err := tx.Commit(ctx); err != nil {\n\t\treturn ErrTxCommitFailed{Err: err}\n\t}\n\t\n\treturn nil\n}"
  },
  {
    "path": "go-b2b-starter/internal/db/helpers/helpers.go",
    "content": "// Package helpers provides utility functions for converting between Go types\n// and PostgreSQL types (pgtype, pgvector). These helpers are used by repository\n// implementations across all modules.\npackage helpers\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\t\"github.com/pgvector/pgvector-go\"\n)\n\n// ToPgText converts a string to pgtype.Text\nfunc ToPgText(s string) pgtype.Text {\n\tif s == \"\" {\n\t\treturn pgtype.Text{Valid: false}\n\t}\n\treturn pgtype.Text{String: s, Valid: true}\n}\n\n// FromPgText converts pgtype.Text to string\nfunc FromPgText(t pgtype.Text) string {\n\tif !t.Valid {\n\t\treturn \"\"\n\t}\n\treturn t.String\n}\n\n// ToPgInt4 converts an int32 to pgtype.Int4\nfunc ToPgInt4(i int32) pgtype.Int4 {\n\treturn pgtype.Int4{Int32: i, Valid: true}\n}\n\n// ToPgInt4Ptr converts a pointer to int32 to pgtype.Int4\nfunc ToPgInt4Ptr(i *int32) pgtype.Int4 {\n\tif i == nil {\n\t\treturn pgtype.Int4{Valid: false}\n\t}\n\treturn pgtype.Int4{Int32: *i, Valid: true}\n}\n\n// FromPgInt4 converts pgtype.Int4 to int32\nfunc FromPgInt4(i pgtype.Int4) int32 {\n\tif !i.Valid {\n\t\treturn 0\n\t}\n\treturn i.Int32\n}\n\n// ToPgBool converts a bool to pgtype.Bool\nfunc ToPgBool(b bool) pgtype.Bool {\n\treturn pgtype.Bool{Bool: b, Valid: true}\n}\n\n// ToPgBoolPtr converts a pointer to bool to pgtype.Bool\nfunc ToPgBoolPtr(b *bool) pgtype.Bool {\n\tif b == nil {\n\t\treturn pgtype.Bool{Valid: false}\n\t}\n\treturn pgtype.Bool{Bool: *b, Valid: true}\n}\n\n// FromPgBool converts pgtype.Bool to bool\nfunc FromPgBool(b pgtype.Bool) bool {\n\tif !b.Valid {\n\t\treturn false\n\t}\n\treturn b.Bool\n}\n\n// ToJSONB converts a map to JSON bytes\nfunc ToJSONB(m map[string]any) []byte {\n\tif m == nil {\n\t\treturn []byte(\"{}\")\n\t}\n\tdata, err := json.Marshal(m)\n\tif err != nil {\n\t\treturn []byte(\"{}\")\n\t}\n\treturn data\n}\n\n// FromJSONB converts JSON bytes to a map\nfunc FromJSONB(b []byte) map[string]any {\n\tif len(b) == 0 {\n\t\treturn nil\n\t}\n\tvar result map[string]any\n\tif err := json.Unmarshal(b, &result); err != nil {\n\t\treturn nil\n\t}\n\treturn result\n}\n\n// ToVector converts a float64 slice to pgvector.Vector\nfunc ToVector(embedding []float64) pgvector.Vector {\n\t// Convert []float64 to []float32 for pgvector\n\tf32 := make([]float32, len(embedding))\n\tfor i, v := range embedding {\n\t\tf32[i] = float32(v)\n\t}\n\treturn pgvector.NewVector(f32)\n}\n\n// FromVector converts pgvector.Vector to float64 slice\nfunc FromVector(v pgvector.Vector) []float64 {\n\tf32 := v.Slice()\n\tresult := make([]float64, len(f32))\n\tfor i, val := range f32 {\n\t\tresult[i] = float64(val)\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/inject.go",
    "content": "package db\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"github.com/jackc/pgx/v5/pgxpool\"\n\t\"github.com/jackc/pgx/v5/stdlib\"\n\t\"go.uber.org/dig\"\n\n\t// Domain interfaces - these are the interfaces we provide\n\tbillingDomain \"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n\tcognitiveDomain \"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n\tdocumentDomain \"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n\tfileDomain \"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\torgDomain \"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\n\t// Repository implementations from module infra layers\n\tbillingRepos \"github.com/moasq/go-b2b-starter/internal/modules/billing/infra/repositories\"\n\tcognitiveRepos \"github.com/moasq/go-b2b-starter/internal/modules/cognitive/infra/repositories\"\n\tdocumentRepos \"github.com/moasq/go-b2b-starter/internal/modules/documents/infra/repositories\"\n\tfileInfra \"github.com/moasq/go-b2b-starter/internal/modules/files/infra\"\n\torgRepos \"github.com/moasq/go-b2b-starter/internal/modules/organizations/infra/repositories\"\n\n\t// Legacy adapters - kept temporarily for backward compatibility\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/postgres\"\n\tadapterImpl \"github.com/moasq/go-b2b-starter/internal/db/postgres/adapter_impl\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// Inject registers all database dependencies in the DI container\nfunc Inject(container *dig.Container) error {\n\t// Register configuration\n\tif err := container.Provide(postgres.LoadConfig); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide database config: %w\", err)\n\t}\n\n\t// Register connection pool\n\tif err := container.Provide(provideDBPool); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide database pool: %w\", err)\n\t}\n\n\t// Register SQLC store\n\tif err := container.Provide(provideSQLCStore); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide SQLC store: %w\", err)\n\t}\n\n\t// Register *sql.DB for modules that need standard database/sql interface\n\tif err := container.Provide(provideSQLDB); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide SQL DB: %w\", err)\n\t}\n\n\t// Register domain stores\n\tif err := registerDomainStores(container); err != nil {\n\t\treturn fmt.Errorf(\"failed to register domain stores: %w\", err)\n\t}\n\n\t// Register database manager\n\tif err := container.Provide(provideDBManager); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide database manager: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// provideDBPool creates the database connection pool\nfunc provideDBPool(config postgres.Config) (*pgxpool.Pool, error) {\n\treturn postgres.InitDB(config)\n}\n\n// provideSQLCStore creates the SQLC store\nfunc provideSQLCStore(pool *pgxpool.Pool) sqlc.Store {\n\treturn sqlc.NewStore(pool)\n}\n\n// provideSQLDB creates a *sql.DB from the pgxpool for compatibility\nfunc provideSQLDB(pool *pgxpool.Pool) *sql.DB {\n\t// Use pgx stdlib to create a sql.DB from the pool connection string\n\tconnConfig := pool.Config().ConnConfig\n\treturn stdlib.OpenDB(*connConfig)\n}\n\n// provideDBManager creates the database manager for migrations and health checks\nfunc provideDBManager(config postgres.Config, pool *pgxpool.Pool) *postgres.PostgresManager {\n\treturn postgres.NewPostgresManager(config, pool)\n}\n\n// registerDomainStores registers all domain-specific repositories.\n// These repositories implement domain ports using SQLC internally - no SQLC types leak out.\nfunc registerDomainStores(container *dig.Container) error {\n\t// ============================================\n\t// NEW: Sealed repository implementations\n\t// These use domain interfaces and hide SQLC internals\n\t// ============================================\n\n\t// Register DocumentRepository - implements documents/domain.DocumentRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) documentDomain.DocumentRepository {\n\t\treturn documentRepos.NewDocumentRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide document repository: %w\", err)\n\t}\n\n\t// Register OrganizationRepository - implements organizations/domain.OrganizationRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) orgDomain.OrganizationRepository {\n\t\treturn orgRepos.NewOrganizationRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide organization repository: %w\", err)\n\t}\n\n\t// Register AccountRepository - implements organizations/domain.AccountRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) orgDomain.AccountRepository {\n\t\treturn orgRepos.NewAccountRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide account repository: %w\", err)\n\t}\n\n\t// Register SubscriptionRepository - implements billing/domain.SubscriptionRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) billingDomain.SubscriptionRepository {\n\t\treturn billingRepos.NewSubscriptionRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide subscription repository: %w\", err)\n\t}\n\n\t// Register EmbeddingRepository - implements cognitive/domain.EmbeddingRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) cognitiveDomain.EmbeddingRepository {\n\t\treturn cognitiveRepos.NewEmbeddingRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide embedding repository: %w\", err)\n\t}\n\n\t// Register ChatRepository - implements cognitive/domain.ChatRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) cognitiveDomain.ChatRepository {\n\t\treturn cognitiveRepos.NewChatRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide chat repository: %w\", err)\n\t}\n\n\t// Register FileMetadataRepository - implements files/domain.FileMetadataRepository\n\tif err := container.Provide(func(sqlcStore sqlc.Store) fileDomain.FileMetadataRepository {\n\t\treturn fileInfra.NewFileMetadataRepository(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide file metadata repository: %w\", err)\n\t}\n\n\t// ============================================\n\t// LEGACY: Adapter stores (kept for backward compatibility)\n\t// TODO: Migrate callers to use domain interfaces, then remove these\n\t// ============================================\n\n\t// Register FileAssetStore - thin wrapper for file management operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.FileAssetStore {\n\t\treturn adapterImpl.NewFileAssetStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide file asset store: %w\", err)\n\t}\n\n\t// Register OrganizationStore - thin wrapper for organization operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.OrganizationStore {\n\t\treturn adapterImpl.NewOrganizationStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide organization store: %w\", err)\n\t}\n\n\t// Register AccountStore - thin wrapper for account operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.AccountStore {\n\t\treturn adapterImpl.NewAccountStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide account store: %w\", err)\n\t}\n\n\t// Register SubscriptionStore - thin wrapper for subscription billing operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.SubscriptionStore {\n\t\treturn adapterImpl.NewSubscriptionStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide subscription store: %w\", err)\n\t}\n\n\t// Register DocumentStore - thin wrapper for document operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.DocumentStore {\n\t\treturn adapterImpl.NewDocumentStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide document store: %w\", err)\n\t}\n\n\t// Register EmbeddingStore - thin wrapper for cognitive embedding operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.EmbeddingStore {\n\t\treturn adapterImpl.NewEmbeddingStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide embedding store: %w\", err)\n\t}\n\n\t// Register ChatStore - thin wrapper for cognitive chat operations\n\tif err := container.Provide(func(sqlcStore sqlc.Store) adapters.ChatStore {\n\t\treturn adapterImpl.NewChatStore(sqlcStore)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide chat store: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// InjectWithOptions allows injecting with custom options\ntype InjectOptions struct {\n\t// SkipMigrations skips running database migrations\n\tSkipMigrations bool\n\n\t// SkipHealthCheck skips the initial health check\n\tSkipHealthCheck bool\n}\n\n// InjectWithOptions registers database dependencies with options\nfunc InjectWithOptions(container *dig.Container, opts InjectOptions) error {\n\tif err := Inject(container); err != nil {\n\t\treturn err\n\t}\n\n\t// Optionally run migrations and health checks\n\tif !opts.SkipMigrations || !opts.SkipHealthCheck {\n\t\tif err := container.Invoke(func(manager *postgres.PostgresManager) error {\n\t\t\tif !opts.SkipHealthCheck {\n\t\t\t\tif err := manager.CheckHealth(context.Background()); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"database health check failed: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !opts.SkipMigrations {\n\t\t\t\tif err := manager.RunMigrations(); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to run migrations: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/adapter_impl/cognitive_store.go",
    "content": "package adapterimpl\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// embeddingStore implements adapters.EmbeddingStore\ntype embeddingStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewEmbeddingStore(store sqlc.Store) adapters.EmbeddingStore {\n\treturn &embeddingStore{store: store}\n}\n\nfunc (s *embeddingStore) CreateDocumentEmbedding(ctx context.Context, arg sqlc.CreateDocumentEmbeddingParams) (sqlc.CognitiveDocumentEmbedding, error) {\n\treturn s.store.CreateDocumentEmbedding(ctx, arg)\n}\n\nfunc (s *embeddingStore) GetDocumentEmbeddingByID(ctx context.Context, arg sqlc.GetDocumentEmbeddingByIDParams) (sqlc.CognitiveDocumentEmbedding, error) {\n\treturn s.store.GetDocumentEmbeddingByID(ctx, arg)\n}\n\nfunc (s *embeddingStore) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg sqlc.GetDocumentEmbeddingsByDocumentIDParams) ([]sqlc.CognitiveDocumentEmbedding, error) {\n\treturn s.store.GetDocumentEmbeddingsByDocumentID(ctx, arg)\n}\n\nfunc (s *embeddingStore) SearchSimilarDocuments(ctx context.Context, arg sqlc.SearchSimilarDocumentsParams) ([]sqlc.SearchSimilarDocumentsRow, error) {\n\treturn s.store.SearchSimilarDocuments(ctx, arg)\n}\n\nfunc (s *embeddingStore) DeleteDocumentEmbeddings(ctx context.Context, arg sqlc.DeleteDocumentEmbeddingsParams) error {\n\treturn s.store.DeleteDocumentEmbeddings(ctx, arg)\n}\n\nfunc (s *embeddingStore) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) {\n\treturn s.store.CountDocumentEmbeddingsByOrganization(ctx, organizationID)\n}\n\n// chatStore implements adapters.ChatStore\ntype chatStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewChatStore(store sqlc.Store) adapters.ChatStore {\n\treturn &chatStore{store: store}\n}\n\n// Sessions\n\nfunc (s *chatStore) CreateChatSession(ctx context.Context, arg sqlc.CreateChatSessionParams) (sqlc.CognitiveChatSession, error) {\n\treturn s.store.CreateChatSession(ctx, arg)\n}\n\nfunc (s *chatStore) GetChatSessionByID(ctx context.Context, arg sqlc.GetChatSessionByIDParams) (sqlc.CognitiveChatSession, error) {\n\treturn s.store.GetChatSessionByID(ctx, arg)\n}\n\nfunc (s *chatStore) ListChatSessionsByAccount(ctx context.Context, arg sqlc.ListChatSessionsByAccountParams) ([]sqlc.CognitiveChatSession, error) {\n\treturn s.store.ListChatSessionsByAccount(ctx, arg)\n}\n\nfunc (s *chatStore) UpdateChatSessionTitle(ctx context.Context, arg sqlc.UpdateChatSessionTitleParams) (sqlc.CognitiveChatSession, error) {\n\treturn s.store.UpdateChatSessionTitle(ctx, arg)\n}\n\nfunc (s *chatStore) DeleteChatSession(ctx context.Context, arg sqlc.DeleteChatSessionParams) error {\n\treturn s.store.DeleteChatSession(ctx, arg)\n}\n\n// Messages\n\nfunc (s *chatStore) CreateChatMessage(ctx context.Context, arg sqlc.CreateChatMessageParams) (sqlc.CognitiveChatMessage, error) {\n\treturn s.store.CreateChatMessage(ctx, arg)\n}\n\nfunc (s *chatStore) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]sqlc.CognitiveChatMessage, error) {\n\treturn s.store.GetChatMessagesBySession(ctx, sessionID)\n}\n\nfunc (s *chatStore) GetRecentChatMessages(ctx context.Context, arg sqlc.GetRecentChatMessagesParams) ([]sqlc.CognitiveChatMessage, error) {\n\treturn s.store.GetRecentChatMessages(ctx, arg)\n}\n\nfunc (s *chatStore) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) {\n\treturn s.store.CountChatMessagesBySession(ctx, sessionID)\n}\n\nfunc (s *chatStore) DeleteChatMessage(ctx context.Context, id int32) error {\n\treturn s.store.DeleteChatMessage(ctx, id)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/adapter_impl/document_store.go",
    "content": "package adapterimpl\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// documentStore implements adapters.DocumentStore\ntype documentStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewDocumentStore(store sqlc.Store) adapters.DocumentStore {\n\treturn &documentStore{store: store}\n}\n\nfunc (s *documentStore) CreateDocument(ctx context.Context, arg sqlc.CreateDocumentParams) (sqlc.DocumentsDocument, error) {\n\treturn s.store.CreateDocument(ctx, arg)\n}\n\nfunc (s *documentStore) GetDocumentByID(ctx context.Context, arg sqlc.GetDocumentByIDParams) (sqlc.DocumentsDocument, error) {\n\treturn s.store.GetDocumentByID(ctx, arg)\n}\n\nfunc (s *documentStore) GetDocumentByFileAssetID(ctx context.Context, arg sqlc.GetDocumentByFileAssetIDParams) (sqlc.DocumentsDocument, error) {\n\treturn s.store.GetDocumentByFileAssetID(ctx, arg)\n}\n\nfunc (s *documentStore) ListDocumentsByOrganization(ctx context.Context, arg sqlc.ListDocumentsByOrganizationParams) ([]sqlc.DocumentsDocument, error) {\n\treturn s.store.ListDocumentsByOrganization(ctx, arg)\n}\n\nfunc (s *documentStore) ListDocumentsByStatus(ctx context.Context, arg sqlc.ListDocumentsByStatusParams) ([]sqlc.DocumentsDocument, error) {\n\treturn s.store.ListDocumentsByStatus(ctx, arg)\n}\n\nfunc (s *documentStore) UpdateDocumentStatus(ctx context.Context, arg sqlc.UpdateDocumentStatusParams) (sqlc.DocumentsDocument, error) {\n\treturn s.store.UpdateDocumentStatus(ctx, arg)\n}\n\nfunc (s *documentStore) UpdateDocumentExtractedText(ctx context.Context, arg sqlc.UpdateDocumentExtractedTextParams) (sqlc.DocumentsDocument, error) {\n\treturn s.store.UpdateDocumentExtractedText(ctx, arg)\n}\n\nfunc (s *documentStore) UpdateDocument(ctx context.Context, arg sqlc.UpdateDocumentParams) (sqlc.DocumentsDocument, error) {\n\treturn s.store.UpdateDocument(ctx, arg)\n}\n\nfunc (s *documentStore) DeleteDocument(ctx context.Context, arg sqlc.DeleteDocumentParams) error {\n\treturn s.store.DeleteDocument(ctx, arg)\n}\n\nfunc (s *documentStore) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) {\n\treturn s.store.CountDocumentsByOrganization(ctx, organizationID)\n}\n\nfunc (s *documentStore) CountDocumentsByStatus(ctx context.Context, arg sqlc.CountDocumentsByStatusParams) (int64, error) {\n\treturn s.store.CountDocumentsByStatus(ctx, arg)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/adapter_impl/file_asset_store.go",
    "content": "package adapterimpl\n\nimport (\n\t\"context\"\n\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n)\n\n// fileAssetStore is a thin wrapper around SQLC store that implements adapters.FileAssetStore\n// It only exposes file asset-related methods and delegates directly to the underlying store\ntype fileAssetStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewFileAssetStore(store sqlc.Store) adapters.FileAssetStore {\n\treturn &fileAssetStore{\n\t\tstore: store,\n\t}\n}\n\n// Basic file asset operations - direct delegation to SQLC store\nfunc (f *fileAssetStore) CreateFileAsset(ctx context.Context, arg sqlc.CreateFileAssetParams) (sqlc.FileManagerFileAsset, error) {\n\treturn f.store.CreateFileAsset(ctx, arg)\n}\n\nfunc (f *fileAssetStore) GetFileAssetByID(ctx context.Context, id int32) (sqlc.FileManagerFileAsset, error) {\n\treturn f.store.GetFileAssetByID(ctx, id)\n}\n\nfunc (f *fileAssetStore) DeleteFileAsset(ctx context.Context, id int32) error {\n\treturn f.store.DeleteFileAsset(ctx, id)\n}\n\nfunc (f *fileAssetStore) GetFileAssetsByEntity(ctx context.Context, arg sqlc.GetFileAssetsByEntityParams) ([]sqlc.FileManagerFileAsset, error) {\n\treturn f.store.GetFileAssetsByEntity(ctx, arg)\n}\n\nfunc (f *fileAssetStore) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg sqlc.GetFileAssetsByEntityAndPurposeParams) ([]sqlc.FileManagerFileAsset, error) {\n\treturn f.store.GetFileAssetsByEntityAndPurpose(ctx, arg)\n}\n\n// Category and context-based operations - direct delegation\nfunc (f *fileAssetStore) GetFileAssetsByCategory(ctx context.Context, categoryName string) ([]sqlc.GetFileAssetsByCategoryRow, error) {\n\treturn f.store.GetFileAssetsByCategory(ctx, categoryName)\n}\n\nfunc (f *fileAssetStore) GetFileAssetsByContext(ctx context.Context, contextName string) ([]sqlc.GetFileAssetsByContextRow, error) {\n\treturn f.store.GetFileAssetsByContext(ctx, contextName)\n}\n\n// Update operations - direct delegation\nfunc (f *fileAssetStore) UpdateFileAsset(ctx context.Context, arg sqlc.UpdateFileAssetParams) error {\n\treturn f.store.UpdateFileAsset(ctx, arg)\n}\n\n// Search and lookup operations - direct delegation\nfunc (f *fileAssetStore) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (sqlc.FileManagerFileAsset, error) {\n\treturn f.store.GetFileAssetByStoragePath(ctx, storagePath)\n}\n\nfunc (f *fileAssetStore) ListFileAssets(ctx context.Context, arg sqlc.ListFileAssetsParams) ([]sqlc.ListFileAssetsRow, error) {\n\treturn f.store.ListFileAssets(ctx, arg)\n}\n\n// Lookup tables operations - direct delegation\nfunc (f *fileAssetStore) GetFileCategories(ctx context.Context) ([]sqlc.FileManagerFileCategory, error) {\n\treturn f.store.GetFileCategories(ctx)\n}\n\nfunc (f *fileAssetStore) GetFileContexts(ctx context.Context) ([]sqlc.FileManagerFileContext, error) {\n\treturn f.store.GetFileContexts(ctx)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/adapter_impl/organization_store.go",
    "content": "package adapterimpl\n\nimport (\n\t\"context\"\n\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\n// organizationStore implements adapters.OrganizationStore\ntype organizationStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewOrganizationStore(store sqlc.Store) adapters.OrganizationStore {\n\treturn &organizationStore{store: store}\n}\n\nfunc (s *organizationStore) GetOrganizationByID(ctx context.Context, id int32) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.GetOrganizationByID(ctx, id)\n}\n\nfunc (s *organizationStore) GetOrganizationBySlug(ctx context.Context, slug string) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.GetOrganizationBySlug(ctx, slug)\n}\n\nfunc (s *organizationStore) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.GetOrganizationByStytchID(ctx, stytchOrgID)\n}\n\nfunc (s *organizationStore) GetOrganizationByUserEmail(ctx context.Context, email string) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.GetOrganizationByUserEmail(ctx, email)\n}\n\nfunc (s *organizationStore) CreateOrganization(ctx context.Context, arg sqlc.CreateOrganizationParams) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.CreateOrganization(ctx, arg)\n}\n\nfunc (s *organizationStore) UpdateOrganization(ctx context.Context, arg sqlc.UpdateOrganizationParams) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.UpdateOrganization(ctx, arg)\n}\n\nfunc (s *organizationStore) UpdateOrganizationStytchInfo(ctx context.Context, arg sqlc.UpdateOrganizationStytchInfoParams) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.UpdateOrganizationStytchInfo(ctx, arg)\n}\n\nfunc (s *organizationStore) ListOrganizations(ctx context.Context, arg sqlc.ListOrganizationsParams) ([]sqlc.OrganizationsOrganization, error) {\n\treturn s.store.ListOrganizations(ctx, arg)\n}\n\nfunc (s *organizationStore) DeleteOrganization(ctx context.Context, id int32) error {\n\treturn s.store.DeleteOrganization(ctx, id)\n}\n\nfunc (s *organizationStore) GetOrganizationStats(ctx context.Context, id int32) (sqlc.GetOrganizationStatsRow, error) {\n\treturn s.store.GetOrganizationStats(ctx, id)\n}\n\n// accountStore implements adapters.AccountStore\ntype accountStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewAccountStore(store sqlc.Store) adapters.AccountStore {\n\treturn &accountStore{store: store}\n}\n\nfunc (s *accountStore) CreateAccount(ctx context.Context, arg sqlc.CreateAccountParams) (sqlc.OrganizationsAccount, error) {\n\treturn s.store.CreateAccount(ctx, arg)\n}\n\nfunc (s *accountStore) GetAccountByID(ctx context.Context, arg sqlc.GetAccountByIDParams) (sqlc.OrganizationsAccount, error) {\n\treturn s.store.GetAccountByID(ctx, arg)\n}\n\nfunc (s *accountStore) GetAccountByEmail(ctx context.Context, arg sqlc.GetAccountByEmailParams) (sqlc.OrganizationsAccount, error) {\n\treturn s.store.GetAccountByEmail(ctx, arg)\n}\n\nfunc (s *accountStore) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]sqlc.OrganizationsAccount, error) {\n\treturn s.store.ListAccountsByOrganization(ctx, organizationID)\n}\n\nfunc (s *accountStore) UpdateAccount(ctx context.Context, arg sqlc.UpdateAccountParams) (sqlc.OrganizationsAccount, error) {\n\treturn s.store.UpdateAccount(ctx, arg)\n}\n\nfunc (s *accountStore) UpdateAccountStytchInfo(ctx context.Context, arg sqlc.UpdateAccountStytchInfoParams) (sqlc.OrganizationsAccount, error) {\n\treturn s.store.UpdateAccountStytchInfo(ctx, arg)\n}\n\nfunc (s *accountStore) UpdateAccountLastLogin(ctx context.Context, arg sqlc.UpdateAccountLastLoginParams) (sqlc.OrganizationsAccount, error) {\n\treturn s.store.UpdateAccountLastLogin(ctx, arg)\n}\n\nfunc (s *accountStore) DeleteAccount(ctx context.Context, arg sqlc.DeleteAccountParams) error {\n\treturn s.store.DeleteAccount(ctx, arg)\n}\n\nfunc (s *accountStore) GetAccountOrganization(ctx context.Context, id int32) (sqlc.OrganizationsOrganization, error) {\n\treturn s.store.GetAccountOrganization(ctx, id)\n}\n\nfunc (s *accountStore) CheckAccountPermission(ctx context.Context, arg sqlc.CheckAccountPermissionParams) (sqlc.CheckAccountPermissionRow, error) {\n\treturn s.store.CheckAccountPermission(ctx, arg)\n}\n\nfunc (s *accountStore) GetAccountStats(ctx context.Context, id int32) (sqlc.GetAccountStatsRow, error) {\n\treturn s.store.GetAccountStats(ctx, id)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/adapter_impl/subscription_store.go",
    "content": "package adapterimpl\n\nimport (\n\t\"context\"\n\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n)\n\n// subscriptionStore implements adapters.SubscriptionStore\ntype subscriptionStore struct {\n\tstore sqlc.Store\n}\n\nfunc NewSubscriptionStore(store sqlc.Store) adapters.SubscriptionStore {\n\treturn &subscriptionStore{store: store}\n}\n\n// Subscription operations\n\nfunc (s *subscriptionStore) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingSubscription, error) {\n\treturn s.store.GetSubscriptionByOrgID(ctx, organizationID)\n}\n\nfunc (s *subscriptionStore) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (sqlc.SubscriptionBillingSubscription, error) {\n\treturn s.store.GetSubscriptionBySubscriptionID(ctx, subscriptionID)\n}\n\nfunc (s *subscriptionStore) UpsertSubscription(ctx context.Context, arg sqlc.UpsertSubscriptionParams) (sqlc.SubscriptionBillingSubscription, error) {\n\treturn s.store.UpsertSubscription(ctx, arg)\n}\n\nfunc (s *subscriptionStore) DeleteSubscription(ctx context.Context, organizationID int32) error {\n\treturn s.store.DeleteSubscription(ctx, organizationID)\n}\n\nfunc (s *subscriptionStore) ListActiveSubscriptions(ctx context.Context) ([]sqlc.SubscriptionBillingSubscription, error) {\n\treturn s.store.ListActiveSubscriptions(ctx)\n}\n\n// Quota operations\n\nfunc (s *subscriptionStore) GetQuotaByOrgID(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingQuotaTracking, error) {\n\treturn s.store.GetQuotaByOrgID(ctx, organizationID)\n}\n\nfunc (s *subscriptionStore) UpsertQuota(ctx context.Context, arg sqlc.UpsertQuotaParams) (sqlc.SubscriptionBillingQuotaTracking, error) {\n\treturn s.store.UpsertQuota(ctx, arg)\n}\n\nfunc (s *subscriptionStore) DecrementInvoiceCount(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingQuotaTracking, error) {\n\treturn s.store.DecrementInvoiceCount(ctx, organizationID)\n}\n\nfunc (s *subscriptionStore) ResetQuotaForPeriod(ctx context.Context, arg sqlc.ResetQuotaForPeriodParams) (sqlc.SubscriptionBillingQuotaTracking, error) {\n\treturn s.store.ResetQuotaForPeriod(ctx, arg)\n}\n\n// Combined operations\n\nfunc (s *subscriptionStore) GetQuotaStatus(ctx context.Context, organizationID int32) (sqlc.GetQuotaStatusRow, error) {\n\treturn s.store.GetQuotaStatus(ctx, organizationID)\n}\n\nfunc (s *subscriptionStore) ListQuotasNearLimit(ctx context.Context, threshold int32) ([]sqlc.ListQuotasNearLimitRow, error) {\n\treturn s.store.ListQuotasNearLimit(ctx, threshold)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/connection.go",
    "content": "package postgres\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\n\t\"github.com/jackc/pgx/v5\"\n\t\"github.com/jackc/pgx/v5/pgxpool\"\n)\n\nfunc connPool(cfg Config) (*pgxpool.Pool, error) {\n\t// Create a pool configuration\n\tpoolConfig, err := pgxpool.ParseConfig(cfg.ConnectionString())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to parse pool config: %w\", err)\n\t}\n\n\t// Set pool configuration parameters\n\tpoolConfig.MaxConns = int32(cfg.MaxConns)\n\tpoolConfig.MinConns = int32(cfg.MinConns)\n\tpoolConfig.MaxConnLifetime = cfg.ConnLifetime\n\tpoolConfig.MaxConnIdleTime = cfg.ConnIdleTime\n\tpoolConfig.HealthCheckPeriod = cfg.HealthCheckPeriod\n\n\t// Add connection lifecycle callbacks\n\tpoolConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {\n\t\t// Optional validation before using a connection\n\t\treturn true\n\t}\n\n\tpoolConfig.AfterRelease = func(conn *pgx.Conn) bool {\n\t\t// Clean up after connection use if needed\n\t\treturn true\n\t}\n\n\t// Create the connection pool with the configured settings\n\tpool, err := pgxpool.NewWithConfig(context.Background(), poolConfig)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create connection pool: %w\", err)\n\t}\n\n\t// Test the connection\n\tif err := pool.Ping(context.Background()); err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to ping database: %w\", err)\n\t}\n\n\tlog.Printf(\"Successfully connected to PostgreSQL database at %s:%s\", cfg.Host, cfg.Port)\n\n\treturn pool, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/db_config.go",
    "content": "package postgres\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype Config struct {\n\tHost         string `mapstructure:\"POSTGRES_HOST\"`\n\tPort         string `mapstructure:\"POSTGRES_PORT\"`\n\tUser         string `mapstructure:\"POSTGRES_USER\"`\n\tPassword     string `mapstructure:\"POSTGRES_PASSWORD\"`\n\tDBName       string `mapstructure:\"POSTGRES_DB\"`\n\tSSLMode      string `mapstructure:\"DB_SSL_MODE\"`\n\tMigrationURL string `mapstructure:\"MIGRATION_URL\"`\n\tSeedURL      string `mapstructure:\"SEED_URL\"`\n\n\t// Connection pool settings\n\tMaxConns          int           `mapstructure:\"DB_MAX_CONNS\"`\n\tMinConns          int           `mapstructure:\"DB_MIN_CONNS\"`\n\tConnLifetime      time.Duration `mapstructure:\"DB_CONN_LIFETIME\"`\n\tConnIdleTime      time.Duration `mapstructure:\"DB_CONN_IDLE_TIME\"`\n\tHealthCheckPeriod time.Duration `mapstructure:\"DB_HEALTH_CHECK_PERIOD\"`\n}\n\n// ConnectionString returns a formatted PostgreSQL connection string\nfunc (c Config) ConnectionString() string {\n\treturn fmt.Sprintf(\"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s application_name=nomadezy_api\",\n\t\tc.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode)\n}\n\n// LoadConfig reads configuration from file or environment variables.\nfunc LoadConfig() (Config, error) {\n\tvar cfg Config\n\n\tviper.SetConfigName(\"app\") // Name of the config file (without extension)\n\tviper.SetConfigType(\"env\") // Set the type of the configuration files - .env\n\tviper.AddConfigPath(\".\")   // Optionally look for config in the working directory\n\tviper.AutomaticEnv()\n\n\t// Set default values, these are overridden if values are present in config or environment variables\n\tviper.SetDefault(\"POSTGRES_PORT\", \"5432\")\n\tviper.SetDefault(\"DB_SSL_MODE\", \"disable\") // Use \"require\" in production, \"disable\" for local dev\n\tviper.SetDefault(\"POSTGRES_HOST\", \"localhost\")\n\tviper.SetDefault(\"POSTGRES_USER\", \"user\")\n\tviper.SetDefault(\"POSTGRES_PASSWORD\", \"password\")\n\tviper.SetDefault(\"POSTGRES_DB\", \"mydatabase\")\n\n\t// Connection pool defaults\n\tviper.SetDefault(\"DB_MAX_CONNS\", 20)\n\tviper.SetDefault(\"DB_MIN_CONNS\", 5)\n\tviper.SetDefault(\"DB_CONN_LIFETIME\", \"1h\")\n\tviper.SetDefault(\"DB_CONN_IDLE_TIME\", \"30m\")\n\tviper.SetDefault(\"DB_HEALTH_CHECK_PERIOD\", \"1m\")\n\n\tviper.SetDefault(\"MIGRATION_URL\", \"/migrations\")\n\tviper.SetDefault(\"SEED_URL\", \"/seed\")\n\n\tif err := viper.ReadInConfig(); err == nil {\n\t\t_ = err // Placeholder statement to avoid empty branch error\n\t}\n\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn cfg, err\n\t}\n\n\treturn cfg, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/init.go",
    "content": "package postgres\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/jackc/pgx/v5/pgxpool\"\n)\n\n// InitDB initializes and returns a connection pool to the database\nfunc InitDB(cfg Config) (*pgxpool.Pool, error) {\n\n\t// Create connection pool with retry logic\n\tvar pool *pgxpool.Pool\n\n\t// Setup context with timeout for initial connection\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\terr := RetryOperation(ctx, func(ctx context.Context) error {\n\t\tvar connErr error\n\t\tpool, connErr = connPool(cfg)\n\t\treturn connErr\n\t})\n\n\tif err != nil {\n\t\tlog.Printf(\"Failed to initialize database connection after retries: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tlog.Println(\"Database connection successfully initialized\")\n\n\t// Perform initial health check\n\thealthCtx, healthCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer healthCancel()\n\n\tmanager := NewPostgresManager(cfg, pool)\n\tif err := manager.CheckHealth(healthCtx); err != nil {\n\t\tlog.Printf(\"Initial database health check failed: %v\", err)\n\t\tpool.Close()\n\t\treturn nil, err\n\t}\n\n\treturn pool, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/postgres_manager.go",
    "content": "package postgres\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"sort\"\n\n\t\"github.com/jackc/pgx/v5/pgxpool\"\n\n\t\"context\"\n\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t_ \"github.com/golang-migrate/migrate/v4/source/file\"\n)\n\n// PostgresManager implements DatabaseManager for PostgreSQL\ntype PostgresManager struct {\n\tconfig   Config\n\tconnPool *pgxpool.Pool\n}\n\nfunc NewPostgresManager(config Config, connPool *pgxpool.Pool) *PostgresManager {\n\treturn &PostgresManager{\n\t\tconfig:   config,\n\t\tconnPool: connPool,\n\t}\n}\n\n// CheckHealth performs a health check on the database connection\nfunc (pm *PostgresManager) CheckHealth(ctx context.Context) error {\n\t// Create a context with timeout for health check\n\tctx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer cancel()\n\n\t// Try to ping the database\n\terr := pm.connPool.Ping(ctx)\n\tif err != nil {\n\t\tlog.Printf(\"Database health check failed: %v\", err)\n\t\treturn fmt.Errorf(\"database health check failed: %w\", err)\n\t}\n\n\tlog.Println(\"Database health check successful\")\n\treturn nil\n}\n\nfunc (pm *PostgresManager) RunMigrations() error {\n\tctx := context.Background()\n\n\t// Log the migration URL\n\tlog.Printf(\"Migration URL: %s\", pm.config.MigrationURL)\n\n\t// Check if the directory exists\n\tif _, err := os.Stat(pm.config.MigrationURL); os.IsNotExist(err) {\n\t\tlog.Printf(\"Migration directory does not exist: %s\", pm.config.MigrationURL)\n\t\treturn fmt.Errorf(\"migration directory does not exist: %w\", err)\n\t}\n\n\tfiles, err := os.ReadDir(pm.config.MigrationURL)\n\tif err != nil {\n\t\tlog.Printf(\"Failed to read migrations directory: %v\", err)\n\t\treturn fmt.Errorf(\"failed to read migrations directory: %w\", err)\n\t}\n\n\tlog.Printf(\"Total files in migration directory: %d\", len(files))\n\n\t// Filter and sort migration files\n\tvar migrationFiles []string\n\tfor _, file := range files {\n\t\tlog.Printf(\"Found file: %s\", file.Name())\n\t\tif strings.HasSuffix(file.Name(), \".up.sql\") {\n\t\t\tmigrationFiles = append(migrationFiles, file.Name())\n\t\t}\n\t}\n\tsort.Strings(migrationFiles)\n\n\tlog.Printf(\"Migration files to execute: %v\", migrationFiles)\n\n\tfor _, fileName := range migrationFiles {\n\t\tfullPath := filepath.Join(pm.config.MigrationURL, fileName)\n\t\tlog.Printf(\"Attempting to read file: %s\", fullPath)\n\n\t\tcontent, err := os.ReadFile(fullPath)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Failed to read migration file %s: %v\", fileName, err)\n\t\t\treturn fmt.Errorf(\"failed to read migration file %s: %w\", fileName, err)\n\t\t}\n\n\t\t_, err = pm.connPool.Exec(ctx, string(content))\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Failed to execute migration file %s: %v\", fileName, err)\n\t\t\treturn fmt.Errorf(\"failed to execute migration file %s: %w\", fileName, err)\n\t\t}\n\n\t\tlog.Printf(\"Executed migration file: %s\", fileName)\n\t}\n\n\treturn nil\n}\n\nfunc (pm *PostgresManager) RunSeeds() error {\n\tctx := context.Background()\n\n\t// Log the seed URL\n\tlog.Printf(\"Seed URL: %s\", pm.config.SeedURL)\n\n\t// Check if the directory exists\n\tif _, err := os.Stat(pm.config.SeedURL); os.IsNotExist(err) {\n\t\tlog.Printf(\"Seed directory does not exist: %s\", pm.config.SeedURL)\n\t\treturn fmt.Errorf(\"seed directory does not exist: %w\", err)\n\t}\n\n\tfiles, err := os.ReadDir(pm.config.SeedURL)\n\tif err != nil {\n\t\tlog.Printf(\"Failed to read seeds directory: %v\", err)\n\t\treturn fmt.Errorf(\"failed to read seeds directory: %w\", err)\n\t}\n\n\tlog.Printf(\"Total files in seed directory: %d\", len(files))\n\n\tfor _, file := range files {\n\t\tlog.Printf(\"Found file: %s\", file.Name())\n\t\tif !strings.HasSuffix(file.Name(), \".sql\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tfullPath := filepath.Join(pm.config.SeedURL, file.Name())\n\t\tlog.Printf(\"Attempting to read file: %s\", fullPath)\n\n\t\tcontent, err := os.ReadFile(fullPath)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Failed to read seed file %s: %v\", file.Name(), err)\n\t\t\treturn fmt.Errorf(\"failed to read seed file %s: %w\", file.Name(), err)\n\t\t}\n\n\t\t_, err = pm.connPool.Exec(ctx, string(content))\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Failed to execute seed file %s: %v\", file.Name(), err)\n\t\t\treturn fmt.Errorf(\"failed to execute seed file %s: %w\", file.Name(), err)\n\t\t}\n\n\t\tlog.Printf(\"Executed seed file: %s\", file.Name())\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/retry.go",
    "content": "package postgres\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"log\"\n\t\"time\"\n)\n\nconst (\n\tmaxRetries    = 3\n\tretryDelay    = 100 * time.Millisecond\n\tmaxRetryDelay = 1 * time.Second\n)\n\n// RetryOperation executes a database operation with exponential backoff retry\nfunc RetryOperation(ctx context.Context, operation func(context.Context) error) error {\n\tvar err error\n\n\tbackoff := retryDelay\n\tfor i := 0; i < maxRetries; i++ {\n\t\t// Execute the operation\n\t\terr = operation(ctx)\n\n\t\t// If no error or context cancelled, return immediately\n\t\tif err == nil || errors.Is(err, context.Canceled) {\n\t\t\treturn err\n\t\t}\n\n\t\t// Log the error for debugging\n\t\tlog.Printf(\"Database operation failed (attempt %d/%d): %v\", i+1, maxRetries, err)\n\n\t\t// Don't retry if the final attempt\n\t\tif i == maxRetries-1 {\n\t\t\tbreak\n\t\t}\n\n\t\t// Wait with backoff before retrying\n\t\tselect {\n\t\tcase <-time.After(backoff):\n\t\t\t// Exponential backoff\n\t\t\tbackoff *= 2\n\t\t\tif backoff > maxRetryDelay {\n\t\t\t\tbackoff = maxRetryDelay\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\t}\n\t}\n\n\treturn err\n}\n\n// CreateDBContext creates a context with an appropriate timeout for database operations\nfunc CreateDBContext(parent context.Context) (context.Context, context.CancelFunc) {\n\t// Default timeout of 10 seconds for database operations\n\treturn context.WithTimeout(parent, 10*time.Second)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/cognitive.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: cognitive.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\tpgvector_go \"github.com/pgvector/pgvector-go\"\n)\n\nconst countChatMessagesBySession = `-- name: CountChatMessagesBySession :one\nSELECT COUNT(*) FROM cognitive.chat_messages\nWHERE session_id = $1\n`\n\nfunc (q *Queries) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) {\n\trow := q.db.QueryRow(ctx, countChatMessagesBySession, sessionID)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst countDocumentEmbeddingsByOrganization = `-- name: CountDocumentEmbeddingsByOrganization :one\nSELECT COUNT(*) FROM cognitive.document_embeddings\nWHERE organization_id = $1\n`\n\nfunc (q *Queries) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) {\n\trow := q.db.QueryRow(ctx, countDocumentEmbeddingsByOrganization, organizationID)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst createChatMessage = `-- name: CreateChatMessage :one\n\nINSERT INTO cognitive.chat_messages (\n    session_id,\n    role,\n    content,\n    referenced_docs,\n    tokens_used\n) VALUES (\n    $1, $2, $3, $4, $5\n) RETURNING id, session_id, role, content, referenced_docs, tokens_used, created_at\n`\n\ntype CreateChatMessageParams struct {\n\tSessionID      int32       `json:\"session_id\"`\n\tRole           string      `json:\"role\"`\n\tContent        string      `json:\"content\"`\n\tReferencedDocs []int32     `json:\"referenced_docs\"`\n\tTokensUsed     pgtype.Int4 `json:\"tokens_used\"`\n}\n\n// Chat Messages\nfunc (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (CognitiveChatMessage, error) {\n\trow := q.db.QueryRow(ctx, createChatMessage,\n\t\targ.SessionID,\n\t\targ.Role,\n\t\targ.Content,\n\t\targ.ReferencedDocs,\n\t\targ.TokensUsed,\n\t)\n\tvar i CognitiveChatMessage\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.SessionID,\n\t\t&i.Role,\n\t\t&i.Content,\n\t\t&i.ReferencedDocs,\n\t\t&i.TokensUsed,\n\t\t&i.CreatedAt,\n\t)\n\treturn i, err\n}\n\nconst createChatSession = `-- name: CreateChatSession :one\n\nINSERT INTO cognitive.chat_sessions (\n    organization_id,\n    account_id,\n    title\n) VALUES (\n    $1, $2, $3\n) RETURNING id, organization_id, account_id, title, created_at, updated_at\n`\n\ntype CreateChatSessionParams struct {\n\tOrganizationID int32       `json:\"organization_id\"`\n\tAccountID      int32       `json:\"account_id\"`\n\tTitle          pgtype.Text `json:\"title\"`\n}\n\n// Chat Sessions\nfunc (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionParams) (CognitiveChatSession, error) {\n\trow := q.db.QueryRow(ctx, createChatSession, arg.OrganizationID, arg.AccountID, arg.Title)\n\tvar i CognitiveChatSession\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.AccountID,\n\t\t&i.Title,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst createDocumentEmbedding = `-- name: CreateDocumentEmbedding :one\n\n\nINSERT INTO cognitive.document_embeddings (\n    document_id,\n    organization_id,\n    embedding,\n    content_hash,\n    content_preview,\n    chunk_index\n) VALUES (\n    $1, $2, $3, $4, $5, $6\n) RETURNING id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at\n`\n\ntype CreateDocumentEmbeddingParams struct {\n\tDocumentID     int32              `json:\"document_id\"`\n\tOrganizationID int32              `json:\"organization_id\"`\n\tEmbedding      pgvector_go.Vector `json:\"embedding\"`\n\tContentHash    pgtype.Text        `json:\"content_hash\"`\n\tContentPreview pgtype.Text        `json:\"content_preview\"`\n\tChunkIndex     pgtype.Int4        `json:\"chunk_index\"`\n}\n\n// Cognitive Agent queries\n// Document Embeddings\nfunc (q *Queries) CreateDocumentEmbedding(ctx context.Context, arg CreateDocumentEmbeddingParams) (CognitiveDocumentEmbedding, error) {\n\trow := q.db.QueryRow(ctx, createDocumentEmbedding,\n\t\targ.DocumentID,\n\t\targ.OrganizationID,\n\t\targ.Embedding,\n\t\targ.ContentHash,\n\t\targ.ContentPreview,\n\t\targ.ChunkIndex,\n\t)\n\tvar i CognitiveDocumentEmbedding\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.DocumentID,\n\t\t&i.OrganizationID,\n\t\t&i.Embedding,\n\t\t&i.ContentHash,\n\t\t&i.ContentPreview,\n\t\t&i.ChunkIndex,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteChatMessage = `-- name: DeleteChatMessage :exec\nDELETE FROM cognitive.chat_messages\nWHERE id = $1\n`\n\nfunc (q *Queries) DeleteChatMessage(ctx context.Context, id int32) error {\n\t_, err := q.db.Exec(ctx, deleteChatMessage, id)\n\treturn err\n}\n\nconst deleteChatSession = `-- name: DeleteChatSession :exec\nDELETE FROM cognitive.chat_sessions\nWHERE id = $1 AND organization_id = $2\n`\n\ntype DeleteChatSessionParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) DeleteChatSession(ctx context.Context, arg DeleteChatSessionParams) error {\n\t_, err := q.db.Exec(ctx, deleteChatSession, arg.ID, arg.OrganizationID)\n\treturn err\n}\n\nconst deleteDocumentEmbeddings = `-- name: DeleteDocumentEmbeddings :exec\nDELETE FROM cognitive.document_embeddings\nWHERE document_id = $1 AND organization_id = $2\n`\n\ntype DeleteDocumentEmbeddingsParams struct {\n\tDocumentID     int32 `json:\"document_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) DeleteDocumentEmbeddings(ctx context.Context, arg DeleteDocumentEmbeddingsParams) error {\n\t_, err := q.db.Exec(ctx, deleteDocumentEmbeddings, arg.DocumentID, arg.OrganizationID)\n\treturn err\n}\n\nconst getChatMessagesBySession = `-- name: GetChatMessagesBySession :many\nSELECT id, session_id, role, content, referenced_docs, tokens_used, created_at FROM cognitive.chat_messages\nWHERE session_id = $1\nORDER BY created_at ASC\n`\n\nfunc (q *Queries) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]CognitiveChatMessage, error) {\n\trows, err := q.db.Query(ctx, getChatMessagesBySession, sessionID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []CognitiveChatMessage{}\n\tfor rows.Next() {\n\t\tvar i CognitiveChatMessage\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.SessionID,\n\t\t\t&i.Role,\n\t\t\t&i.Content,\n\t\t\t&i.ReferencedDocs,\n\t\t\t&i.TokensUsed,\n\t\t\t&i.CreatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getChatSessionByID = `-- name: GetChatSessionByID :one\nSELECT id, organization_id, account_id, title, created_at, updated_at FROM cognitive.chat_sessions\nWHERE id = $1 AND organization_id = $2\n`\n\ntype GetChatSessionByIDParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetChatSessionByID(ctx context.Context, arg GetChatSessionByIDParams) (CognitiveChatSession, error) {\n\trow := q.db.QueryRow(ctx, getChatSessionByID, arg.ID, arg.OrganizationID)\n\tvar i CognitiveChatSession\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.AccountID,\n\t\t&i.Title,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getDocumentEmbeddingByID = `-- name: GetDocumentEmbeddingByID :one\nSELECT id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at FROM cognitive.document_embeddings\nWHERE id = $1 AND organization_id = $2\n`\n\ntype GetDocumentEmbeddingByIDParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetDocumentEmbeddingByID(ctx context.Context, arg GetDocumentEmbeddingByIDParams) (CognitiveDocumentEmbedding, error) {\n\trow := q.db.QueryRow(ctx, getDocumentEmbeddingByID, arg.ID, arg.OrganizationID)\n\tvar i CognitiveDocumentEmbedding\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.DocumentID,\n\t\t&i.OrganizationID,\n\t\t&i.Embedding,\n\t\t&i.ContentHash,\n\t\t&i.ContentPreview,\n\t\t&i.ChunkIndex,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getDocumentEmbeddingsByDocumentID = `-- name: GetDocumentEmbeddingsByDocumentID :many\nSELECT id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at FROM cognitive.document_embeddings\nWHERE document_id = $1 AND organization_id = $2\nORDER BY chunk_index\n`\n\ntype GetDocumentEmbeddingsByDocumentIDParams struct {\n\tDocumentID     int32 `json:\"document_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg GetDocumentEmbeddingsByDocumentIDParams) ([]CognitiveDocumentEmbedding, error) {\n\trows, err := q.db.Query(ctx, getDocumentEmbeddingsByDocumentID, arg.DocumentID, arg.OrganizationID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []CognitiveDocumentEmbedding{}\n\tfor rows.Next() {\n\t\tvar i CognitiveDocumentEmbedding\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.DocumentID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.Embedding,\n\t\t\t&i.ContentHash,\n\t\t\t&i.ContentPreview,\n\t\t\t&i.ChunkIndex,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getRecentChatMessages = `-- name: GetRecentChatMessages :many\nSELECT id, session_id, role, content, referenced_docs, tokens_used, created_at FROM cognitive.chat_messages\nWHERE session_id = $1\nORDER BY created_at DESC\nLIMIT $2\n`\n\ntype GetRecentChatMessagesParams struct {\n\tSessionID int32 `json:\"session_id\"`\n\tLimit     int32 `json:\"limit\"`\n}\n\nfunc (q *Queries) GetRecentChatMessages(ctx context.Context, arg GetRecentChatMessagesParams) ([]CognitiveChatMessage, error) {\n\trows, err := q.db.Query(ctx, getRecentChatMessages, arg.SessionID, arg.Limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []CognitiveChatMessage{}\n\tfor rows.Next() {\n\t\tvar i CognitiveChatMessage\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.SessionID,\n\t\t\t&i.Role,\n\t\t\t&i.Content,\n\t\t\t&i.ReferencedDocs,\n\t\t\t&i.TokensUsed,\n\t\t\t&i.CreatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst listChatSessionsByAccount = `-- name: ListChatSessionsByAccount :many\nSELECT id, organization_id, account_id, title, created_at, updated_at FROM cognitive.chat_sessions\nWHERE organization_id = $1 AND account_id = $2\nORDER BY updated_at DESC\nLIMIT $3 OFFSET $4\n`\n\ntype ListChatSessionsByAccountParams struct {\n\tOrganizationID int32 `json:\"organization_id\"`\n\tAccountID      int32 `json:\"account_id\"`\n\tLimit          int32 `json:\"limit\"`\n\tOffset         int32 `json:\"offset\"`\n}\n\nfunc (q *Queries) ListChatSessionsByAccount(ctx context.Context, arg ListChatSessionsByAccountParams) ([]CognitiveChatSession, error) {\n\trows, err := q.db.Query(ctx, listChatSessionsByAccount,\n\t\targ.OrganizationID,\n\t\targ.AccountID,\n\t\targ.Limit,\n\t\targ.Offset,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []CognitiveChatSession{}\n\tfor rows.Next() {\n\t\tvar i CognitiveChatSession\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.AccountID,\n\t\t\t&i.Title,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst searchSimilarDocuments = `-- name: SearchSimilarDocuments :many\nSELECT\n    de.id,\n    de.document_id,\n    de.organization_id,\n    de.content_hash,\n    de.content_preview,\n    de.chunk_index,\n    de.created_at,\n    de.updated_at,\n    (1 - (de.embedding <=> $1::vector))::double precision as similarity_score\nFROM cognitive.document_embeddings de\nWHERE de.organization_id = $2\nORDER BY de.embedding <=> $1::vector\nLIMIT $3\n`\n\ntype SearchSimilarDocumentsParams struct {\n\tColumn1        pgvector_go.Vector `json:\"column_1\"`\n\tOrganizationID int32              `json:\"organization_id\"`\n\tLimit          int32              `json:\"limit\"`\n}\n\ntype SearchSimilarDocumentsRow struct {\n\tID              int32            `json:\"id\"`\n\tDocumentID      int32            `json:\"document_id\"`\n\tOrganizationID  int32            `json:\"organization_id\"`\n\tContentHash     pgtype.Text      `json:\"content_hash\"`\n\tContentPreview  pgtype.Text      `json:\"content_preview\"`\n\tChunkIndex      pgtype.Int4      `json:\"chunk_index\"`\n\tCreatedAt       pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt       pgtype.Timestamp `json:\"updated_at\"`\n\tSimilarityScore float64          `json:\"similarity_score\"`\n}\n\nfunc (q *Queries) SearchSimilarDocuments(ctx context.Context, arg SearchSimilarDocumentsParams) ([]SearchSimilarDocumentsRow, error) {\n\trows, err := q.db.Query(ctx, searchSimilarDocuments, arg.Column1, arg.OrganizationID, arg.Limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []SearchSimilarDocumentsRow{}\n\tfor rows.Next() {\n\t\tvar i SearchSimilarDocumentsRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.DocumentID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.ContentHash,\n\t\t\t&i.ContentPreview,\n\t\t\t&i.ChunkIndex,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.SimilarityScore,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst updateChatSessionTitle = `-- name: UpdateChatSessionTitle :one\nUPDATE cognitive.chat_sessions\nSET title = $3, updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING id, organization_id, account_id, title, created_at, updated_at\n`\n\ntype UpdateChatSessionTitleParams struct {\n\tID             int32       `json:\"id\"`\n\tOrganizationID int32       `json:\"organization_id\"`\n\tTitle          pgtype.Text `json:\"title\"`\n}\n\nfunc (q *Queries) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSessionTitleParams) (CognitiveChatSession, error) {\n\trow := q.db.QueryRow(ctx, updateChatSessionTitle, arg.ID, arg.OrganizationID, arg.Title)\n\tvar i CognitiveChatSession\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.AccountID,\n\t\t&i.Title,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/db.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5\"\n\t\"github.com/jackc/pgx/v5/pgconn\"\n)\n\ntype DBTX interface {\n\tExec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)\n\tQuery(context.Context, string, ...interface{}) (pgx.Rows, error)\n\tQueryRow(context.Context, string, ...interface{}) pgx.Row\n}\n\nfunc New(db DBTX) *Queries {\n\treturn &Queries{db: db}\n}\n\ntype Queries struct {\n\tdb DBTX\n}\n\nfunc (q *Queries) WithTx(tx pgx.Tx) *Queries {\n\treturn &Queries{\n\t\tdb: tx,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/documents.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: documents.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\nconst countDocumentsByOrganization = `-- name: CountDocumentsByOrganization :one\nSELECT COUNT(*) FROM documents.documents\nWHERE organization_id = $1\n`\n\nfunc (q *Queries) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) {\n\trow := q.db.QueryRow(ctx, countDocumentsByOrganization, organizationID)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst countDocumentsByStatus = `-- name: CountDocumentsByStatus :one\nSELECT COUNT(*) FROM documents.documents\nWHERE organization_id = $1 AND status = $2\n`\n\ntype CountDocumentsByStatusParams struct {\n\tOrganizationID int32  `json:\"organization_id\"`\n\tStatus         string `json:\"status\"`\n}\n\nfunc (q *Queries) CountDocumentsByStatus(ctx context.Context, arg CountDocumentsByStatusParams) (int64, error) {\n\trow := q.db.QueryRow(ctx, countDocumentsByStatus, arg.OrganizationID, arg.Status)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst createDocument = `-- name: CreateDocument :one\n\nINSERT INTO documents.documents (\n    organization_id,\n    file_asset_id,\n    title,\n    file_name,\n    content_type,\n    file_size,\n    extracted_text,\n    status,\n    metadata\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9\n) RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at\n`\n\ntype CreateDocumentParams struct {\n\tOrganizationID int32       `json:\"organization_id\"`\n\tFileAssetID    int32       `json:\"file_asset_id\"`\n\tTitle          string      `json:\"title\"`\n\tFileName       string      `json:\"file_name\"`\n\tContentType    string      `json:\"content_type\"`\n\tFileSize       int64       `json:\"file_size\"`\n\tExtractedText  pgtype.Text `json:\"extracted_text\"`\n\tStatus         string      `json:\"status\"`\n\tMetadata       []byte      `json:\"metadata\"`\n}\n\n// Documents queries\nfunc (q *Queries) CreateDocument(ctx context.Context, arg CreateDocumentParams) (DocumentsDocument, error) {\n\trow := q.db.QueryRow(ctx, createDocument,\n\t\targ.OrganizationID,\n\t\targ.FileAssetID,\n\t\targ.Title,\n\t\targ.FileName,\n\t\targ.ContentType,\n\t\targ.FileSize,\n\t\targ.ExtractedText,\n\t\targ.Status,\n\t\targ.Metadata,\n\t)\n\tvar i DocumentsDocument\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.FileAssetID,\n\t\t&i.Title,\n\t\t&i.FileName,\n\t\t&i.ContentType,\n\t\t&i.FileSize,\n\t\t&i.ExtractedText,\n\t\t&i.Status,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteDocument = `-- name: DeleteDocument :exec\nDELETE FROM documents.documents\nWHERE id = $1 AND organization_id = $2\n`\n\ntype DeleteDocumentParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error {\n\t_, err := q.db.Exec(ctx, deleteDocument, arg.ID, arg.OrganizationID)\n\treturn err\n}\n\nconst getDocumentByFileAssetID = `-- name: GetDocumentByFileAssetID :one\nSELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents\nWHERE file_asset_id = $1 AND organization_id = $2\n`\n\ntype GetDocumentByFileAssetIDParams struct {\n\tFileAssetID    int32 `json:\"file_asset_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetDocumentByFileAssetID(ctx context.Context, arg GetDocumentByFileAssetIDParams) (DocumentsDocument, error) {\n\trow := q.db.QueryRow(ctx, getDocumentByFileAssetID, arg.FileAssetID, arg.OrganizationID)\n\tvar i DocumentsDocument\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.FileAssetID,\n\t\t&i.Title,\n\t\t&i.FileName,\n\t\t&i.ContentType,\n\t\t&i.FileSize,\n\t\t&i.ExtractedText,\n\t\t&i.Status,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getDocumentByID = `-- name: GetDocumentByID :one\nSELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents\nWHERE id = $1 AND organization_id = $2\n`\n\ntype GetDocumentByIDParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (DocumentsDocument, error) {\n\trow := q.db.QueryRow(ctx, getDocumentByID, arg.ID, arg.OrganizationID)\n\tvar i DocumentsDocument\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.FileAssetID,\n\t\t&i.Title,\n\t\t&i.FileName,\n\t\t&i.ContentType,\n\t\t&i.FileSize,\n\t\t&i.ExtractedText,\n\t\t&i.Status,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst listDocumentsByOrganization = `-- name: ListDocumentsByOrganization :many\nSELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents\nWHERE organization_id = $1\nORDER BY created_at DESC\nLIMIT $2 OFFSET $3\n`\n\ntype ListDocumentsByOrganizationParams struct {\n\tOrganizationID int32 `json:\"organization_id\"`\n\tLimit          int32 `json:\"limit\"`\n\tOffset         int32 `json:\"offset\"`\n}\n\nfunc (q *Queries) ListDocumentsByOrganization(ctx context.Context, arg ListDocumentsByOrganizationParams) ([]DocumentsDocument, error) {\n\trows, err := q.db.Query(ctx, listDocumentsByOrganization, arg.OrganizationID, arg.Limit, arg.Offset)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []DocumentsDocument{}\n\tfor rows.Next() {\n\t\tvar i DocumentsDocument\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.FileAssetID,\n\t\t\t&i.Title,\n\t\t\t&i.FileName,\n\t\t\t&i.ContentType,\n\t\t\t&i.FileSize,\n\t\t\t&i.ExtractedText,\n\t\t\t&i.Status,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst listDocumentsByStatus = `-- name: ListDocumentsByStatus :many\nSELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents\nWHERE organization_id = $1 AND status = $2\nORDER BY created_at DESC\nLIMIT $3 OFFSET $4\n`\n\ntype ListDocumentsByStatusParams struct {\n\tOrganizationID int32  `json:\"organization_id\"`\n\tStatus         string `json:\"status\"`\n\tLimit          int32  `json:\"limit\"`\n\tOffset         int32  `json:\"offset\"`\n}\n\nfunc (q *Queries) ListDocumentsByStatus(ctx context.Context, arg ListDocumentsByStatusParams) ([]DocumentsDocument, error) {\n\trows, err := q.db.Query(ctx, listDocumentsByStatus,\n\t\targ.OrganizationID,\n\t\targ.Status,\n\t\targ.Limit,\n\t\targ.Offset,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []DocumentsDocument{}\n\tfor rows.Next() {\n\t\tvar i DocumentsDocument\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.FileAssetID,\n\t\t\t&i.Title,\n\t\t\t&i.FileName,\n\t\t\t&i.ContentType,\n\t\t\t&i.FileSize,\n\t\t\t&i.ExtractedText,\n\t\t\t&i.Status,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst updateDocument = `-- name: UpdateDocument :one\nUPDATE documents.documents\nSET\n    title = COALESCE($3, title),\n    metadata = COALESCE($4, metadata),\n    updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at\n`\n\ntype UpdateDocumentParams struct {\n\tID             int32  `json:\"id\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n\tTitle          string `json:\"title\"`\n\tMetadata       []byte `json:\"metadata\"`\n}\n\nfunc (q *Queries) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (DocumentsDocument, error) {\n\trow := q.db.QueryRow(ctx, updateDocument,\n\t\targ.ID,\n\t\targ.OrganizationID,\n\t\targ.Title,\n\t\targ.Metadata,\n\t)\n\tvar i DocumentsDocument\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.FileAssetID,\n\t\t&i.Title,\n\t\t&i.FileName,\n\t\t&i.ContentType,\n\t\t&i.FileSize,\n\t\t&i.ExtractedText,\n\t\t&i.Status,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateDocumentExtractedText = `-- name: UpdateDocumentExtractedText :one\nUPDATE documents.documents\nSET extracted_text = $3, status = 'processed', updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at\n`\n\ntype UpdateDocumentExtractedTextParams struct {\n\tID             int32       `json:\"id\"`\n\tOrganizationID int32       `json:\"organization_id\"`\n\tExtractedText  pgtype.Text `json:\"extracted_text\"`\n}\n\nfunc (q *Queries) UpdateDocumentExtractedText(ctx context.Context, arg UpdateDocumentExtractedTextParams) (DocumentsDocument, error) {\n\trow := q.db.QueryRow(ctx, updateDocumentExtractedText, arg.ID, arg.OrganizationID, arg.ExtractedText)\n\tvar i DocumentsDocument\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.FileAssetID,\n\t\t&i.Title,\n\t\t&i.FileName,\n\t\t&i.ContentType,\n\t\t&i.FileSize,\n\t\t&i.ExtractedText,\n\t\t&i.Status,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateDocumentStatus = `-- name: UpdateDocumentStatus :one\nUPDATE documents.documents\nSET status = $3, updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at\n`\n\ntype UpdateDocumentStatusParams struct {\n\tID             int32  `json:\"id\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n\tStatus         string `json:\"status\"`\n}\n\nfunc (q *Queries) UpdateDocumentStatus(ctx context.Context, arg UpdateDocumentStatusParams) (DocumentsDocument, error) {\n\trow := q.db.QueryRow(ctx, updateDocumentStatus, arg.ID, arg.OrganizationID, arg.Status)\n\tvar i DocumentsDocument\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.FileAssetID,\n\t\t&i.Title,\n\t\t&i.FileName,\n\t\t&i.ContentType,\n\t\t&i.FileSize,\n\t\t&i.ExtractedText,\n\t\t&i.Status,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/error.go",
    "content": "package postgres\n\nimport (\n\t\"errors\"\n\n\t\"github.com/jackc/pgx/v5\"\n\t\"github.com/jackc/pgx/v5/pgconn\"\n)\n\nconst (\n\tForeignKeyViolation = \"23503\"\n\tUniqueViolation     = \"23505\"\n)\n\nvar ErrRecordNotFound = pgx.ErrNoRows\n\nvar ErrUniqueViolation = &pgconn.PgError{\n\tCode: UniqueViolation,\n}\n\nfunc ErrorCode(err error) string {\n\tvar pgErr *pgconn.PgError\n\tif errors.As(err, &pgErr) {\n\t\treturn pgErr.Code\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/example_resource.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: example_resource.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\nconst assignResourceApproval = `-- name: AssignResourceApproval :exec\nUPDATE example_resources SET\n    approval_assigned_to_id = $3,\n    approval_status = 'pending',\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true\n`\n\ntype AssignResourceApprovalParams struct {\n\tID                   int32       `json:\"id\"`\n\tOrganizationID       int32       `json:\"organization_id\"`\n\tApprovalAssignedToID pgtype.Int4 `json:\"approval_assigned_to_id\"`\n}\n\n// Assign resource to someone for approval\nfunc (q *Queries) AssignResourceApproval(ctx context.Context, arg AssignResourceApprovalParams) error {\n\t_, err := q.db.Exec(ctx, assignResourceApproval, arg.ID, arg.OrganizationID, arg.ApprovalAssignedToID)\n\treturn err\n}\n\nconst attachFileToResource = `-- name: AttachFileToResource :exec\nUPDATE example_resources SET\n    file_id = $3,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true\n`\n\ntype AttachFileToResourceParams struct {\n\tID             int32       `json:\"id\"`\n\tOrganizationID int32       `json:\"organization_id\"`\n\tFileID         pgtype.Int4 `json:\"file_id\"`\n}\n\n// Attach a file to a resource\nfunc (q *Queries) AttachFileToResource(ctx context.Context, arg AttachFileToResourceParams) error {\n\t_, err := q.db.Exec(ctx, attachFileToResource, arg.ID, arg.OrganizationID, arg.FileID)\n\treturn err\n}\n\nconst countResources = `-- name: CountResources :one\nSELECT COUNT(*) FROM example_resources\nWHERE organization_id = $1 AND is_active = true\n    AND ($2::smallint IS NULL OR status_id = $2)\n    AND ($3::varchar IS NULL OR approval_status = $3)\n    AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%')\n`\n\ntype CountResourcesParams struct {\n\tOrganizationID int32  `json:\"organization_id\"`\n\tColumn2        int16  `json:\"column_2\"`\n\tColumn3        string `json:\"column_3\"`\n\tColumn4        string `json:\"column_4\"`\n}\n\n// Count resources for pagination\nfunc (q *Queries) CountResources(ctx context.Context, arg CountResourcesParams) (int64, error) {\n\trow := q.db.QueryRow(ctx, countResources,\n\t\targ.OrganizationID,\n\t\targ.Column2,\n\t\targ.Column3,\n\t\targ.Column4,\n\t)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst createMinimalResource = `-- name: CreateMinimalResource :one\nINSERT INTO example_resources (\n    resource_number, title, organization_id, created_by_account_id, status_id\n) VALUES (\n    $1, $2, $3, $4, 1\n) RETURNING id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at\n`\n\ntype CreateMinimalResourceParams struct {\n\tResourceNumber     string      `json:\"resource_number\"`\n\tTitle              string      `json:\"title\"`\n\tOrganizationID     int32       `json:\"organization_id\"`\n\tCreatedByAccountID pgtype.Int4 `json:\"created_by_account_id\"`\n}\n\n// Creates a minimal placeholder resource\nfunc (q *Queries) CreateMinimalResource(ctx context.Context, arg CreateMinimalResourceParams) (ExampleResource, error) {\n\trow := q.db.QueryRow(ctx, createMinimalResource,\n\t\targ.ResourceNumber,\n\t\targ.Title,\n\t\targ.OrganizationID,\n\t\targ.CreatedByAccountID,\n\t)\n\tvar i ExampleResource\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceNumber,\n\t\t&i.Title,\n\t\t&i.Description,\n\t\t&i.StatusID,\n\t\t&i.FileID,\n\t\t&i.ExtractedData,\n\t\t&i.ProcessedData,\n\t\t&i.Confidence,\n\t\t&i.OrganizationID,\n\t\t&i.CreatedByAccountID,\n\t\t&i.ApprovalStatus,\n\t\t&i.ApprovalAssignedToID,\n\t\t&i.ApprovalActionTakerID,\n\t\t&i.ApprovalNotes,\n\t\t&i.Metadata,\n\t\t&i.IsActive,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst createResource = `-- name: CreateResource :one\n\n\nINSERT INTO example_resources (\n    resource_number, title, description, status_id, file_id,\n    extracted_data, processed_data, confidence,\n    organization_id, created_by_account_id, metadata\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11\n) RETURNING id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at\n`\n\ntype CreateResourceParams struct {\n\tResourceNumber     string         `json:\"resource_number\"`\n\tTitle              string         `json:\"title\"`\n\tDescription        pgtype.Text    `json:\"description\"`\n\tStatusID           int16          `json:\"status_id\"`\n\tFileID             pgtype.Int4    `json:\"file_id\"`\n\tExtractedData      []byte         `json:\"extracted_data\"`\n\tProcessedData      []byte         `json:\"processed_data\"`\n\tConfidence         pgtype.Numeric `json:\"confidence\"`\n\tOrganizationID     int32          `json:\"organization_id\"`\n\tCreatedByAccountID pgtype.Int4    `json:\"created_by_account_id\"`\n\tMetadata           []byte         `json:\"metadata\"`\n}\n\n// Example Resource Queries\n// Demonstrates Clean Architecture patterns with CRUD operations,\n// file attachments, OCR/LLM processing, and approval workflows\n// CREATE operations\nfunc (q *Queries) CreateResource(ctx context.Context, arg CreateResourceParams) (ExampleResource, error) {\n\trow := q.db.QueryRow(ctx, createResource,\n\t\targ.ResourceNumber,\n\t\targ.Title,\n\t\targ.Description,\n\t\targ.StatusID,\n\t\targ.FileID,\n\t\targ.ExtractedData,\n\t\targ.ProcessedData,\n\t\targ.Confidence,\n\t\targ.OrganizationID,\n\t\targ.CreatedByAccountID,\n\t\targ.Metadata,\n\t)\n\tvar i ExampleResource\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceNumber,\n\t\t&i.Title,\n\t\t&i.Description,\n\t\t&i.StatusID,\n\t\t&i.FileID,\n\t\t&i.ExtractedData,\n\t\t&i.ProcessedData,\n\t\t&i.Confidence,\n\t\t&i.OrganizationID,\n\t\t&i.CreatedByAccountID,\n\t\t&i.ApprovalStatus,\n\t\t&i.ApprovalAssignedToID,\n\t\t&i.ApprovalActionTakerID,\n\t\t&i.ApprovalNotes,\n\t\t&i.Metadata,\n\t\t&i.IsActive,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteResource = `-- name: DeleteResource :exec\n\nUPDATE example_resources SET\n    is_active = false,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\n`\n\ntype DeleteResourceParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\n// DELETE operations\n// Soft delete a resource\nfunc (q *Queries) DeleteResource(ctx context.Context, arg DeleteResourceParams) error {\n\t_, err := q.db.Exec(ctx, deleteResource, arg.ID, arg.OrganizationID)\n\treturn err\n}\n\nconst getRecentResources = `-- name: GetRecentResources :many\nSELECT\n    id, resource_number, title, status_id, confidence,\n    created_by_account_id, created_at\nFROM example_resources\nWHERE organization_id = $1 AND is_active = true\nORDER BY created_at DESC\nLIMIT $2\n`\n\ntype GetRecentResourcesParams struct {\n\tOrganizationID int32 `json:\"organization_id\"`\n\tLimit          int32 `json:\"limit\"`\n}\n\ntype GetRecentResourcesRow struct {\n\tID                 int32            `json:\"id\"`\n\tResourceNumber     string           `json:\"resource_number\"`\n\tTitle              string           `json:\"title\"`\n\tStatusID           int16            `json:\"status_id\"`\n\tConfidence         pgtype.Numeric   `json:\"confidence\"`\n\tCreatedByAccountID pgtype.Int4      `json:\"created_by_account_id\"`\n\tCreatedAt          pgtype.Timestamp `json:\"created_at\"`\n}\n\n// Get most recently created resources\nfunc (q *Queries) GetRecentResources(ctx context.Context, arg GetRecentResourcesParams) ([]GetRecentResourcesRow, error) {\n\trows, err := q.db.Query(ctx, getRecentResources, arg.OrganizationID, arg.Limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []GetRecentResourcesRow{}\n\tfor rows.Next() {\n\t\tvar i GetRecentResourcesRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.ResourceNumber,\n\t\t\t&i.Title,\n\t\t\t&i.StatusID,\n\t\t\t&i.Confidence,\n\t\t\t&i.CreatedByAccountID,\n\t\t\t&i.CreatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getResourceByID = `-- name: GetResourceByID :one\n\nSELECT id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at FROM example_resources\nWHERE id = $1 AND organization_id = $2 AND is_active = true\n`\n\ntype GetResourceByIDParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\n// READ operations\nfunc (q *Queries) GetResourceByID(ctx context.Context, arg GetResourceByIDParams) (ExampleResource, error) {\n\trow := q.db.QueryRow(ctx, getResourceByID, arg.ID, arg.OrganizationID)\n\tvar i ExampleResource\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceNumber,\n\t\t&i.Title,\n\t\t&i.Description,\n\t\t&i.StatusID,\n\t\t&i.FileID,\n\t\t&i.ExtractedData,\n\t\t&i.ProcessedData,\n\t\t&i.Confidence,\n\t\t&i.OrganizationID,\n\t\t&i.CreatedByAccountID,\n\t\t&i.ApprovalStatus,\n\t\t&i.ApprovalAssignedToID,\n\t\t&i.ApprovalActionTakerID,\n\t\t&i.ApprovalNotes,\n\t\t&i.Metadata,\n\t\t&i.IsActive,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getResourceByNumber = `-- name: GetResourceByNumber :one\nSELECT id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at FROM example_resources\nWHERE resource_number = $1 AND organization_id = $2 AND is_active = true\n`\n\ntype GetResourceByNumberParams struct {\n\tResourceNumber string `json:\"resource_number\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetResourceByNumber(ctx context.Context, arg GetResourceByNumberParams) (ExampleResource, error) {\n\trow := q.db.QueryRow(ctx, getResourceByNumber, arg.ResourceNumber, arg.OrganizationID)\n\tvar i ExampleResource\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceNumber,\n\t\t&i.Title,\n\t\t&i.Description,\n\t\t&i.StatusID,\n\t\t&i.FileID,\n\t\t&i.ExtractedData,\n\t\t&i.ProcessedData,\n\t\t&i.Confidence,\n\t\t&i.OrganizationID,\n\t\t&i.CreatedByAccountID,\n\t\t&i.ApprovalStatus,\n\t\t&i.ApprovalAssignedToID,\n\t\t&i.ApprovalActionTakerID,\n\t\t&i.ApprovalNotes,\n\t\t&i.Metadata,\n\t\t&i.IsActive,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getResourceStats = `-- name: GetResourceStats :one\n\nSELECT\n    COUNT(*) as total_resources,\n    COUNT(*) FILTER (WHERE status_id = 1) as draft_count,\n    COUNT(*) FILTER (WHERE status_id = 2) as processing_count,\n    COUNT(*) FILTER (WHERE status_id = 3) as completed_count,\n    COUNT(*) FILTER (WHERE approval_status = 'pending') as pending_approval,\n    COUNT(*) FILTER (WHERE approval_status = 'approved') as approved_count,\n    AVG(confidence) as avg_confidence\nFROM example_resources\nWHERE organization_id = $1 AND is_active = true\n`\n\ntype GetResourceStatsRow struct {\n\tTotalResources  int64   `json:\"total_resources\"`\n\tDraftCount      int64   `json:\"draft_count\"`\n\tProcessingCount int64   `json:\"processing_count\"`\n\tCompletedCount  int64   `json:\"completed_count\"`\n\tPendingApproval int64   `json:\"pending_approval\"`\n\tApprovedCount   int64   `json:\"approved_count\"`\n\tAvgConfidence   float64 `json:\"avg_confidence\"`\n}\n\n// ANALYTICS queries\n// Get statistics for dashboard\nfunc (q *Queries) GetResourceStats(ctx context.Context, organizationID int32) (GetResourceStatsRow, error) {\n\trow := q.db.QueryRow(ctx, getResourceStats, organizationID)\n\tvar i GetResourceStatsRow\n\terr := row.Scan(\n\t\t&i.TotalResources,\n\t\t&i.DraftCount,\n\t\t&i.ProcessingCount,\n\t\t&i.CompletedCount,\n\t\t&i.PendingApproval,\n\t\t&i.ApprovedCount,\n\t\t&i.AvgConfidence,\n\t)\n\treturn i, err\n}\n\nconst getResourcesByCreator = `-- name: GetResourcesByCreator :many\nSELECT id, resource_number, title, description, status_id, file_id, extracted_data, processed_data, confidence, organization_id, created_by_account_id, approval_status, approval_assigned_to_id, approval_action_taker_id, approval_notes, metadata, is_active, created_at, updated_at FROM example_resources\nWHERE organization_id = $1\n    AND created_by_account_id = $2\n    AND is_active = true\nORDER BY created_at DESC\nLIMIT $3 OFFSET $4\n`\n\ntype GetResourcesByCreatorParams struct {\n\tOrganizationID     int32       `json:\"organization_id\"`\n\tCreatedByAccountID pgtype.Int4 `json:\"created_by_account_id\"`\n\tLimit              int32       `json:\"limit\"`\n\tOffset             int32       `json:\"offset\"`\n}\n\n// Get resources created by a specific user\nfunc (q *Queries) GetResourcesByCreator(ctx context.Context, arg GetResourcesByCreatorParams) ([]ExampleResource, error) {\n\trows, err := q.db.Query(ctx, getResourcesByCreator,\n\t\targ.OrganizationID,\n\t\targ.CreatedByAccountID,\n\t\targ.Limit,\n\t\targ.Offset,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []ExampleResource{}\n\tfor rows.Next() {\n\t\tvar i ExampleResource\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.ResourceNumber,\n\t\t\t&i.Title,\n\t\t\t&i.Description,\n\t\t\t&i.StatusID,\n\t\t\t&i.FileID,\n\t\t\t&i.ExtractedData,\n\t\t\t&i.ProcessedData,\n\t\t\t&i.Confidence,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.CreatedByAccountID,\n\t\t\t&i.ApprovalStatus,\n\t\t\t&i.ApprovalAssignedToID,\n\t\t\t&i.ApprovalActionTakerID,\n\t\t\t&i.ApprovalNotes,\n\t\t\t&i.Metadata,\n\t\t\t&i.IsActive,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst hardDeleteResource = `-- name: HardDeleteResource :exec\nDELETE FROM example_resources\nWHERE id = $1 AND organization_id = $2\n`\n\ntype HardDeleteResourceParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\n// Hard delete a resource (use with caution)\nfunc (q *Queries) HardDeleteResource(ctx context.Context, arg HardDeleteResourceParams) error {\n\t_, err := q.db.Exec(ctx, hardDeleteResource, arg.ID, arg.OrganizationID)\n\treturn err\n}\n\nconst listResources = `-- name: ListResources :many\nSELECT\n    id, resource_number, title, description, status_id, file_id,\n    confidence, organization_id, created_by_account_id,\n    approval_status, approval_assigned_to_id,\n    is_active, created_at, updated_at\nFROM example_resources\nWHERE organization_id = $1 AND is_active = true\n    AND ($2::smallint IS NULL OR status_id = $2)\n    AND ($3::varchar IS NULL OR approval_status = $3)\n    AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%')\nORDER BY created_at DESC\nLIMIT $5 OFFSET $6\n`\n\ntype ListResourcesParams struct {\n\tOrganizationID int32  `json:\"organization_id\"`\n\tColumn2        int16  `json:\"column_2\"`\n\tColumn3        string `json:\"column_3\"`\n\tColumn4        string `json:\"column_4\"`\n\tLimit          int32  `json:\"limit\"`\n\tOffset         int32  `json:\"offset\"`\n}\n\ntype ListResourcesRow struct {\n\tID                   int32            `json:\"id\"`\n\tResourceNumber       string           `json:\"resource_number\"`\n\tTitle                string           `json:\"title\"`\n\tDescription          pgtype.Text      `json:\"description\"`\n\tStatusID             int16            `json:\"status_id\"`\n\tFileID               pgtype.Int4      `json:\"file_id\"`\n\tConfidence           pgtype.Numeric   `json:\"confidence\"`\n\tOrganizationID       int32            `json:\"organization_id\"`\n\tCreatedByAccountID   pgtype.Int4      `json:\"created_by_account_id\"`\n\tApprovalStatus       pgtype.Text      `json:\"approval_status\"`\n\tApprovalAssignedToID pgtype.Int4      `json:\"approval_assigned_to_id\"`\n\tIsActive             bool             `json:\"is_active\"`\n\tCreatedAt            pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt            pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// List resources with filtering and pagination\nfunc (q *Queries) ListResources(ctx context.Context, arg ListResourcesParams) ([]ListResourcesRow, error) {\n\trows, err := q.db.Query(ctx, listResources,\n\t\targ.OrganizationID,\n\t\targ.Column2,\n\t\targ.Column3,\n\t\targ.Column4,\n\t\targ.Limit,\n\t\targ.Offset,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []ListResourcesRow{}\n\tfor rows.Next() {\n\t\tvar i ListResourcesRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.ResourceNumber,\n\t\t\t&i.Title,\n\t\t\t&i.Description,\n\t\t\t&i.StatusID,\n\t\t\t&i.FileID,\n\t\t\t&i.Confidence,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.CreatedByAccountID,\n\t\t\t&i.ApprovalStatus,\n\t\t\t&i.ApprovalAssignedToID,\n\t\t\t&i.IsActive,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst searchResourcesByText = `-- name: SearchResourcesByText :many\n\nSELECT\n    id, resource_number, title, description, status_id,\n    confidence, created_at, updated_at,\n    ts_rank(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')), to_tsquery('english', $2)) AS rank\nFROM example_resources\nWHERE organization_id = $1\n    AND is_active = true\n    AND to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ to_tsquery('english', $2)\nORDER BY rank DESC, created_at DESC\nLIMIT $3 OFFSET $4\n`\n\ntype SearchResourcesByTextParams struct {\n\tOrganizationID int32  `json:\"organization_id\"`\n\tToTsquery      string `json:\"to_tsquery\"`\n\tLimit          int32  `json:\"limit\"`\n\tOffset         int32  `json:\"offset\"`\n}\n\ntype SearchResourcesByTextRow struct {\n\tID             int32            `json:\"id\"`\n\tResourceNumber string           `json:\"resource_number\"`\n\tTitle          string           `json:\"title\"`\n\tDescription    pgtype.Text      `json:\"description\"`\n\tStatusID       int16            `json:\"status_id\"`\n\tConfidence     pgtype.Numeric   `json:\"confidence\"`\n\tCreatedAt      pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt      pgtype.Timestamp `json:\"updated_at\"`\n\tRank           float32          `json:\"rank\"`\n}\n\n// SEARCH operations\n// Full-text search on title and description\nfunc (q *Queries) SearchResourcesByText(ctx context.Context, arg SearchResourcesByTextParams) ([]SearchResourcesByTextRow, error) {\n\trows, err := q.db.Query(ctx, searchResourcesByText,\n\t\targ.OrganizationID,\n\t\targ.ToTsquery,\n\t\targ.Limit,\n\t\targ.Offset,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []SearchResourcesByTextRow{}\n\tfor rows.Next() {\n\t\tvar i SearchResourcesByTextRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.ResourceNumber,\n\t\t\t&i.Title,\n\t\t\t&i.Description,\n\t\t\t&i.StatusID,\n\t\t\t&i.Confidence,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.Rank,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst updateResource = `-- name: UpdateResource :exec\n\nUPDATE example_resources SET\n    title = COALESCE($1, title),\n    description = COALESCE($2, description),\n    status_id = COALESCE($3, status_id),\n    metadata = COALESCE($4, metadata),\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $5 AND organization_id = $6 AND is_active = true\n`\n\ntype UpdateResourceParams struct {\n\tTitle          pgtype.Text `json:\"title\"`\n\tDescription    pgtype.Text `json:\"description\"`\n\tStatusID       pgtype.Int2 `json:\"status_id\"`\n\tMetadata       []byte      `json:\"metadata\"`\n\tID             int32       `json:\"id\"`\n\tOrganizationID int32       `json:\"organization_id\"`\n}\n\n// UPDATE operations\nfunc (q *Queries) UpdateResource(ctx context.Context, arg UpdateResourceParams) error {\n\t_, err := q.db.Exec(ctx, updateResource,\n\t\targ.Title,\n\t\targ.Description,\n\t\targ.StatusID,\n\t\targ.Metadata,\n\t\targ.ID,\n\t\targ.OrganizationID,\n\t)\n\treturn err\n}\n\nconst updateResourceApprovalStatus = `-- name: UpdateResourceApprovalStatus :exec\nUPDATE example_resources SET\n    approval_status = $3,\n    approval_action_taker_id = $4,\n    approval_notes = $5,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true\n`\n\ntype UpdateResourceApprovalStatusParams struct {\n\tID                    int32       `json:\"id\"`\n\tOrganizationID        int32       `json:\"organization_id\"`\n\tApprovalStatus        pgtype.Text `json:\"approval_status\"`\n\tApprovalActionTakerID pgtype.Int4 `json:\"approval_action_taker_id\"`\n\tApprovalNotes         pgtype.Text `json:\"approval_notes\"`\n}\n\n// Update approval workflow status\nfunc (q *Queries) UpdateResourceApprovalStatus(ctx context.Context, arg UpdateResourceApprovalStatusParams) error {\n\t_, err := q.db.Exec(ctx, updateResourceApprovalStatus,\n\t\targ.ID,\n\t\targ.OrganizationID,\n\t\targ.ApprovalStatus,\n\t\targ.ApprovalActionTakerID,\n\t\targ.ApprovalNotes,\n\t)\n\treturn err\n}\n\nconst updateResourceProcessingData = `-- name: UpdateResourceProcessingData :exec\nUPDATE example_resources SET\n    extracted_data = COALESCE($1, extracted_data),\n    processed_data = COALESCE($2, processed_data),\n    confidence = COALESCE($3, confidence),\n    status_id = COALESCE($4, status_id),\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $5 AND organization_id = $6\n`\n\ntype UpdateResourceProcessingDataParams struct {\n\tExtractedData  []byte         `json:\"extracted_data\"`\n\tProcessedData  []byte         `json:\"processed_data\"`\n\tConfidence     pgtype.Numeric `json:\"confidence\"`\n\tStatusID       pgtype.Int2    `json:\"status_id\"`\n\tID             int32          `json:\"id\"`\n\tOrganizationID int32          `json:\"organization_id\"`\n}\n\n// Update OCR/LLM processing results\nfunc (q *Queries) UpdateResourceProcessingData(ctx context.Context, arg UpdateResourceProcessingDataParams) error {\n\t_, err := q.db.Exec(ctx, updateResourceProcessingData,\n\t\targ.ExtractedData,\n\t\targ.ProcessedData,\n\t\targ.Confidence,\n\t\targ.StatusID,\n\t\targ.ID,\n\t\targ.OrganizationID,\n\t)\n\treturn err\n}\n\nconst updateResourceStatus = `-- name: UpdateResourceStatus :exec\nUPDATE example_resources SET\n    status_id = $3,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true\n`\n\ntype UpdateResourceStatusParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n\tStatusID       int16 `json:\"status_id\"`\n}\n\nfunc (q *Queries) UpdateResourceStatus(ctx context.Context, arg UpdateResourceStatusParams) error {\n\t_, err := q.db.Exec(ctx, updateResourceStatus, arg.ID, arg.OrganizationID, arg.StatusID)\n\treturn err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/exec.go",
    "content": "package postgres\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\n// ExecTx executes a function within a database transaction\nfunc (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error {\n\ttx, err := store.connPool.Begin(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tq := New(tx)\n\terr = fn(q)\n\tif err != nil {\n\t\tif rbErr := tx.Rollback(ctx); rbErr != nil {\n\t\t\treturn fmt.Errorf(\"tx err: %v, rb err: %v\", err, rbErr)\n\t\t}\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/file_manager.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: files.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\nconst createFileAsset = `-- name: CreateFileAsset :one\nINSERT INTO files.file_assets (\n    file_name,\n    original_file_name,\n    storage_path,\n    bucket_name,\n    file_size,\n    mime_type,\n    file_category_id,\n    file_context_id,\n    is_public,\n    entity_type,\n    entity_id,\n    purpose,\n    metadata\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13\n)\nRETURNING id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at\n`\n\ntype CreateFileAssetParams struct {\n\tFileName         string      `json:\"file_name\"`\n\tOriginalFileName string      `json:\"original_file_name\"`\n\tStoragePath      string      `json:\"storage_path\"`\n\tBucketName       string      `json:\"bucket_name\"`\n\tFileSize         int64       `json:\"file_size\"`\n\tMimeType         string      `json:\"mime_type\"`\n\tFileCategoryID   int16       `json:\"file_category_id\"`\n\tFileContextID    int16       `json:\"file_context_id\"`\n\tIsPublic         pgtype.Bool `json:\"is_public\"`\n\tEntityType       pgtype.Text `json:\"entity_type\"`\n\tEntityID         pgtype.Int4 `json:\"entity_id\"`\n\tPurpose          pgtype.Text `json:\"purpose\"`\n\tMetadata         []byte      `json:\"metadata\"`\n}\n\nfunc (q *Queries) CreateFileAsset(ctx context.Context, arg CreateFileAssetParams) (FileManagerFileAsset, error) {\n\trow := q.db.QueryRow(ctx, createFileAsset,\n\t\targ.FileName,\n\t\targ.OriginalFileName,\n\t\targ.StoragePath,\n\t\targ.BucketName,\n\t\targ.FileSize,\n\t\targ.MimeType,\n\t\targ.FileCategoryID,\n\t\targ.FileContextID,\n\t\targ.IsPublic,\n\t\targ.EntityType,\n\t\targ.EntityID,\n\t\targ.Purpose,\n\t\targ.Metadata,\n\t)\n\tvar i FileManagerFileAsset\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.FileName,\n\t\t&i.OriginalFileName,\n\t\t&i.StoragePath,\n\t\t&i.BucketName,\n\t\t&i.FileSize,\n\t\t&i.MimeType,\n\t\t&i.FileCategoryID,\n\t\t&i.FileContextID,\n\t\t&i.IsPublic,\n\t\t&i.EntityType,\n\t\t&i.EntityID,\n\t\t&i.Purpose,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteFileAsset = `-- name: DeleteFileAsset :exec\nDELETE FROM files.file_assets\nWHERE id = $1\n`\n\nfunc (q *Queries) DeleteFileAsset(ctx context.Context, id int32) error {\n\t_, err := q.db.Exec(ctx, deleteFileAsset, id)\n\treturn err\n}\n\nconst getFileAssetByID = `-- name: GetFileAssetByID :one\nSELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets\nWHERE id = $1\n`\n\nfunc (q *Queries) GetFileAssetByID(ctx context.Context, id int32) (FileManagerFileAsset, error) {\n\trow := q.db.QueryRow(ctx, getFileAssetByID, id)\n\tvar i FileManagerFileAsset\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.FileName,\n\t\t&i.OriginalFileName,\n\t\t&i.StoragePath,\n\t\t&i.BucketName,\n\t\t&i.FileSize,\n\t\t&i.MimeType,\n\t\t&i.FileCategoryID,\n\t\t&i.FileContextID,\n\t\t&i.IsPublic,\n\t\t&i.EntityType,\n\t\t&i.EntityID,\n\t\t&i.Purpose,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getFileAssetByStoragePath = `-- name: GetFileAssetByStoragePath :one\nSELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets\nWHERE storage_path = $1\n`\n\nfunc (q *Queries) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (FileManagerFileAsset, error) {\n\trow := q.db.QueryRow(ctx, getFileAssetByStoragePath, storagePath)\n\tvar i FileManagerFileAsset\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.FileName,\n\t\t&i.OriginalFileName,\n\t\t&i.StoragePath,\n\t\t&i.BucketName,\n\t\t&i.FileSize,\n\t\t&i.MimeType,\n\t\t&i.FileCategoryID,\n\t\t&i.FileContextID,\n\t\t&i.IsPublic,\n\t\t&i.EntityType,\n\t\t&i.EntityID,\n\t\t&i.Purpose,\n\t\t&i.Metadata,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getFileAssetsByCategory = `-- name: GetFileAssetsByCategory :many\nSELECT fa.id, fa.file_name, fa.original_file_name, fa.storage_path, fa.bucket_name, fa.file_size, fa.mime_type, fa.file_category_id, fa.file_context_id, fa.is_public, fa.entity_type, fa.entity_id, fa.purpose, fa.metadata, fa.created_at, fa.updated_at, fc.name as category_name\nFROM files.file_assets fa\nJOIN files.file_categories fc ON fa.file_category_id = fc.id  \nWHERE fc.name = $1\nORDER BY fa.created_at DESC\n`\n\ntype GetFileAssetsByCategoryRow struct {\n\tID               int32              `json:\"id\"`\n\tFileName         string             `json:\"file_name\"`\n\tOriginalFileName string             `json:\"original_file_name\"`\n\tStoragePath      string             `json:\"storage_path\"`\n\tBucketName       string             `json:\"bucket_name\"`\n\tFileSize         int64              `json:\"file_size\"`\n\tMimeType         string             `json:\"mime_type\"`\n\tFileCategoryID   int16              `json:\"file_category_id\"`\n\tFileContextID    int16              `json:\"file_context_id\"`\n\tIsPublic         pgtype.Bool        `json:\"is_public\"`\n\tEntityType       pgtype.Text        `json:\"entity_type\"`\n\tEntityID         pgtype.Int4        `json:\"entity_id\"`\n\tPurpose          pgtype.Text        `json:\"purpose\"`\n\tMetadata         []byte             `json:\"metadata\"`\n\tCreatedAt        pgtype.Timestamptz `json:\"created_at\"`\n\tUpdatedAt        pgtype.Timestamptz `json:\"updated_at\"`\n\tCategoryName     string             `json:\"category_name\"`\n}\n\nfunc (q *Queries) GetFileAssetsByCategory(ctx context.Context, name string) ([]GetFileAssetsByCategoryRow, error) {\n\trows, err := q.db.Query(ctx, getFileAssetsByCategory, name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []GetFileAssetsByCategoryRow{}\n\tfor rows.Next() {\n\t\tvar i GetFileAssetsByCategoryRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.FileName,\n\t\t\t&i.OriginalFileName,\n\t\t\t&i.StoragePath,\n\t\t\t&i.BucketName,\n\t\t\t&i.FileSize,\n\t\t\t&i.MimeType,\n\t\t\t&i.FileCategoryID,\n\t\t\t&i.FileContextID,\n\t\t\t&i.IsPublic,\n\t\t\t&i.EntityType,\n\t\t\t&i.EntityID,\n\t\t\t&i.Purpose,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.CategoryName,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getFileAssetsByContext = `-- name: GetFileAssetsByContext :many\nSELECT fa.id, fa.file_name, fa.original_file_name, fa.storage_path, fa.bucket_name, fa.file_size, fa.mime_type, fa.file_category_id, fa.file_context_id, fa.is_public, fa.entity_type, fa.entity_id, fa.purpose, fa.metadata, fa.created_at, fa.updated_at, fctx.name as context_name\nFROM files.file_assets fa\nJOIN files.file_contexts fctx ON fa.file_context_id = fctx.id\nWHERE fctx.name = $1\nORDER BY fa.created_at DESC\n`\n\ntype GetFileAssetsByContextRow struct {\n\tID               int32              `json:\"id\"`\n\tFileName         string             `json:\"file_name\"`\n\tOriginalFileName string             `json:\"original_file_name\"`\n\tStoragePath      string             `json:\"storage_path\"`\n\tBucketName       string             `json:\"bucket_name\"`\n\tFileSize         int64              `json:\"file_size\"`\n\tMimeType         string             `json:\"mime_type\"`\n\tFileCategoryID   int16              `json:\"file_category_id\"`\n\tFileContextID    int16              `json:\"file_context_id\"`\n\tIsPublic         pgtype.Bool        `json:\"is_public\"`\n\tEntityType       pgtype.Text        `json:\"entity_type\"`\n\tEntityID         pgtype.Int4        `json:\"entity_id\"`\n\tPurpose          pgtype.Text        `json:\"purpose\"`\n\tMetadata         []byte             `json:\"metadata\"`\n\tCreatedAt        pgtype.Timestamptz `json:\"created_at\"`\n\tUpdatedAt        pgtype.Timestamptz `json:\"updated_at\"`\n\tContextName      string             `json:\"context_name\"`\n}\n\nfunc (q *Queries) GetFileAssetsByContext(ctx context.Context, name string) ([]GetFileAssetsByContextRow, error) {\n\trows, err := q.db.Query(ctx, getFileAssetsByContext, name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []GetFileAssetsByContextRow{}\n\tfor rows.Next() {\n\t\tvar i GetFileAssetsByContextRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.FileName,\n\t\t\t&i.OriginalFileName,\n\t\t\t&i.StoragePath,\n\t\t\t&i.BucketName,\n\t\t\t&i.FileSize,\n\t\t\t&i.MimeType,\n\t\t\t&i.FileCategoryID,\n\t\t\t&i.FileContextID,\n\t\t\t&i.IsPublic,\n\t\t\t&i.EntityType,\n\t\t\t&i.EntityID,\n\t\t\t&i.Purpose,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.ContextName,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getFileAssetsByEntity = `-- name: GetFileAssetsByEntity :many\nSELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets\nWHERE entity_type = $1 AND entity_id = $2\n`\n\ntype GetFileAssetsByEntityParams struct {\n\tEntityType pgtype.Text `json:\"entity_type\"`\n\tEntityID   pgtype.Int4 `json:\"entity_id\"`\n}\n\nfunc (q *Queries) GetFileAssetsByEntity(ctx context.Context, arg GetFileAssetsByEntityParams) ([]FileManagerFileAsset, error) {\n\trows, err := q.db.Query(ctx, getFileAssetsByEntity, arg.EntityType, arg.EntityID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []FileManagerFileAsset{}\n\tfor rows.Next() {\n\t\tvar i FileManagerFileAsset\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.FileName,\n\t\t\t&i.OriginalFileName,\n\t\t\t&i.StoragePath,\n\t\t\t&i.BucketName,\n\t\t\t&i.FileSize,\n\t\t\t&i.MimeType,\n\t\t\t&i.FileCategoryID,\n\t\t\t&i.FileContextID,\n\t\t\t&i.IsPublic,\n\t\t\t&i.EntityType,\n\t\t\t&i.EntityID,\n\t\t\t&i.Purpose,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getFileAssetsByEntityAndPurpose = `-- name: GetFileAssetsByEntityAndPurpose :many\nSELECT id, file_name, original_file_name, storage_path, bucket_name, file_size, mime_type, file_category_id, file_context_id, is_public, entity_type, entity_id, purpose, metadata, created_at, updated_at FROM files.file_assets\nWHERE entity_type = $1 AND entity_id = $2 AND purpose = $3\nORDER BY created_at DESC\n`\n\ntype GetFileAssetsByEntityAndPurposeParams struct {\n\tEntityType pgtype.Text `json:\"entity_type\"`\n\tEntityID   pgtype.Int4 `json:\"entity_id\"`\n\tPurpose    pgtype.Text `json:\"purpose\"`\n}\n\nfunc (q *Queries) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg GetFileAssetsByEntityAndPurposeParams) ([]FileManagerFileAsset, error) {\n\trows, err := q.db.Query(ctx, getFileAssetsByEntityAndPurpose, arg.EntityType, arg.EntityID, arg.Purpose)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []FileManagerFileAsset{}\n\tfor rows.Next() {\n\t\tvar i FileManagerFileAsset\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.FileName,\n\t\t\t&i.OriginalFileName,\n\t\t\t&i.StoragePath,\n\t\t\t&i.BucketName,\n\t\t\t&i.FileSize,\n\t\t\t&i.MimeType,\n\t\t\t&i.FileCategoryID,\n\t\t\t&i.FileContextID,\n\t\t\t&i.IsPublic,\n\t\t\t&i.EntityType,\n\t\t\t&i.EntityID,\n\t\t\t&i.Purpose,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getFileCategories = `-- name: GetFileCategories :many\nSELECT id, name, max_size_bytes FROM files.file_categories ORDER BY name\n`\n\nfunc (q *Queries) GetFileCategories(ctx context.Context) ([]FileManagerFileCategory, error) {\n\trows, err := q.db.Query(ctx, getFileCategories)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []FileManagerFileCategory{}\n\tfor rows.Next() {\n\t\tvar i FileManagerFileCategory\n\t\tif err := rows.Scan(&i.ID, &i.Name, &i.MaxSizeBytes); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getFileContexts = `-- name: GetFileContexts :many\nSELECT id, name FROM files.file_contexts ORDER BY name\n`\n\nfunc (q *Queries) GetFileContexts(ctx context.Context) ([]FileManagerFileContext, error) {\n\trows, err := q.db.Query(ctx, getFileContexts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []FileManagerFileContext{}\n\tfor rows.Next() {\n\t\tvar i FileManagerFileContext\n\t\tif err := rows.Scan(&i.ID, &i.Name); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst listFileAssets = `-- name: ListFileAssets :many\nSELECT fa.id, fa.file_name, fa.original_file_name, fa.storage_path, fa.bucket_name, fa.file_size, fa.mime_type, fa.file_category_id, fa.file_context_id, fa.is_public, fa.entity_type, fa.entity_id, fa.purpose, fa.metadata, fa.created_at, fa.updated_at, fc.name as category_name, fctx.name as context_name\nFROM files.file_assets fa\nJOIN files.file_categories fc ON fa.file_category_id = fc.id\nJOIN files.file_contexts fctx ON fa.file_context_id = fctx.id\nORDER BY fa.created_at DESC\nLIMIT $1 OFFSET $2\n`\n\ntype ListFileAssetsParams struct {\n\tLimit  int32 `json:\"limit\"`\n\tOffset int32 `json:\"offset\"`\n}\n\ntype ListFileAssetsRow struct {\n\tID               int32              `json:\"id\"`\n\tFileName         string             `json:\"file_name\"`\n\tOriginalFileName string             `json:\"original_file_name\"`\n\tStoragePath      string             `json:\"storage_path\"`\n\tBucketName       string             `json:\"bucket_name\"`\n\tFileSize         int64              `json:\"file_size\"`\n\tMimeType         string             `json:\"mime_type\"`\n\tFileCategoryID   int16              `json:\"file_category_id\"`\n\tFileContextID    int16              `json:\"file_context_id\"`\n\tIsPublic         pgtype.Bool        `json:\"is_public\"`\n\tEntityType       pgtype.Text        `json:\"entity_type\"`\n\tEntityID         pgtype.Int4        `json:\"entity_id\"`\n\tPurpose          pgtype.Text        `json:\"purpose\"`\n\tMetadata         []byte             `json:\"metadata\"`\n\tCreatedAt        pgtype.Timestamptz `json:\"created_at\"`\n\tUpdatedAt        pgtype.Timestamptz `json:\"updated_at\"`\n\tCategoryName     string             `json:\"category_name\"`\n\tContextName      string             `json:\"context_name\"`\n}\n\nfunc (q *Queries) ListFileAssets(ctx context.Context, arg ListFileAssetsParams) ([]ListFileAssetsRow, error) {\n\trows, err := q.db.Query(ctx, listFileAssets, arg.Limit, arg.Offset)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []ListFileAssetsRow{}\n\tfor rows.Next() {\n\t\tvar i ListFileAssetsRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.FileName,\n\t\t\t&i.OriginalFileName,\n\t\t\t&i.StoragePath,\n\t\t\t&i.BucketName,\n\t\t\t&i.FileSize,\n\t\t\t&i.MimeType,\n\t\t\t&i.FileCategoryID,\n\t\t\t&i.FileContextID,\n\t\t\t&i.IsPublic,\n\t\t\t&i.EntityType,\n\t\t\t&i.EntityID,\n\t\t\t&i.Purpose,\n\t\t\t&i.Metadata,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.CategoryName,\n\t\t\t&i.ContextName,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst updateFileAsset = `-- name: UpdateFileAsset :exec\nUPDATE files.file_assets\nSET \n    file_name = $2,\n    storage_path = $3,\n    purpose = $4,\n    metadata = $5,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1\n`\n\ntype UpdateFileAssetParams struct {\n\tID          int32       `json:\"id\"`\n\tFileName    string      `json:\"file_name\"`\n\tStoragePath string      `json:\"storage_path\"`\n\tPurpose     pgtype.Text `json:\"purpose\"`\n\tMetadata    []byte      `json:\"metadata\"`\n}\n\nfunc (q *Queries) UpdateFileAsset(ctx context.Context, arg UpdateFileAssetParams) error {\n\t_, err := q.db.Exec(ctx, updateFileAsset,\n\t\targ.ID,\n\t\targ.FileName,\n\t\targ.StoragePath,\n\t\targ.Purpose,\n\t\targ.Metadata,\n\t)\n\treturn err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/models.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n\npackage postgres\n\nimport (\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\tpgvector_go \"github.com/pgvector/pgvector-go\"\n)\n\n// Messages within chat sessions with role (user/assistant/system)\ntype CognitiveChatMessage struct {\n\tID             int32            `json:\"id\"`\n\tSessionID      int32            `json:\"session_id\"`\n\tRole           string           `json:\"role\"`\n\tContent        string           `json:\"content\"`\n\tReferencedDocs []int32          `json:\"referenced_docs\"`\n\tTokensUsed     pgtype.Int4      `json:\"tokens_used\"`\n\tCreatedAt      pgtype.Timestamp `json:\"created_at\"`\n}\n\n// Conversational AI sessions for RAG-based chat\ntype CognitiveChatSession struct {\n\tID             int32            `json:\"id\"`\n\tOrganizationID int32            `json:\"organization_id\"`\n\tAccountID      int32            `json:\"account_id\"`\n\tTitle          pgtype.Text      `json:\"title\"`\n\tCreatedAt      pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt      pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Vector embeddings for documents using OpenAI text-embedding-3-small (1536 dimensions)\ntype CognitiveDocumentEmbedding struct {\n\tID             int32 `json:\"id\"`\n\tDocumentID     int32 `json:\"document_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n\t// Vector embedding for semantic similarity search\n\tEmbedding      pgvector_go.Vector `json:\"embedding\"`\n\tContentHash    pgtype.Text        `json:\"content_hash\"`\n\tContentPreview pgtype.Text        `json:\"content_preview\"`\n\t// Index for chunked documents (0 for single-chunk docs)\n\tChunkIndex pgtype.Int4      `json:\"chunk_index\"`\n\tCreatedAt  pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt  pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Stores uploaded documents (PDFs) with extracted text for RAG\ntype DocumentsDocument struct {\n\tID             int32  `json:\"id\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n\tFileAssetID    int32  `json:\"file_asset_id\"`\n\tTitle          string `json:\"title\"`\n\tFileName       string `json:\"file_name\"`\n\tContentType    string `json:\"content_type\"`\n\tFileSize       int64  `json:\"file_size\"`\n\t// Text extracted from PDF using OCR or direct parsing\n\tExtractedText pgtype.Text `json:\"extracted_text\"`\n\t// Processing status: pending, processing, processed, failed\n\tStatus    string           `json:\"status\"`\n\tMetadata  []byte           `json:\"metadata\"`\n\tCreatedAt pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Stores potential duplicate resources found via vector similarity and LLM adjudication\ntype DuplicateCandidate struct {\n\tID                  int32 `json:\"id\"`\n\tResourceID          int32 `json:\"resource_id\"`\n\tCandidateResourceID int32 `json:\"candidate_resource_id\"`\n\t// Cosine similarity score from pgvector (0.0000 = completely different, 1.0000 = identical)\n\tSimilarityScore pgtype.Numeric `json:\"similarity_score\"`\n\t// How the duplicate was detected: exact_match (similarity >= 0.95) or llm_adjudicated (similarity >= 0.85, confirmed by LLM)\n\tDetectionMethod  string           `json:\"detection_method\"`\n\tConfidenceLevel  pgtype.Text      `json:\"confidence_level\"`\n\tLlmReason        pgtype.Text      `json:\"llm_reason\"`\n\tLlmSimilarFields []byte           `json:\"llm_similar_fields\"`\n\tLlmResponse      []byte           `json:\"llm_response\"`\n\tOrganizationID   int32            `json:\"organization_id\"`\n\tStatus           pgtype.Text      `json:\"status\"`\n\tCreatedAt        pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt        pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Example module demonstrating Clean Architecture patterns with file uploads, OCR/LLM processing, RBAC, approval workflows, and multi-tenancy\ntype ExampleResource struct {\n\tID             int32       `json:\"id\"`\n\tResourceNumber string      `json:\"resource_number\"`\n\tTitle          string      `json:\"title\"`\n\tDescription    pgtype.Text `json:\"description\"`\n\tStatusID       int16       `json:\"status_id\"`\n\tFileID         pgtype.Int4 `json:\"file_id\"`\n\t// Raw OCR-extracted text and metadata\n\tExtractedData []byte `json:\"extracted_data\"`\n\t// LLM-processed structured data\n\tProcessedData []byte `json:\"processed_data\"`\n\t// AI confidence score between 0 and 1\n\tConfidence         pgtype.Numeric `json:\"confidence\"`\n\tOrganizationID     int32          `json:\"organization_id\"`\n\tCreatedByAccountID pgtype.Int4    `json:\"created_by_account_id\"`\n\t// Workflow status: pending, approved, rejected\n\tApprovalStatus        pgtype.Text      `json:\"approval_status\"`\n\tApprovalAssignedToID  pgtype.Int4      `json:\"approval_assigned_to_id\"`\n\tApprovalActionTakerID pgtype.Int4      `json:\"approval_action_taker_id\"`\n\tApprovalNotes         pgtype.Text      `json:\"approval_notes\"`\n\tMetadata              []byte           `json:\"metadata\"`\n\tIsActive              bool             `json:\"is_active\"`\n\tCreatedAt             pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt             pgtype.Timestamp `json:\"updated_at\"`\n}\n\ntype FileManagerFileAsset struct {\n\tID               int32              `json:\"id\"`\n\tFileName         string             `json:\"file_name\"`\n\tOriginalFileName string             `json:\"original_file_name\"`\n\tStoragePath      string             `json:\"storage_path\"`\n\tBucketName       string             `json:\"bucket_name\"`\n\tFileSize         int64              `json:\"file_size\"`\n\tMimeType         string             `json:\"mime_type\"`\n\tFileCategoryID   int16              `json:\"file_category_id\"`\n\tFileContextID    int16              `json:\"file_context_id\"`\n\tIsPublic         pgtype.Bool        `json:\"is_public\"`\n\tEntityType       pgtype.Text        `json:\"entity_type\"`\n\tEntityID         pgtype.Int4        `json:\"entity_id\"`\n\tPurpose          pgtype.Text        `json:\"purpose\"`\n\tMetadata         []byte             `json:\"metadata\"`\n\tCreatedAt        pgtype.Timestamptz `json:\"created_at\"`\n\tUpdatedAt        pgtype.Timestamptz `json:\"updated_at\"`\n}\n\ntype FileManagerFileCategory struct {\n\tID           int16  `json:\"id\"`\n\tName         string `json:\"name\"`\n\tMaxSizeBytes int64  `json:\"max_size_bytes\"`\n}\n\ntype FileManagerFileContext struct {\n\tID   int16  `json:\"id\"`\n\tName string `json:\"name\"`\n}\n\n// User accounts within organizations\ntype OrganizationsAccount struct {\n\tID             int32  `json:\"id\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n\tEmail          string `json:\"email\"`\n\tFullName       string `json:\"full_name\"`\n\t// Stytch member identifier (member_xxx)\n\tStytchMemberID pgtype.Text `json:\"stytch_member_id\"`\n\t// Stytch role identifier assigned to the member\n\tStytchRoleID pgtype.Text `json:\"stytch_role_id\"`\n\t// Human-readable Stytch role slug assigned to the member\n\tStytchRoleSlug pgtype.Text `json:\"stytch_role_slug\"`\n\t// Whether Stytch reports the member email as verified\n\tStytchEmailVerified bool `json:\"stytch_email_verified\"`\n\t// Last known role for business logic (e.g., owner, reviewer, employee)\n\tRole        string           `json:\"role\"`\n\tStatus      string           `json:\"status\"`\n\tLastLoginAt pgtype.Timestamp `json:\"last_login_at\"`\n\tCreatedAt   pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt   pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Organizations (tenants) in the system\ntype OrganizationsOrganization struct {\n\tID int32 `json:\"id\"`\n\t// URL-friendly unique identifier for organization\n\tSlug   string `json:\"slug\"`\n\tName   string `json:\"name\"`\n\tStatus string `json:\"status\"`\n\t// Stytch organization identifier (org_xxx)\n\tStytchOrgID pgtype.Text `json:\"stytch_org_id\"`\n\t// Optional Stytch connection or project identifier associated with the organization\n\tStytchConnectionID pgtype.Text `json:\"stytch_connection_id\"`\n\t// Optional Stytch connection name associated with the organization\n\tStytchConnectionName pgtype.Text      `json:\"stytch_connection_name\"`\n\tCreatedAt            pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt            pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Stores vector embeddings for resources using OpenAI text-embedding-3-small (1536 dimensions)\ntype ResourceEmbedding struct {\n\tID         int32 `json:\"id\"`\n\tResourceID int32 `json:\"resource_id\"`\n\t// Vector embedding for semantic similarity search (1536 dimensions from OpenAI)\n\tEmbedding      pgvector_go.Vector `json:\"embedding\"`\n\tOrganizationID int32              `json:\"organization_id\"`\n\t// SHA-256 hash of normalized content for exact duplicate detection\n\tContentHash    pgtype.Text      `json:\"content_hash\"`\n\tContentPreview pgtype.Text      `json:\"content_preview\"`\n\tCreatedAt      pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt      pgtype.Timestamp `json:\"updated_at\"`\n}\n\n// Tracks usage quotas per organization for fast quota checks\ntype SubscriptionBillingQuotaTracking struct {\n\tID             int32            `json:\"id\"`\n\tOrganizationID int32            `json:\"organization_id\"`\n\tMaxSeats       pgtype.Int4      `json:\"max_seats\"`\n\tPeriodStart    pgtype.Timestamp `json:\"period_start\"`\n\tPeriodEnd      pgtype.Timestamp `json:\"period_end\"`\n\tLastSyncedAt   pgtype.Timestamp `json:\"last_synced_at\"`\n\tCreatedAt      pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt      pgtype.Timestamp `json:\"updated_at\"`\n\t// Remaining invoices in current billing period (decremented on use)\n\tInvoiceCount int32 `json:\"invoice_count\"`\n}\n\n// Stores subscription details from Polar, synced via webhooks\ntype SubscriptionBillingSubscription struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n\t// Polar customer ID (maps to organization via stytch_org_id)\n\tExternalCustomerID string           `json:\"external_customer_id\"`\n\tSubscriptionID     string           `json:\"subscription_id\"`\n\tSubscriptionStatus string           `json:\"subscription_status\"`\n\tProductID          string           `json:\"product_id\"`\n\tProductName        pgtype.Text      `json:\"product_name\"`\n\tPlanName           pgtype.Text      `json:\"plan_name\"`\n\tCurrentPeriodStart pgtype.Timestamp `json:\"current_period_start\"`\n\tCurrentPeriodEnd   pgtype.Timestamp `json:\"current_period_end\"`\n\tCancelAtPeriodEnd  pgtype.Bool      `json:\"cancel_at_period_end\"`\n\tCanceledAt         pgtype.Timestamp `json:\"canceled_at\"`\n\tCreatedAt          pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt          pgtype.Timestamp `json:\"updated_at\"`\n\tMetadata           []byte           `json:\"metadata\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/organizations.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: organizations.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\nconst checkAccountPermission = `-- name: CheckAccountPermission :one\nSELECT\n    a.id,\n    a.role,\n    a.status,\n    o.status as org_status\nFROM organizations.accounts a\nINNER JOIN organizations.organizations o ON a.organization_id = o.id\nWHERE a.id = $1 AND a.organization_id = $2\n`\n\ntype CheckAccountPermissionParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\ntype CheckAccountPermissionRow struct {\n\tID        int32  `json:\"id\"`\n\tRole      string `json:\"role\"`\n\tStatus    string `json:\"status\"`\n\tOrgStatus string `json:\"org_status\"`\n}\n\nfunc (q *Queries) CheckAccountPermission(ctx context.Context, arg CheckAccountPermissionParams) (CheckAccountPermissionRow, error) {\n\trow := q.db.QueryRow(ctx, checkAccountPermission, arg.ID, arg.OrganizationID)\n\tvar i CheckAccountPermissionRow\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.OrgStatus,\n\t)\n\treturn i, err\n}\n\nconst createAccount = `-- name: CreateAccount :one\n\nINSERT INTO organizations.accounts (\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status\n) VALUES (\n    $1,\n    $2,\n    $3,\n    $4,\n    $5,\n    $6,\n    $7,\n    $8,\n    $9\n) RETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\n`\n\ntype CreateAccountParams struct {\n\tOrganizationID      int32       `json:\"organization_id\"`\n\tEmail               string      `json:\"email\"`\n\tFullName            string      `json:\"full_name\"`\n\tStytchMemberID      pgtype.Text `json:\"stytch_member_id\"`\n\tStytchRoleID        pgtype.Text `json:\"stytch_role_id\"`\n\tStytchRoleSlug      pgtype.Text `json:\"stytch_role_slug\"`\n\tStytchEmailVerified bool        `json:\"stytch_email_verified\"`\n\tRole                string      `json:\"role\"`\n\tStatus              string      `json:\"status\"`\n}\n\n// Accounts queries\nfunc (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (OrganizationsAccount, error) {\n\trow := q.db.QueryRow(ctx, createAccount,\n\t\targ.OrganizationID,\n\t\targ.Email,\n\t\targ.FullName,\n\t\targ.StytchMemberID,\n\t\targ.StytchRoleID,\n\t\targ.StytchRoleSlug,\n\t\targ.StytchEmailVerified,\n\t\targ.Role,\n\t\targ.Status,\n\t)\n\tvar i OrganizationsAccount\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst createOrganization = `-- name: CreateOrganization :one\nINSERT INTO organizations.organizations (\n    slug,\n    name,\n    status\n) VALUES (\n    $1,\n    $2,\n    $3\n) RETURNING\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\n`\n\ntype CreateOrganizationParams struct {\n\tSlug   string `json:\"slug\"`\n\tName   string `json:\"name\"`\n\tStatus string `json:\"status\"`\n}\n\nfunc (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, createOrganization, arg.Slug, arg.Name, arg.Status)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteAccount = `-- name: DeleteAccount :exec\nUPDATE organizations.accounts\nSET\n    status = 'inactive',\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\n`\n\ntype DeleteAccountParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error {\n\t_, err := q.db.Exec(ctx, deleteAccount, arg.ID, arg.OrganizationID)\n\treturn err\n}\n\nconst deleteOrganization = `-- name: DeleteOrganization :exec\nDELETE FROM organizations.organizations\nWHERE id = $1\n`\n\nfunc (q *Queries) DeleteOrganization(ctx context.Context, id int32) error {\n\t_, err := q.db.Exec(ctx, deleteOrganization, id)\n\treturn err\n}\n\nconst getAccountByEmail = `-- name: GetAccountByEmail :one\nSELECT\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\nFROM organizations.accounts\nWHERE email = $1 AND organization_id = $2\n`\n\ntype GetAccountByEmailParams struct {\n\tEmail          string `json:\"email\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetAccountByEmail(ctx context.Context, arg GetAccountByEmailParams) (OrganizationsAccount, error) {\n\trow := q.db.QueryRow(ctx, getAccountByEmail, arg.Email, arg.OrganizationID)\n\tvar i OrganizationsAccount\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getAccountByID = `-- name: GetAccountByID :one\nSELECT\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\nFROM organizations.accounts\nWHERE id = $1 AND organization_id = $2\n`\n\ntype GetAccountByIDParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) GetAccountByID(ctx context.Context, arg GetAccountByIDParams) (OrganizationsAccount, error) {\n\trow := q.db.QueryRow(ctx, getAccountByID, arg.ID, arg.OrganizationID)\n\tvar i OrganizationsAccount\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getAccountOrganization = `-- name: GetAccountOrganization :one\nSELECT\n    o.id,\n    o.slug,\n    o.name,\n    o.status,\n    o.stytch_org_id,\n    o.stytch_connection_id,\n    o.stytch_connection_name,\n    o.created_at,\n    o.updated_at\nFROM organizations.organizations o\nINNER JOIN organizations.accounts a ON o.id = a.organization_id\nWHERE a.id = $1\n`\n\nfunc (q *Queries) GetAccountOrganization(ctx context.Context, id int32) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, getAccountOrganization, id)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getAccountStats = `-- name: GetAccountStats :one\nSELECT\n    a.id,\n    a.organization_id,\n    a.email,\n    a.full_name,\n    a.stytch_member_id,\n    a.stytch_role_id,\n    a.stytch_role_slug,\n    a.stytch_email_verified,\n    a.role,\n    a.status,\n    a.last_login_at,\n    a.created_at,\n    a.updated_at,\n    o.name as organization_name,\n    o.slug as organization_slug\nFROM organizations.accounts a\nINNER JOIN organizations.organizations o ON a.organization_id = o.id\nWHERE a.id = $1\n`\n\ntype GetAccountStatsRow struct {\n\tID                  int32            `json:\"id\"`\n\tOrganizationID      int32            `json:\"organization_id\"`\n\tEmail               string           `json:\"email\"`\n\tFullName            string           `json:\"full_name\"`\n\tStytchMemberID      pgtype.Text      `json:\"stytch_member_id\"`\n\tStytchRoleID        pgtype.Text      `json:\"stytch_role_id\"`\n\tStytchRoleSlug      pgtype.Text      `json:\"stytch_role_slug\"`\n\tStytchEmailVerified bool             `json:\"stytch_email_verified\"`\n\tRole                string           `json:\"role\"`\n\tStatus              string           `json:\"status\"`\n\tLastLoginAt         pgtype.Timestamp `json:\"last_login_at\"`\n\tCreatedAt           pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt           pgtype.Timestamp `json:\"updated_at\"`\n\tOrganizationName    string           `json:\"organization_name\"`\n\tOrganizationSlug    string           `json:\"organization_slug\"`\n}\n\nfunc (q *Queries) GetAccountStats(ctx context.Context, id int32) (GetAccountStatsRow, error) {\n\trow := q.db.QueryRow(ctx, getAccountStats, id)\n\tvar i GetAccountStatsRow\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.OrganizationName,\n\t\t&i.OrganizationSlug,\n\t)\n\treturn i, err\n}\n\nconst getOrganizationByID = `-- name: GetOrganizationByID :one\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nWHERE id = $1\n`\n\nfunc (q *Queries) GetOrganizationByID(ctx context.Context, id int32) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, getOrganizationByID, id)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getOrganizationBySlug = `-- name: GetOrganizationBySlug :one\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nWHERE slug = $1\n`\n\nfunc (q *Queries) GetOrganizationBySlug(ctx context.Context, slug string) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, getOrganizationBySlug, slug)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getOrganizationByStytchID = `-- name: GetOrganizationByStytchID :one\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nWHERE stytch_org_id = $1\n`\n\nfunc (q *Queries) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, getOrganizationByStytchID, stytchOrgID)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getOrganizationByUserEmail = `-- name: GetOrganizationByUserEmail :one\n\nSELECT\n    o.id,\n    o.slug,\n    o.name,\n    o.status,\n    o.stytch_org_id,\n    o.stytch_connection_id,\n    o.stytch_connection_name,\n    o.created_at,\n    o.updated_at\nFROM organizations.organizations o\nINNER JOIN organizations.accounts a ON o.id = a.organization_id\nWHERE a.email = $1\n  AND a.status = 'active'\n  AND o.status = 'active'\nLIMIT 1\n`\n\n// Organization membership queries\nfunc (q *Queries) GetOrganizationByUserEmail(ctx context.Context, email string) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, getOrganizationByUserEmail, email)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getOrganizationStats = `-- name: GetOrganizationStats :one\n\nSELECT\n    o.id,\n    o.slug,\n    o.name,\n    o.status,\n    o.stytch_org_id,\n    o.stytch_connection_id,\n    o.stytch_connection_name,\n    o.created_at,\n    o.updated_at,\n    COUNT(a.id) as account_count,\n    COUNT(CASE WHEN a.status = 'active' THEN 1 END) as active_account_count\nFROM organizations.organizations o\nLEFT JOIN organizations.accounts a ON o.id = a.organization_id\nWHERE o.id = $1\nGROUP BY o.id\n`\n\ntype GetOrganizationStatsRow struct {\n\tID                   int32            `json:\"id\"`\n\tSlug                 string           `json:\"slug\"`\n\tName                 string           `json:\"name\"`\n\tStatus               string           `json:\"status\"`\n\tStytchOrgID          pgtype.Text      `json:\"stytch_org_id\"`\n\tStytchConnectionID   pgtype.Text      `json:\"stytch_connection_id\"`\n\tStytchConnectionName pgtype.Text      `json:\"stytch_connection_name\"`\n\tCreatedAt            pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt            pgtype.Timestamp `json:\"updated_at\"`\n\tAccountCount         int64            `json:\"account_count\"`\n\tActiveAccountCount   int64            `json:\"active_account_count\"`\n}\n\n// Statistics queries (useful for admin panels)\nfunc (q *Queries) GetOrganizationStats(ctx context.Context, id int32) (GetOrganizationStatsRow, error) {\n\trow := q.db.QueryRow(ctx, getOrganizationStats, id)\n\tvar i GetOrganizationStatsRow\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.AccountCount,\n\t\t&i.ActiveAccountCount,\n\t)\n\treturn i, err\n}\n\nconst listAccountsByOrganization = `-- name: ListAccountsByOrganization :many\nSELECT\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\nFROM organizations.accounts\nWHERE organization_id = $1\nORDER BY created_at DESC\n`\n\nfunc (q *Queries) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]OrganizationsAccount, error) {\n\trows, err := q.db.Query(ctx, listAccountsByOrganization, organizationID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []OrganizationsAccount{}\n\tfor rows.Next() {\n\t\tvar i OrganizationsAccount\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.Email,\n\t\t\t&i.FullName,\n\t\t\t&i.StytchMemberID,\n\t\t\t&i.StytchRoleID,\n\t\t\t&i.StytchRoleSlug,\n\t\t\t&i.StytchEmailVerified,\n\t\t\t&i.Role,\n\t\t\t&i.Status,\n\t\t\t&i.LastLoginAt,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst listOrganizations = `-- name: ListOrganizations :many\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nORDER BY created_at DESC\nLIMIT $1 OFFSET $2\n`\n\ntype ListOrganizationsParams struct {\n\tLimit  int32 `json:\"limit\"`\n\tOffset int32 `json:\"offset\"`\n}\n\nfunc (q *Queries) ListOrganizations(ctx context.Context, arg ListOrganizationsParams) ([]OrganizationsOrganization, error) {\n\trows, err := q.db.Query(ctx, listOrganizations, arg.Limit, arg.Offset)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []OrganizationsOrganization{}\n\tfor rows.Next() {\n\t\tvar i OrganizationsOrganization\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.Slug,\n\t\t\t&i.Name,\n\t\t\t&i.Status,\n\t\t\t&i.StytchOrgID,\n\t\t\t&i.StytchConnectionID,\n\t\t\t&i.StytchConnectionName,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst updateAccount = `-- name: UpdateAccount :one\nUPDATE organizations.accounts\nSET\n    full_name = $3,\n    stytch_role_id = $4,\n    stytch_role_slug = $5,\n    stytch_email_verified = $6,\n    role = $7,\n    status = $8,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\nRETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\n`\n\ntype UpdateAccountParams struct {\n\tID                  int32       `json:\"id\"`\n\tOrganizationID      int32       `json:\"organization_id\"`\n\tFullName            string      `json:\"full_name\"`\n\tStytchRoleID        pgtype.Text `json:\"stytch_role_id\"`\n\tStytchRoleSlug      pgtype.Text `json:\"stytch_role_slug\"`\n\tStytchEmailVerified bool        `json:\"stytch_email_verified\"`\n\tRole                string      `json:\"role\"`\n\tStatus              string      `json:\"status\"`\n}\n\nfunc (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (OrganizationsAccount, error) {\n\trow := q.db.QueryRow(ctx, updateAccount,\n\t\targ.ID,\n\t\targ.OrganizationID,\n\t\targ.FullName,\n\t\targ.StytchRoleID,\n\t\targ.StytchRoleSlug,\n\t\targ.StytchEmailVerified,\n\t\targ.Role,\n\t\targ.Status,\n\t)\n\tvar i OrganizationsAccount\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateAccountLastLogin = `-- name: UpdateAccountLastLogin :one\nUPDATE organizations.accounts\nSET\n    last_login_at = CURRENT_TIMESTAMP,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\nRETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\n`\n\ntype UpdateAccountLastLoginParams struct {\n\tID             int32 `json:\"id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\nfunc (q *Queries) UpdateAccountLastLogin(ctx context.Context, arg UpdateAccountLastLoginParams) (OrganizationsAccount, error) {\n\trow := q.db.QueryRow(ctx, updateAccountLastLogin, arg.ID, arg.OrganizationID)\n\tvar i OrganizationsAccount\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateAccountStytchInfo = `-- name: UpdateAccountStytchInfo :one\nUPDATE organizations.accounts\nSET\n    stytch_member_id = $3,\n    stytch_role_id = $4,\n    stytch_role_slug = $5,\n    stytch_email_verified = $6,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\nRETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\n`\n\ntype UpdateAccountStytchInfoParams struct {\n\tID                  int32       `json:\"id\"`\n\tOrganizationID      int32       `json:\"organization_id\"`\n\tStytchMemberID      pgtype.Text `json:\"stytch_member_id\"`\n\tStytchRoleID        pgtype.Text `json:\"stytch_role_id\"`\n\tStytchRoleSlug      pgtype.Text `json:\"stytch_role_slug\"`\n\tStytchEmailVerified bool        `json:\"stytch_email_verified\"`\n}\n\nfunc (q *Queries) UpdateAccountStytchInfo(ctx context.Context, arg UpdateAccountStytchInfoParams) (OrganizationsAccount, error) {\n\trow := q.db.QueryRow(ctx, updateAccountStytchInfo,\n\t\targ.ID,\n\t\targ.OrganizationID,\n\t\targ.StytchMemberID,\n\t\targ.StytchRoleID,\n\t\targ.StytchRoleSlug,\n\t\targ.StytchEmailVerified,\n\t)\n\tvar i OrganizationsAccount\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.Email,\n\t\t&i.FullName,\n\t\t&i.StytchMemberID,\n\t\t&i.StytchRoleID,\n\t\t&i.StytchRoleSlug,\n\t\t&i.StytchEmailVerified,\n\t\t&i.Role,\n\t\t&i.Status,\n\t\t&i.LastLoginAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateOrganization = `-- name: UpdateOrganization :one\nUPDATE organizations.organizations\nSET\n    name = $2,\n    status = $3,\n    stytch_org_id = $4,\n    stytch_connection_id = $5,\n    stytch_connection_name = $6,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1\nRETURNING\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\n`\n\ntype UpdateOrganizationParams struct {\n\tID                   int32       `json:\"id\"`\n\tName                 string      `json:\"name\"`\n\tStatus               string      `json:\"status\"`\n\tStytchOrgID          pgtype.Text `json:\"stytch_org_id\"`\n\tStytchConnectionID   pgtype.Text `json:\"stytch_connection_id\"`\n\tStytchConnectionName pgtype.Text `json:\"stytch_connection_name\"`\n}\n\nfunc (q *Queries) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, updateOrganization,\n\t\targ.ID,\n\t\targ.Name,\n\t\targ.Status,\n\t\targ.StytchOrgID,\n\t\targ.StytchConnectionID,\n\t\targ.StytchConnectionName,\n\t)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateOrganizationStytchInfo = `-- name: UpdateOrganizationStytchInfo :one\nUPDATE organizations.organizations\nSET\n    stytch_org_id = $2,\n    stytch_connection_id = $3,\n    stytch_connection_name = $4,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1\nRETURNING\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\n`\n\ntype UpdateOrganizationStytchInfoParams struct {\n\tID                   int32       `json:\"id\"`\n\tStytchOrgID          pgtype.Text `json:\"stytch_org_id\"`\n\tStytchConnectionID   pgtype.Text `json:\"stytch_connection_id\"`\n\tStytchConnectionName pgtype.Text `json:\"stytch_connection_name\"`\n}\n\nfunc (q *Queries) UpdateOrganizationStytchInfo(ctx context.Context, arg UpdateOrganizationStytchInfoParams) (OrganizationsOrganization, error) {\n\trow := q.db.QueryRow(ctx, updateOrganizationStytchInfo,\n\t\targ.ID,\n\t\targ.StytchOrgID,\n\t\targ.StytchConnectionID,\n\t\targ.StytchConnectionName,\n\t)\n\tvar i OrganizationsOrganization\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.Slug,\n\t\t&i.Name,\n\t\t&i.Status,\n\t\t&i.StytchOrgID,\n\t\t&i.StytchConnectionID,\n\t\t&i.StytchConnectionName,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/querier.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\ntype Querier interface {\n\t// Assign resource to someone for approval\n\tAssignResourceApproval(ctx context.Context, arg AssignResourceApprovalParams) error\n\t// Attach a file to a resource\n\tAttachFileToResource(ctx context.Context, arg AttachFileToResourceParams) error\n\tCheckAccountPermission(ctx context.Context, arg CheckAccountPermissionParams) (CheckAccountPermissionRow, error)\n\tCountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error)\n\tCountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error)\n\tCountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error)\n\tCountDocumentsByStatus(ctx context.Context, arg CountDocumentsByStatusParams) (int64, error)\n\t// Count resources for pagination\n\tCountResources(ctx context.Context, arg CountResourcesParams) (int64, error)\n\t// Accounts queries\n\tCreateAccount(ctx context.Context, arg CreateAccountParams) (OrganizationsAccount, error)\n\t// Chat Messages\n\tCreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (CognitiveChatMessage, error)\n\t// Chat Sessions\n\tCreateChatSession(ctx context.Context, arg CreateChatSessionParams) (CognitiveChatSession, error)\n\t// Documents queries\n\tCreateDocument(ctx context.Context, arg CreateDocumentParams) (DocumentsDocument, error)\n\t// Cognitive Agent queries\n\t// Document Embeddings\n\tCreateDocumentEmbedding(ctx context.Context, arg CreateDocumentEmbeddingParams) (CognitiveDocumentEmbedding, error)\n\tCreateFileAsset(ctx context.Context, arg CreateFileAssetParams) (FileManagerFileAsset, error)\n\t// Creates a minimal placeholder resource\n\tCreateMinimalResource(ctx context.Context, arg CreateMinimalResourceParams) (ExampleResource, error)\n\tCreateOrganization(ctx context.Context, arg CreateOrganizationParams) (OrganizationsOrganization, error)\n\t// Example Resource Queries\n\t// Demonstrates Clean Architecture patterns with CRUD operations,\n\t// file attachments, OCR/LLM processing, and approval workflows\n\t// CREATE operations\n\tCreateResource(ctx context.Context, arg CreateResourceParams) (ExampleResource, error)\n\t// Decrement invoice count by 1 (called after successful invoice processing)\n\tDecrementInvoiceCount(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error)\n\tDeleteAccount(ctx context.Context, arg DeleteAccountParams) error\n\tDeleteChatMessage(ctx context.Context, id int32) error\n\tDeleteChatSession(ctx context.Context, arg DeleteChatSessionParams) error\n\tDeleteDocument(ctx context.Context, arg DeleteDocumentParams) error\n\tDeleteDocumentEmbeddings(ctx context.Context, arg DeleteDocumentEmbeddingsParams) error\n\tDeleteFileAsset(ctx context.Context, id int32) error\n\tDeleteOrganization(ctx context.Context, id int32) error\n\t// DELETE operations\n\t// Soft delete a resource\n\tDeleteResource(ctx context.Context, arg DeleteResourceParams) error\n\t// Delete subscription (when subscription is permanently deleted)\n\tDeleteSubscription(ctx context.Context, organizationID int32) error\n\tGetAccountByEmail(ctx context.Context, arg GetAccountByEmailParams) (OrganizationsAccount, error)\n\tGetAccountByID(ctx context.Context, arg GetAccountByIDParams) (OrganizationsAccount, error)\n\tGetAccountOrganization(ctx context.Context, id int32) (OrganizationsOrganization, error)\n\tGetAccountStats(ctx context.Context, id int32) (GetAccountStatsRow, error)\n\tGetChatMessagesBySession(ctx context.Context, sessionID int32) ([]CognitiveChatMessage, error)\n\tGetChatSessionByID(ctx context.Context, arg GetChatSessionByIDParams) (CognitiveChatSession, error)\n\tGetDocumentByFileAssetID(ctx context.Context, arg GetDocumentByFileAssetIDParams) (DocumentsDocument, error)\n\tGetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (DocumentsDocument, error)\n\tGetDocumentEmbeddingByID(ctx context.Context, arg GetDocumentEmbeddingByIDParams) (CognitiveDocumentEmbedding, error)\n\tGetDocumentEmbeddingsByDocumentID(ctx context.Context, arg GetDocumentEmbeddingsByDocumentIDParams) ([]CognitiveDocumentEmbedding, error)\n\tGetFileAssetByID(ctx context.Context, id int32) (FileManagerFileAsset, error)\n\tGetFileAssetByStoragePath(ctx context.Context, storagePath string) (FileManagerFileAsset, error)\n\tGetFileAssetsByCategory(ctx context.Context, name string) ([]GetFileAssetsByCategoryRow, error)\n\tGetFileAssetsByContext(ctx context.Context, name string) ([]GetFileAssetsByContextRow, error)\n\tGetFileAssetsByEntity(ctx context.Context, arg GetFileAssetsByEntityParams) ([]FileManagerFileAsset, error)\n\tGetFileAssetsByEntityAndPurpose(ctx context.Context, arg GetFileAssetsByEntityAndPurposeParams) ([]FileManagerFileAsset, error)\n\tGetFileCategories(ctx context.Context) ([]FileManagerFileCategory, error)\n\tGetFileContexts(ctx context.Context) ([]FileManagerFileContext, error)\n\tGetOrganizationByID(ctx context.Context, id int32) (OrganizationsOrganization, error)\n\tGetOrganizationBySlug(ctx context.Context, slug string) (OrganizationsOrganization, error)\n\tGetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (OrganizationsOrganization, error)\n\t// Organization membership queries\n\tGetOrganizationByUserEmail(ctx context.Context, email string) (OrganizationsOrganization, error)\n\t// Statistics queries (useful for admin panels)\n\tGetOrganizationStats(ctx context.Context, id int32) (GetOrganizationStatsRow, error)\n\t// Get quota tracking for an organization\n\tGetQuotaByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error)\n\t// Get combined subscription and quota status for fast quota checks\n\tGetQuotaStatus(ctx context.Context, organizationID int32) (GetQuotaStatusRow, error)\n\tGetRecentChatMessages(ctx context.Context, arg GetRecentChatMessagesParams) ([]CognitiveChatMessage, error)\n\t// Get most recently created resources\n\tGetRecentResources(ctx context.Context, arg GetRecentResourcesParams) ([]GetRecentResourcesRow, error)\n\t// READ operations\n\tGetResourceByID(ctx context.Context, arg GetResourceByIDParams) (ExampleResource, error)\n\tGetResourceByNumber(ctx context.Context, arg GetResourceByNumberParams) (ExampleResource, error)\n\t// ANALYTICS queries\n\t// Get statistics for dashboard\n\tGetResourceStats(ctx context.Context, organizationID int32) (GetResourceStatsRow, error)\n\t// Get resources created by a specific user\n\tGetResourcesByCreator(ctx context.Context, arg GetResourcesByCreatorParams) ([]ExampleResource, error)\n\t// Get subscription details for an organization\n\tGetSubscriptionByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingSubscription, error)\n\t// Get subscription by Polar subscription ID\n\tGetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (SubscriptionBillingSubscription, error)\n\t// Hard delete a resource (use with caution)\n\tHardDeleteResource(ctx context.Context, arg HardDeleteResourceParams) error\n\tListAccountsByOrganization(ctx context.Context, organizationID int32) ([]OrganizationsAccount, error)\n\t// List all active subscriptions for monitoring/admin purposes\n\tListActiveSubscriptions(ctx context.Context) ([]SubscriptionBillingSubscription, error)\n\tListChatSessionsByAccount(ctx context.Context, arg ListChatSessionsByAccountParams) ([]CognitiveChatSession, error)\n\tListDocumentsByOrganization(ctx context.Context, arg ListDocumentsByOrganizationParams) ([]DocumentsDocument, error)\n\tListDocumentsByStatus(ctx context.Context, arg ListDocumentsByStatusParams) ([]DocumentsDocument, error)\n\tListFileAssets(ctx context.Context, arg ListFileAssetsParams) ([]ListFileAssetsRow, error)\n\tListOrganizations(ctx context.Context, arg ListOrganizationsParams) ([]OrganizationsOrganization, error)\n\t// List organizations approaching their quota limit (for alerting)\n\tListQuotasNearLimit(ctx context.Context, invoiceCount int32) ([]ListQuotasNearLimitRow, error)\n\t// List resources with filtering and pagination\n\tListResources(ctx context.Context, arg ListResourcesParams) ([]ListResourcesRow, error)\n\t// Reset quota counters for a new billing period\n\tResetQuotaForPeriod(ctx context.Context, arg ResetQuotaForPeriodParams) (SubscriptionBillingQuotaTracking, error)\n\t// SEARCH operations\n\t// Full-text search on title and description\n\tSearchResourcesByText(ctx context.Context, arg SearchResourcesByTextParams) ([]SearchResourcesByTextRow, error)\n\tSearchSimilarDocuments(ctx context.Context, arg SearchSimilarDocumentsParams) ([]SearchSimilarDocumentsRow, error)\n\tUpdateAccount(ctx context.Context, arg UpdateAccountParams) (OrganizationsAccount, error)\n\tUpdateAccountLastLogin(ctx context.Context, arg UpdateAccountLastLoginParams) (OrganizationsAccount, error)\n\tUpdateAccountStytchInfo(ctx context.Context, arg UpdateAccountStytchInfoParams) (OrganizationsAccount, error)\n\tUpdateChatSessionTitle(ctx context.Context, arg UpdateChatSessionTitleParams) (CognitiveChatSession, error)\n\tUpdateDocument(ctx context.Context, arg UpdateDocumentParams) (DocumentsDocument, error)\n\tUpdateDocumentExtractedText(ctx context.Context, arg UpdateDocumentExtractedTextParams) (DocumentsDocument, error)\n\tUpdateDocumentStatus(ctx context.Context, arg UpdateDocumentStatusParams) (DocumentsDocument, error)\n\tUpdateFileAsset(ctx context.Context, arg UpdateFileAssetParams) error\n\tUpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (OrganizationsOrganization, error)\n\tUpdateOrganizationStytchInfo(ctx context.Context, arg UpdateOrganizationStytchInfoParams) (OrganizationsOrganization, error)\n\t// UPDATE operations\n\tUpdateResource(ctx context.Context, arg UpdateResourceParams) error\n\t// Update approval workflow status\n\tUpdateResourceApprovalStatus(ctx context.Context, arg UpdateResourceApprovalStatusParams) error\n\t// Update OCR/LLM processing results\n\tUpdateResourceProcessingData(ctx context.Context, arg UpdateResourceProcessingDataParams) error\n\tUpdateResourceStatus(ctx context.Context, arg UpdateResourceStatusParams) error\n\t// Create or update quota tracking\n\tUpsertQuota(ctx context.Context, arg UpsertQuotaParams) (SubscriptionBillingQuotaTracking, error)\n\t// Create or update subscription from Polar webhook\n\tUpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (SubscriptionBillingSubscription, error)\n}\n\nvar _ Querier = (*Queries)(nil)\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/resource_embeddings.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: resource_embeddings.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\tpgvector_go \"github.com/pgvector/pgvector-go\"\n)\n\nconst confirmDuplicate = `-- name: ConfirmDuplicate :exec\nUPDATE duplicate_candidates\nSET status = 'confirmed', updated_at = NOW()\nWHERE id = $1\n`\n\n// Marks a duplicate candidate as confirmed\nfunc (q *Queries) ConfirmDuplicate(ctx context.Context, id int32) error {\n\t_, err := q.db.Exec(ctx, confirmDuplicate, id)\n\treturn err\n}\n\nconst countDuplicatesByStatus = `-- name: CountDuplicatesByStatus :one\nSELECT COUNT(*) FROM duplicate_candidates\nWHERE organization_id = $1 AND status = $2\n`\n\ntype CountDuplicatesByStatusParams struct {\n\tOrganizationID int32       `json:\"organization_id\"`\n\tStatus         pgtype.Text `json:\"status\"`\n}\n\n// Counts duplicate candidates by status for an organization\nfunc (q *Queries) CountDuplicatesByStatus(ctx context.Context, arg CountDuplicatesByStatusParams) (int64, error) {\n\trow := q.db.QueryRow(ctx, countDuplicatesByStatus, arg.OrganizationID, arg.Status)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst countEmbeddingsByOrganization = `-- name: CountEmbeddingsByOrganization :one\nSELECT COUNT(*) FROM resource_embeddings\nWHERE organization_id = $1\n`\n\n// Counts total embeddings for an organization\nfunc (q *Queries) CountEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) {\n\trow := q.db.QueryRow(ctx, countEmbeddingsByOrganization, organizationID)\n\tvar count int64\n\terr := row.Scan(&count)\n\treturn count, err\n}\n\nconst createDuplicateCandidateExactMatch = `-- name: CreateDuplicateCandidateExactMatch :one\nINSERT INTO duplicate_candidates (\n    resource_id,\n    candidate_resource_id,\n    similarity_score,\n    detection_method,\n    confidence_level,\n    organization_id,\n    status\n) VALUES (\n    $1, $2, $3, 'exact_match', 'very_high', $4, 'pending'\n) RETURNING id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at\n`\n\ntype CreateDuplicateCandidateExactMatchParams struct {\n\tResourceID          int32          `json:\"resource_id\"`\n\tCandidateResourceID int32          `json:\"candidate_resource_id\"`\n\tSimilarityScore     pgtype.Numeric `json:\"similarity_score\"`\n\tOrganizationID      int32          `json:\"organization_id\"`\n}\n\n// Creates a duplicate candidate for exact/perfect matches (no LLM data)\nfunc (q *Queries) CreateDuplicateCandidateExactMatch(ctx context.Context, arg CreateDuplicateCandidateExactMatchParams) (DuplicateCandidate, error) {\n\trow := q.db.QueryRow(ctx, createDuplicateCandidateExactMatch,\n\t\targ.ResourceID,\n\t\targ.CandidateResourceID,\n\t\targ.SimilarityScore,\n\t\targ.OrganizationID,\n\t)\n\tvar i DuplicateCandidate\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceID,\n\t\t&i.CandidateResourceID,\n\t\t&i.SimilarityScore,\n\t\t&i.DetectionMethod,\n\t\t&i.ConfidenceLevel,\n\t\t&i.LlmReason,\n\t\t&i.LlmSimilarFields,\n\t\t&i.LlmResponse,\n\t\t&i.OrganizationID,\n\t\t&i.Status,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst createDuplicateCandidateLLM = `-- name: CreateDuplicateCandidateLLM :one\nINSERT INTO duplicate_candidates (\n    resource_id,\n    candidate_resource_id,\n    similarity_score,\n    detection_method,\n    confidence_level,\n    llm_reason,\n    llm_similar_fields,\n    llm_response,\n    organization_id,\n    status\n) VALUES (\n    $1, $2, $3, 'llm_adjudicated', $4, $5, $6, $7, $8, 'pending'\n) RETURNING id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at\n`\n\ntype CreateDuplicateCandidateLLMParams struct {\n\tResourceID          int32          `json:\"resource_id\"`\n\tCandidateResourceID int32          `json:\"candidate_resource_id\"`\n\tSimilarityScore     pgtype.Numeric `json:\"similarity_score\"`\n\tConfidenceLevel     pgtype.Text    `json:\"confidence_level\"`\n\tLlmReason           pgtype.Text    `json:\"llm_reason\"`\n\tLlmSimilarFields    []byte         `json:\"llm_similar_fields\"`\n\tLlmResponse         []byte         `json:\"llm_response\"`\n\tOrganizationID      int32          `json:\"organization_id\"`\n}\n\n// Creates a duplicate candidate with LLM adjudication data\nfunc (q *Queries) CreateDuplicateCandidateLLM(ctx context.Context, arg CreateDuplicateCandidateLLMParams) (DuplicateCandidate, error) {\n\trow := q.db.QueryRow(ctx, createDuplicateCandidateLLM,\n\t\targ.ResourceID,\n\t\targ.CandidateResourceID,\n\t\targ.SimilarityScore,\n\t\targ.ConfidenceLevel,\n\t\targ.LlmReason,\n\t\targ.LlmSimilarFields,\n\t\targ.LlmResponse,\n\t\targ.OrganizationID,\n\t)\n\tvar i DuplicateCandidate\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceID,\n\t\t&i.CandidateResourceID,\n\t\t&i.SimilarityScore,\n\t\t&i.DetectionMethod,\n\t\t&i.ConfidenceLevel,\n\t\t&i.LlmReason,\n\t\t&i.LlmSimilarFields,\n\t\t&i.LlmResponse,\n\t\t&i.OrganizationID,\n\t\t&i.Status,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst createResourceDuplicateCandidate = `-- name: CreateResourceDuplicateCandidate :one\n\nINSERT INTO duplicate_candidates (\n    resource_id,\n    candidate_resource_id,\n    similarity_score,\n    detection_method,\n    confidence_level,\n    llm_reason,\n    llm_similar_fields,\n    llm_response,\n    organization_id,\n    status\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n) RETURNING id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at\n`\n\ntype CreateResourceDuplicateCandidateParams struct {\n\tResourceID          int32          `json:\"resource_id\"`\n\tCandidateResourceID int32          `json:\"candidate_resource_id\"`\n\tSimilarityScore     pgtype.Numeric `json:\"similarity_score\"`\n\tDetectionMethod     string         `json:\"detection_method\"`\n\tConfidenceLevel     pgtype.Text    `json:\"confidence_level\"`\n\tLlmReason           pgtype.Text    `json:\"llm_reason\"`\n\tLlmSimilarFields    []byte         `json:\"llm_similar_fields\"`\n\tLlmResponse         []byte         `json:\"llm_response\"`\n\tOrganizationID      int32          `json:\"organization_id\"`\n\tStatus              pgtype.Text    `json:\"status\"`\n}\n\n// Duplicate Candidates Queries\n// Creates a new duplicate candidate record\nfunc (q *Queries) CreateResourceDuplicateCandidate(ctx context.Context, arg CreateResourceDuplicateCandidateParams) (DuplicateCandidate, error) {\n\trow := q.db.QueryRow(ctx, createResourceDuplicateCandidate,\n\t\targ.ResourceID,\n\t\targ.CandidateResourceID,\n\t\targ.SimilarityScore,\n\t\targ.DetectionMethod,\n\t\targ.ConfidenceLevel,\n\t\targ.LlmReason,\n\t\targ.LlmSimilarFields,\n\t\targ.LlmResponse,\n\t\targ.OrganizationID,\n\t\targ.Status,\n\t)\n\tvar i DuplicateCandidate\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceID,\n\t\t&i.CandidateResourceID,\n\t\t&i.SimilarityScore,\n\t\t&i.DetectionMethod,\n\t\t&i.ConfidenceLevel,\n\t\t&i.LlmReason,\n\t\t&i.LlmSimilarFields,\n\t\t&i.LlmResponse,\n\t\t&i.OrganizationID,\n\t\t&i.Status,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteDuplicateCandidate = `-- name: DeleteDuplicateCandidate :exec\nDELETE FROM duplicate_candidates\nWHERE id = $1\n`\n\n// Deletes a duplicate candidate record\nfunc (q *Queries) DeleteDuplicateCandidate(ctx context.Context, id int32) error {\n\t_, err := q.db.Exec(ctx, deleteDuplicateCandidate, id)\n\treturn err\n}\n\nconst deleteResourceEmbedding = `-- name: DeleteResourceEmbedding :exec\n\nDELETE FROM resource_embeddings\nWHERE resource_id = $1 AND organization_id = $2\n`\n\ntype DeleteResourceEmbeddingParams struct {\n\tResourceID     int32 `json:\"resource_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\n// Exclude the current resource\n// Deletes an embedding for a resource\nfunc (q *Queries) DeleteResourceEmbedding(ctx context.Context, arg DeleteResourceEmbeddingParams) error {\n\t_, err := q.db.Exec(ctx, deleteResourceEmbedding, arg.ResourceID, arg.OrganizationID)\n\treturn err\n}\n\nconst dismissDuplicate = `-- name: DismissDuplicate :exec\nUPDATE duplicate_candidates\nSET status = 'dismissed', updated_at = NOW()\nWHERE id = $1\n`\n\n// Marks a duplicate candidate as dismissed\nfunc (q *Queries) DismissDuplicate(ctx context.Context, id int32) error {\n\t_, err := q.db.Exec(ctx, dismissDuplicate, id)\n\treturn err\n}\n\nconst findExactDuplicateByHash = `-- name: FindExactDuplicateByHash :many\nSELECT\n    resource_id,\n    content_hash,\n    content_preview\nFROM resource_embeddings\nWHERE organization_id = $1\n    AND content_hash = $2\n    AND resource_id != $3\n`\n\ntype FindExactDuplicateByHashParams struct {\n\tOrganizationID int32       `json:\"organization_id\"`\n\tContentHash    pgtype.Text `json:\"content_hash\"`\n\tResourceID     int32       `json:\"resource_id\"`\n}\n\ntype FindExactDuplicateByHashRow struct {\n\tResourceID     int32       `json:\"resource_id\"`\n\tContentHash    pgtype.Text `json:\"content_hash\"`\n\tContentPreview pgtype.Text `json:\"content_preview\"`\n}\n\n// Finds exact duplicates using content hash (faster than vector search for exact matches)\nfunc (q *Queries) FindExactDuplicateByHash(ctx context.Context, arg FindExactDuplicateByHashParams) ([]FindExactDuplicateByHashRow, error) {\n\trows, err := q.db.Query(ctx, findExactDuplicateByHash, arg.OrganizationID, arg.ContentHash, arg.ResourceID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []FindExactDuplicateByHashRow{}\n\tfor rows.Next() {\n\t\tvar i FindExactDuplicateByHashRow\n\t\tif err := rows.Scan(&i.ResourceID, &i.ContentHash, &i.ContentPreview); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst findSimilarResources = `-- name: FindSimilarResources :many\nSELECT\n    resource_id,\n    1 - (embedding <=> $1::vector) AS similarity_score,\n    content_hash,\n    content_preview\nFROM resource_embeddings\nWHERE organization_id = $2\n    AND resource_id != $3\n    AND 1 - (embedding <=> $1::vector) >= $4\nORDER BY embedding <=> $1::vector -- Order by distance (closest first)\nLIMIT $5\n`\n\ntype FindSimilarResourcesParams struct {\n\tColumn1        pgvector_go.Vector `json:\"column_1\"`\n\tOrganizationID int32              `json:\"organization_id\"`\n\tResourceID     int32              `json:\"resource_id\"`\n\tEmbedding      pgvector_go.Vector `json:\"embedding\"`\n\tLimit          int32              `json:\"limit\"`\n}\n\ntype FindSimilarResourcesRow struct {\n\tResourceID      int32       `json:\"resource_id\"`\n\tSimilarityScore int32       `json:\"similarity_score\"`\n\tContentHash     pgtype.Text `json:\"content_hash\"`\n\tContentPreview  pgtype.Text `json:\"content_preview\"`\n}\n\n// Finds similar resources using vector cosine similarity search\n// The <=> operator calculates cosine distance (0 = identical, 2 = opposite)\n// We convert to similarity score: 1 - distance/2 = similarity (0 to 1)\n//\n// Parameters:\n// $1: embedding vector to search for\n// $2: organization_id to scope the search\n// $3: resource_id to exclude (don't match against itself)\n// $4: minimum similarity threshold (e.g., 0.85)\n// $5: limit on number of results\nfunc (q *Queries) FindSimilarResources(ctx context.Context, arg FindSimilarResourcesParams) ([]FindSimilarResourcesRow, error) {\n\trows, err := q.db.Query(ctx, findSimilarResources,\n\t\targ.Column1,\n\t\targ.OrganizationID,\n\t\targ.ResourceID,\n\t\targ.Embedding,\n\t\targ.Limit,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []FindSimilarResourcesRow{}\n\tfor rows.Next() {\n\t\tvar i FindSimilarResourcesRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ResourceID,\n\t\t\t&i.SimilarityScore,\n\t\t\t&i.ContentHash,\n\t\t\t&i.ContentPreview,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst getDuplicateCandidate = `-- name: GetDuplicateCandidate :one\nSELECT id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at FROM duplicate_candidates\nWHERE id = $1\n`\n\n// Gets a specific duplicate candidate by ID\nfunc (q *Queries) GetDuplicateCandidate(ctx context.Context, id int32) (DuplicateCandidate, error) {\n\trow := q.db.QueryRow(ctx, getDuplicateCandidate, id)\n\tvar i DuplicateCandidate\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceID,\n\t\t&i.CandidateResourceID,\n\t\t&i.SimilarityScore,\n\t\t&i.DetectionMethod,\n\t\t&i.ConfidenceLevel,\n\t\t&i.LlmReason,\n\t\t&i.LlmSimilarFields,\n\t\t&i.LlmResponse,\n\t\t&i.OrganizationID,\n\t\t&i.Status,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst getResourceDuplicateStats = `-- name: GetResourceDuplicateStats :one\nSELECT\n    COUNT(*) as total_candidates,\n    COUNT(*) FILTER (WHERE status = 'pending') as pending_count,\n    COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,\n    COUNT(*) FILTER (WHERE status = 'dismissed') as dismissed_count,\n    COUNT(*) FILTER (WHERE detection_method = 'exact_match') as exact_match_count,\n    COUNT(*) FILTER (WHERE detection_method = 'llm_adjudicated') as llm_adjudicated_count,\n    AVG(similarity_score) as avg_similarity_score\nFROM duplicate_candidates\nWHERE organization_id = $1\n`\n\ntype GetResourceDuplicateStatsRow struct {\n\tTotalCandidates     int64   `json:\"total_candidates\"`\n\tPendingCount        int64   `json:\"pending_count\"`\n\tConfirmedCount      int64   `json:\"confirmed_count\"`\n\tDismissedCount      int64   `json:\"dismissed_count\"`\n\tExactMatchCount     int64   `json:\"exact_match_count\"`\n\tLlmAdjudicatedCount int64   `json:\"llm_adjudicated_count\"`\n\tAvgSimilarityScore  float64 `json:\"avg_similarity_score\"`\n}\n\n// Gets statistics about duplicate detection for an organization\nfunc (q *Queries) GetResourceDuplicateStats(ctx context.Context, organizationID int32) (GetResourceDuplicateStatsRow, error) {\n\trow := q.db.QueryRow(ctx, getResourceDuplicateStats, organizationID)\n\tvar i GetResourceDuplicateStatsRow\n\terr := row.Scan(\n\t\t&i.TotalCandidates,\n\t\t&i.PendingCount,\n\t\t&i.ConfirmedCount,\n\t\t&i.DismissedCount,\n\t\t&i.ExactMatchCount,\n\t\t&i.LlmAdjudicatedCount,\n\t\t&i.AvgSimilarityScore,\n\t)\n\treturn i, err\n}\n\nconst getResourceEmbedding = `-- name: GetResourceEmbedding :one\nSELECT id, resource_id, embedding, organization_id, content_hash, content_preview, created_at, updated_at FROM resource_embeddings\nWHERE resource_id = $1 AND organization_id = $2\nLIMIT 1\n`\n\ntype GetResourceEmbeddingParams struct {\n\tResourceID     int32 `json:\"resource_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\n// Retrieves the embedding for a specific resource\nfunc (q *Queries) GetResourceEmbedding(ctx context.Context, arg GetResourceEmbeddingParams) (ResourceEmbedding, error) {\n\trow := q.db.QueryRow(ctx, getResourceEmbedding, arg.ResourceID, arg.OrganizationID)\n\tvar i ResourceEmbedding\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.ResourceID,\n\t\t&i.Embedding,\n\t\t&i.OrganizationID,\n\t\t&i.ContentHash,\n\t\t&i.ContentPreview,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst listDuplicateCandidatesForResource = `-- name: ListDuplicateCandidatesForResource :many\nSELECT id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at FROM duplicate_candidates\nWHERE resource_id = $1 AND organization_id = $2\nORDER BY similarity_score DESC, created_at DESC\n`\n\ntype ListDuplicateCandidatesForResourceParams struct {\n\tResourceID     int32 `json:\"resource_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n}\n\n// Lists all duplicate candidates for a specific resource\nfunc (q *Queries) ListDuplicateCandidatesForResource(ctx context.Context, arg ListDuplicateCandidatesForResourceParams) ([]DuplicateCandidate, error) {\n\trows, err := q.db.Query(ctx, listDuplicateCandidatesForResource, arg.ResourceID, arg.OrganizationID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []DuplicateCandidate{}\n\tfor rows.Next() {\n\t\tvar i DuplicateCandidate\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.ResourceID,\n\t\t\t&i.CandidateResourceID,\n\t\t\t&i.SimilarityScore,\n\t\t\t&i.DetectionMethod,\n\t\t\t&i.ConfidenceLevel,\n\t\t\t&i.LlmReason,\n\t\t\t&i.LlmSimilarFields,\n\t\t\t&i.LlmResponse,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.Status,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst listPendingDuplicates = `-- name: ListPendingDuplicates :many\nSELECT id, resource_id, candidate_resource_id, similarity_score, detection_method, confidence_level, llm_reason, llm_similar_fields, llm_response, organization_id, status, created_at, updated_at FROM duplicate_candidates\nWHERE organization_id = $1 AND status = 'pending'\nORDER BY similarity_score DESC, created_at DESC\nLIMIT $2 OFFSET $3\n`\n\ntype ListPendingDuplicatesParams struct {\n\tOrganizationID int32 `json:\"organization_id\"`\n\tLimit          int32 `json:\"limit\"`\n\tOffset         int32 `json:\"offset\"`\n}\n\n// Lists all pending duplicate candidates for an organization\nfunc (q *Queries) ListPendingDuplicates(ctx context.Context, arg ListPendingDuplicatesParams) ([]DuplicateCandidate, error) {\n\trows, err := q.db.Query(ctx, listPendingDuplicates, arg.OrganizationID, arg.Limit, arg.Offset)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []DuplicateCandidate{}\n\tfor rows.Next() {\n\t\tvar i DuplicateCandidate\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.ResourceID,\n\t\t\t&i.CandidateResourceID,\n\t\t\t&i.SimilarityScore,\n\t\t\t&i.DetectionMethod,\n\t\t\t&i.ConfidenceLevel,\n\t\t\t&i.LlmReason,\n\t\t\t&i.LlmSimilarFields,\n\t\t\t&i.LlmResponse,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.Status,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst saveResourceEmbedding = `-- name: SaveResourceEmbedding :exec\n\nINSERT INTO resource_embeddings (\n    resource_id,\n    embedding,\n    organization_id,\n    content_hash,\n    content_preview\n) VALUES (\n    $1, $2, $3, $4, $5\n) ON CONFLICT (resource_id, organization_id)\nDO UPDATE SET\n    embedding = EXCLUDED.embedding,\n    content_hash = EXCLUDED.content_hash,\n    content_preview = EXCLUDED.content_preview,\n    updated_at = NOW()\n`\n\ntype SaveResourceEmbeddingParams struct {\n\tResourceID     int32              `json:\"resource_id\"`\n\tEmbedding      pgvector_go.Vector `json:\"embedding\"`\n\tOrganizationID int32              `json:\"organization_id\"`\n\tContentHash    pgtype.Text        `json:\"content_hash\"`\n\tContentPreview pgtype.Text        `json:\"content_preview\"`\n}\n\n// Resource Embeddings Queries\n// These queries demonstrate pgvector usage for semantic similarity search\n// Saves or updates an embedding for a resource\n// Uses ON CONFLICT to handle duplicate resource_id + organization_id pairs\nfunc (q *Queries) SaveResourceEmbedding(ctx context.Context, arg SaveResourceEmbeddingParams) error {\n\t_, err := q.db.Exec(ctx, saveResourceEmbedding,\n\t\targ.ResourceID,\n\t\targ.Embedding,\n\t\targ.OrganizationID,\n\t\targ.ContentHash,\n\t\targ.ContentPreview,\n\t)\n\treturn err\n}\n\nconst updateDuplicateCandidateStatus = `-- name: UpdateDuplicateCandidateStatus :exec\nUPDATE duplicate_candidates\nSET status = $2, updated_at = NOW()\nWHERE id = $1\n`\n\ntype UpdateDuplicateCandidateStatusParams struct {\n\tID     int32       `json:\"id\"`\n\tStatus pgtype.Text `json:\"status\"`\n}\n\n// Updates the status of a duplicate candidate\nfunc (q *Queries) UpdateDuplicateCandidateStatus(ctx context.Context, arg UpdateDuplicateCandidateStatusParams) error {\n\t_, err := q.db.Exec(ctx, updateDuplicateCandidateStatus, arg.ID, arg.Status)\n\treturn err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/store.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgxpool\"\n)\n\ntype Store interface {\n\tQuerier\n}\n\ntype SQLStore struct {\n\tconnPool *pgxpool.Pool\n\t*Queries\n}\n\nfunc NewStore(connPool *pgxpool.Pool) Store {\n\treturn &SQLStore{\n\t\tconnPool: connPool,\n\t\tQueries:  New(connPool),\n\t}\n}\n\nfunc (store *SQLStore) WithTx(db DBTX) Store {\n\treturn &SQLStore{\n\t\tQueries: New(db),\n\t}\n}\n\n// ExecTx executes a function within a database transaction\nfunc (store *SQLStore) ExecTx(ctx context.Context, fn func(*Queries) error) error {\n\treturn fn(store.Queries)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/gen/subscription_billing.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.26.0\n// source: subscription_billing.sql\n\npackage postgres\n\nimport (\n\t\"context\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\nconst decrementInvoiceCount = `-- name: DecrementInvoiceCount :one\nUPDATE subscription_billing.quota_tracking\nSET\n    invoice_count = invoice_count - 1,\n    updated_at = CURRENT_TIMESTAMP\nWHERE organization_id = $1\nRETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count\n`\n\n// Decrement invoice count by 1 (called after successful invoice processing)\nfunc (q *Queries) DecrementInvoiceCount(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) {\n\trow := q.db.QueryRow(ctx, decrementInvoiceCount, organizationID)\n\tvar i SubscriptionBillingQuotaTracking\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.MaxSeats,\n\t\t&i.PeriodStart,\n\t\t&i.PeriodEnd,\n\t\t&i.LastSyncedAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.InvoiceCount,\n\t)\n\treturn i, err\n}\n\nconst deleteSubscription = `-- name: DeleteSubscription :exec\nDELETE FROM subscription_billing.subscriptions\nWHERE organization_id = $1\n`\n\n// Delete subscription (when subscription is permanently deleted)\nfunc (q *Queries) DeleteSubscription(ctx context.Context, organizationID int32) error {\n\t_, err := q.db.Exec(ctx, deleteSubscription, organizationID)\n\treturn err\n}\n\nconst getQuotaByOrgID = `-- name: GetQuotaByOrgID :one\nSELECT id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count FROM subscription_billing.quota_tracking\nWHERE organization_id = $1\nLIMIT 1\n`\n\n// Get quota tracking for an organization\nfunc (q *Queries) GetQuotaByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) {\n\trow := q.db.QueryRow(ctx, getQuotaByOrgID, organizationID)\n\tvar i SubscriptionBillingQuotaTracking\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.MaxSeats,\n\t\t&i.PeriodStart,\n\t\t&i.PeriodEnd,\n\t\t&i.LastSyncedAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.InvoiceCount,\n\t)\n\treturn i, err\n}\n\nconst getQuotaStatus = `-- name: GetQuotaStatus :one\nSELECT\n    s.subscription_status,\n    s.current_period_start,\n    s.current_period_end,\n    s.cancel_at_period_end,\n    q.invoice_count,\n    q.max_seats,\n    CASE\n        WHEN s.subscription_status = 'active' AND q.invoice_count > 0\n        THEN TRUE\n        ELSE FALSE\n    END AS can_process_invoice\nFROM subscription_billing.subscriptions s\nINNER JOIN subscription_billing.quota_tracking q ON s.organization_id = q.organization_id\nWHERE s.organization_id = $1\nLIMIT 1\n`\n\ntype GetQuotaStatusRow struct {\n\tSubscriptionStatus string           `json:\"subscription_status\"`\n\tCurrentPeriodStart pgtype.Timestamp `json:\"current_period_start\"`\n\tCurrentPeriodEnd   pgtype.Timestamp `json:\"current_period_end\"`\n\tCancelAtPeriodEnd  pgtype.Bool      `json:\"cancel_at_period_end\"`\n\tInvoiceCount       int32            `json:\"invoice_count\"`\n\tMaxSeats           pgtype.Int4      `json:\"max_seats\"`\n\tCanProcessInvoice  bool             `json:\"can_process_invoice\"`\n}\n\n// Get combined subscription and quota status for fast quota checks\nfunc (q *Queries) GetQuotaStatus(ctx context.Context, organizationID int32) (GetQuotaStatusRow, error) {\n\trow := q.db.QueryRow(ctx, getQuotaStatus, organizationID)\n\tvar i GetQuotaStatusRow\n\terr := row.Scan(\n\t\t&i.SubscriptionStatus,\n\t\t&i.CurrentPeriodStart,\n\t\t&i.CurrentPeriodEnd,\n\t\t&i.CancelAtPeriodEnd,\n\t\t&i.InvoiceCount,\n\t\t&i.MaxSeats,\n\t\t&i.CanProcessInvoice,\n\t)\n\treturn i, err\n}\n\nconst getSubscriptionByOrgID = `-- name: GetSubscriptionByOrgID :one\nSELECT id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata FROM subscription_billing.subscriptions\nWHERE organization_id = $1\nLIMIT 1\n`\n\n// Get subscription details for an organization\nfunc (q *Queries) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingSubscription, error) {\n\trow := q.db.QueryRow(ctx, getSubscriptionByOrgID, organizationID)\n\tvar i SubscriptionBillingSubscription\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.ExternalCustomerID,\n\t\t&i.SubscriptionID,\n\t\t&i.SubscriptionStatus,\n\t\t&i.ProductID,\n\t\t&i.ProductName,\n\t\t&i.PlanName,\n\t\t&i.CurrentPeriodStart,\n\t\t&i.CurrentPeriodEnd,\n\t\t&i.CancelAtPeriodEnd,\n\t\t&i.CanceledAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.Metadata,\n\t)\n\treturn i, err\n}\n\nconst getSubscriptionBySubscriptionID = `-- name: GetSubscriptionBySubscriptionID :one\nSELECT id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata FROM subscription_billing.subscriptions\nWHERE subscription_id = $1\nLIMIT 1\n`\n\n// Get subscription by Polar subscription ID\nfunc (q *Queries) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (SubscriptionBillingSubscription, error) {\n\trow := q.db.QueryRow(ctx, getSubscriptionBySubscriptionID, subscriptionID)\n\tvar i SubscriptionBillingSubscription\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.ExternalCustomerID,\n\t\t&i.SubscriptionID,\n\t\t&i.SubscriptionStatus,\n\t\t&i.ProductID,\n\t\t&i.ProductName,\n\t\t&i.PlanName,\n\t\t&i.CurrentPeriodStart,\n\t\t&i.CurrentPeriodEnd,\n\t\t&i.CancelAtPeriodEnd,\n\t\t&i.CanceledAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.Metadata,\n\t)\n\treturn i, err\n}\n\nconst listActiveSubscriptions = `-- name: ListActiveSubscriptions :many\nSELECT id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata FROM subscription_billing.subscriptions\nWHERE subscription_status = 'active'\nORDER BY created_at DESC\n`\n\n// List all active subscriptions for monitoring/admin purposes\nfunc (q *Queries) ListActiveSubscriptions(ctx context.Context) ([]SubscriptionBillingSubscription, error) {\n\trows, err := q.db.Query(ctx, listActiveSubscriptions)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []SubscriptionBillingSubscription{}\n\tfor rows.Next() {\n\t\tvar i SubscriptionBillingSubscription\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.ExternalCustomerID,\n\t\t\t&i.SubscriptionID,\n\t\t\t&i.SubscriptionStatus,\n\t\t\t&i.ProductID,\n\t\t\t&i.ProductName,\n\t\t\t&i.PlanName,\n\t\t\t&i.CurrentPeriodStart,\n\t\t\t&i.CurrentPeriodEnd,\n\t\t\t&i.CancelAtPeriodEnd,\n\t\t\t&i.CanceledAt,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.Metadata,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst listQuotasNearLimit = `-- name: ListQuotasNearLimit :many\nSELECT\n    q.id, q.organization_id, q.max_seats, q.period_start, q.period_end, q.last_synced_at, q.created_at, q.updated_at, q.invoice_count,\n    s.subscription_status,\n    s.product_name\nFROM subscription_billing.quota_tracking q\nINNER JOIN subscription_billing.subscriptions s ON q.organization_id = s.organization_id\nWHERE\n    s.subscription_status = 'active'\n    AND q.invoice_count <= $1\nORDER BY q.invoice_count ASC\n`\n\ntype ListQuotasNearLimitRow struct {\n\tID                 int32            `json:\"id\"`\n\tOrganizationID     int32            `json:\"organization_id\"`\n\tMaxSeats           pgtype.Int4      `json:\"max_seats\"`\n\tPeriodStart        pgtype.Timestamp `json:\"period_start\"`\n\tPeriodEnd          pgtype.Timestamp `json:\"period_end\"`\n\tLastSyncedAt       pgtype.Timestamp `json:\"last_synced_at\"`\n\tCreatedAt          pgtype.Timestamp `json:\"created_at\"`\n\tUpdatedAt          pgtype.Timestamp `json:\"updated_at\"`\n\tInvoiceCount       int32            `json:\"invoice_count\"`\n\tSubscriptionStatus string           `json:\"subscription_status\"`\n\tProductName        pgtype.Text      `json:\"product_name\"`\n}\n\n// List organizations approaching their quota limit (for alerting)\nfunc (q *Queries) ListQuotasNearLimit(ctx context.Context, invoiceCount int32) ([]ListQuotasNearLimitRow, error) {\n\trows, err := q.db.Query(ctx, listQuotasNearLimit, invoiceCount)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\titems := []ListQuotasNearLimitRow{}\n\tfor rows.Next() {\n\t\tvar i ListQuotasNearLimitRow\n\t\tif err := rows.Scan(\n\t\t\t&i.ID,\n\t\t\t&i.OrganizationID,\n\t\t\t&i.MaxSeats,\n\t\t\t&i.PeriodStart,\n\t\t\t&i.PeriodEnd,\n\t\t\t&i.LastSyncedAt,\n\t\t\t&i.CreatedAt,\n\t\t\t&i.UpdatedAt,\n\t\t\t&i.InvoiceCount,\n\t\t\t&i.SubscriptionStatus,\n\t\t\t&i.ProductName,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst resetQuotaForPeriod = `-- name: ResetQuotaForPeriod :one\nUPDATE subscription_billing.quota_tracking\nSET\n    invoice_count = $2,\n    period_start = $3,\n    period_end = $4,\n    updated_at = CURRENT_TIMESTAMP\nWHERE organization_id = $1\nRETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count\n`\n\ntype ResetQuotaForPeriodParams struct {\n\tOrganizationID int32            `json:\"organization_id\"`\n\tInvoiceCount   int32            `json:\"invoice_count\"`\n\tPeriodStart    pgtype.Timestamp `json:\"period_start\"`\n\tPeriodEnd      pgtype.Timestamp `json:\"period_end\"`\n}\n\n// Reset quota counters for a new billing period\nfunc (q *Queries) ResetQuotaForPeriod(ctx context.Context, arg ResetQuotaForPeriodParams) (SubscriptionBillingQuotaTracking, error) {\n\trow := q.db.QueryRow(ctx, resetQuotaForPeriod,\n\t\targ.OrganizationID,\n\t\targ.InvoiceCount,\n\t\targ.PeriodStart,\n\t\targ.PeriodEnd,\n\t)\n\tvar i SubscriptionBillingQuotaTracking\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.MaxSeats,\n\t\t&i.PeriodStart,\n\t\t&i.PeriodEnd,\n\t\t&i.LastSyncedAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.InvoiceCount,\n\t)\n\treturn i, err\n}\n\nconst upsertQuota = `-- name: UpsertQuota :one\nINSERT INTO subscription_billing.quota_tracking (\n    organization_id,\n    invoice_count,\n    max_seats,\n    period_start,\n    period_end,\n    last_synced_at,\n    updated_at\n) VALUES (\n    $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP\n)\nON CONFLICT (organization_id)\nDO UPDATE SET\n    invoice_count = EXCLUDED.invoice_count,\n    max_seats = EXCLUDED.max_seats,\n    period_start = EXCLUDED.period_start,\n    period_end = EXCLUDED.period_end,\n    last_synced_at = CURRENT_TIMESTAMP,\n    updated_at = CURRENT_TIMESTAMP\nRETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count\n`\n\ntype UpsertQuotaParams struct {\n\tOrganizationID int32            `json:\"organization_id\"`\n\tInvoiceCount   int32            `json:\"invoice_count\"`\n\tMaxSeats       pgtype.Int4      `json:\"max_seats\"`\n\tPeriodStart    pgtype.Timestamp `json:\"period_start\"`\n\tPeriodEnd      pgtype.Timestamp `json:\"period_end\"`\n}\n\n// Create or update quota tracking\nfunc (q *Queries) UpsertQuota(ctx context.Context, arg UpsertQuotaParams) (SubscriptionBillingQuotaTracking, error) {\n\trow := q.db.QueryRow(ctx, upsertQuota,\n\t\targ.OrganizationID,\n\t\targ.InvoiceCount,\n\t\targ.MaxSeats,\n\t\targ.PeriodStart,\n\t\targ.PeriodEnd,\n\t)\n\tvar i SubscriptionBillingQuotaTracking\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.MaxSeats,\n\t\t&i.PeriodStart,\n\t\t&i.PeriodEnd,\n\t\t&i.LastSyncedAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.InvoiceCount,\n\t)\n\treturn i, err\n}\n\nconst upsertSubscription = `-- name: UpsertSubscription :one\nINSERT INTO subscription_billing.subscriptions (\n    organization_id,\n    external_customer_id,\n    subscription_id,\n    subscription_status,\n    product_id,\n    product_name,\n    plan_name,\n    current_period_start,\n    current_period_end,\n    cancel_at_period_end,\n    canceled_at,\n    metadata,\n    updated_at\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP\n)\nON CONFLICT (organization_id)\nDO UPDATE SET\n    external_customer_id = EXCLUDED.external_customer_id,\n    subscription_id = EXCLUDED.subscription_id,\n    subscription_status = EXCLUDED.subscription_status,\n    product_id = EXCLUDED.product_id,\n    product_name = EXCLUDED.product_name,\n    plan_name = EXCLUDED.plan_name,\n    current_period_start = EXCLUDED.current_period_start,\n    current_period_end = EXCLUDED.current_period_end,\n    cancel_at_period_end = EXCLUDED.cancel_at_period_end,\n    canceled_at = EXCLUDED.canceled_at,\n    metadata = EXCLUDED.metadata,\n    updated_at = CURRENT_TIMESTAMP\nRETURNING id, organization_id, external_customer_id, subscription_id, subscription_status, product_id, product_name, plan_name, current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at, metadata\n`\n\ntype UpsertSubscriptionParams struct {\n\tOrganizationID     int32            `json:\"organization_id\"`\n\tExternalCustomerID string           `json:\"external_customer_id\"`\n\tSubscriptionID     string           `json:\"subscription_id\"`\n\tSubscriptionStatus string           `json:\"subscription_status\"`\n\tProductID          string           `json:\"product_id\"`\n\tProductName        pgtype.Text      `json:\"product_name\"`\n\tPlanName           pgtype.Text      `json:\"plan_name\"`\n\tCurrentPeriodStart pgtype.Timestamp `json:\"current_period_start\"`\n\tCurrentPeriodEnd   pgtype.Timestamp `json:\"current_period_end\"`\n\tCancelAtPeriodEnd  pgtype.Bool      `json:\"cancel_at_period_end\"`\n\tCanceledAt         pgtype.Timestamp `json:\"canceled_at\"`\n\tMetadata           []byte           `json:\"metadata\"`\n}\n\n// Create or update subscription from Polar webhook\nfunc (q *Queries) UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (SubscriptionBillingSubscription, error) {\n\trow := q.db.QueryRow(ctx, upsertSubscription,\n\t\targ.OrganizationID,\n\t\targ.ExternalCustomerID,\n\t\targ.SubscriptionID,\n\t\targ.SubscriptionStatus,\n\t\targ.ProductID,\n\t\targ.ProductName,\n\t\targ.PlanName,\n\t\targ.CurrentPeriodStart,\n\t\targ.CurrentPeriodEnd,\n\t\targ.CancelAtPeriodEnd,\n\t\targ.CanceledAt,\n\t\targ.Metadata,\n\t)\n\tvar i SubscriptionBillingSubscription\n\terr := row.Scan(\n\t\t&i.ID,\n\t\t&i.OrganizationID,\n\t\t&i.ExternalCustomerID,\n\t\t&i.SubscriptionID,\n\t\t&i.SubscriptionStatus,\n\t\t&i.ProductID,\n\t\t&i.ProductName,\n\t\t&i.PlanName,\n\t\t&i.CurrentPeriodStart,\n\t\t&i.CurrentPeriodEnd,\n\t\t&i.CancelAtPeriodEnd,\n\t\t&i.CanceledAt,\n\t\t&i.CreatedAt,\n\t\t&i.UpdatedAt,\n\t\t&i.Metadata,\n\t)\n\treturn i, err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000001_create_file_manager_schema.down.sql",
    "content": "BEGIN;\n\n-- Drop indexes\nDROP INDEX IF EXISTS file_manager.idx_file_assets_entity;\nDROP INDEX IF EXISTS file_manager.idx_file_assets_category;  \nDROP INDEX IF EXISTS file_manager.idx_file_assets_context;\nDROP INDEX IF EXISTS file_manager.idx_file_assets_created_at;\n\n-- Drop tables in reverse order\nDROP TABLE IF EXISTS file_manager.file_assets;\nDROP TABLE IF EXISTS file_manager.file_contexts;\nDROP TABLE IF EXISTS file_manager.file_categories;\n\n-- Drop schema\nDROP SCHEMA IF EXISTS file_manager CASCADE;\n\nCOMMIT;"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000001_create_file_manager_schema.up.sql",
    "content": "CREATE SCHEMA IF NOT EXISTS file_manager;\n\n-- Create file categories table (similar to asset_types)\nCREATE TABLE file_manager.file_categories (\n    id SMALLINT PRIMARY KEY,\n    name VARCHAR(50) UNIQUE NOT NULL,  -- 'document', 'image', 'archive'\n    max_size_bytes BIGINT NOT NULL\n);\n\n-- Create file contexts table \nCREATE TABLE file_manager.file_contexts (\n    id SMALLINT PRIMARY KEY,\n    name VARCHAR(50) UNIQUE NOT NULL  -- 'invoice', 'receipt', 'contract', etc.\n);\n\n-- Create file_assets table (following assets pattern)\nCREATE TABLE file_manager.file_assets (\n    id SERIAL PRIMARY KEY,\n    file_name VARCHAR(255) NOT NULL,\n    original_file_name VARCHAR(255) NOT NULL,\n    storage_path VARCHAR(1000) NOT NULL, \n    bucket_name VARCHAR(50) NOT NULL,\n    file_size BIGINT NOT NULL CHECK (file_size > 0),\n    mime_type VARCHAR(100) NOT NULL,\n    file_category_id SMALLINT NOT NULL REFERENCES file_manager.file_categories(id),\n    file_context_id SMALLINT NOT NULL REFERENCES file_manager.file_contexts(id),\n    is_public BOOLEAN DEFAULT false,\n    entity_type VARCHAR(50),  -- 'user', 'invoice', 'contract', etc.\n    entity_id INTEGER,        -- The ID of the related entity\n    purpose VARCHAR(100),     -- Additional purpose description\n    metadata JSONB DEFAULT '{}',\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Create indexes following the same pattern\nCREATE INDEX idx_file_assets_entity ON file_manager.file_assets(entity_type, entity_id);\nCREATE INDEX idx_file_assets_category ON file_manager.file_assets(file_category_id);\nCREATE INDEX idx_file_assets_context ON file_manager.file_assets(file_context_id);\nCREATE INDEX idx_file_assets_created_at ON file_manager.file_assets(created_at DESC);\n\n-- Insert default categories\nINSERT INTO file_manager.file_categories (id, name, max_size_bytes) VALUES\n(1, 'document', 52428800),  -- 50MB\n(2, 'image', 10485760),     -- 10MB  \n(3, 'archive', 104857600);  -- 100MB\n\n-- Insert default contexts\nINSERT INTO file_manager.file_contexts (id, name) VALUES\n(1, 'invoice'),\n(2, 'receipt'),\n(3, 'contract'),\n(4, 'report'),\n(5, 'profile'),\n(6, 'general');"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000002_create_organizations_schema.down.sql",
    "content": "-- Drop triggers\nDROP TRIGGER IF EXISTS trigger_accounts_updated_at ON organizations.accounts;\nDROP TRIGGER IF EXISTS trigger_organizations_updated_at ON organizations.organizations;\n\n-- Drop indexes (will be dropped automatically with tables, but being explicit)\nDROP INDEX IF EXISTS organizations.idx_accounts_role;\nDROP INDEX IF EXISTS organizations.idx_accounts_status;\nDROP INDEX IF EXISTS organizations.idx_accounts_email;\nDROP INDEX IF EXISTS organizations.idx_accounts_org_id;\nDROP INDEX IF EXISTS organizations.idx_accounts_stytch_member_id;\n\nDROP INDEX IF EXISTS organizations.idx_organizations_created_at;\nDROP INDEX IF EXISTS organizations.idx_organizations_status;\nDROP INDEX IF EXISTS organizations.idx_organizations_slug;\nDROP INDEX IF EXISTS organizations.idx_organizations_stytch_org_id;\n\n-- Drop tables in dependency order\nDROP TABLE IF EXISTS organizations.accounts;\nDROP TABLE IF EXISTS organizations.organizations;\n\n-- Drop the schema\nDROP SCHEMA IF EXISTS organizations CASCADE;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000002_create_organizations_schema.up.sql",
    "content": "-- Create organizations schema\nCREATE SCHEMA IF NOT EXISTS organizations;\n\n-- Organizations table (top-level tenant)\nCREATE TABLE organizations.organizations (\n    id SERIAL PRIMARY KEY,\n    slug VARCHAR(100) UNIQUE NOT NULL,\n    name VARCHAR(255) NOT NULL,\n    status VARCHAR(50) DEFAULT 'active' NOT NULL,\n\n    -- Stytch linkage\n    stytch_org_id VARCHAR(100) UNIQUE,\n    stytch_connection_id VARCHAR(100),\n    stytch_connection_name VARCHAR(255),\n\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n\n    CONSTRAINT chk_organizations_status CHECK (status IN ('active', 'suspended', 'cancelled')),\n    CONSTRAINT chk_organizations_slug CHECK (slug ~ '^[a-z0-9-]+$' AND LENGTH(slug) >= 3)\n);\n\n-- Accounts table (users within organizations)\nCREATE TABLE organizations.accounts (\n    id SERIAL PRIMARY KEY,\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n\n    -- Account info\n    email VARCHAR(255) NOT NULL,\n    full_name VARCHAR(255) NOT NULL,\n    stytch_member_id VARCHAR(100),\n    stytch_role_id VARCHAR(100),\n    stytch_role_slug VARCHAR(100),\n    stytch_email_verified BOOLEAN DEFAULT FALSE NOT NULL,\n\n    -- Role and status (legacy field retained for business logic)\n    role VARCHAR(50) DEFAULT 'member' NOT NULL,\n    status VARCHAR(50) DEFAULT 'active' NOT NULL,\n\n    -- Activity tracking\n    last_login_at TIMESTAMP,\n\n    -- Timestamps\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,\n\n    -- Unique constraints\n    UNIQUE(organization_id, email),\n    UNIQUE(organization_id, stytch_member_id),\n\n    -- Check constraints\n    CONSTRAINT chk_accounts_role CHECK (role IN ('owner', 'admin', 'member', 'reviewer', 'employee')),\n    CONSTRAINT chk_accounts_status CHECK (status IN ('active', 'inactive', 'suspended')),\n    CONSTRAINT chk_accounts_email CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$')\n);\n\n-- Indexes for performance\nCREATE INDEX idx_organizations_slug ON organizations.organizations(slug) WHERE status = 'active';\nCREATE INDEX idx_organizations_status ON organizations.organizations(status);\nCREATE INDEX idx_organizations_created_at ON organizations.organizations(created_at DESC);\nCREATE UNIQUE INDEX idx_organizations_stytch_org_id ON organizations.organizations(stytch_org_id);\n\nCREATE INDEX idx_accounts_org_id ON organizations.accounts(organization_id);\nCREATE INDEX idx_accounts_email ON organizations.accounts(email);\nCREATE INDEX idx_accounts_status ON organizations.accounts(status);\nCREATE INDEX idx_accounts_role ON organizations.accounts(role);\nCREATE INDEX idx_accounts_stytch_member_id ON organizations.accounts(stytch_member_id);\n\n-- Updated at trigger function (reuse existing function if available)\nCREATE OR REPLACE FUNCTION update_updated_at_column()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = CURRENT_TIMESTAMP;\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\n-- Triggers to automatically update updated_at\nCREATE TRIGGER trigger_organizations_updated_at\n    BEFORE UPDATE ON organizations.organizations\n    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();\n\nCREATE TRIGGER trigger_accounts_updated_at\n    BEFORE UPDATE ON organizations.accounts\n    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();\n\n-- Comments for documentation\nCOMMENT ON SCHEMA organizations IS 'Schema for organization and account management';\nCOMMENT ON TABLE organizations.organizations IS 'Organizations (tenants) in the system';\nCOMMENT ON TABLE organizations.accounts IS 'User accounts within organizations';\nCOMMENT ON COLUMN organizations.organizations.slug IS 'URL-friendly unique identifier for organization';\nCOMMENT ON COLUMN organizations.organizations.stytch_org_id IS 'Stytch organization identifier (org_xxx)';\nCOMMENT ON COLUMN organizations.organizations.stytch_connection_id IS 'Optional Stytch connection or project identifier associated with the organization';\nCOMMENT ON COLUMN organizations.organizations.stytch_connection_name IS 'Optional Stytch connection name associated with the organization';\nCOMMENT ON COLUMN organizations.accounts.stytch_member_id IS 'Stytch member identifier (member_xxx)';\nCOMMENT ON COLUMN organizations.accounts.stytch_role_id IS 'Stytch role identifier assigned to the member';\nCOMMENT ON COLUMN organizations.accounts.stytch_role_slug IS 'Human-readable Stytch role slug assigned to the member';\nCOMMENT ON COLUMN organizations.accounts.stytch_email_verified IS 'Whether Stytch reports the member email as verified';\nCOMMENT ON COLUMN organizations.accounts.role IS 'Last known role for business logic (e.g., owner, reviewer, employee)';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000003_enforce_role_enum.down.sql",
    "content": "-- Rollback: Remove RBAC roles enum constraint\n\n-- Drop the check constraint that enforces valid role values\nALTER TABLE organizations.accounts\nDROP CONSTRAINT valid_role_enum;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000003_enforce_role_enum.up.sql",
    "content": "-- Enforce RBAC roles enum according to PERMISSIONS.md specification\n-- Valid roles: member, approver, admin (plus legacy roles for backward compatibility)\n\n-- Add check constraint to organizations.accounts.role column\n-- Ensures only valid role values are stored in the database\nALTER TABLE organizations.accounts\nADD CONSTRAINT valid_role_enum CHECK (\n    role IN ('member', 'approver', 'admin', 'owner', 'reviewer', 'employee')\n);\n\n-- Add comment documenting the role enum constraint\nCOMMENT ON CONSTRAINT valid_role_enum ON organizations.accounts IS\n'RBAC Role Enum Constraint per PERMISSIONS.md:\n- member: Process invoices day-to-day (Member role)\n- approver: Review and approve invoices assigned to them (Approver role)\n- admin: Full system control and management (Admin role)\n\nLegacy roles (for backward compatibility during migration):\n- owner: Legacy mapping to admin role\n- reviewer: Legacy mapping to approver role\n- employee: Legacy mapping to member role\n\nAll new role assignments MUST use the three core roles: member, approver, admin';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.down.sql",
    "content": "-- Drop subscription billing schema and all its tables\nDROP TABLE IF EXISTS subscription_billing.quota_tracking CASCADE;\nDROP TABLE IF EXISTS subscription_billing.subscriptions CASCADE;\nDROP SCHEMA IF EXISTS subscription_billing CASCADE;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.up.sql",
    "content": "-- Create subscription_billing schema for local subscription and quota tracking\nCREATE SCHEMA IF NOT EXISTS subscription_billing;\n\n-- Subscriptions table: Stores Polar subscription data locally for fast access\nCREATE TABLE subscription_billing.subscriptions (\n    id SERIAL PRIMARY KEY,\n    organization_id INT NOT NULL UNIQUE REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n\n    -- Polar identifiers\n    external_customer_id VARCHAR(100) NOT NULL,  -- Polar customer ID\n    subscription_id VARCHAR(100) NOT NULL UNIQUE, -- Polar subscription ID\n\n    -- Subscription details\n    subscription_status VARCHAR(50) NOT NULL,     -- active, canceled, past_due, etc.\n    product_id VARCHAR(100) NOT NULL,            -- Polar product ID\n    product_name VARCHAR(255),                    -- Product display name\n    plan_name VARCHAR(100),                       -- From subscription metadata\n\n    -- Billing period\n    current_period_start TIMESTAMP NOT NULL,\n    current_period_end TIMESTAMP NOT NULL,\n\n    -- Cancellation details\n    cancel_at_period_end BOOLEAN DEFAULT FALSE,\n    canceled_at TIMESTAMP,\n\n    -- Audit timestamps\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n\n    -- Metadata from Polar (stored as JSONB for flexibility)\n    metadata JSONB DEFAULT '{}'::jsonb\n);\n\n-- Quota tracking table: Tracks usage quotas per organization\nCREATE TABLE subscription_billing.quota_tracking (\n    id SERIAL PRIMARY KEY,\n    organization_id INT NOT NULL UNIQUE REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n\n    -- Invoice quota (main quota we're tracking)\n    invoice_count_current INT DEFAULT 0,         -- Current usage in period\n    invoice_count_max INT NOT NULL,              -- Maximum allowed in period\n\n    -- Additional quotas from product metadata\n    max_seats INT,                                -- Maximum seats allowed\n\n    -- Quota period (should match subscription period)\n    period_start TIMESTAMP NOT NULL,\n    period_end TIMESTAMP NOT NULL,\n\n    -- Sync tracking\n    last_synced_at TIMESTAMP,                    -- Last time synced with Polar\n\n    -- Audit timestamps\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Indexes for fast lookups\nCREATE INDEX idx_subscriptions_organization_id ON subscription_billing.subscriptions(organization_id);\nCREATE INDEX idx_subscriptions_subscription_status ON subscription_billing.subscriptions(subscription_status);\nCREATE INDEX idx_subscriptions_external_customer_id ON subscription_billing.subscriptions(external_customer_id);\n\nCREATE INDEX idx_quota_tracking_organization_id ON subscription_billing.quota_tracking(organization_id);\nCREATE INDEX idx_quota_tracking_period_end ON subscription_billing.quota_tracking(period_end);\n\n-- Comments for documentation\nCOMMENT ON SCHEMA subscription_billing IS 'Local cache of Polar subscription and quota data for fast access';\nCOMMENT ON TABLE subscription_billing.subscriptions IS 'Stores subscription details from Polar, synced via webhooks';\nCOMMENT ON TABLE subscription_billing.quota_tracking IS 'Tracks usage quotas per organization for fast quota checks';\n\nCOMMENT ON COLUMN subscription_billing.subscriptions.external_customer_id IS 'Polar customer ID (maps to organization via stytch_org_id)';\nCOMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_current IS 'Current invoice count in billing period';\nCOMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_max IS 'Maximum invoices allowed from product metadata';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.down.sql",
    "content": "-- Rollback quota_tracking schema changes\n-- Restore invoice_count_max and invoice_count_current\n\n-- Step 1: Add back old columns\nALTER TABLE subscription_billing.quota_tracking\nADD COLUMN invoice_count_current INT DEFAULT 0,\nADD COLUMN invoice_count_max INT;\n\n-- Step 2: Migrate data back (this is a best-effort rollback, data may be lost)\n-- We can't perfectly restore the original split, so we set current=0 and max=invoice_count\nUPDATE subscription_billing.quota_tracking\nSET invoice_count_current = 0,\n    invoice_count_max = invoice_count;\n\n-- Step 3: Make columns NOT NULL\nALTER TABLE subscription_billing.quota_tracking\nALTER COLUMN invoice_count_current SET NOT NULL,\nALTER COLUMN invoice_count_max SET NOT NULL;\n\n-- Step 4: Drop new column\nALTER TABLE subscription_billing.quota_tracking\nDROP COLUMN invoice_count;\n\n-- Restore comments\nCOMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_current IS 'Current invoice count in billing period';\nCOMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_max IS 'Maximum invoices allowed from product metadata';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.up.sql",
    "content": "-- Update quota_tracking schema to use count-down system\n-- Remove invoice_count_max, rename invoice_count_current to invoice_count (remaining invoices)\n\n-- Step 1: Add new invoice_count column (temporary)\nALTER TABLE subscription_billing.quota_tracking\nADD COLUMN invoice_count INT;\n\n-- Step 2: Migrate data: invoice_count = invoice_count_max - invoice_count_current\n-- For existing rows, calculate remaining invoices\nUPDATE subscription_billing.quota_tracking\nSET invoice_count = GREATEST(invoice_count_max - invoice_count_current, 0);\n\n-- Step 3: Make invoice_count NOT NULL with default 0\nALTER TABLE subscription_billing.quota_tracking\nALTER COLUMN invoice_count SET NOT NULL,\nALTER COLUMN invoice_count SET DEFAULT 0;\n\n-- Step 4: Drop old columns\nALTER TABLE subscription_billing.quota_tracking\nDROP COLUMN invoice_count_current,\nDROP COLUMN invoice_count_max;\n\n-- Update comment\nCOMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count IS 'Remaining invoices in current billing period (decremented on use)';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000006_create_example_resources.down.sql",
    "content": "-- Rollback example_resources table\n\n-- Drop trigger first\nDROP TRIGGER IF EXISTS trigger_update_example_resources_updated_at ON example_resources;\nDROP FUNCTION IF EXISTS update_example_resources_updated_at();\n\n-- Drop indexes\nDROP INDEX IF EXISTS idx_example_resources_search;\nDROP INDEX IF EXISTS idx_example_resources_active;\nDROP INDEX IF EXISTS idx_example_resources_created_at;\nDROP INDEX IF EXISTS idx_example_resources_approval_assigned;\nDROP INDEX IF EXISTS idx_example_resources_file;\nDROP INDEX IF EXISTS idx_example_resources_created_by;\nDROP INDEX IF EXISTS idx_example_resources_status;\nDROP INDEX IF EXISTS idx_example_resources_organization;\n\n-- Drop table\nDROP TABLE IF EXISTS example_resources;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000006_create_example_resources.up.sql",
    "content": "-- Create example_resources table\n-- This table demonstrates the full architecture pattern with:\n-- - File attachments\n-- - OCR/LLM processing\n-- - Multi-status workflow\n-- - RBAC integration\n-- - Multi-tenancy\n-- - Approval workflow\n-- - Audit tracking\n\nCREATE TABLE IF NOT EXISTS example_resources (\n    id SERIAL PRIMARY KEY,\n    resource_number VARCHAR(100) UNIQUE NOT NULL,\n    title VARCHAR(255) NOT NULL,\n    description TEXT,\n\n    -- Status workflow\n    status_id SMALLINT NOT NULL DEFAULT 1,\n\n    -- File attachment\n    file_id INTEGER REFERENCES file_manager.file_assets(id) ON DELETE SET NULL,\n\n    -- AI Processing results\n    extracted_data JSONB DEFAULT '{}',  -- OCR output\n    processed_data JSONB DEFAULT '{}',  -- LLM structured output\n    confidence DECIMAL(5,4),  -- AI confidence score (0.0000 to 1.0000)\n\n    -- Multi-tenancy (required)\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n    created_by_account_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL,\n\n    -- Approval workflow\n    approval_status VARCHAR(50) DEFAULT 'pending',\n    approval_assigned_to_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL,\n    approval_action_taker_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL,\n    approval_notes TEXT,\n\n    -- Additional metadata\n    metadata JSONB DEFAULT '{}',\n\n    -- Audit fields\n    is_active BOOLEAN NOT NULL DEFAULT true,\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMP NOT NULL DEFAULT NOW()\n);\n\n-- Indexes for performance\nCREATE INDEX idx_example_resources_organization ON example_resources(organization_id);\nCREATE INDEX idx_example_resources_status ON example_resources(status_id);\nCREATE INDEX idx_example_resources_created_by ON example_resources(created_by_account_id);\nCREATE INDEX idx_example_resources_file ON example_resources(file_id);\nCREATE INDEX idx_example_resources_approval_assigned ON example_resources(approval_assigned_to_id);\nCREATE INDEX idx_example_resources_created_at ON example_resources(created_at DESC);\nCREATE INDEX idx_example_resources_active ON example_resources(is_active);\n\n-- Full text search on title and description\nCREATE INDEX idx_example_resources_search ON example_resources USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')));\n\n-- Trigger to automatically update updated_at timestamp\nCREATE OR REPLACE FUNCTION update_example_resources_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER trigger_update_example_resources_updated_at\n    BEFORE UPDATE ON example_resources\n    FOR EACH ROW\n    EXECUTE FUNCTION update_example_resources_updated_at();\n\n-- Comments for documentation\nCOMMENT ON TABLE example_resources IS 'Example module demonstrating Clean Architecture patterns with file uploads, OCR/LLM processing, RBAC, approval workflows, and multi-tenancy';\nCOMMENT ON COLUMN example_resources.extracted_data IS 'Raw OCR-extracted text and metadata';\nCOMMENT ON COLUMN example_resources.processed_data IS 'LLM-processed structured data';\nCOMMENT ON COLUMN example_resources.confidence IS 'AI confidence score between 0 and 1';\nCOMMENT ON COLUMN example_resources.approval_status IS 'Workflow status: pending, approved, rejected';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000007_create_resource_embeddings.down.sql",
    "content": "-- Drop triggers\nDROP TRIGGER IF EXISTS duplicate_candidates_updated_at ON duplicate_candidates;\nDROP TRIGGER IF EXISTS resource_embeddings_updated_at ON resource_embeddings;\n\n-- Drop trigger functions\nDROP FUNCTION IF EXISTS update_duplicate_candidates_updated_at();\nDROP FUNCTION IF EXISTS update_resource_embeddings_updated_at();\n\n-- Drop tables (cascade to remove dependent objects)\nDROP TABLE IF EXISTS duplicate_candidates CASCADE;\nDROP TABLE IF EXISTS resource_embeddings CASCADE;\n\n-- Note: We don't drop the vector extension as it might be used by other tables\n-- If you want to remove it completely, uncomment the line below:\n-- DROP EXTENSION IF EXISTS vector CASCADE;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000007_create_resource_embeddings.up.sql",
    "content": "-- Enable pgvector extension for vector similarity search\nCREATE EXTENSION IF NOT EXISTS vector;\n\n-- Resource embeddings table\n-- Stores vector embeddings generated from resource text content for semantic similarity search\nCREATE TABLE resource_embeddings (\n    id SERIAL PRIMARY KEY,\n    resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE,\n    embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension is 1536\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n    content_hash VARCHAR(64), -- SHA-256 hash for exact duplicate detection\n    content_preview TEXT, -- First 500 chars of content for debugging\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    UNIQUE(resource_id, organization_id) -- One embedding per resource per organization\n);\n\n-- Create ivfflat index for fast vector similarity search using cosine distance\n-- ivfflat divides vectors into lists for approximate nearest neighbor search\n-- lists=100 is a good starting point (adjust based on dataset size)\nCREATE INDEX idx_resource_embeddings_vector\nON resource_embeddings\nUSING ivfflat (embedding vector_cosine_ops)\nWITH (lists = 100);\n\n-- Regular indexes for lookups\nCREATE INDEX idx_resource_embeddings_organization ON resource_embeddings(organization_id);\nCREATE INDEX idx_resource_embeddings_content_hash ON resource_embeddings(content_hash);\nCREATE INDEX idx_resource_embeddings_resource ON resource_embeddings(resource_id);\n\n-- Duplicate candidates table\n-- Stores potential duplicates found through vector similarity search and LLM adjudication\nCREATE TABLE duplicate_candidates (\n    id SERIAL PRIMARY KEY,\n    resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE,\n    candidate_resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE,\n    similarity_score DECIMAL(5,4) NOT NULL, -- Vector cosine similarity score (0.0000 to 1.0000)\n    detection_method VARCHAR(50) NOT NULL, -- 'exact_match' or 'llm_adjudicated'\n    confidence_level VARCHAR(50), -- 'very_high', 'high', 'medium', 'low' (from LLM)\n    llm_reason TEXT, -- LLM's explanation for duplicate decision\n    llm_similar_fields JSONB, -- Fields identified as similar by LLM\n    llm_response JSONB, -- Full LLM response for audit trail\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n    status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'confirmed', 'dismissed'\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    CHECK (resource_id != candidate_resource_id) -- Prevent self-duplicates\n);\n\n-- Indexes for duplicate candidates\nCREATE INDEX idx_duplicate_candidates_resource ON duplicate_candidates(resource_id);\nCREATE INDEX idx_duplicate_candidates_candidate ON duplicate_candidates(candidate_resource_id);\nCREATE INDEX idx_duplicate_candidates_organization ON duplicate_candidates(organization_id);\nCREATE INDEX idx_duplicate_candidates_status ON duplicate_candidates(status);\nCREATE INDEX idx_duplicate_candidates_method ON duplicate_candidates(detection_method);\n\n-- Composite index for common query pattern: find all duplicates for a resource\nCREATE INDEX idx_duplicate_candidates_resource_org ON duplicate_candidates(resource_id, organization_id);\n\n-- Auto-update trigger for updated_at\nCREATE OR REPLACE FUNCTION update_resource_embeddings_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER resource_embeddings_updated_at\n    BEFORE UPDATE ON resource_embeddings\n    FOR EACH ROW\n    EXECUTE FUNCTION update_resource_embeddings_updated_at();\n\nCREATE OR REPLACE FUNCTION update_duplicate_candidates_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER duplicate_candidates_updated_at\n    BEFORE UPDATE ON duplicate_candidates\n    FOR EACH ROW\n    EXECUTE FUNCTION update_duplicate_candidates_updated_at();\n\n-- Comments for documentation\nCOMMENT ON TABLE resource_embeddings IS 'Stores vector embeddings for resources using OpenAI text-embedding-3-small (1536 dimensions)';\nCOMMENT ON COLUMN resource_embeddings.embedding IS 'Vector embedding for semantic similarity search (1536 dimensions from OpenAI)';\nCOMMENT ON COLUMN resource_embeddings.content_hash IS 'SHA-256 hash of normalized content for exact duplicate detection';\nCOMMENT ON INDEX idx_resource_embeddings_vector IS 'IVFFlat index for fast approximate nearest neighbor search using cosine distance';\n\nCOMMENT ON TABLE duplicate_candidates IS 'Stores potential duplicate resources found via vector similarity and LLM adjudication';\nCOMMENT ON COLUMN duplicate_candidates.similarity_score IS 'Cosine similarity score from pgvector (0.0000 = completely different, 1.0000 = identical)';\nCOMMENT ON COLUMN duplicate_candidates.detection_method IS 'How the duplicate was detected: exact_match (similarity >= 0.95) or llm_adjudicated (similarity >= 0.85, confirmed by LLM)';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000008_create_documents_schema.down.sql",
    "content": "-- Drop documents schema\nDROP TRIGGER IF EXISTS documents_updated_at ON documents.documents;\nDROP FUNCTION IF EXISTS documents.update_documents_updated_at();\nDROP TABLE IF EXISTS documents.documents;\nDROP SCHEMA IF EXISTS documents;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000008_create_documents_schema.up.sql",
    "content": "-- Documents schema for PDF upload and text extraction\nCREATE SCHEMA IF NOT EXISTS documents;\n\n-- Documents table\nCREATE TABLE documents.documents (\n    id SERIAL PRIMARY KEY,\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n    file_asset_id INTEGER NOT NULL REFERENCES file_manager.file_assets(id) ON DELETE CASCADE,\n    title VARCHAR(500) NOT NULL,\n    file_name VARCHAR(500) NOT NULL,\n    content_type VARCHAR(100) NOT NULL,\n    file_size BIGINT NOT NULL,\n    extracted_text TEXT,\n    status VARCHAR(50) NOT NULL DEFAULT 'pending',\n    metadata JSONB DEFAULT '{}',\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    CONSTRAINT valid_status CHECK (status IN ('pending', 'processing', 'processed', 'failed'))\n);\n\n-- Indexes\nCREATE INDEX idx_documents_organization ON documents.documents(organization_id);\nCREATE INDEX idx_documents_file_asset ON documents.documents(file_asset_id);\nCREATE INDEX idx_documents_status ON documents.documents(status);\nCREATE INDEX idx_documents_created_at ON documents.documents(created_at DESC);\n\n-- Auto-update trigger for updated_at\nCREATE OR REPLACE FUNCTION documents.update_documents_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER documents_updated_at\n    BEFORE UPDATE ON documents.documents\n    FOR EACH ROW\n    EXECUTE FUNCTION documents.update_documents_updated_at();\n\n-- Comments for documentation\nCOMMENT ON TABLE documents.documents IS 'Stores uploaded documents (PDFs) with extracted text for RAG';\nCOMMENT ON COLUMN documents.documents.extracted_text IS 'Text extracted from PDF using OCR or direct parsing';\nCOMMENT ON COLUMN documents.documents.status IS 'Processing status: pending, processing, processed, failed';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000009_create_cognitive_schema.down.sql",
    "content": "-- Drop cognitive schema\nDROP TRIGGER IF EXISTS chat_sessions_updated_at ON cognitive.chat_sessions;\nDROP TRIGGER IF EXISTS doc_embeddings_updated_at ON cognitive.document_embeddings;\nDROP FUNCTION IF EXISTS cognitive.update_sessions_updated_at();\nDROP FUNCTION IF EXISTS cognitive.update_embeddings_updated_at();\nDROP TABLE IF EXISTS cognitive.chat_messages;\nDROP TABLE IF EXISTS cognitive.chat_sessions;\nDROP TABLE IF EXISTS cognitive.document_embeddings;\nDROP SCHEMA IF EXISTS cognitive;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/migrations/000009_create_cognitive_schema.up.sql",
    "content": "-- Cognitive Agent schema for RAG and AI-powered features\nCREATE SCHEMA IF NOT EXISTS cognitive;\n\n-- Ensure pgvector extension is available\nCREATE EXTENSION IF NOT EXISTS vector;\n\n-- Document embeddings for RAG (vector search)\nCREATE TABLE cognitive.document_embeddings (\n    id SERIAL PRIMARY KEY,\n    document_id INTEGER NOT NULL REFERENCES documents.documents(id) ON DELETE CASCADE,\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n    embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension\n    content_hash VARCHAR(64),\n    content_preview TEXT,\n    chunk_index INTEGER DEFAULT 0,\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    UNIQUE(document_id, chunk_index)\n);\n\n-- IVFFlat index for fast vector similarity search using cosine distance\nCREATE INDEX idx_doc_embeddings_vector ON cognitive.document_embeddings\nUSING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);\n\nCREATE INDEX idx_doc_embeddings_organization ON cognitive.document_embeddings(organization_id);\nCREATE INDEX idx_doc_embeddings_document ON cognitive.document_embeddings(document_id);\nCREATE INDEX idx_doc_embeddings_content_hash ON cognitive.document_embeddings(content_hash);\n\n-- Chat sessions for conversational AI\nCREATE TABLE cognitive.chat_sessions (\n    id SERIAL PRIMARY KEY,\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE,\n    account_id INTEGER NOT NULL,\n    title VARCHAR(500),\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    updated_at TIMESTAMP NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX idx_chat_sessions_organization ON cognitive.chat_sessions(organization_id);\nCREATE INDEX idx_chat_sessions_account ON cognitive.chat_sessions(account_id);\nCREATE INDEX idx_chat_sessions_created_at ON cognitive.chat_sessions(created_at DESC);\n\n-- Chat messages within sessions\nCREATE TABLE cognitive.chat_messages (\n    id SERIAL PRIMARY KEY,\n    session_id INTEGER NOT NULL REFERENCES cognitive.chat_sessions(id) ON DELETE CASCADE,\n    role VARCHAR(20) NOT NULL,\n    content TEXT NOT NULL,\n    referenced_docs INTEGER[],\n    tokens_used INTEGER DEFAULT 0,\n    created_at TIMESTAMP NOT NULL DEFAULT NOW(),\n    CONSTRAINT valid_role CHECK (role IN ('user', 'assistant', 'system'))\n);\n\nCREATE INDEX idx_chat_messages_session ON cognitive.chat_messages(session_id);\nCREATE INDEX idx_chat_messages_created_at ON cognitive.chat_messages(created_at);\n\n-- Auto-update triggers for updated_at\nCREATE OR REPLACE FUNCTION cognitive.update_embeddings_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER doc_embeddings_updated_at\n    BEFORE UPDATE ON cognitive.document_embeddings\n    FOR EACH ROW\n    EXECUTE FUNCTION cognitive.update_embeddings_updated_at();\n\nCREATE OR REPLACE FUNCTION cognitive.update_sessions_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = NOW();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER chat_sessions_updated_at\n    BEFORE UPDATE ON cognitive.chat_sessions\n    FOR EACH ROW\n    EXECUTE FUNCTION cognitive.update_sessions_updated_at();\n\n-- Comments for documentation\nCOMMENT ON TABLE cognitive.document_embeddings IS 'Vector embeddings for documents using OpenAI text-embedding-3-small (1536 dimensions)';\nCOMMENT ON COLUMN cognitive.document_embeddings.embedding IS 'Vector embedding for semantic similarity search';\nCOMMENT ON COLUMN cognitive.document_embeddings.chunk_index IS 'Index for chunked documents (0 for single-chunk docs)';\nCOMMENT ON TABLE cognitive.chat_sessions IS 'Conversational AI sessions for RAG-based chat';\nCOMMENT ON TABLE cognitive.chat_messages IS 'Messages within chat sessions with role (user/assistant/system)';\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/query/cognitive.sql",
    "content": "-- Cognitive Agent queries\n\n-- Document Embeddings\n\n-- name: CreateDocumentEmbedding :one\nINSERT INTO cognitive.document_embeddings (\n    document_id,\n    organization_id,\n    embedding,\n    content_hash,\n    content_preview,\n    chunk_index\n) VALUES (\n    $1, $2, $3, $4, $5, $6\n) RETURNING *;\n\n-- name: GetDocumentEmbeddingByID :one\nSELECT * FROM cognitive.document_embeddings\nWHERE id = $1 AND organization_id = $2;\n\n-- name: GetDocumentEmbeddingsByDocumentID :many\nSELECT * FROM cognitive.document_embeddings\nWHERE document_id = $1 AND organization_id = $2\nORDER BY chunk_index;\n\n-- name: SearchSimilarDocuments :many\nSELECT\n    de.id,\n    de.document_id,\n    de.organization_id,\n    de.content_hash,\n    de.content_preview,\n    de.chunk_index,\n    de.created_at,\n    de.updated_at,\n    (1 - (de.embedding <=> $1::vector))::double precision as similarity_score\nFROM cognitive.document_embeddings de\nWHERE de.organization_id = $2\nORDER BY de.embedding <=> $1::vector\nLIMIT $3;\n\n-- name: DeleteDocumentEmbeddings :exec\nDELETE FROM cognitive.document_embeddings\nWHERE document_id = $1 AND organization_id = $2;\n\n-- name: CountDocumentEmbeddingsByOrganization :one\nSELECT COUNT(*) FROM cognitive.document_embeddings\nWHERE organization_id = $1;\n\n-- Chat Sessions\n\n-- name: CreateChatSession :one\nINSERT INTO cognitive.chat_sessions (\n    organization_id,\n    account_id,\n    title\n) VALUES (\n    $1, $2, $3\n) RETURNING *;\n\n-- name: GetChatSessionByID :one\nSELECT * FROM cognitive.chat_sessions\nWHERE id = $1 AND organization_id = $2;\n\n-- name: ListChatSessionsByAccount :many\nSELECT * FROM cognitive.chat_sessions\nWHERE organization_id = $1 AND account_id = $2\nORDER BY updated_at DESC\nLIMIT $3 OFFSET $4;\n\n-- name: UpdateChatSessionTitle :one\nUPDATE cognitive.chat_sessions\nSET title = $3, updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING *;\n\n-- name: DeleteChatSession :exec\nDELETE FROM cognitive.chat_sessions\nWHERE id = $1 AND organization_id = $2;\n\n-- Chat Messages\n\n-- name: CreateChatMessage :one\nINSERT INTO cognitive.chat_messages (\n    session_id,\n    role,\n    content,\n    referenced_docs,\n    tokens_used\n) VALUES (\n    $1, $2, $3, $4, $5\n) RETURNING *;\n\n-- name: GetChatMessagesBySession :many\nSELECT * FROM cognitive.chat_messages\nWHERE session_id = $1\nORDER BY created_at ASC;\n\n-- name: GetRecentChatMessages :many\nSELECT * FROM cognitive.chat_messages\nWHERE session_id = $1\nORDER BY created_at DESC\nLIMIT $2;\n\n-- name: CountChatMessagesBySession :one\nSELECT COUNT(*) FROM cognitive.chat_messages\nWHERE session_id = $1;\n\n-- name: DeleteChatMessage :exec\nDELETE FROM cognitive.chat_messages\nWHERE id = $1;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/query/documents.sql",
    "content": "-- Documents queries\n\n-- name: CreateDocument :one\nINSERT INTO documents.documents (\n    organization_id,\n    file_asset_id,\n    title,\n    file_name,\n    content_type,\n    file_size,\n    extracted_text,\n    status,\n    metadata\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9\n) RETURNING *;\n\n-- name: GetDocumentByID :one\nSELECT * FROM documents.documents\nWHERE id = $1 AND organization_id = $2;\n\n-- name: GetDocumentByFileAssetID :one\nSELECT * FROM documents.documents\nWHERE file_asset_id = $1 AND organization_id = $2;\n\n-- name: ListDocumentsByOrganization :many\nSELECT * FROM documents.documents\nWHERE organization_id = $1\nORDER BY created_at DESC\nLIMIT $2 OFFSET $3;\n\n-- name: ListDocumentsByStatus :many\nSELECT * FROM documents.documents\nWHERE organization_id = $1 AND status = $2\nORDER BY created_at DESC\nLIMIT $3 OFFSET $4;\n\n-- name: UpdateDocumentStatus :one\nUPDATE documents.documents\nSET status = $3, updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING *;\n\n-- name: UpdateDocumentExtractedText :one\nUPDATE documents.documents\nSET extracted_text = $3, status = 'processed', updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING *;\n\n-- name: UpdateDocument :one\nUPDATE documents.documents\nSET\n    title = COALESCE($3, title),\n    metadata = COALESCE($4, metadata),\n    updated_at = NOW()\nWHERE id = $1 AND organization_id = $2\nRETURNING *;\n\n-- name: DeleteDocument :exec\nDELETE FROM documents.documents\nWHERE id = $1 AND organization_id = $2;\n\n-- name: CountDocumentsByOrganization :one\nSELECT COUNT(*) FROM documents.documents\nWHERE organization_id = $1;\n\n-- name: CountDocumentsByStatus :one\nSELECT COUNT(*) FROM documents.documents\nWHERE organization_id = $1 AND status = $2;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/query/example_resource.sql",
    "content": "-- Example Resource Queries\n-- Demonstrates Clean Architecture patterns with CRUD operations,\n-- file attachments, OCR/LLM processing, and approval workflows\n\n-- CREATE operations\n\n-- name: CreateResource :one\nINSERT INTO example_resources (\n    resource_number, title, description, status_id, file_id,\n    extracted_data, processed_data, confidence,\n    organization_id, created_by_account_id, metadata\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11\n) RETURNING *;\n\n-- name: CreateMinimalResource :one\n-- Creates a minimal placeholder resource\nINSERT INTO example_resources (\n    resource_number, title, organization_id, created_by_account_id, status_id\n) VALUES (\n    $1, $2, $3, $4, 1\n) RETURNING *;\n\n-- READ operations\n\n-- name: GetResourceByID :one\nSELECT * FROM example_resources\nWHERE id = $1 AND organization_id = $2 AND is_active = true;\n\n-- name: GetResourceByNumber :one\nSELECT * FROM example_resources\nWHERE resource_number = $1 AND organization_id = $2 AND is_active = true;\n\n-- name: ListResources :many\n-- List resources with filtering and pagination\nSELECT\n    id, resource_number, title, description, status_id, file_id,\n    confidence, organization_id, created_by_account_id,\n    approval_status, approval_assigned_to_id,\n    is_active, created_at, updated_at\nFROM example_resources\nWHERE organization_id = $1 AND is_active = true\n    AND ($2::smallint IS NULL OR status_id = $2)\n    AND ($3::varchar IS NULL OR approval_status = $3)\n    AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%')\nORDER BY created_at DESC\nLIMIT $5 OFFSET $6;\n\n-- name: CountResources :one\n-- Count resources for pagination\nSELECT COUNT(*) FROM example_resources\nWHERE organization_id = $1 AND is_active = true\n    AND ($2::smallint IS NULL OR status_id = $2)\n    AND ($3::varchar IS NULL OR approval_status = $3)\n    AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%');\n\n-- UPDATE operations\n\n-- name: UpdateResource :exec\nUPDATE example_resources SET\n    title = COALESCE(sqlc.narg('title'), title),\n    description = COALESCE(sqlc.narg('description'), description),\n    status_id = COALESCE(sqlc.narg('status_id'), status_id),\n    metadata = COALESCE(sqlc.narg('metadata'), metadata),\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = sqlc.arg('id') AND organization_id = sqlc.arg('organization_id') AND is_active = true;\n\n-- name: UpdateResourceProcessingData :exec\n-- Update OCR/LLM processing results\nUPDATE example_resources SET\n    extracted_data = COALESCE(sqlc.narg('extracted_data'), extracted_data),\n    processed_data = COALESCE(sqlc.narg('processed_data'), processed_data),\n    confidence = COALESCE(sqlc.narg('confidence'), confidence),\n    status_id = COALESCE(sqlc.narg('status_id'), status_id),\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = sqlc.arg('id') AND organization_id = sqlc.arg('organization_id');\n\n-- name: UpdateResourceStatus :exec\nUPDATE example_resources SET\n    status_id = $3,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true;\n\n-- name: UpdateResourceApprovalStatus :exec\n-- Update approval workflow status\nUPDATE example_resources SET\n    approval_status = $3,\n    approval_action_taker_id = $4,\n    approval_notes = $5,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true;\n\n-- name: AssignResourceApproval :exec\n-- Assign resource to someone for approval\nUPDATE example_resources SET\n    approval_assigned_to_id = $3,\n    approval_status = 'pending',\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true;\n\n-- name: AttachFileToResource :exec\n-- Attach a file to a resource\nUPDATE example_resources SET\n    file_id = $3,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2 AND is_active = true;\n\n-- DELETE operations\n\n-- name: DeleteResource :exec\n-- Soft delete a resource\nUPDATE example_resources SET\n    is_active = false,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2;\n\n-- name: HardDeleteResource :exec\n-- Hard delete a resource (use with caution)\nDELETE FROM example_resources\nWHERE id = $1 AND organization_id = $2;\n\n-- SEARCH operations\n\n-- name: SearchResourcesByText :many\n-- Full-text search on title and description\nSELECT\n    id, resource_number, title, description, status_id,\n    confidence, created_at, updated_at,\n    ts_rank(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')), to_tsquery('english', $2)) AS rank\nFROM example_resources\nWHERE organization_id = $1\n    AND is_active = true\n    AND to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ to_tsquery('english', $2)\nORDER BY rank DESC, created_at DESC\nLIMIT $3 OFFSET $4;\n\n-- ANALYTICS queries\n\n-- name: GetResourceStats :one\n-- Get statistics for dashboard\nSELECT\n    COUNT(*) as total_resources,\n    COUNT(*) FILTER (WHERE status_id = 1) as draft_count,\n    COUNT(*) FILTER (WHERE status_id = 2) as processing_count,\n    COUNT(*) FILTER (WHERE status_id = 3) as completed_count,\n    COUNT(*) FILTER (WHERE approval_status = 'pending') as pending_approval,\n    COUNT(*) FILTER (WHERE approval_status = 'approved') as approved_count,\n    AVG(confidence) as avg_confidence\nFROM example_resources\nWHERE organization_id = $1 AND is_active = true;\n\n-- name: GetRecentResources :many\n-- Get most recently created resources\nSELECT\n    id, resource_number, title, status_id, confidence,\n    created_by_account_id, created_at\nFROM example_resources\nWHERE organization_id = $1 AND is_active = true\nORDER BY created_at DESC\nLIMIT $2;\n\n-- name: GetResourcesByCreator :many\n-- Get resources created by a specific user\nSELECT * FROM example_resources\nWHERE organization_id = $1\n    AND created_by_account_id = $2\n    AND is_active = true\nORDER BY created_at DESC\nLIMIT $3 OFFSET $4;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/query/file_manager.sql",
    "content": "-- name: CreateFileAsset :one\nINSERT INTO file_manager.file_assets (\n    file_name,\n    original_file_name,\n    storage_path,\n    bucket_name,\n    file_size,\n    mime_type,\n    file_category_id,\n    file_context_id,\n    is_public,\n    entity_type,\n    entity_id,\n    purpose,\n    metadata\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13\n)\nRETURNING *;\n\n-- name: GetFileAssetByID :one\nSELECT * FROM file_manager.file_assets\nWHERE id = $1;\n\n-- name: DeleteFileAsset :exec\nDELETE FROM file_manager.file_assets\nWHERE id = $1;\n\n-- name: GetFileAssetsByEntity :many\nSELECT * FROM file_manager.file_assets\nWHERE entity_type = $1 AND entity_id = $2;\n\n-- name: GetFileAssetsByEntityAndPurpose :many\nSELECT * FROM file_manager.file_assets\nWHERE entity_type = $1 AND entity_id = $2 AND purpose = $3\nORDER BY created_at DESC;\n\n-- name: GetFileAssetsByCategory :many\nSELECT fa.*, fc.name as category_name\nFROM file_manager.file_assets fa\nJOIN file_manager.file_categories fc ON fa.file_category_id = fc.id  \nWHERE fc.name = $1\nORDER BY fa.created_at DESC;\n\n-- name: GetFileAssetsByContext :many\nSELECT fa.*, fctx.name as context_name\nFROM file_manager.file_assets fa\nJOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id\nWHERE fctx.name = $1\nORDER BY fa.created_at DESC;\n\n-- name: UpdateFileAsset :exec\nUPDATE file_manager.file_assets\nSET \n    file_name = $2,\n    storage_path = $3,\n    purpose = $4,\n    metadata = $5,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1;\n\n-- name: GetFileAssetByStoragePath :one\nSELECT * FROM file_manager.file_assets\nWHERE storage_path = $1;\n\n-- name: ListFileAssets :many\nSELECT fa.*, fc.name as category_name, fctx.name as context_name\nFROM file_manager.file_assets fa\nJOIN file_manager.file_categories fc ON fa.file_category_id = fc.id\nJOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id\nORDER BY fa.created_at DESC\nLIMIT $1 OFFSET $2;\n\n-- name: GetFileCategories :many\nSELECT * FROM file_manager.file_categories ORDER BY name;\n\n-- name: GetFileContexts :many\nSELECT * FROM file_manager.file_contexts ORDER BY name;"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/query/organizations.sql",
    "content": "-- name: CreateOrganization :one\nINSERT INTO organizations.organizations (\n    slug,\n    name,\n    status\n) VALUES (\n    $1,\n    $2,\n    $3\n) RETURNING\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at;\n\n-- name: GetOrganizationByID :one\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nWHERE id = $1;\n\n-- name: GetOrganizationBySlug :one\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nWHERE slug = $1;\n\n-- name: GetOrganizationByStytchID :one\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nWHERE stytch_org_id = $1;\n\n-- name: UpdateOrganization :one\nUPDATE organizations.organizations\nSET\n    name = $2,\n    status = $3,\n    stytch_org_id = $4,\n    stytch_connection_id = $5,\n    stytch_connection_name = $6,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1\nRETURNING\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at;\n\n-- name: UpdateOrganizationStytchInfo :one\nUPDATE organizations.organizations\nSET\n    stytch_org_id = $2,\n    stytch_connection_id = $3,\n    stytch_connection_name = $4,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1\nRETURNING\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at;\n\n-- name: ListOrganizations :many\nSELECT\n    id,\n    slug,\n    name,\n    status,\n    stytch_org_id,\n    stytch_connection_id,\n    stytch_connection_name,\n    created_at,\n    updated_at\nFROM organizations.organizations\nORDER BY created_at DESC\nLIMIT $1 OFFSET $2;\n\n-- name: DeleteOrganization :exec\nDELETE FROM organizations.organizations\nWHERE id = $1;\n\n-- Accounts queries\n\n-- name: CreateAccount :one\nINSERT INTO organizations.accounts (\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status\n) VALUES (\n    $1,\n    $2,\n    $3,\n    $4,\n    $5,\n    $6,\n    $7,\n    $8,\n    $9\n) RETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at;\n\n-- name: GetAccountByID :one\nSELECT\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\nFROM organizations.accounts\nWHERE id = $1 AND organization_id = $2;\n\n-- name: GetAccountByEmail :one\nSELECT\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\nFROM organizations.accounts\nWHERE email = $1 AND organization_id = $2;\n\n-- name: ListAccountsByOrganization :many\nSELECT\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at\nFROM organizations.accounts\nWHERE organization_id = $1\nORDER BY created_at DESC;\n\n-- name: UpdateAccount :one\nUPDATE organizations.accounts\nSET\n    full_name = $3,\n    stytch_role_id = $4,\n    stytch_role_slug = $5,\n    stytch_email_verified = $6,\n    role = $7,\n    status = $8,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\nRETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at;\n\n-- name: UpdateAccountStytchInfo :one\nUPDATE organizations.accounts\nSET\n    stytch_member_id = $3,\n    stytch_role_id = $4,\n    stytch_role_slug = $5,\n    stytch_email_verified = $6,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\nRETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at;\n\n-- name: UpdateAccountLastLogin :one\nUPDATE organizations.accounts\nSET\n    last_login_at = CURRENT_TIMESTAMP,\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2\nRETURNING\n    id,\n    organization_id,\n    email,\n    full_name,\n    stytch_member_id,\n    stytch_role_id,\n    stytch_role_slug,\n    stytch_email_verified,\n    role,\n    status,\n    last_login_at,\n    created_at,\n    updated_at;\n\n-- name: DeleteAccount :exec\nUPDATE organizations.accounts\nSET\n    status = 'inactive',\n    updated_at = CURRENT_TIMESTAMP\nWHERE id = $1 AND organization_id = $2;\n\n-- Organization membership queries\n\n-- name: GetOrganizationByUserEmail :one\nSELECT\n    o.id,\n    o.slug,\n    o.name,\n    o.status,\n    o.stytch_org_id,\n    o.stytch_connection_id,\n    o.stytch_connection_name,\n    o.created_at,\n    o.updated_at\nFROM organizations.organizations o\nINNER JOIN organizations.accounts a ON o.id = a.organization_id\nWHERE a.email = $1\n  AND a.status = 'active'\n  AND o.status = 'active'\nLIMIT 1;\n\n-- name: GetAccountOrganization :one\nSELECT\n    o.id,\n    o.slug,\n    o.name,\n    o.status,\n    o.stytch_org_id,\n    o.stytch_connection_id,\n    o.stytch_connection_name,\n    o.created_at,\n    o.updated_at\nFROM organizations.organizations o\nINNER JOIN organizations.accounts a ON o.id = a.organization_id\nWHERE a.id = $1;\n\n-- name: CheckAccountPermission :one\nSELECT\n    a.id,\n    a.role,\n    a.status,\n    o.status as org_status\nFROM organizations.accounts a\nINNER JOIN organizations.organizations o ON a.organization_id = o.id\nWHERE a.id = $1 AND a.organization_id = $2;\n\n-- Statistics queries (useful for admin panels)\n\n-- name: GetOrganizationStats :one\nSELECT\n    o.id,\n    o.slug,\n    o.name,\n    o.status,\n    o.stytch_org_id,\n    o.stytch_connection_id,\n    o.stytch_connection_name,\n    o.created_at,\n    o.updated_at,\n    COUNT(a.id) as account_count,\n    COUNT(CASE WHEN a.status = 'active' THEN 1 END) as active_account_count\nFROM organizations.organizations o\nLEFT JOIN organizations.accounts a ON o.id = a.organization_id\nWHERE o.id = $1\nGROUP BY o.id;\n\n-- name: GetAccountStats :one\nSELECT\n    a.id,\n    a.organization_id,\n    a.email,\n    a.full_name,\n    a.stytch_member_id,\n    a.stytch_role_id,\n    a.stytch_role_slug,\n    a.stytch_email_verified,\n    a.role,\n    a.status,\n    a.last_login_at,\n    a.created_at,\n    a.updated_at,\n    o.name as organization_name,\n    o.slug as organization_slug\nFROM organizations.accounts a\nINNER JOIN organizations.organizations o ON a.organization_id = o.id\nWHERE a.id = $1;\n\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/query/subscription_billing.sql",
    "content": "-- name: GetSubscriptionByOrgID :one\n-- Get subscription details for an organization\nSELECT * FROM subscription_billing.subscriptions\nWHERE organization_id = $1\nLIMIT 1;\n\n-- name: GetSubscriptionBySubscriptionID :one\n-- Get subscription by Polar subscription ID\nSELECT * FROM subscription_billing.subscriptions\nWHERE subscription_id = $1\nLIMIT 1;\n\n-- name: UpsertSubscription :one\n-- Create or update subscription from Polar webhook\nINSERT INTO subscription_billing.subscriptions (\n    organization_id,\n    external_customer_id,\n    subscription_id,\n    subscription_status,\n    product_id,\n    product_name,\n    plan_name,\n    current_period_start,\n    current_period_end,\n    cancel_at_period_end,\n    canceled_at,\n    metadata,\n    updated_at\n) VALUES (\n    $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP\n)\nON CONFLICT (organization_id)\nDO UPDATE SET\n    external_customer_id = EXCLUDED.external_customer_id,\n    subscription_id = EXCLUDED.subscription_id,\n    subscription_status = EXCLUDED.subscription_status,\n    product_id = EXCLUDED.product_id,\n    product_name = EXCLUDED.product_name,\n    plan_name = EXCLUDED.plan_name,\n    current_period_start = EXCLUDED.current_period_start,\n    current_period_end = EXCLUDED.current_period_end,\n    cancel_at_period_end = EXCLUDED.cancel_at_period_end,\n    canceled_at = EXCLUDED.canceled_at,\n    metadata = EXCLUDED.metadata,\n    updated_at = CURRENT_TIMESTAMP\nRETURNING *;\n\n-- name: DeleteSubscription :exec\n-- Delete subscription (when subscription is permanently deleted)\nDELETE FROM subscription_billing.subscriptions\nWHERE organization_id = $1;\n\n-- name: GetQuotaByOrgID :one\n-- Get quota tracking for an organization\nSELECT * FROM subscription_billing.quota_tracking\nWHERE organization_id = $1\nLIMIT 1;\n\n-- name: UpsertQuota :one\n-- Create or update quota tracking\nINSERT INTO subscription_billing.quota_tracking (\n    organization_id,\n    invoice_count,\n    max_seats,\n    period_start,\n    period_end,\n    last_synced_at,\n    updated_at\n) VALUES (\n    $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP\n)\nON CONFLICT (organization_id)\nDO UPDATE SET\n    invoice_count = EXCLUDED.invoice_count,\n    max_seats = EXCLUDED.max_seats,\n    period_start = EXCLUDED.period_start,\n    period_end = EXCLUDED.period_end,\n    last_synced_at = CURRENT_TIMESTAMP,\n    updated_at = CURRENT_TIMESTAMP\nRETURNING *;\n\n-- name: DecrementInvoiceCount :one\n-- Decrement invoice count by 1 (called after successful invoice processing)\nUPDATE subscription_billing.quota_tracking\nSET\n    invoice_count = invoice_count - 1,\n    updated_at = CURRENT_TIMESTAMP\nWHERE organization_id = $1\nRETURNING *;\n\n-- name: ResetQuotaForPeriod :one\n-- Reset quota counters for a new billing period\nUPDATE subscription_billing.quota_tracking\nSET\n    invoice_count = $2,\n    period_start = $3,\n    period_end = $4,\n    updated_at = CURRENT_TIMESTAMP\nWHERE organization_id = $1\nRETURNING *;\n\n-- name: GetQuotaStatus :one\n-- Get combined subscription and quota status for fast quota checks\nSELECT\n    s.subscription_status,\n    s.current_period_start,\n    s.current_period_end,\n    s.cancel_at_period_end,\n    q.invoice_count,\n    q.max_seats,\n    CASE\n        WHEN s.subscription_status = 'active' AND q.invoice_count > 0\n        THEN TRUE\n        ELSE FALSE\n    END AS can_process_invoice\nFROM subscription_billing.subscriptions s\nINNER JOIN subscription_billing.quota_tracking q ON s.organization_id = q.organization_id\nWHERE s.organization_id = $1\nLIMIT 1;\n\n-- name: ListActiveSubscriptions :many\n-- List all active subscriptions for monitoring/admin purposes\nSELECT * FROM subscription_billing.subscriptions\nWHERE subscription_status = 'active'\nORDER BY created_at DESC;\n\n-- name: ListQuotasNearLimit :many\n-- List organizations approaching their quota limit (for alerting)\nSELECT\n    q.*,\n    s.subscription_status,\n    s.product_name\nFROM subscription_billing.quota_tracking q\nINNER JOIN subscription_billing.subscriptions s ON q.organization_id = s.organization_id\nWHERE\n    s.subscription_status = 'active'\n    AND q.invoice_count <= $1\nORDER BY q.invoice_count ASC;\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/sqlc/sqlc.yml",
    "content": "version: \"2\"\nsql:\n  - schema: \"./migrations\"\n    queries: \"./query\"\n    engine: \"postgresql\"\n    gen:\n      go:\n        package: \"postgres\"\n        out: \"./gen\"\n        sql_package: \"pgx/v5\"\n        emit_json_tags: true\n        emit_interface: true\n        emit_empty_slices: true\n        overrides:\n          - column: \"travel.places.geom\"\n            go_type: \"string\"\n          - db_type: \"vector\"\n            go_type: \n              type: \"Vector\"\n              import: \"github.com/pgvector/pgvector-go\"\n"
  },
  {
    "path": "go-b2b-starter/internal/db/postgres/types_transform.go",
    "content": "package postgres\n\nimport (\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\t\"github.com/shopspring/decimal\"\n\n\tgeomPkg \"github.com/twpayne/go-geom\"\n\t\"github.com/twpayne/go-geom/encoding/ewkb\"\n\t\"github.com/twpayne/go-geom/encoding/wkb\"\n)\n\n// Int16Ptr converts pgtype.Int2 to *int16\nfunc Int16Ptr(i pgtype.Int2) *int16 {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\treturn &i.Int16\n}\n\n// Int32Ptr converts pgtype.Int4 to *int32\nfunc Int32Ptr(i pgtype.Int4) *int32 {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\treturn &i.Int32\n}\n\n// Int64Ptr converts pgtype.Int8 to *int64\nfunc Int64Ptr(i pgtype.Int8) *int64 {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\treturn &i.Int64\n}\n\n// Float32Ptr converts pgtype.Float4 to *float32\nfunc Float32Ptr(f pgtype.Float4) *float32 {\n\tif !f.Valid {\n\t\treturn nil\n\t}\n\treturn &f.Float32\n}\n\n// Float64Ptr converts pgtype.Float8 to *float64\nfunc Float64Ptr(f pgtype.Float8) *float64 {\n\tif !f.Valid {\n\t\treturn nil\n\t}\n\treturn &f.Float64\n}\n\n// StringPtr converts pgtype.Text to *string\nfunc StringPtr(t pgtype.Text) *string {\n\tif !t.Valid {\n\t\treturn nil\n\t}\n\treturn &t.String\n}\n\n// TimeStampPtr converts pgtype.Timestamp to *time.Time\nfunc TimeStampPtr(t pgtype.Timestamp) *time.Time {\n\tif !t.Valid {\n\t\treturn nil\n\t}\n\treturn &t.Time\n}\n\n// time tampz\n// TimeStampTzPtr converts pgtype.Timestamptz to *time.Time\nfunc TimeStampTzPtr(t pgtype.Timestamptz) *time.Time {\n\tif !t.Valid {\n\t\treturn nil\n\t}\n\treturn &t.Time\n}\n\n// time ptr\n// TimePtr converts pgtype.Time to *time.Time\nfunc TimePtr(t pgtype.Time) *time.Time {\n\tif !t.Valid {\n\t\treturn nil\n\t}\n\t// Convert microseconds to time.Time\n\tseconds := t.Microseconds / 1_000_000        // Convert to seconds\n\tnanos := (t.Microseconds % 1_000_000) * 1000 // Convert remaining microseconds to nanoseconds\n\ttimeVal := time.Unix(seconds, nanos)\n\treturn &timeVal\n}\n\n// BoolPtr converts pgtype.Bool to *bool\nfunc BoolPtr(b pgtype.Bool) *bool {\n\tif !b.Valid {\n\t\treturn nil\n\t}\n\treturn &b.Bool\n}\n\n// from go types to pg types\n// PgInt2 converts *int16 to pgtype.PgInt2\nfunc PgInt2(i *int16) pgtype.Int2 {\n\tif i == nil {\n\t\treturn pgtype.Int2{Valid: false}\n\t}\n\treturn pgtype.Int2{Int16: *i, Valid: true}\n}\n\n// PgInt4 converts *int32 to pgtype.PgInt4\nfunc PgInt4(i *int32) pgtype.Int4 {\n\tif i == nil {\n\t\treturn pgtype.Int4{Valid: false}\n\t}\n\treturn pgtype.Int4{Int32: *i, Valid: true}\n}\n\n// PgInt8 converts *int64 to pgtype.PgInt8\nfunc PgInt8(i *int64) pgtype.Int8 {\n\tif i == nil {\n\t\treturn pgtype.Int8{Valid: false}\n\t}\n\treturn pgtype.Int8{Int64: *i, Valid: true}\n}\n\n// PgFloat4 converts *float32 to pgtype.PgFloat4\nfunc PgFloat4(f *float32) pgtype.Float4 {\n\tif f == nil {\n\t\treturn pgtype.Float4{Valid: false}\n\t}\n\treturn pgtype.Float4{Float32: *f, Valid: true}\n}\n\n// PgFloat8 converts *float64 to pgtype.PgFloat8\nfunc PgFloat8(f *float64) pgtype.Float8 {\n\tif f == nil {\n\t\treturn pgtype.Float8{Valid: false}\n\t}\n\treturn pgtype.Float8{Float64: *f, Valid: true}\n}\n\n// PgText converts *string to pgtype.PgText\nfunc PgText(s *string) pgtype.Text {\n\tif s == nil {\n\t\treturn pgtype.Text{Valid: false}\n\t}\n\treturn pgtype.Text{String: *s, Valid: true}\n}\n\n// PgTimestamp converts *time.Time to pgtype.PgTimestamp\nfunc PgTimestamp(t *time.Time) pgtype.Timestamp {\n\tif t == nil {\n\t\treturn pgtype.Timestamp{Valid: false}\n\t}\n\treturn pgtype.Timestamp{Time: *t, Valid: true}\n}\n\n// PgTimestamptz converts *time.Time to pgtype.PgTimestamptz\nfunc PgTimestamptz(t *time.Time) pgtype.Timestamptz {\n\tif t == nil {\n\t\treturn pgtype.Timestamptz{Valid: false}\n\t}\n\treturn pgtype.Timestamptz{Time: *t, Valid: true}\n}\n\n// PgTime converts *time.Time to pgtype.PgTime\nfunc PgTime(t *time.Time) pgtype.Time {\n\tif t == nil {\n\t\treturn pgtype.Time{Valid: false}\n\t}\n\t// Convert time.Time to microseconds\n\tmicroseconds := t.Unix()*1_000_000 + int64(t.Nanosecond())/1000\n\treturn pgtype.Time{\n\t\tMicroseconds: microseconds,\n\t\tValid:        true,\n\t}\n}\n\n// PgBool converts *bool to pgtype.PgBool\nfunc PgBool(b *bool) pgtype.Bool {\n\tif b == nil {\n\t\treturn pgtype.Bool{Valid: false}\n\t}\n\treturn pgtype.Bool{Bool: *b, Valid: true}\n}\n\n// UUIDPtr converts pgtype.UUID to *uuid.UUID\nfunc UUIDPtr(u pgtype.UUID) *uuid.UUID {\n\tif !u.Valid {\n\t\treturn nil\n\t}\n\tid := uuid.UUID(u.Bytes)\n\treturn &id\n}\n\n// PgUUID converts *uuid.UUID to pgtype.UUID\nfunc PgUUID(u *uuid.UUID) pgtype.UUID {\n\tif u == nil {\n\t\treturn pgtype.UUID{Valid: false}\n\t}\n\treturn pgtype.UUID{\n\t\tBytes: [16]byte(*u),\n\t\tValid: true,\n\t}\n}\n\n// NumericPtr converts pgtype.Numeric to *float64 with improved error handling\nfunc NumericPtr(n pgtype.Numeric) *float64 {\n\tif !n.Valid {\n\t\treturn nil\n\t}\n\n\t// Use Value() method for safe conversion\n\tval, err := n.Value()\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\t// Handle different return types from PostgreSQL numeric\n\tswitch v := val.(type) {\n\tcase float64:\n\t\treturn &v\n\tcase string:\n\t\t// Parse string representation like \"1315.0000\"\n\t\tif f64, err := strconv.ParseFloat(v, 64); err == nil {\n\t\t\treturn &f64\n\t\t}\n\tcase int64:\n\t\tf64 := float64(v)\n\t\treturn &f64\n\tcase int32:\n\t\tf64 := float64(v)\n\t\treturn &f64\n\tcase int:\n\t\tf64 := float64(v)\n\t\treturn &f64\n\t}\n\treturn nil\n}\n\n// Numeric converts *float64 to pgtype.Numeric with improved accuracy\nfunc Numeric(f *float64) pgtype.Numeric {\n\tif f == nil {\n\t\treturn pgtype.Numeric{Valid: false}\n\t}\n\n\t// Use Scan for proper conversion\n\tnumeric := pgtype.Numeric{}\n\terr := numeric.Scan(fmt.Sprintf(\"%.6f\", *f))\n\tif err != nil {\n\t\treturn pgtype.Numeric{Valid: false}\n\t}\n\treturn numeric\n}\n\n// numeric from decimal\n// NumericFromDecimal converts decimal.Decimal to pgtype.Numeric\nfunc NumericFromDecimal(d *decimal.Decimal) pgtype.Numeric {\n\tif d == nil {\n\t\treturn pgtype.Numeric{Valid: false}\n\t}\n\tnumeric := pgtype.Numeric{}\n\terr := numeric.Scan(d.String())\n\tif err != nil {\n\t\treturn pgtype.Numeric{Valid: false}\n\t}\n\treturn numeric\n}\n\n// NumericFromFloat32 converts float32 to pgtype.Numeric\nfunc NumericFromFloat32(f float32) pgtype.Numeric {\n\tnumeric := pgtype.Numeric{}\n\terr := numeric.Scan(fmt.Sprintf(\"%.6f\", f))\n\tif err != nil {\n\t\treturn pgtype.Numeric{Valid: false}\n\t}\n\treturn numeric\n}\n\n// Float32FromNumeric converts pgtype.Numeric to float32\nfunc Float32FromNumeric(n pgtype.Numeric) float32 {\n\tif !n.Valid {\n\t\treturn 0\n\t}\n\n\tval, err := n.Value()\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\n\t// Handle different return types from PostgreSQL numeric\n\tswitch v := val.(type) {\n\tcase float64:\n\t\tresult := float32(v)\n\t\treturn result\n\tcase string:\n\t\t// Parse string representation like \"0.9000\"\n\t\tif f64, err := strconv.ParseFloat(v, 64); err == nil {\n\t\t\tresult := float32(f64)\n\t\t\treturn result\n\t\t}\n\tcase int64:\n\t\tresult := float32(v)\n\t\treturn result\n\tcase int32:\n\t\tresult := float32(v)\n\t\treturn result\n\tcase int:\n\t\tresult := float32(v)\n\t\treturn result\n\t}\n\treturn 0\n}\n\n// DatePtr converts pgtype.Date to *time.Time\nfunc DatePtr(d pgtype.Date) *time.Time {\n\tif !d.Valid {\n\t\treturn nil\n\t}\n\treturn &d.Time\n}\n\n// PgDate converts *time.Time to pgtype.Date\nfunc PgDate(t *time.Time) pgtype.Date {\n\tif t == nil {\n\t\treturn pgtype.Date{Valid: false}\n\t}\n\treturn pgtype.Date{Time: *t, Valid: true}\n}\n\n// PgTextFromString converts string to pgtype.Text (for non-empty strings)\nfunc PgTextFromString(s string) pgtype.Text {\n\tif s == \"\" {\n\t\treturn pgtype.Text{Valid: false}\n\t}\n\treturn pgtype.Text{String: s, Valid: true}\n}\n\n// StringFromPgText converts pgtype.Text to string (empty string if invalid)\nfunc StringFromPgText(t pgtype.Text) string {\n\tif !t.Valid {\n\t\treturn \"\"\n\t}\n\treturn t.String\n}\n\n// PgInt4FromInt32 converts int32 to pgtype.Int4\nfunc PgInt4FromInt32(i int32) pgtype.Int4 {\n\treturn pgtype.Int4{Int32: i, Valid: true}\n}\n\n// Int32FromPgInt4 converts pgtype.Int4 to int32 (0 if invalid)\nfunc Int32FromPgInt4(i pgtype.Int4) int32 {\n\tif !i.Valid {\n\t\treturn 0\n\t}\n\treturn i.Int32\n}\n\nfunc ConvertWKBToPoint(wkbHex string) (*geomPkg.Point, error) {\n\t// Decode the hex string to bytes\n\twkbBytes, err := hex.DecodeString(wkbHex)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode WKB hex: %w\", err)\n\t}\n\n\t// Parse the WKB bytes\n\tgeom, err := wkb.Unmarshal(wkbBytes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal WKB: %w\", err)\n\t}\n\n\t// Assert that it's a Point\n\tgeom.Bounds()\n\tpoint, ok := geom.(*geomPkg.Point)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"geometry is not a Point\")\n\t}\n\n\treturn point, nil\n}\n\nfunc ConvertWKBToPointString(wkbHex string) (string, error) {\n\t// Decode the hex string to bytes\n\twkbBytes, err := hex.DecodeString(wkbHex)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode WKB hex: %w\", err)\n\t}\n\n\t// Parse the EWKB bytes\n\tg, err := ewkb.Unmarshal(wkbBytes)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to unmarshal EWKB: %w\", err)\n\t}\n\n\t// Get the coordinates\n\tcoords := g.FlatCoords()\n\tif len(coords) < 2 {\n\t\treturn \"\", fmt.Errorf(\"invalid point data\")\n\t}\n\n\t// Convert to \"POINT(longitude latitude)\" format\n\treturn fmt.Sprintf(\"POINT(%.6f %.6f)\", coords[0], coords[1]), nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/docs/api/handler.go",
    "content": "package api\n\ntype Handler struct {\n}\n\nfunc NewHandler() *Handler {\n\treturn &Handler{}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/docs/api/routes.go",
    "content": "package api\n\nimport (\n\tdocs \"github.com/moasq/go-b2b-starter/internal/docs/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n\t\"github.com/gin-gonic/gin\"\n\tswaggerFiles \"github.com/swaggo/files\"\n\tginSwagger \"github.com/swaggo/gin-swagger\"\n)\n\nfunc (h *Handler) Routes(router *gin.RouterGroup, resolver domain.MiddlewareResolver) {\n\tif gin.Mode() != gin.ReleaseMode {\n\t\tdocs.SwaggerInfo.Title = \"API\"\n\t\tdocs.SwaggerInfo.Description = \"API\"\n\t\tdocs.SwaggerInfo.BasePath = \"/\"\n\n\t\trouter.GET(\"/swagger/*any\", ginSwagger.WrapHandler(swaggerFiles.Handler))\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/docs/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"log\"\n\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/docs/api\"\n\tserver \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\nfunc Init(container *dig.Container) {\n\terr := container.Invoke(func(srv server.Server) {\n\t\thandler := api.NewHandler()\n\t\tsrv.RegisterRoutes(handler.Routes, \"\")\n\t})\n\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to start server: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/docs/gen/docs.go",
    "content": "// Package gen Code generated by swaggo/swag. DO NOT EDIT\npackage gen\n\nimport \"github.com/swaggo/swag\"\n\nconst docTemplate = `{\n    \"schemes\": {{ marshal .Schemes }},\n    \"swagger\": \"2.0\",\n    \"info\": {\n        \"description\": \"{{escape .Description}}\",\n        \"title\": \"{{.Title}}\",\n        \"termsOfService\": \"http://swagger.io/terms/\",\n        \"contact\": {\n            \"name\": \"API Support\",\n            \"url\": \"http://www.swagger.io/support\",\n            \"email\": \"support@swagger.io\"\n        },\n        \"license\": {\n            \"name\": \"Apache 2.0\",\n            \"url\": \"http://www.apache.org/licenses/LICENSE-2.0.html\"\n        },\n        \"version\": \"{{.Version}}\"\n    },\n    \"host\": \"{{.Host}}\",\n    \"basePath\": \"{{.BasePath}}\",\n    \"paths\": {\n        \"/api/subscriptions/status\": {\n            \"get\": {\n                \"description\": \"Retrieve the current subscription billing status and invoice quota information for the organization\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"subscriptions\"\n                ],\n                \"summary\": \"Get current billing and quota status\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Current billing and quota status\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request parameters or missing organization context\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/api/subscriptions/verify-payment\": {\n            \"post\": {\n                \"description\": \"Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for \\\"Verification on Redirect\\\" pattern when user returns from payment page.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"subscriptions\"\n                ],\n                \"summary\": \"Verify payment from checkout session\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Checkout session ID\",\n                        \"name\": \"request\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_billing.VerifyPaymentRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Verification result with updated billing status\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request parameters or checkout session failed\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Checkout session not found\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/check-email\": {\n            \"get\": {\n                \"description\": \"Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Check if email exists\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Email address to check\",\n                        \"name\": \"email\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Email exists\"\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid email format\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Email not found\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/members\": {\n            \"get\": {\n                \"description\": \"Retrieves all members of the current organization. Restricted to admin role only.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"List organization members\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Missing organization context\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Insufficient permissions - admin role required\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to list members\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {\\\"email\\\": \\\"user@example.com\\\", \\\"name\\\": \\\"Full Name\\\", \\\"role_slug\\\": \\\"member\\\"}\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Add member to organization\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Bearer JWT token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Member email address\",\n                        \"name\": \"email\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    },\n                    {\n                        \"description\": \"Member full name\",\n                        \"name\": \"name\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    },\n                    {\n                        \"description\": \"Role slug (defaults to 'member')\",\n                        \"name\": \"role_slug\",\n                        \"in\": \"body\",\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request payload or missing organization context\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to add member\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/members/{member_id}\": {\n            \"delete\": {\n                \"description\": \"Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Delete organization member\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Bearer JWT token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Member ID to delete\",\n                        \"name\": \"member_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"Member deleted successfully\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid member ID or missing organization context\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Insufficient permissions - admin role required\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Member not found\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to delete member\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/profile/me\": {\n            \"get\": {\n                \"description\": \"Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Get current user profile\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Missing required context (organization or claims)\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"401\": {\n                        \"description\": \"Authentication required\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to retrieve profile\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/signup\": {\n            \"post\": {\n                \"description\": \"Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Bootstrap organization\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Organization bootstrap request (passwordless - no password required)\",\n                        \"name\": \"request\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request payload\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to bootstrap organization\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_cognitive/chat\": {\n            \"post\": {\n                \"description\": \"Sends a message to the AI and gets a response, optionally using RAG\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Cognitive\"\n                ],\n                \"summary\": \"Chat with AI\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Chat request\",\n                        \"name\": \"request\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_cognitive.ChatRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_cognitive/sessions\": {\n            \"get\": {\n                \"description\": \"Lists chat sessions for the current user with pagination\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Cognitive\"\n                ],\n                \"summary\": \"List chat sessions\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 10,\n                        \"description\": \"Limit\",\n                        \"name\": \"limit\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 0,\n                        \"description\": \"Offset\",\n                        \"name\": \"offset\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_cognitive/sessions/{id}/messages\": {\n            \"get\": {\n                \"description\": \"Retrieves all messages for a chat session\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Cognitive\"\n                ],\n                \"summary\": \"Get session history\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Session ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage\"\n                            }\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_documents\": {\n            \"get\": {\n                \"description\": \"Lists documents with optional filtering and pagination\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Documents\"\n                ],\n                \"summary\": \"List documents\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 10,\n                        \"description\": \"Limit\",\n                        \"name\": \"limit\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 0,\n                        \"description\": \"Offset\",\n                        \"name\": \"offset\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Filter by status (pending, processing, processed, failed)\",\n                        \"name\": \"status\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_documents/upload\": {\n            \"post\": {\n                \"description\": \"Uploads a PDF document, extracts text, and creates embeddings\",\n                \"consumes\": [\n                    \"multipart/form-data\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Documents\"\n                ],\n                \"summary\": \"Upload PDF document\",\n                \"parameters\": [\n                    {\n                        \"type\": \"file\",\n                        \"description\": \"PDF file to upload\",\n                        \"name\": \"file\",\n                        \"in\": \"formData\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Document title\",\n                        \"name\": \"title\",\n                        \"in\": \"formData\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_documents/{id}\": {\n            \"delete\": {\n                \"description\": \"Deletes a document and its associated file\",\n                \"tags\": [\n                    \"Documents\"\n                ],\n                \"summary\": \"Delete document\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Document ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/check-permission\": {\n            \"post\": {\n                \"description\": \"Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Check if a role has a specific permission\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Role and permission to check\",\n                        \"name\": \"body\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionCheckRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Permission check result\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionCheckResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/metadata\": {\n            \"get\": {\n                \"description\": \"Returns summary information about the RBAC system including total roles, permissions, and categories.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get RBAC system metadata\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"RBAC system metadata\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.RBACMetadata\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/permissions\": {\n            \"get\": {\n                \"description\": \"Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get all permissions\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"All permissions\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionsResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/permissions/by-category\": {\n            \"get\": {\n                \"description\": \"Returns all permissions organized by their category for better UI organization.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get permissions grouped by category\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Permissions by category\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionsByCategoryResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/roles\": {\n            \"get\": {\n                \"description\": \"Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get all roles with permissions\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Roles with permissions\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.RolesResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/roles/{role_id}\": {\n            \"get\": {\n                \"description\": \"Returns comprehensive information about a role including permissions, statistics, and restrictions.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get detailed information about a specific role\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Role ID (member, approver, admin)\",\n                        \"name\": \"role_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Role details with statistics\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.RolePermissionsResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid role ID\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Role not found\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    },\n    \"definitions\": {\n        \"github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"canProcessInvoices\": {\n                    \"type\": \"boolean\"\n                },\n                \"checkedAt\": {\n                    \"type\": \"string\"\n                },\n                \"externalID\": {\n                    \"type\": \"string\"\n                },\n                \"hasActiveSubscription\": {\n                    \"type\": \"boolean\"\n                },\n                \"invoiceCount\": {\n                    \"description\": \"Remaining invoices\",\n                    \"type\": \"integer\",\n                    \"format\": \"int32\"\n                },\n                \"organizationID\": {\n                    \"type\": \"integer\",\n                    \"format\": \"int32\"\n                },\n                \"reason\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"content\": {\n                    \"type\": \"string\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"referenced_docs\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"role\": {\n                    \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole\"\n                },\n                \"session_id\": {\n                    \"type\": \"integer\"\n                },\n                \"tokens_used\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"message\": {\n                    \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage\"\n                },\n                \"referenced_docs\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument\"\n                    }\n                },\n                \"session_id\": {\n                    \"type\": \"integer\"\n                },\n                \"tokens_used\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"user\",\n                \"assistant\",\n                \"system\"\n            ],\n            \"x-enum-varnames\": [\n                \"ChatRoleUser\",\n                \"ChatRoleAssistant\",\n                \"ChatRoleSystem\"\n            ]\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"chunk_index\": {\n                    \"type\": \"integer\"\n                },\n                \"content_hash\": {\n                    \"type\": \"string\"\n                },\n                \"content_preview\": {\n                    \"type\": \"string\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"document_id\": {\n                    \"type\": \"integer\"\n                },\n                \"embedding\": {\n                    \"description\": \"1536 dimensions for OpenAI\",\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"number\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"organization_id\": {\n                    \"type\": \"integer\"\n                },\n                \"similarity_score\": {\n                    \"type\": \"number\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"documents\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document\"\n                    }\n                },\n                \"limit\": {\n                    \"type\": \"integer\"\n                },\n                \"offset\": {\n                    \"type\": \"integer\"\n                },\n                \"total\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"content_type\": {\n                    \"type\": \"string\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"extracted_text\": {\n                    \"type\": \"string\"\n                },\n                \"file_asset_id\": {\n                    \"type\": \"integer\"\n                },\n                \"file_name\": {\n                    \"type\": \"string\"\n                },\n                \"file_size\": {\n                    \"type\": \"integer\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"metadata\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": true\n                },\n                \"organization_id\": {\n                    \"type\": \"integer\"\n                },\n                \"status\": {\n                    \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus\"\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"pending\",\n                \"processing\",\n                \"processed\",\n                \"failed\"\n            ],\n            \"x-enum-varnames\": [\n                \"DocumentStatusPending\",\n                \"DocumentStatusProcessing\",\n                \"DocumentStatusProcessed\",\n                \"DocumentStatusFailed\"\n            ]\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"invite_sent\": {\n                    \"type\": \"boolean\"\n                },\n                \"member_id\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"org_id\": {\n                    \"type\": \"string\"\n                },\n                \"role_slug\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"org_display_name\",\n                \"owner_email\",\n                \"owner_name\"\n            ],\n            \"properties\": {\n                \"org_display_name\": {\n                    \"description\": \"Organization details\",\n                    \"type\": \"string\"\n                },\n                \"owner_email\": {\n                    \"description\": \"Owner member details\",\n                    \"type\": \"string\"\n                },\n                \"owner_name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"display_name\": {\n                    \"type\": \"string\"\n                },\n                \"invite_sent\": {\n                    \"type\": \"boolean\"\n                },\n                \"magic_link_sent\": {\n                    \"type\": \"boolean\"\n                },\n                \"org_slug\": {\n                    \"type\": \"string\"\n                },\n                \"organization_id\": {\n                    \"type\": \"string\"\n                },\n                \"owner_email\": {\n                    \"type\": \"string\"\n                },\n                \"owner_member_id\": {\n                    \"type\": \"string\"\n                },\n                \"owner_name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"members\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo\"\n                    }\n                },\n                \"total\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"email_verified\": {\n                    \"type\": \"boolean\"\n                },\n                \"member_id\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"roles\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"organization_id\": {\n                    \"type\": \"string\"\n                },\n                \"slug\": {\n                    \"type\": \"string\"\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"account_id\": {\n                    \"description\": \"Internal account details\",\n                    \"type\": \"integer\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"email_verified\": {\n                    \"type\": \"boolean\"\n                },\n                \"member_id\": {\n                    \"description\": \"Auth provider member details\",\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"organization\": {\n                    \"description\": \"Organization details\",\n                    \"allOf\": [\n                        {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization\"\n                        }\n                    ]\n                },\n                \"permissions\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"roles\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"code\": {\n                    \"type\": \"string\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionCheckRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"permission_id\",\n                \"role_id\"\n            ],\n            \"properties\": {\n                \"permission_id\": {\n                    \"type\": \"string\"\n                },\n                \"role_id\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionCheckResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"has_permission\": {\n                    \"type\": \"boolean\"\n                },\n                \"permission_id\": {\n                    \"type\": \"string\"\n                },\n                \"role_id\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"action\": {\n                    \"type\": \"string\"\n                },\n                \"category\": {\n                    \"type\": \"string\"\n                },\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"display_name\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"resource\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionsByCategoryResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"categories\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"array\",\n                        \"items\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionDTO\"\n                        }\n                    }\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionsResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"permissions\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/internal_modules_auth.PermissionDTO\"\n                    }\n                }\n            }\n        },\n        \"internal_modules_auth.RBACMetadata\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"permissions_by_role\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"total_permissions\": {\n                    \"type\": \"integer\"\n                },\n                \"total_roles\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"internal_modules_auth.RoleDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"permissions\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/internal_modules_auth.PermissionDTO\"\n                    }\n                }\n            }\n        },\n        \"internal_modules_auth.RolePermissionsResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"restrictions\": {\n                    \"$ref\": \"#/definitions/internal_modules_auth.RoleRestrictions\"\n                },\n                \"role\": {\n                    \"$ref\": \"#/definitions/internal_modules_auth.RoleDTO\"\n                },\n                \"statistics\": {\n                    \"$ref\": \"#/definitions/internal_modules_auth.RoleStatistics\"\n                }\n            }\n        },\n        \"internal_modules_auth.RoleRestrictions\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"cannot_do\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"data_access_level\": {\n                    \"type\": \"string\"\n                },\n                \"scope\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.RoleStatistics\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"can_approve\": {\n                    \"type\": \"boolean\"\n                },\n                \"can_manage_org\": {\n                    \"type\": \"boolean\"\n                },\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"total_permissions\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"internal_modules_auth.RolesResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"roles\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/internal_modules_auth.RoleDTO\"\n                    }\n                }\n            }\n        },\n        \"internal_modules_billing.VerifyPaymentRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"session_id\"\n            ],\n            \"properties\": {\n                \"session_id\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_cognitive.ChatRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"message\"\n            ],\n            \"properties\": {\n                \"context_history\": {\n                    \"type\": \"integer\"\n                },\n                \"max_documents\": {\n                    \"type\": \"integer\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                },\n                \"session_id\": {\n                    \"type\": \"integer\"\n                },\n                \"use_rag\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        }\n    },\n    \"securityDefinitions\": {\n        \"BasicAuth\": {\n            \"type\": \"basic\"\n        }\n    },\n    \"externalDocs\": {\n        \"description\": \"OpenAPI\",\n        \"url\": \"https://swagger.io/resources/open-api/\"\n    }\n}`\n\n// SwaggerInfo holds exported Swagger Info so clients can modify it\nvar SwaggerInfo = &swag.Spec{\n\tVersion:          \"1.0\",\n\tHost:             \"localhost:8080\",\n\tBasePath:         \"/api\",\n\tSchemes:          []string{},\n\tTitle:            \"B2B SaaS Starter API\",\n\tDescription:      \"This is the API server for B2B SaaS Starter.\",\n\tInfoInstanceName: \"swagger\",\n\tSwaggerTemplate:  docTemplate,\n\tLeftDelim:        \"{{\",\n\tRightDelim:       \"}}\",\n}\n\nfunc init() {\n\tswag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/docs/gen/swagger.json",
    "content": "{\n    \"swagger\": \"2.0\",\n    \"info\": {\n        \"description\": \"This is the API server for B2B SaaS Starter.\",\n        \"title\": \"B2B SaaS Starter API\",\n        \"termsOfService\": \"http://swagger.io/terms/\",\n        \"contact\": {\n            \"name\": \"API Support\",\n            \"url\": \"http://www.swagger.io/support\",\n            \"email\": \"support@swagger.io\"\n        },\n        \"license\": {\n            \"name\": \"Apache 2.0\",\n            \"url\": \"http://www.apache.org/licenses/LICENSE-2.0.html\"\n        },\n        \"version\": \"1.0\"\n    },\n    \"host\": \"localhost:8080\",\n    \"basePath\": \"/api\",\n    \"paths\": {\n        \"/api/subscriptions/status\": {\n            \"get\": {\n                \"description\": \"Retrieve the current subscription billing status and invoice quota information for the organization\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"subscriptions\"\n                ],\n                \"summary\": \"Get current billing and quota status\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Current billing and quota status\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request parameters or missing organization context\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/api/subscriptions/verify-payment\": {\n            \"post\": {\n                \"description\": \"Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for \\\"Verification on Redirect\\\" pattern when user returns from payment page.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"subscriptions\"\n                ],\n                \"summary\": \"Verify payment from checkout session\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Checkout session ID\",\n                        \"name\": \"request\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_billing.VerifyPaymentRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Verification result with updated billing status\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request parameters or checkout session failed\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Checkout session not found\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/check-email\": {\n            \"get\": {\n                \"description\": \"Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Check if email exists\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Email address to check\",\n                        \"name\": \"email\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Email exists\"\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid email format\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Email not found\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/members\": {\n            \"get\": {\n                \"description\": \"Retrieves all members of the current organization. Restricted to admin role only.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"List organization members\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Missing organization context\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Insufficient permissions - admin role required\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to list members\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {\\\"email\\\": \\\"user@example.com\\\", \\\"name\\\": \\\"Full Name\\\", \\\"role_slug\\\": \\\"member\\\"}\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Add member to organization\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Bearer JWT token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Member email address\",\n                        \"name\": \"email\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    },\n                    {\n                        \"description\": \"Member full name\",\n                        \"name\": \"name\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    },\n                    {\n                        \"description\": \"Role slug (defaults to 'member')\",\n                        \"name\": \"role_slug\",\n                        \"in\": \"body\",\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request payload or missing organization context\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to add member\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/members/{member_id}\": {\n            \"delete\": {\n                \"description\": \"Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Delete organization member\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Bearer JWT token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Member ID to delete\",\n                        \"name\": \"member_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"Member deleted successfully\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid member ID or missing organization context\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Insufficient permissions - admin role required\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Member not found\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to delete member\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/profile/me\": {\n            \"get\": {\n                \"description\": \"Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Get current user profile\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Missing required context (organization or claims)\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"401\": {\n                        \"description\": \"Authentication required\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to retrieve profile\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/auth/signup\": {\n            \"post\": {\n                \"description\": \"Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"auth\"\n                ],\n                \"summary\": \"Bootstrap organization\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Organization bootstrap request (passwordless - no password required)\",\n                        \"name\": \"request\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request payload\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Failed to bootstrap organization\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_cognitive/chat\": {\n            \"post\": {\n                \"description\": \"Sends a message to the AI and gets a response, optionally using RAG\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Cognitive\"\n                ],\n                \"summary\": \"Chat with AI\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Chat request\",\n                        \"name\": \"request\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_cognitive.ChatRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_cognitive/sessions\": {\n            \"get\": {\n                \"description\": \"Lists chat sessions for the current user with pagination\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Cognitive\"\n                ],\n                \"summary\": \"List chat sessions\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 10,\n                        \"description\": \"Limit\",\n                        \"name\": \"limit\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 0,\n                        \"description\": \"Offset\",\n                        \"name\": \"offset\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": true\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_cognitive/sessions/{id}/messages\": {\n            \"get\": {\n                \"description\": \"Retrieves all messages for a chat session\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Cognitive\"\n                ],\n                \"summary\": \"Get session history\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Session ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage\"\n                            }\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_documents\": {\n            \"get\": {\n                \"description\": \"Lists documents with optional filtering and pagination\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Documents\"\n                ],\n                \"summary\": \"List documents\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 10,\n                        \"description\": \"Limit\",\n                        \"name\": \"limit\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 0,\n                        \"description\": \"Offset\",\n                        \"name\": \"offset\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Filter by status (pending, processing, processed, failed)\",\n                        \"name\": \"status\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_documents/upload\": {\n            \"post\": {\n                \"description\": \"Uploads a PDF document, extracts text, and creates embeddings\",\n                \"consumes\": [\n                    \"multipart/form-data\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Documents\"\n                ],\n                \"summary\": \"Upload PDF document\",\n                \"parameters\": [\n                    {\n                        \"type\": \"file\",\n                        \"description\": \"PDF file to upload\",\n                        \"name\": \"file\",\n                        \"in\": \"formData\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Document title\",\n                        \"name\": \"title\",\n                        \"in\": \"formData\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/example_documents/{id}\": {\n            \"delete\": {\n                \"description\": \"Deletes a document and its associated file\",\n                \"tags\": [\n                    \"Documents\"\n                ],\n                \"summary\": \"Delete document\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Document ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/check-permission\": {\n            \"post\": {\n                \"description\": \"Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Check if a role has a specific permission\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Role and permission to check\",\n                        \"name\": \"body\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionCheckRequest\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Permission check result\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionCheckResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/metadata\": {\n            \"get\": {\n                \"description\": \"Returns summary information about the RBAC system including total roles, permissions, and categories.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get RBAC system metadata\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"RBAC system metadata\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.RBACMetadata\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/permissions\": {\n            \"get\": {\n                \"description\": \"Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get all permissions\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"All permissions\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionsResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/permissions/by-category\": {\n            \"get\": {\n                \"description\": \"Returns all permissions organized by their category for better UI organization.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get permissions grouped by category\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Permissions by category\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionsByCategoryResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/roles\": {\n            \"get\": {\n                \"description\": \"Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get all roles with permissions\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Roles with permissions\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.RolesResponse\"\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal error\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/rbac/roles/{role_id}\": {\n            \"get\": {\n                \"description\": \"Returns comprehensive information about a role including permissions, statistics, and restrictions.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"RBAC\"\n                ],\n                \"summary\": \"Get detailed information about a specific role\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Role ID (member, approver, admin)\",\n                        \"name\": \"role_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Role details with statistics\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.RolePermissionsResponse\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid role ID\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    },\n                    \"404\": {\n                        \"description\": \"Role not found\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"additionalProperties\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    },\n    \"definitions\": {\n        \"github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"canProcessInvoices\": {\n                    \"type\": \"boolean\"\n                },\n                \"checkedAt\": {\n                    \"type\": \"string\"\n                },\n                \"externalID\": {\n                    \"type\": \"string\"\n                },\n                \"hasActiveSubscription\": {\n                    \"type\": \"boolean\"\n                },\n                \"invoiceCount\": {\n                    \"description\": \"Remaining invoices\",\n                    \"type\": \"integer\",\n                    \"format\": \"int32\"\n                },\n                \"organizationID\": {\n                    \"type\": \"integer\",\n                    \"format\": \"int32\"\n                },\n                \"reason\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"content\": {\n                    \"type\": \"string\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"referenced_docs\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"role\": {\n                    \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole\"\n                },\n                \"session_id\": {\n                    \"type\": \"integer\"\n                },\n                \"tokens_used\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"message\": {\n                    \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage\"\n                },\n                \"referenced_docs\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument\"\n                    }\n                },\n                \"session_id\": {\n                    \"type\": \"integer\"\n                },\n                \"tokens_used\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"user\",\n                \"assistant\",\n                \"system\"\n            ],\n            \"x-enum-varnames\": [\n                \"ChatRoleUser\",\n                \"ChatRoleAssistant\",\n                \"ChatRoleSystem\"\n            ]\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"chunk_index\": {\n                    \"type\": \"integer\"\n                },\n                \"content_hash\": {\n                    \"type\": \"string\"\n                },\n                \"content_preview\": {\n                    \"type\": \"string\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"document_id\": {\n                    \"type\": \"integer\"\n                },\n                \"embedding\": {\n                    \"description\": \"1536 dimensions for OpenAI\",\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"number\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"organization_id\": {\n                    \"type\": \"integer\"\n                },\n                \"similarity_score\": {\n                    \"type\": \"number\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"documents\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document\"\n                    }\n                },\n                \"limit\": {\n                    \"type\": \"integer\"\n                },\n                \"offset\": {\n                    \"type\": \"integer\"\n                },\n                \"total\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"content_type\": {\n                    \"type\": \"string\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"extracted_text\": {\n                    \"type\": \"string\"\n                },\n                \"file_asset_id\": {\n                    \"type\": \"integer\"\n                },\n                \"file_name\": {\n                    \"type\": \"string\"\n                },\n                \"file_size\": {\n                    \"type\": \"integer\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"metadata\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": true\n                },\n                \"organization_id\": {\n                    \"type\": \"integer\"\n                },\n                \"status\": {\n                    \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus\"\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"pending\",\n                \"processing\",\n                \"processed\",\n                \"failed\"\n            ],\n            \"x-enum-varnames\": [\n                \"DocumentStatusPending\",\n                \"DocumentStatusProcessing\",\n                \"DocumentStatusProcessed\",\n                \"DocumentStatusFailed\"\n            ]\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"invite_sent\": {\n                    \"type\": \"boolean\"\n                },\n                \"member_id\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"org_id\": {\n                    \"type\": \"string\"\n                },\n                \"role_slug\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"org_display_name\",\n                \"owner_email\",\n                \"owner_name\"\n            ],\n            \"properties\": {\n                \"org_display_name\": {\n                    \"description\": \"Organization details\",\n                    \"type\": \"string\"\n                },\n                \"owner_email\": {\n                    \"description\": \"Owner member details\",\n                    \"type\": \"string\"\n                },\n                \"owner_name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"display_name\": {\n                    \"type\": \"string\"\n                },\n                \"invite_sent\": {\n                    \"type\": \"boolean\"\n                },\n                \"magic_link_sent\": {\n                    \"type\": \"boolean\"\n                },\n                \"org_slug\": {\n                    \"type\": \"string\"\n                },\n                \"organization_id\": {\n                    \"type\": \"string\"\n                },\n                \"owner_email\": {\n                    \"type\": \"string\"\n                },\n                \"owner_member_id\": {\n                    \"type\": \"string\"\n                },\n                \"owner_name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"members\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo\"\n                    }\n                },\n                \"total\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"email_verified\": {\n                    \"type\": \"boolean\"\n                },\n                \"member_id\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"roles\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"organization_id\": {\n                    \"type\": \"string\"\n                },\n                \"slug\": {\n                    \"type\": \"string\"\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"account_id\": {\n                    \"description\": \"Internal account details\",\n                    \"type\": \"integer\"\n                },\n                \"created_at\": {\n                    \"type\": \"string\"\n                },\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"email_verified\": {\n                    \"type\": \"boolean\"\n                },\n                \"member_id\": {\n                    \"description\": \"Auth provider member details\",\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"organization\": {\n                    \"description\": \"Organization details\",\n                    \"allOf\": [\n                        {\n                            \"$ref\": \"#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization\"\n                        }\n                    ]\n                },\n                \"permissions\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"roles\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                },\n                \"updated_at\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"code\": {\n                    \"type\": \"string\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionCheckRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"permission_id\",\n                \"role_id\"\n            ],\n            \"properties\": {\n                \"permission_id\": {\n                    \"type\": \"string\"\n                },\n                \"role_id\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionCheckResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"has_permission\": {\n                    \"type\": \"boolean\"\n                },\n                \"permission_id\": {\n                    \"type\": \"string\"\n                },\n                \"role_id\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"action\": {\n                    \"type\": \"string\"\n                },\n                \"category\": {\n                    \"type\": \"string\"\n                },\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"display_name\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"resource\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionsByCategoryResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"categories\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"array\",\n                        \"items\": {\n                            \"$ref\": \"#/definitions/internal_modules_auth.PermissionDTO\"\n                        }\n                    }\n                }\n            }\n        },\n        \"internal_modules_auth.PermissionsResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"permissions\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/internal_modules_auth.PermissionDTO\"\n                    }\n                }\n            }\n        },\n        \"internal_modules_auth.RBACMetadata\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"permissions_by_role\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"total_permissions\": {\n                    \"type\": \"integer\"\n                },\n                \"total_roles\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"internal_modules_auth.RoleDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"permissions\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/internal_modules_auth.PermissionDTO\"\n                    }\n                }\n            }\n        },\n        \"internal_modules_auth.RolePermissionsResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"restrictions\": {\n                    \"$ref\": \"#/definitions/internal_modules_auth.RoleRestrictions\"\n                },\n                \"role\": {\n                    \"$ref\": \"#/definitions/internal_modules_auth.RoleDTO\"\n                },\n                \"statistics\": {\n                    \"$ref\": \"#/definitions/internal_modules_auth.RoleStatistics\"\n                }\n            }\n        },\n        \"internal_modules_auth.RoleRestrictions\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"cannot_do\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"data_access_level\": {\n                    \"type\": \"string\"\n                },\n                \"scope\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_auth.RoleStatistics\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"can_approve\": {\n                    \"type\": \"boolean\"\n                },\n                \"can_manage_org\": {\n                    \"type\": \"boolean\"\n                },\n                \"description\": {\n                    \"type\": \"string\"\n                },\n                \"total_permissions\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"internal_modules_auth.RolesResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"roles\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/internal_modules_auth.RoleDTO\"\n                    }\n                }\n            }\n        },\n        \"internal_modules_billing.VerifyPaymentRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"session_id\"\n            ],\n            \"properties\": {\n                \"session_id\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"internal_modules_cognitive.ChatRequest\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"message\"\n            ],\n            \"properties\": {\n                \"context_history\": {\n                    \"type\": \"integer\"\n                },\n                \"max_documents\": {\n                    \"type\": \"integer\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                },\n                \"session_id\": {\n                    \"type\": \"integer\"\n                },\n                \"use_rag\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        }\n    },\n    \"securityDefinitions\": {\n        \"BasicAuth\": {\n            \"type\": \"basic\"\n        }\n    },\n    \"externalDocs\": {\n        \"description\": \"OpenAPI\",\n        \"url\": \"https://swagger.io/resources/open-api/\"\n    }\n}"
  },
  {
    "path": "go-b2b-starter/internal/docs/gen/swagger.yaml",
    "content": "basePath: /api\ndefinitions:\n  github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus:\n    properties:\n      canProcessInvoices:\n        type: boolean\n      checkedAt:\n        type: string\n      externalID:\n        type: string\n      hasActiveSubscription:\n        type: boolean\n      invoiceCount:\n        description: Remaining invoices\n        format: int32\n        type: integer\n      organizationID:\n        format: int32\n        type: integer\n      reason:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage:\n    properties:\n      content:\n        type: string\n      created_at:\n        type: string\n      id:\n        type: integer\n      referenced_docs:\n        items:\n          type: integer\n        type: array\n      role:\n        $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole'\n      session_id:\n        type: integer\n      tokens_used:\n        type: integer\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse:\n    properties:\n      message:\n        $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage'\n      referenced_docs:\n        items:\n          $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument'\n        type: array\n      session_id:\n        type: integer\n      tokens_used:\n        type: integer\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatRole:\n    enum:\n    - user\n    - assistant\n    - system\n    type: string\n    x-enum-varnames:\n    - ChatRoleUser\n    - ChatRoleAssistant\n    - ChatRoleSystem\n  github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.SimilarDocument:\n    properties:\n      chunk_index:\n        type: integer\n      content_hash:\n        type: string\n      content_preview:\n        type: string\n      created_at:\n        type: string\n      document_id:\n        type: integer\n      embedding:\n        description: 1536 dimensions for OpenAI\n        items:\n          type: number\n        type: array\n      id:\n        type: integer\n      organization_id:\n        type: integer\n      similarity_score:\n        type: number\n      updated_at:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse:\n    properties:\n      documents:\n        items:\n          $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document'\n        type: array\n      limit:\n        type: integer\n      offset:\n        type: integer\n      total:\n        type: integer\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document:\n    properties:\n      content_type:\n        type: string\n      created_at:\n        type: string\n      extracted_text:\n        type: string\n      file_asset_id:\n        type: integer\n      file_name:\n        type: string\n      file_size:\n        type: integer\n      id:\n        type: integer\n      metadata:\n        additionalProperties: true\n        type: object\n      organization_id:\n        type: integer\n      status:\n        $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus'\n      title:\n        type: string\n      updated_at:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_documents_domain.DocumentStatus:\n    enum:\n    - pending\n    - processing\n    - processed\n    - failed\n    type: string\n    x-enum-varnames:\n    - DocumentStatusPending\n    - DocumentStatusProcessing\n    - DocumentStatusProcessed\n    - DocumentStatusFailed\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse:\n    properties:\n      email:\n        type: string\n      invite_sent:\n        type: boolean\n      member_id:\n        type: string\n      name:\n        type: string\n      org_id:\n        type: string\n      role_slug:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest:\n    properties:\n      org_display_name:\n        description: Organization details\n        type: string\n      owner_email:\n        description: Owner member details\n        type: string\n      owner_name:\n        type: string\n    required:\n    - org_display_name\n    - owner_email\n    - owner_name\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse:\n    properties:\n      display_name:\n        type: string\n      invite_sent:\n        type: boolean\n      magic_link_sent:\n        type: boolean\n      org_slug:\n        type: string\n      organization_id:\n        type: string\n      owner_email:\n        type: string\n      owner_member_id:\n        type: string\n      owner_name:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse:\n    properties:\n      members:\n        items:\n          $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo'\n        type: array\n      total:\n        type: integer\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.MemberInfo:\n    properties:\n      created_at:\n        type: string\n      email:\n        type: string\n      email_verified:\n        type: boolean\n      member_id:\n        type: string\n      name:\n        type: string\n      roles:\n        items:\n          type: string\n        type: array\n      status:\n        type: string\n      updated_at:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization:\n    properties:\n      name:\n        type: string\n      organization_id:\n        type: string\n      slug:\n        type: string\n      status:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse:\n    properties:\n      account_id:\n        description: Internal account details\n        type: integer\n      created_at:\n        type: string\n      email:\n        type: string\n      email_verified:\n        type: boolean\n      member_id:\n        description: Auth provider member details\n        type: string\n      name:\n        type: string\n      organization:\n        allOf:\n        - $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileOrganization'\n        description: Organization details\n      permissions:\n        items:\n          type: string\n        type: array\n      roles:\n        items:\n          type: string\n        type: array\n      status:\n        type: string\n      updated_at:\n        type: string\n    type: object\n  github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError:\n    properties:\n      code:\n        type: string\n      message:\n        type: string\n    type: object\n  internal_modules_auth.PermissionCheckRequest:\n    properties:\n      permission_id:\n        type: string\n      role_id:\n        type: string\n    required:\n    - permission_id\n    - role_id\n    type: object\n  internal_modules_auth.PermissionCheckResponse:\n    properties:\n      has_permission:\n        type: boolean\n      permission_id:\n        type: string\n      role_id:\n        type: string\n    type: object\n  internal_modules_auth.PermissionDTO:\n    properties:\n      action:\n        type: string\n      category:\n        type: string\n      description:\n        type: string\n      display_name:\n        type: string\n      id:\n        type: string\n      resource:\n        type: string\n    type: object\n  internal_modules_auth.PermissionsByCategoryResponse:\n    properties:\n      categories:\n        additionalProperties:\n          items:\n            $ref: '#/definitions/internal_modules_auth.PermissionDTO'\n          type: array\n        type: object\n    type: object\n  internal_modules_auth.PermissionsResponse:\n    properties:\n      permissions:\n        items:\n          $ref: '#/definitions/internal_modules_auth.PermissionDTO'\n        type: array\n    type: object\n  internal_modules_auth.RBACMetadata:\n    properties:\n      description:\n        type: string\n      permissions_by_role:\n        additionalProperties:\n          type: integer\n        type: object\n      total_permissions:\n        type: integer\n      total_roles:\n        type: integer\n    type: object\n  internal_modules_auth.RoleDTO:\n    properties:\n      description:\n        type: string\n      id:\n        type: string\n      name:\n        type: string\n      permissions:\n        items:\n          $ref: '#/definitions/internal_modules_auth.PermissionDTO'\n        type: array\n    type: object\n  internal_modules_auth.RolePermissionsResponse:\n    properties:\n      restrictions:\n        $ref: '#/definitions/internal_modules_auth.RoleRestrictions'\n      role:\n        $ref: '#/definitions/internal_modules_auth.RoleDTO'\n      statistics:\n        $ref: '#/definitions/internal_modules_auth.RoleStatistics'\n    type: object\n  internal_modules_auth.RoleRestrictions:\n    properties:\n      cannot_do:\n        items:\n          type: string\n        type: array\n      data_access_level:\n        type: string\n      scope:\n        type: string\n    type: object\n  internal_modules_auth.RoleStatistics:\n    properties:\n      can_approve:\n        type: boolean\n      can_manage_org:\n        type: boolean\n      description:\n        type: string\n      total_permissions:\n        type: integer\n    type: object\n  internal_modules_auth.RolesResponse:\n    properties:\n      roles:\n        items:\n          $ref: '#/definitions/internal_modules_auth.RoleDTO'\n        type: array\n    type: object\n  internal_modules_billing.VerifyPaymentRequest:\n    properties:\n      session_id:\n        type: string\n    required:\n    - session_id\n    type: object\n  internal_modules_cognitive.ChatRequest:\n    properties:\n      context_history:\n        type: integer\n      max_documents:\n        type: integer\n      message:\n        type: string\n      session_id:\n        type: integer\n      use_rag:\n        type: boolean\n    required:\n    - message\n    type: object\nexternalDocs:\n  description: OpenAPI\n  url: https://swagger.io/resources/open-api/\nhost: localhost:8080\ninfo:\n  contact:\n    email: support@swagger.io\n    name: API Support\n    url: http://www.swagger.io/support\n  description: This is the API server for B2B SaaS Starter.\n  license:\n    name: Apache 2.0\n    url: http://www.apache.org/licenses/LICENSE-2.0.html\n  termsOfService: http://swagger.io/terms/\n  title: B2B SaaS Starter API\n  version: \"1.0\"\npaths:\n  /api/subscriptions/status:\n    get:\n      consumes:\n      - application/json\n      description: Retrieve the current subscription billing status and invoice quota\n        information for the organization\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Current billing and quota status\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus'\n        \"400\":\n          description: Invalid request parameters or missing organization context\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"500\":\n          description: Internal server error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: Get current billing and quota status\n      tags:\n      - subscriptions\n  /api/subscriptions/verify-payment:\n    post:\n      consumes:\n      - application/json\n      description: Verifies a payment by checking the Polar checkout session and updates\n        subscription status. This is the primary mechanism for \"Verification on Redirect\"\n        pattern when user returns from payment page.\n      parameters:\n      - description: Checkout session ID\n        in: body\n        name: request\n        required: true\n        schema:\n          $ref: '#/definitions/internal_modules_billing.VerifyPaymentRequest'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Verification result with updated billing status\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_billing_domain.BillingStatus'\n        \"400\":\n          description: Invalid request parameters or checkout session failed\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"404\":\n          description: Checkout session not found\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"500\":\n          description: Internal server error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: Verify payment from checkout session\n      tags:\n      - subscriptions\n  /auth/check-email:\n    get:\n      consumes:\n      - application/json\n      description: Checks if an email exists in any organization. Returns 200 OK (empty\n        response) if exists, 404 Not Found if doesn't exist. This is a public endpoint\n        used during login flow.\n      parameters:\n      - description: Email address to check\n        in: query\n        name: email\n        required: true\n        type: string\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Email exists\n        \"400\":\n          description: Invalid email format\n          schema:\n            additionalProperties: true\n            type: object\n        \"404\":\n          description: Email not found\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Internal server error\n          schema:\n            additionalProperties: true\n            type: object\n      summary: Check if email exists\n      tags:\n      - auth\n  /auth/members:\n    get:\n      consumes:\n      - application/json\n      description: Retrieves all members of the current organization. Restricted to\n        admin role only.\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ListMembersResponse'\n        \"400\":\n          description: Missing organization context\n          schema:\n            additionalProperties: true\n            type: object\n        \"403\":\n          description: Insufficient permissions - admin role required\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Failed to list members\n          schema:\n            additionalProperties: true\n            type: object\n      summary: List organization members\n      tags:\n      - auth\n    post:\n      consumes:\n      - application/json\n      description: 'Adds a new member to an existing organization with a specified\n        role. Organization ID is automatically extracted from JWT token. Member receives\n        a magic link invite email for passwordless authentication. Request body: {\"email\":\n        \"user@example.com\", \"name\": \"Full Name\", \"role_slug\": \"member\"}'\n      parameters:\n      - description: Bearer JWT token\n        in: header\n        name: Authorization\n        required: true\n        type: string\n      - description: Member email address\n        in: body\n        name: email\n        required: true\n        schema:\n          type: string\n      - description: Member full name\n        in: body\n        name: name\n        required: true\n        schema:\n          type: string\n      - description: Role slug (defaults to 'member')\n        in: body\n        name: role_slug\n        schema:\n          type: string\n      produces:\n      - application/json\n      responses:\n        \"201\":\n          description: Created\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.AddMemberResponse'\n        \"400\":\n          description: Invalid request payload or missing organization context\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Failed to add member\n          schema:\n            additionalProperties: true\n            type: object\n      summary: Add member to organization\n      tags:\n      - auth\n  /auth/members/{member_id}:\n    delete:\n      consumes:\n      - application/json\n      description: Removes a member from the organization (deletes from both Stytch\n        and internal database). Only admins can delete members.\n      parameters:\n      - description: Bearer JWT token\n        in: header\n        name: Authorization\n        required: true\n        type: string\n      - description: Member ID to delete\n        in: path\n        name: member_id\n        required: true\n        type: string\n      produces:\n      - application/json\n      responses:\n        \"204\":\n          description: Member deleted successfully\n          schema:\n            additionalProperties: true\n            type: object\n        \"400\":\n          description: Invalid member ID or missing organization context\n          schema:\n            additionalProperties: true\n            type: object\n        \"403\":\n          description: Insufficient permissions - admin role required\n          schema:\n            additionalProperties: true\n            type: object\n        \"404\":\n          description: Member not found\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Failed to delete member\n          schema:\n            additionalProperties: true\n            type: object\n      summary: Delete organization member\n      tags:\n      - auth\n  /auth/profile/me:\n    get:\n      consumes:\n      - application/json\n      description: Retrieves comprehensive profile information for the currently authenticated\n        user, including member details, organization info, and account status.\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.ProfileResponse'\n        \"400\":\n          description: Missing required context (organization or claims)\n          schema:\n            additionalProperties: true\n            type: object\n        \"401\":\n          description: Authentication required\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Failed to retrieve profile\n          schema:\n            additionalProperties: true\n            type: object\n      summary: Get current user profile\n      tags:\n      - auth\n  /auth/signup:\n    post:\n      consumes:\n      - application/json\n      description: Creates a new organization in Stytch with an initial admin member.\n        The admin receives a magic link invite email to complete passwordless onboarding.\n        Organization slug is auto-generated from the organization name.\n      parameters:\n      - description: Organization bootstrap request (passwordless - no password required)\n        in: body\n        name: request\n        required: true\n        schema:\n          $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationRequest'\n      produces:\n      - application/json\n      responses:\n        \"201\":\n          description: Created\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_organizations_app_services.BootstrapOrganizationResponse'\n        \"400\":\n          description: Invalid request payload\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Failed to bootstrap organization\n          schema:\n            additionalProperties: true\n            type: object\n      summary: Bootstrap organization\n      tags:\n      - auth\n  /example_cognitive/chat:\n    post:\n      consumes:\n      - application/json\n      description: Sends a message to the AI and gets a response, optionally using\n        RAG\n      parameters:\n      - description: Chat request\n        in: body\n        name: request\n        required: true\n        schema:\n          $ref: '#/definitions/internal_modules_cognitive.ChatRequest'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatResponse'\n        \"400\":\n          description: Bad Request\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"500\":\n          description: Internal Server Error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: Chat with AI\n      tags:\n      - Cognitive\n  /example_cognitive/sessions:\n    get:\n      description: Lists chat sessions for the current user with pagination\n      parameters:\n      - default: 10\n        description: Limit\n        in: query\n        name: limit\n        type: integer\n      - default: 0\n        description: Offset\n        in: query\n        name: offset\n        type: integer\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            additionalProperties: true\n            type: object\n        \"500\":\n          description: Internal Server Error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: List chat sessions\n      tags:\n      - Cognitive\n  /example_cognitive/sessions/{id}/messages:\n    get:\n      description: Retrieves all messages for a chat session\n      parameters:\n      - description: Session ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            items:\n              $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_cognitive_domain.ChatMessage'\n            type: array\n        \"400\":\n          description: Bad Request\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"500\":\n          description: Internal Server Error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: Get session history\n      tags:\n      - Cognitive\n  /example_documents:\n    get:\n      description: Lists documents with optional filtering and pagination\n      parameters:\n      - default: 10\n        description: Limit\n        in: query\n        name: limit\n        type: integer\n      - default: 0\n        description: Offset\n        in: query\n        name: offset\n        type: integer\n      - description: Filter by status (pending, processing, processed, failed)\n        in: query\n        name: status\n        type: string\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_app_services.ListDocumentsResponse'\n        \"500\":\n          description: Internal Server Error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: List documents\n      tags:\n      - Documents\n  /example_documents/{id}:\n    delete:\n      description: Deletes a document and its associated file\n      parameters:\n      - description: Document ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      responses:\n        \"204\":\n          description: No Content\n        \"400\":\n          description: Bad Request\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"500\":\n          description: Internal Server Error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: Delete document\n      tags:\n      - Documents\n  /example_documents/upload:\n    post:\n      consumes:\n      - multipart/form-data\n      description: Uploads a PDF document, extracts text, and creates embeddings\n      parameters:\n      - description: PDF file to upload\n        in: formData\n        name: file\n        required: true\n        type: file\n      - description: Document title\n        in: formData\n        name: title\n        required: true\n        type: string\n      produces:\n      - application/json\n      responses:\n        \"201\":\n          description: Created\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_internal_modules_documents_domain.Document'\n        \"400\":\n          description: Bad Request\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n        \"500\":\n          description: Internal Server Error\n          schema:\n            $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_httperr.HTTPError'\n      summary: Upload PDF document\n      tags:\n      - Documents\n  /rbac/check-permission:\n    post:\n      consumes:\n      - application/json\n      description: Verifies whether a role has been granted a specific permission.\n        Useful for conditional UI rendering.\n      parameters:\n      - description: Role and permission to check\n        in: body\n        name: body\n        required: true\n        schema:\n          $ref: '#/definitions/internal_modules_auth.PermissionCheckRequest'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Permission check result\n          schema:\n            $ref: '#/definitions/internal_modules_auth.PermissionCheckResponse'\n        \"400\":\n          description: Invalid request\n          schema:\n            additionalProperties:\n              type: string\n            type: object\n      summary: Check if a role has a specific permission\n      tags:\n      - RBAC\n  /rbac/metadata:\n    get:\n      description: Returns summary information about the RBAC system including total\n        roles, permissions, and categories.\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: RBAC system metadata\n          schema:\n            $ref: '#/definitions/internal_modules_auth.RBACMetadata'\n      summary: Get RBAC system metadata\n      tags:\n      - RBAC\n  /rbac/permissions:\n    get:\n      description: Returns all available permissions in the system. Each permission\n        includes resource, action, display name, and description for frontend rendering.\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: All permissions\n          schema:\n            $ref: '#/definitions/internal_modules_auth.PermissionsResponse'\n        \"500\":\n          description: Internal error\n          schema:\n            additionalProperties:\n              type: string\n            type: object\n      summary: Get all permissions\n      tags:\n      - RBAC\n  /rbac/permissions/by-category:\n    get:\n      description: Returns all permissions organized by their category for better\n        UI organization.\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Permissions by category\n          schema:\n            $ref: '#/definitions/internal_modules_auth.PermissionsByCategoryResponse'\n        \"500\":\n          description: Internal error\n          schema:\n            additionalProperties:\n              type: string\n            type: object\n      summary: Get permissions grouped by category\n      tags:\n      - RBAC\n  /rbac/roles:\n    get:\n      description: Returns all available roles in the system with their associated\n        permissions. This is the single source of truth for frontend role/permission\n        discovery.\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Roles with permissions\n          schema:\n            $ref: '#/definitions/internal_modules_auth.RolesResponse'\n        \"500\":\n          description: Internal error\n          schema:\n            additionalProperties:\n              type: string\n            type: object\n      summary: Get all roles with permissions\n      tags:\n      - RBAC\n  /rbac/roles/{role_id}:\n    get:\n      description: Returns comprehensive information about a role including permissions,\n        statistics, and restrictions.\n      parameters:\n      - description: Role ID (member, approver, admin)\n        in: path\n        name: role_id\n        required: true\n        type: string\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Role details with statistics\n          schema:\n            $ref: '#/definitions/internal_modules_auth.RolePermissionsResponse'\n        \"400\":\n          description: Invalid role ID\n          schema:\n            additionalProperties:\n              type: string\n            type: object\n        \"404\":\n          description: Role not found\n          schema:\n            additionalProperties:\n              type: string\n            type: object\n      summary: Get detailed information about a specific role\n      tags:\n      - RBAC\nsecurityDefinitions:\n  BasicAuth:\n    type: basic\nswagger: \"2.0\"\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/README.md",
    "content": "# Auth Package\n\nProvider-agnostic authentication and authorization with type-safe middleware. Supports JWT verification, RBAC permissions, and multi-tenant organization context.\n\n## Quick Start\n\n### Setup Middleware\n\n```go\n// In server initialization\nauthMiddleware := auth.NewMiddleware(authProvider, orgResolver, accResolver, nil)\n\n// Apply to routes\nrouter.Use(authMiddleware.RequireAuth())          // Verify JWT token\nrouter.Use(authMiddleware.RequireOrganization())  // Resolve org/account IDs\n```\n\n### Protect Routes\n\n```go\nrouter.GET(\"/invoices\",\n    auth.RequirePermissionFunc(\"invoice\", \"view\"),\n    handler.ListInvoices)\n\nrouter.POST(\"/invoices\",\n    auth.RequirePermissionFunc(\"invoice\", \"create\"),\n    handler.CreateInvoice)\n```\n\n### Get Context in Handlers\n\n```go\nfunc (h *Handler) MyHandler(c *gin.Context) {\n    reqCtx := auth.GetRequestContext(c)\n\n    orgID := reqCtx.OrganizationID      // int32 database ID\n    accountID := reqCtx.AccountID       // int32 database ID\n    email := reqCtx.Identity.Email      // User's email\n}\n```\n\n## Core Concepts\n\n- **Identity**: User info from auth provider (email, roles, permissions)\n- **RequestContext**: Resolved database IDs (OrganizationID, AccountID)\n- **Permissions**: Format `\"resource:action\"` (e.g., `\"invoice:create\"`, `\"org:manage\"`)\n\n## Common Patterns\n\n### Pattern 1: Public Route\n\nNo authentication required:\n\n```go\nrouter.POST(\"/auth/signup\", handler.Signup)\nrouter.GET(\"/auth/check-email\", handler.CheckEmail)\n```\n\n### Pattern 2: Authenticated Route\n\nAny logged-in user can access:\n\n```go\nrouter.GET(\"/profile/me\",\n    authMiddleware.RequireAuth(),\n    handler.GetProfile)\n```\n\n### Pattern 3: Organization Route\n\nRequires organization context (most common pattern):\n\n```go\norgGroup := router.Group(\"/organizations\")\norgGroup.Use(\n    authMiddleware.RequireAuth(),\n    authMiddleware.RequireOrganization(),\n)\n{\n    orgGroup.GET(\"\", handler.GetOrganization)\n    orgGroup.PUT(\"\", handler.UpdateOrganization)\n    orgGroup.GET(\"/stats\", handler.GetOrganizationStats)\n}\n```\n\n### Pattern 4: Permission-Protected Route\n\nRequires specific permission:\n\n```go\nrouter.POST(\"/invoices\",\n    authMiddleware.RequireAuth(),\n    authMiddleware.RequireOrganization(),\n    auth.RequirePermissionFunc(\"invoice\", \"create\"),\n    handler.CreateInvoice)\n\nrouter.DELETE(\"/invoices/:id\",\n    authMiddleware.RequireAuth(),\n    authMiddleware.RequireOrganization(),\n    auth.RequirePermissionFunc(\"invoice\", \"delete\"),\n    handler.DeleteInvoice)\n```\n\n### Pattern 5: Role-Protected Route\n\nRequires specific role:\n\n```go\nrouter.DELETE(\"/organizations/:id\",\n    authMiddleware.RequireAuth(),\n    authMiddleware.RequireRole(auth.RoleAdmin),\n    handler.DeleteOrganization)\n```\n\n## Stytch Project Setup\n\n### Create Stytch Account & Project\n\n1. Go to [https://stytch.com](https://stytch.com) and sign up\n2. Create a new **B2B project**\n3. Choose **\"Test\"** environment for development\n\n### Get Your Credentials\n\nFrom your Stytch project dashboard:\n\n```env\nSTYTCH_PROJECT_ID=project-test-xxx-xxx    # Project Settings → Project ID\nSTYTCH_SECRET=secret-test-xxx             # API Keys → Secret\nSTYTCH_ENV=test                           # \"test\" or \"live\"\n```\n\n### Configure RBAC Policies\n\nGo to **Dashboard → RBAC → Policies** and create these resources:\n\n| Resource | Actions | Description |\n|----------|---------|-------------|\n| `resource` | `view`, `create`, `edit`, `delete`, `approve` | Your domain entity (rename to your business) |\n| `org` | `view`, `manage` | Organization settings |\n\n> **Tip**: Rename \"resource\" to your domain entity (e.g., `invoice`, `patient`, `project`).\n\n### Set Up Roles\n\nGo to **Dashboard → RBAC → Roles**:\n\n- **stytch_admin** (built-in): Auto-assigned to organization creator, has all permissions\n- **stytch_member** (built-in): Default role for all members\n- **manager** (custom): Create for elevated access\n\n| Role | Permissions |\n|------|-------------|\n| member | `resource:view`, `resource:create` |\n| manager | All resource permissions + `org:view` |\n| admin | All permissions |\n\nAssign permissions to roles based on your business needs.\n\n### Test Your Setup\n\n```bash\n# Add credentials to app.env\nSTYTCH_PROJECT_ID=project-test-xxx-xxx\nSTYTCH_SECRET=secret-test-xxx\nSTYTCH_ENV=test\nSTYTCH_SESSION_DURATION_MINUTES=1440  # Optional: 24 hours\n\n# Run the application\nmake server\n\n# First user to sign up becomes stytch_admin\n# Subsequent users get stytch_member role\n```\n\n## Configuration\n\nAfter completing setup above, your `app.env` should have:\n\n```env\nSTYTCH_PROJECT_ID=project-test-xxx-xxx    # Required\nSTYTCH_SECRET=secret-test-xxx             # Required\nSTYTCH_ENV=test                           # Optional: \"test\" or \"live\"\nSTYTCH_SESSION_DURATION_MINUTES=1440      # Optional: 24 hours (default)\nSTYTCH_API_TIMEOUT=15s                   # Optional: 15 seconds (default)\n```\n\n## Adding New Permissions\n\n**Step 1:** Define in `rbac.go`\n\n```go\n// In the permissions section of rbac.go\nvar (\n    // Add your new permissions\n    PermReportView   = NewPermission(\"report\", \"view\")\n    PermReportExport = NewPermission(\"report\", \"export\")\n)\n\n// Don't forget to add to AllPermissions\nvar AllPermissions = []Permission{\n    // ... existing permissions\n    PermReportView,\n    PermReportExport,\n}\n```\n\n**Step 2:** Use in routes\n\n```go\nrouter.GET(\"/reports\",\n    auth.RequirePermissionFunc(\"report\", \"view\"),\n    handler.ListReports)\n\nrouter.POST(\"/reports/export\",\n    auth.RequirePermissionFunc(\"report\", \"export\"),\n    handler.ExportReport)\n```\n\n**Step 3:** Configure in Stytch Dashboard\n- Go to Stytch Dashboard → RBAC → Policies\n- Add resource: `report`\n- Add actions: `view`, `export`\n- Assign to roles\n\n## Common Handler Patterns\n\n### Check Permission\n\n```go\nidentity := auth.GetIdentity(c)\nif identity.HasResourcePermission(\"invoice\", \"delete\") {\n    // Show delete button\n}\n```\n\n### Check Role\n\n```go\nidentity := auth.GetIdentity(c)\nif identity.HasRole(auth.RoleAdmin) {\n    // Show admin panel\n}\n```\n\n### Get Organization ID\n\n```go\n// Safe: returns 0 if not set\norgID := auth.GetOrganizationID(c)\n\n// Panics if not set (use only after RequireOrganization)\nreqCtx := auth.MustGetRequestContext(c)\n```\n\n### Get Account ID\n\n```go\n// Safe: returns 0 if not set\naccountID := auth.GetAccountID(c)\n```\n\n### Get Full Context\n\n```go\nreqCtx := auth.GetRequestContext(c)\nif reqCtx == nil {\n    // Handle missing context\n    return\n}\n\n// Access all fields\norgID := reqCtx.OrganizationID\naccountID := reqCtx.AccountID\nemail := reqCtx.Identity.Email\nroles := reqCtx.Identity.Roles\npermissions := reqCtx.Identity.Permissions\n```\n\n## Multiple Permission Checks\n\n### Require Any Permission\n\nAt least one permission required:\n\n```go\nrouter.GET(\"/reports\",\n    auth.RequireAnyPermissionFunc(\n        auth.PermReportView,\n        auth.PermReportExport,\n    ),\n    handler.GetReports)\n```\n\n### Require All Permissions\n\nAll permissions required:\n\n```go\nrouter.POST(\"/admin/dangerous\",\n    authMiddleware.RequireAllPermissions(\n        auth.NewPermission(\"admin\", \"access\"),\n        auth.NewPermission(\"admin\", \"write\"),\n    ),\n    handler.DangerousOperation)\n```\n\n## Troubleshooting\n\n**\"authentication required\"**\n- Check `Authorization: Bearer <token>` header format\n- Verify token not expired\n- Check STYTCH_PROJECT_ID matches token issuer\n\n**\"organization not found\"**\n- Organization must exist in database before authentication\n- Check provider org ID is mapped to database ID\n\n**\"insufficient permissions\"**\n- Verify user has required permission in Stytch RBAC dashboard\n- Check permission format: `\"resource:action\"` (not `resource.action`)\n- Check role-based permissions in `roles.go`\n\n## Predefined Permissions\n\nGeneric permissions available as constants (rename \"resource\" to your domain):\n\n```go\n// Resource (rename to your domain: invoice, patient, project, etc.)\nauth.PermResourceView     // \"resource:view\"\nauth.PermResourceCreate   // \"resource:create\"\nauth.PermResourceEdit     // \"resource:edit\"\nauth.PermResourceDelete   // \"resource:delete\"\nauth.PermResourceApprove  // \"resource:approve\"\n\n// Organization\nauth.PermOrgView          // \"org:view\"\nauth.PermOrgManage        // \"org:manage\"\n\n// See rbac.go for complete list and customization instructions\n```\n\n## Custom Middleware Example\n\nCreate custom auth middleware for specific needs:\n\n```go\nfunc RequireAdminOrOwner() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        identity := auth.GetIdentity(c)\n        if identity == nil {\n            c.JSON(http.StatusUnauthorized, gin.H{\"error\": \"unauthorized\"})\n            c.Abort()\n            return\n        }\n\n        if !identity.HasRole(auth.RoleAdmin) && !identity.HasRole(auth.RoleOwner) {\n            c.JSON(http.StatusForbidden, gin.H{\"error\": \"admin or owner required\"})\n            c.Abort()\n            return\n        }\n\n        c.Next()\n    }\n}\n\n// Usage\nrouter.DELETE(\"/organizations/:id\", RequireAdminOrOwner(), handler.Delete)\n```\n\n## Learn More\n\n- **RBAC Definitions**: See `rbac.go` for all roles, permissions, and customization instructions\n- **Stytch Setup**: See `STYTCH_SETUP.md` for dashboard configuration\n- **API reference**: Run `go doc github.com/moasq/go-b2b-starter/pkg/auth`\n- **Examples**: See `src/api/organizations/routes.go` for real-world usage\n- **Stytch B2B RBAC**: https://stytch.com/docs/b2b/guides/rbac/overview\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/adapter.go",
    "content": "// Package stytch provides Stytch B2B authentication integration.\n//\n// This package implements the auth.AuthProvider interface using Stytch\n// as the identity provider. It handles JWT verification, JWKS caching,\n// and RBAC policy management.\n//\n// # Architecture\n//\n// The adapter uses a two-tier verification strategy:\n//  1. Fast Path: Local JWT verification using cached JWKS (Redis)\n//  2. Slow Path: Stytch API verification (fallback)\n//\n// This optimization saves 300-500ms per request for the common case.\n//\n// # Components\n//\n//   - StytchAuthAdapter: Main entry point implementing auth.AuthProvider\n//   - TokenVerifier: JWT verification with local/API fallback\n//   - JWKSCache: Public key caching in Redis\n//   - RBACPolicyService: Role permission resolution\n//\n// # Usage\n//\n//\tcfg, err := stytch.LoadConfig()\n//\tif err != nil {\n//\t    log.Fatal(err)\n//\t}\n//\n//\tadapter, err := stytch.NewStytchAuthAdapter(cfg, redisClient, logger)\n//\tif err != nil {\n//\t    log.Fatal(err)\n//\t}\n//\n//\t// Use as auth.AuthProvider\n//\tidentity, err := adapter.VerifyToken(ctx, token)\npackage stytch\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi\"\n)\n\n// StytchAuthAdapter implements auth.AuthProvider using Stytch B2B.\n//\n// It provides authentication and authorization using Stytch's\n// session management and RBAC capabilities.\ntype StytchAuthAdapter struct {\n\tclient        *b2bstytchapi.API\n\ttokenVerifier *TokenVerifier\n\tpolicyService *RBACPolicyService\n\tcfg           *Config\n\tlogger        logger.Logger\n}\n\n// Ensure StytchAuthAdapter implements auth.AuthProvider.\nvar _ auth.AuthProvider = (*StytchAuthAdapter)(nil)\n\n// It initializes the Stytch client, JWKS cache, and RBAC policy service.\n// Returns an error if configuration or client initialization fails.\nfunc NewStytchAuthAdapter(\n\tcfg *Config,\n\tredisClient redis.Client,\n\tlog logger.Logger,\n) (*StytchAuthAdapter, error) {\n\t// Validate configuration\n\tif err := cfg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid stytch config: %w\", err)\n\t}\n\n\t// Create Stytch API client\n\tclient, err := b2bstytchapi.NewClient(cfg.ProjectID, cfg.Secret)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stytch client: %w\", err)\n\t}\n\n\t// Create JWKS cache for local JWT verification\n\tjwksCache := NewJWKSCache(cfg.JWKSURL, redisClient, log)\n\n\t// Create RBAC policy service for permission resolution\n\tpolicyService := NewRBACPolicyService(client, redisClient, log)\n\n\t// Create token verifier with two-tier strategy\n\ttokenVerifier := NewTokenVerifier(client, jwksCache, policyService, cfg, log)\n\n\treturn &StytchAuthAdapter{\n\t\tclient:        client,\n\t\ttokenVerifier: tokenVerifier,\n\t\tpolicyService: policyService,\n\t\tcfg:           cfg,\n\t\tlogger:        log,\n\t}, nil\n}\n\n// NewStytchAuthAdapterWithClient creates an adapter with an existing Stytch client.\n//\n// This is useful for testing or when you want to reuse an existing client.\nfunc NewStytchAuthAdapterWithClient(\n\tclient *b2bstytchapi.API,\n\tcfg *Config,\n\tredisClient redis.Client,\n\tlog logger.Logger,\n) *StytchAuthAdapter {\n\tjwksCache := NewJWKSCache(cfg.JWKSURL, redisClient, log)\n\tpolicyService := NewRBACPolicyService(client, redisClient, log)\n\ttokenVerifier := NewTokenVerifier(client, jwksCache, policyService, cfg, log)\n\n\treturn &StytchAuthAdapter{\n\t\tclient:        client,\n\t\ttokenVerifier: tokenVerifier,\n\t\tpolicyService: policyService,\n\t\tcfg:           cfg,\n\t\tlogger:        log,\n\t}\n}\n\n// VerifyToken validates the supplied session JWT and returns an Identity.\n//\n// This implements auth.AuthProvider.VerifyToken.\n//\n// The verification uses a two-tier strategy:\n//  1. Fast Path: Local JWT verification using cached JWKS\n//  2. Slow Path: Stytch API verification (fallback)\n//\n// Returns auth.ErrInvalidToken if the token is invalid.\n// Returns auth.ErrTokenExpired if the token has expired.\n// Returns auth.ErrEmailNotVerified if email is not verified.\nfunc (a *StytchAuthAdapter) VerifyToken(ctx context.Context, token string) (*auth.Identity, error) {\n\tif token == \"\" {\n\t\treturn nil, auth.ErrInvalidToken\n\t}\n\n\tidentity, err := a.tokenVerifier.Verify(ctx, token)\n\tif err != nil {\n\t\ta.logger.Debug(\"token verification failed\", logger.Fields{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn nil, err\n\t}\n\n\ta.logger.Debug(\"token verified successfully\", logger.Fields{\n\t\t\"user_id\":         identity.UserID,\n\t\t\"email\":           identity.Email,\n\t\t\"organization_id\": identity.OrganizationID,\n\t\t\"roles_count\":     len(identity.Roles),\n\t\t\"permissions_count\": len(identity.Permissions),\n\t})\n\n\treturn identity, nil\n}\n\n// Client returns the underlying Stytch API client.\n//\n// This is useful for advanced operations not covered by auth.AuthProvider,\n// such as member management, organization settings, etc.\nfunc (a *StytchAuthAdapter) Client() *b2bstytchapi.API {\n\treturn a.client\n}\n\n// Config returns the Stytch configuration.\nfunc (a *StytchAuthAdapter) Config() *Config {\n\treturn a.cfg\n}\n\n// PolicyService returns the RBAC policy service.\n//\n// This is useful for permission queries outside the normal auth flow.\nfunc (a *StytchAuthAdapter) PolicyService() *RBACPolicyService {\n\treturn a.policyService\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/config.go",
    "content": "package stytch\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/viper\"\n)\n\n// Environment constants supported by the Stytch B2B API.\nconst (\n\tEnvTest = \"test\"\n\tEnvLive = \"live\"\n)\n\n// Config captures the runtime configuration for Stytch authentication.\n//\n// All configuration values can be set via environment variables with the\n// STYTCH_ prefix (e.g., STYTCH_PROJECT_ID, STYTCH_SECRET).\ntype Config struct {\n\t// ProjectID is the Stytch project identifier (required)\n\tProjectID string `mapstructure:\"STYTCH_PROJECT_ID\"`\n\n\t// Secret is the Stytch API secret (required)\n\tSecret string `mapstructure:\"STYTCH_SECRET\"`\n\n\t// Env is the Stytch environment: \"test\" or \"live\"\n\tEnv string `mapstructure:\"STYTCH_ENV\"`\n\n\t// BaseURL is the Stytch API base URL (derived from Env if not set)\n\tBaseURL string `mapstructure:\"STYTCH_BASE_URL\"`\n\n\t// CustomDomain is an optional custom domain for Stytch\n\tCustomDomain string `mapstructure:\"STYTCH_CUSTOM_DOMAIN\"`\n\n\t// JWKSURL is the JWKS endpoint URL (derived from BaseURL if not set)\n\tJWKSURL string `mapstructure:\"STYTCH_JWKS_URL\"`\n\n\t// SessionDurationMinutes is how long sessions should last\n\tSessionDurationMinutes int32 `mapstructure:\"STYTCH_SESSION_DURATION_MINUTES\"`\n\n\t// DisableSessionVerification disables JWT signature verification (testing only!)\n\tDisableSessionVerification bool `mapstructure:\"STYTCH_DISABLE_SESSION_VERIFICATION\"`\n\n\t// OwnerRoleSlug is the role slug for organization owners\n\tOwnerRoleSlug string `mapstructure:\"STYTCH_OWNER_ROLE_SLUG\"`\n\n\t// InviteRedirectURL is where to redirect after invitation acceptance\n\tInviteRedirectURL string `mapstructure:\"STYTCH_INVITE_REDIRECT_URL\"`\n\n\t// LoginRedirectURL is where to redirect after login\n\tLoginRedirectURL string `mapstructure:\"STYTCH_LOGIN_REDIRECT_URL\"`\n\n\t// APITimeout is the timeout for Stytch API calls\n\tAPITimeout time.Duration `mapstructure:\"STYTCH_API_TIMEOUT\"`\n}\n\n// LoadConfig loads the Stytch configuration from environment variables and app.env file.\n//\n// Configuration priority:\n//  1. Environment variables (highest)\n//  2. app.env file\n//  3. Default values (lowest)\nfunc LoadConfig() (*Config, error) {\n\tv := viper.New()\n\tv.SetConfigName(\"app\")\n\tv.SetConfigType(\"env\")\n\tv.AddConfigPath(\".\")\n\tv.AutomaticEnv()\n\n\t// Set defaults\n\tv.SetDefault(\"STYTCH_ENV\", EnvTest)\n\tv.SetDefault(\"STYTCH_SESSION_DURATION_MINUTES\", 1440) // 24 hours\n\tv.SetDefault(\"STYTCH_API_TIMEOUT\", \"15s\")\n\tv.SetDefault(\"STYTCH_DISABLE_SESSION_VERIFICATION\", false)\n\n\t// Try to read config file (ignore if not found)\n\tif err := v.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); !ok {\n\t\t\treturn nil, fmt.Errorf(\"failed to read config: %w\", err)\n\t\t}\n\t}\n\n\tvar cfg Config\n\tif err := v.Unmarshal(&cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to decode stytch config: %w\", err)\n\t}\n\n\t// Normalize environment\n\tcfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env))\n\tif cfg.Env == \"\" {\n\t\tcfg.Env = EnvTest\n\t}\n\n\t// Validate required fields\n\tif cfg.ProjectID == \"\" {\n\t\treturn nil, fmt.Errorf(\"stytch configuration invalid: STYTCH_PROJECT_ID is required\")\n\t}\n\tif cfg.Secret == \"\" {\n\t\treturn nil, fmt.Errorf(\"stytch configuration invalid: STYTCH_SECRET is required\")\n\t}\n\n\t// Normalize timeout\n\tif cfg.APITimeout <= 0 {\n\t\tcfg.APITimeout = 15 * time.Second\n\t}\n\n\t// Derive base URL if not set\n\tif cfg.BaseURL == \"\" {\n\t\tswitch cfg.Env {\n\t\tcase EnvLive:\n\t\t\tcfg.BaseURL = \"https://api.stytch.com\"\n\t\tdefault:\n\t\t\tcfg.BaseURL = \"https://test.stytch.com\"\n\t\t}\n\t}\n\n\t// Custom domain overrides base URL\n\tif cfg.CustomDomain != \"\" {\n\t\tcfg.BaseURL = fmt.Sprintf(\"https://%s\", strings.TrimSuffix(cfg.CustomDomain, \"/\"))\n\t}\n\n\t// Derive JWKS URL if not set\n\tif cfg.JWKSURL == \"\" {\n\t\tif cfg.CustomDomain != \"\" {\n\t\t\tcfg.JWKSURL = fmt.Sprintf(\"https://%s/.well-known/jwks.json\", strings.TrimSuffix(cfg.CustomDomain, \"/\"))\n\t\t} else {\n\t\t\tcfg.JWKSURL = fmt.Sprintf(\"%s/v1/b2b/sessions/jwks/%s\", strings.TrimSuffix(cfg.BaseURL, \"/\"), cfg.ProjectID)\n\t\t}\n\t}\n\n\treturn &cfg, nil\n}\n\n// Validate checks that the configuration has all required fields.\nfunc (c *Config) Validate() error {\n\tif c.ProjectID == \"\" {\n\t\treturn fmt.Errorf(\"stytch configuration invalid: ProjectID is required\")\n\t}\n\tif c.Secret == \"\" {\n\t\treturn fmt.Errorf(\"stytch configuration invalid: Secret is required\")\n\t}\n\treturn nil\n}\n\n// This allows gradual migration from the old config type.\nfunc NewConfigFromExisting(projectID, secret, env, baseURL, jwksURL string, sessionDurationMinutes int32, disableVerification bool, apiTimeout time.Duration) *Config {\n\treturn &Config{\n\t\tProjectID:                  projectID,\n\t\tSecret:                     secret,\n\t\tEnv:                        env,\n\t\tBaseURL:                    baseURL,\n\t\tJWKSURL:                    jwksURL,\n\t\tSessionDurationMinutes:     sessionDurationMinutes,\n\t\tDisableSessionVerification: disableVerification,\n\t\tAPITimeout:                 apiTimeout,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/jwks_cache.go",
    "content": "package stytch\n\nimport (\n\t\"context\"\n\t\"crypto/rsa\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n)\n\nconst (\n\t// Redis cache keys for JWKS\n\tjwksCacheKeyPattern = \"auth:stytch:jwks:key:%s\" // Individual public key by kid\n\tjwksCacheTTL        = 24 * time.Hour            // 24-hour cache\n)\n\n// JWKSCache manages caching of JSON Web Key Sets from Stytch.\n//\n// It fetches JWKS from Stytch's endpoint and caches public keys in Redis.\n// This enables local JWT verification without making Stytch API calls\n// on every request (saving 300-500ms per request).\ntype JWKSCache struct {\n\tjwksURL    string\n\tredis      redis.Client\n\tlogger     logger.Logger\n\thttpClient *http.Client\n}\n\n// JWKS represents the JSON Web Key Set structure from Stytch.\ntype JWKS struct {\n\tKeys []JWK `json:\"keys\"`\n}\n\n// JWK represents a single JSON Web Key.\ntype JWK struct {\n\tKid string `json:\"kid\"` // Key ID\n\tKty string `json:\"kty\"` // Key type (RSA)\n\tN   string `json:\"n\"`   // Modulus (base64url encoded)\n\tE   string `json:\"e\"`   // Exponent (base64url encoded)\n\tAlg string `json:\"alg\"` // Algorithm (RS256)\n\tUse string `json:\"use\"` // Public key use (sig)\n}\n\n// serializedPublicKey represents RSA public key components for Redis storage.\ntype serializedPublicKey struct {\n\tN string `json:\"n\"` // Modulus (base64url encoded)\n\tE string `json:\"e\"` // Exponent (base64url encoded)\n}\n\nfunc NewJWKSCache(jwksURL string, redisClient redis.Client, logger logger.Logger) *JWKSCache {\n\treturn &JWKSCache{\n\t\tjwksURL: jwksURL,\n\t\tredis:   redisClient,\n\t\tlogger:  logger,\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\n// GetPublicKey retrieves a public key by kid from cache or fetches from Stytch.\nfunc (c *JWKSCache) GetPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {\n\t// Try to get from Redis cache first\n\tcacheKey := fmt.Sprintf(jwksCacheKeyPattern, kid)\n\tcached, err := c.redis.Get(ctx, cacheKey)\n\tif err == nil && cached != \"\" {\n\t\tvar serialized serializedPublicKey\n\t\tif err := json.Unmarshal([]byte(cached), &serialized); err == nil {\n\t\t\tkey, err := c.deserializePublicKey(&serialized)\n\t\t\tif err == nil {\n\t\t\t\tc.logger.Debug(\"public key fetched from Redis cache\", logger.Fields{\n\t\t\t\t\t\"kid\": kid,\n\t\t\t\t})\n\t\t\t\treturn key, nil\n\t\t\t}\n\t\t\tc.logger.Warn(\"failed to deserialize cached public key\", logger.Fields{\n\t\t\t\t\"kid\":   kid,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\t// Cache miss - fetch JWKS from Stytch\n\tc.logger.Info(\"fetching JWKS from Stytch\", logger.Fields{\n\t\t\"jwks_url\": c.jwksURL,\n\t\t\"kid\":      kid,\n\t})\n\n\tjwks, err := c.fetchJWKS(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch JWKS: %w\", err)\n\t}\n\n\t// Find the key with matching kid\n\tfor _, jwk := range jwks.Keys {\n\t\tif jwk.Kid == kid {\n\t\t\tpublicKey, err := c.jwkToPublicKey(&jwk)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to convert JWK to public key: %w\", err)\n\t\t\t}\n\n\t\t\t// Cache the key\n\t\t\tc.cachePublicKey(ctx, kid, &jwk)\n\n\t\t\tc.logger.Info(\"public key fetched and cached\", logger.Fields{\n\t\t\t\t\"kid\": kid,\n\t\t\t})\n\n\t\t\treturn publicKey, nil\n\t\t}\n\t}\n\n\t// Log available keys for debugging\n\tavailableKids := make([]string, 0, len(jwks.Keys))\n\tfor _, jwk := range jwks.Keys {\n\t\tavailableKids = append(availableKids, jwk.Kid)\n\t}\n\tc.logger.Error(\"key not found in JWKS\", logger.Fields{\n\t\t\"kid\":            kid,\n\t\t\"available_kids\": availableKids,\n\t})\n\n\treturn nil, fmt.Errorf(\"key with ID %s not found in JWKS\", kid)\n}\n\n// fetchJWKS fetches the JWKS from Stytch's endpoint.\nfunc (c *JWKSCache) fetchJWKS(ctx context.Context) (*JWKS, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", c.jwksURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create JWKS request: %w\", err)\n\t}\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"JWKS HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"JWKS endpoint returned status %d\", resp.StatusCode)\n\t}\n\n\tvar jwks JWKS\n\tif err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode JWKS JSON: %w\", err)\n\t}\n\n\tc.logger.Debug(\"successfully fetched JWKS\", logger.Fields{\n\t\t\"keys_count\": len(jwks.Keys),\n\t})\n\n\treturn &jwks, nil\n}\n\n// jwkToPublicKey converts a JWK to an RSA public key.\nfunc (c *JWKSCache) jwkToPublicKey(jwk *JWK) (*rsa.PublicKey, error) {\n\t// Decode modulus (n)\n\tnBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode modulus: %w\", err)\n\t}\n\n\t// Decode exponent (e)\n\teBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode exponent: %w\", err)\n\t}\n\n\t// Convert to RSA public key\n\tn := new(big.Int).SetBytes(nBytes)\n\n\t// Convert exponent bytes to int\n\tvar e int\n\tfor i := 0; i < len(eBytes); i++ {\n\t\te = e<<8 + int(eBytes[i])\n\t}\n\n\treturn &rsa.PublicKey{N: n, E: e}, nil\n}\n\n// cachePublicKey stores a public key in Redis.\nfunc (c *JWKSCache) cachePublicKey(ctx context.Context, kid string, jwk *JWK) {\n\tserialized := &serializedPublicKey{N: jwk.N, E: jwk.E}\n\n\tdata, err := json.Marshal(serialized)\n\tif err != nil {\n\t\tc.logger.Warn(\"failed to marshal public key for caching\", logger.Fields{\n\t\t\t\"kid\":   kid,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tcacheKey := fmt.Sprintf(jwksCacheKeyPattern, kid)\n\tif err := c.redis.Set(ctx, cacheKey, string(data), jwksCacheTTL); err != nil {\n\t\tc.logger.Warn(\"failed to cache public key in Redis\", logger.Fields{\n\t\t\t\"kid\":   kid,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t}\n}\n\n// deserializePublicKey converts serialized key components back to RSA public key.\nfunc (c *JWKSCache) deserializePublicKey(serialized *serializedPublicKey) (*rsa.PublicKey, error) {\n\tnBytes, err := base64.RawURLEncoding.DecodeString(serialized.N)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode cached modulus: %w\", err)\n\t}\n\n\teBytes, err := base64.RawURLEncoding.DecodeString(serialized.E)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode cached exponent: %w\", err)\n\t}\n\n\tn := new(big.Int).SetBytes(nBytes)\n\tvar e int\n\tfor i := 0; i < len(eBytes); i++ {\n\t\te = e<<8 + int(eBytes[i])\n\t}\n\n\treturn &rsa.PublicKey{N: n, E: e}, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/jwt_parser.go",
    "content": "package stytch\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// JWTParser provides JWT token parsing utilities.\n//\n// This parser can decode JWT tokens without verifying the signature,\n// which is useful for extracting the key ID (kid) from the header\n// before signature verification.\ntype JWTParser struct{}\n\nfunc NewJWTParser() *JWTParser {\n\treturn &JWTParser{}\n}\n\n// ParseWithoutVerification decodes a JWT token without verifying the signature.\n//\n// This extracts the header and claims from the token for inspection.\n// The signature is NOT verified - use this only for extracting metadata\n// like the key ID (kid) before performing proper verification.\n//\n// Returns:\n//   - header: JWT header containing algorithm (alg), key ID (kid), etc.\n//   - claims: JWT claims containing user information\n//   - err: Error if the token format is invalid\nfunc (p *JWTParser) ParseWithoutVerification(token string) (header map[string]any, claims map[string]any, err error) {\n\t// JWT format: header.payload.signature\n\tparts := strings.Split(token, \".\")\n\tif len(parts) != 3 {\n\t\treturn nil, nil, fmt.Errorf(\"invalid JWT format: expected 3 parts, got %d\", len(parts))\n\t}\n\n\t// Decode header (first part)\n\theaderBytes, err := base64.RawURLEncoding.DecodeString(parts[0])\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to decode JWT header: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(headerBytes, &header); err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to parse JWT header JSON: %w\", err)\n\t}\n\n\t// Decode payload (second part)\n\tpayloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to decode JWT payload: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(payloadBytes, &claims); err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to parse JWT claims JSON: %w\", err)\n\t}\n\n\treturn header, claims, nil\n}\n\n// ExtractKeyID extracts the key ID (kid) from a JWT token header.\n//\n// This is a convenience method for getting the kid without needing\n// to handle the full header map.\nfunc (p *JWTParser) ExtractKeyID(token string) (string, error) {\n\theader, _, err := p.ParseWithoutVerification(token)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tkid, ok := header[\"kid\"].(string)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"kid not found in JWT header\")\n\t}\n\n\treturn kid, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/mock_adapter.go",
    "content": "package stytch\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\n// MockAuthAdapter is a development-only auth adapter that bypasses Stytch.\n//\n// WARNING: This should NEVER be used in production. It accepts any token\n// and returns a mock identity. It's only for local development when\n// Stytch credentials are not configured.\ntype MockAuthAdapter struct {\n\tlogger logger.Logger\n}\n\n// Ensure MockAuthAdapter implements auth.AuthProvider.\nvar _ auth.AuthProvider = (*MockAuthAdapter)(nil)\n\nfunc NewMockAuthAdapter(log logger.Logger) *MockAuthAdapter {\n\treturn &MockAuthAdapter{\n\t\tlogger: log,\n\t}\n}\n\n// VerifyToken accepts any token and returns a mock identity.\n// This is for development only and should never be used in production.\nfunc (m *MockAuthAdapter) VerifyToken(ctx context.Context, token string) (*auth.Identity, error) {\n\tm.logger.Warn(\"Using mock auth adapter - accepting any token\", map[string]any{\n\t\t\"warning\": \"This is for development only. Configure real Stytch credentials for production.\",\n\t})\n\n\t// Return a mock identity for development\n\treturn &auth.Identity{\n\t\tUserID:         \"mock-user-123\",\n\t\tEmail:          \"dev@example.com\",\n\t\tEmailVerified:  true,\n\t\tOrganizationID: \"mock-org-stytch-id\",\n\t\tRoles: []auth.Role{\n\t\t\tauth.RoleOwner,\n\t\t\tauth.RoleAdmin,\n\t\t},\n\t\tPermissions: []auth.Permission{\n\t\t\tauth.NewPermission(\"*\", \"*\"), // Wildcard permission for development\n\t\t},\n\t\tExpiresAt: time.Now().Add(24 * time.Hour),\n\t\tRaw: map[string]any{\n\t\t\t\"mock\":       true,\n\t\t\t\"session_id\": \"mock-session-123\",\n\t\t\t\"member_id\":  \"mock-member-123\",\n\t\t},\n\t}, nil\n}\n\n// GetRolePermissions returns empty permissions for the mock adapter.\nfunc (m *MockAuthAdapter) GetRolePermissions(ctx context.Context, roleID string) ([]auth.Permission, error) {\n\tm.logger.Debug(\"Mock adapter returning all permissions for role\", map[string]any{\n\t\t\"role_id\": roleID,\n\t})\n\n\t// Return wildcard permission for development\n\treturn []auth.Permission{\n\t\tauth.NewPermission(\"*\", \"*\"),\n\t}, nil\n}\n\n// ValidatePermission always returns true in mock mode.\nfunc (m *MockAuthAdapter) ValidatePermission(ctx context.Context, identity *auth.Identity, resource, action string) error {\n\tm.logger.Debug(\"Mock adapter allowing all permissions\", map[string]any{\n\t\t\"resource\": resource,\n\t\t\"action\":   action,\n\t\t\"user_id\":  identity.UserID,\n\t})\n\treturn nil\n}\n\n// RefreshSession is not implemented in mock mode.\nfunc (m *MockAuthAdapter) RefreshSession(ctx context.Context, sessionToken string) (*auth.Identity, error) {\n\treturn nil, fmt.Errorf(\"mock adapter: RefreshSession not implemented\")\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/rbac_policy.go",
    "content": "package stytch\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac\"\n)\n\nconst (\n\t// Redis cache key for RBAC policy\n\trbacPolicyCacheKey = \"auth:stytch:rbac:policy\"\n\t// Cache TTL matches Stytch SDK default (5 minutes)\n\trbacPolicyCacheTTL = 5 * time.Minute\n)\n\n// RBACPolicyService fetches and caches the Stytch RBAC policy.\n//\n// It retrieves the role-permission mappings from Stytch and caches them\n// in Redis to avoid API calls on every request.\ntype RBACPolicyService struct {\n\tclient *b2bstytchapi.API\n\tredis  redis.Client\n\tlogger logger.Logger\n}\n\nfunc NewRBACPolicyService(client *b2bstytchapi.API, redisClient redis.Client, logger logger.Logger) *RBACPolicyService {\n\treturn &RBACPolicyService{\n\t\tclient: client,\n\t\tredis:  redisClient,\n\t\tlogger: logger,\n\t}\n}\n\n// GetRolePermissions returns all permissions for a given role from Stytch RBAC policy.\n//\n// Returns permissions in \"resource:action\" format (e.g., \"invoice:create\").\nfunc (s *RBACPolicyService) GetRolePermissions(ctx context.Context, roleID string) ([]auth.Permission, error) {\n\t// Normalize role ID\n\tnormalizedRoleID := normalizeRoleID(roleID)\n\n\t// Get policy from cache or Stytch\n\tpolicy, err := s.getPolicy(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get RBAC policy: %w\", err)\n\t}\n\n\t// Find role in policy\n\tfor _, role := range policy.Roles {\n\t\tif strings.EqualFold(role.RoleID, normalizedRoleID) {\n\t\t\treturn s.convertPermissions(role.Permissions, policy), nil\n\t\t}\n\t}\n\n\t// Role not found in policy\n\ts.logger.Debug(\"role not found in Stytch RBAC policy\", logger.Fields{\n\t\t\"role_id\":    roleID,\n\t\t\"normalized\": normalizedRoleID,\n\t})\n\treturn nil, nil\n}\n\n// getPolicy fetches policy from Redis cache or Stytch API.\nfunc (s *RBACPolicyService) getPolicy(ctx context.Context) (*rbac.Policy, error) {\n\t// Try cache first\n\tcached, err := s.redis.Get(ctx, rbacPolicyCacheKey)\n\tif err == nil && cached != \"\" {\n\t\tvar policy rbac.Policy\n\t\tif unmarshalErr := json.Unmarshal([]byte(cached), &policy); unmarshalErr == nil {\n\t\t\ts.logger.Debug(\"RBAC policy fetched from cache\", logger.Fields{})\n\t\t\treturn &policy, nil\n\t\t} else {\n\t\t\ts.logger.Warn(\"failed to unmarshal cached RBAC policy\", logger.Fields{\n\t\t\t\t\"error\": unmarshalErr.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\t// Cache miss - fetch from Stytch\n\tpolicy, err := s.fetchPolicyFromStytch(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Cache the policy\n\ts.cachePolicy(ctx, policy)\n\n\treturn policy, nil\n}\n\n// fetchPolicyFromStytch fetches RBAC policy from Stytch API.\nfunc (s *RBACPolicyService) fetchPolicyFromStytch(ctx context.Context) (*rbac.Policy, error) {\n\ts.logger.Info(\"fetching RBAC policy from Stytch\", logger.Fields{})\n\n\tresp, err := s.client.RBAC.Policy(ctx, &rbac.PolicyParams{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch RBAC policy API call failed: %w\", err)\n\t}\n\n\tif resp.Policy == nil {\n\t\treturn nil, fmt.Errorf(\"stytch returned empty policy\")\n\t}\n\n\ts.logger.Info(\"successfully fetched RBAC policy\", logger.Fields{\n\t\t\"roles_count\":     len(resp.Policy.Roles),\n\t\t\"resources_count\": len(resp.Policy.Resources),\n\t})\n\n\treturn resp.Policy, nil\n}\n\n// cachePolicy stores policy in Redis.\nfunc (s *RBACPolicyService) cachePolicy(ctx context.Context, policy *rbac.Policy) {\n\tdata, err := json.Marshal(policy)\n\tif err != nil {\n\t\ts.logger.Warn(\"failed to marshal RBAC policy for caching\", logger.Fields{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tif err := s.redis.Set(ctx, rbacPolicyCacheKey, string(data), rbacPolicyCacheTTL); err != nil {\n\t\ts.logger.Warn(\"failed to cache RBAC policy in Redis\", logger.Fields{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t}\n}\n\n// convertPermissions converts Stytch permission format to auth.Permission slice.\n//\n// Handles wildcard expansion:\n//\n//\tInput: []PolicyRolePermission{{ResourceID: \"invoice\", Actions: [\"view\", \"create\"]}}\n//\tOutput: [Permission(\"invoice:view\"), Permission(\"invoice:create\")]\nfunc (s *RBACPolicyService) convertPermissions(permissions []rbac.PolicyRolePermission, policy *rbac.Policy) []auth.Permission {\n\tif len(permissions) == 0 {\n\t\treturn nil\n\t}\n\n\tresult := make([]auth.Permission, 0, len(permissions)*5)\n\n\tfor _, perm := range permissions {\n\t\tresourceID := strings.ToLower(perm.ResourceID)\n\t\tif resourceID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Expand wildcard actions\n\t\texpandedActions := s.expandWildcardActions(perm.ResourceID, perm.Actions, policy)\n\n\t\t// Convert each action to Permission\n\t\tfor _, action := range expandedActions {\n\t\t\tif action == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresult = append(result, auth.NewPermission(resourceID, strings.ToLower(action)))\n\t\t}\n\t}\n\n\treturn result\n}\n\n// expandWildcardActions expands wildcard (*) to all resource actions from policy.\nfunc (s *RBACPolicyService) expandWildcardActions(resourceID string, actions []string, policy *rbac.Policy) []string {\n\t// Check if actions contain wildcard\n\thasWildcard := false\n\tfor _, action := range actions {\n\t\tif action == \"*\" {\n\t\t\thasWildcard = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !hasWildcard {\n\t\treturn actions\n\t}\n\n\t// Find resource definition to get all actions\n\tfor _, resource := range policy.Resources {\n\t\tif strings.EqualFold(resource.ResourceID, resourceID) {\n\t\t\tif len(resource.Actions) > 0 {\n\t\t\t\ts.logger.Debug(\"expanded wildcard permission\", logger.Fields{\n\t\t\t\t\t\"resource\":      resourceID,\n\t\t\t\t\t\"actions_count\": len(resource.Actions),\n\t\t\t\t})\n\t\t\t\treturn resource.Actions\n\t\t\t}\n\t\t}\n\t}\n\n\t// Resource not found, keep wildcard as-is\n\treturn actions\n}\n\n// normalizeRoleID removes common prefixes from role IDs.\nfunc normalizeRoleID(roleID string) string {\n\troleID = strings.TrimSpace(roleID)\n\troleID = strings.TrimPrefix(roleID, \"stytch_\")\n\treturn roleID\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/adapters/stytch/token_verifier.go",
    "content": "package stytch\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/sessions\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/stytcherror\"\n)\n\n// TokenVerifier verifies Stytch session JWTs using a two-tier strategy:\n//  1. Fast Path: Local JWT verification using cached JWKS (no API calls)\n//  2. Slow Path: Stytch API verification (fallback when local fails)\n//\n// This optimization saves 300-500ms per request for the common case.\ntype TokenVerifier struct {\n\tclient        *b2bstytchapi.API\n\tjwksCache     *JWKSCache\n\tjwtParser     *JWTParser\n\tpolicyService *RBACPolicyService\n\tcfg           *Config\n\tlogger        logger.Logger\n}\n\nfunc NewTokenVerifier(\n\tclient *b2bstytchapi.API,\n\tjwksCache *JWKSCache,\n\tpolicyService *RBACPolicyService,\n\tcfg *Config,\n\tlogger logger.Logger,\n) *TokenVerifier {\n\treturn &TokenVerifier{\n\t\tclient:        client,\n\t\tjwksCache:     jwksCache,\n\t\tjwtParser:     NewJWTParser(),\n\t\tpolicyService: policyService,\n\t\tcfg:           cfg,\n\t\tlogger:        logger,\n\t}\n}\n\n// internalClaims holds parsed JWT claims before conversion to auth.Identity.\ntype internalClaims struct {\n\tSubject        string\n\tEmail          string\n\tEmailVerified  bool\n\tOrganizationID string\n\tRoles          []string\n\tPermissions    []auth.Permission\n\tIssuedAt       time.Time\n\tExpiresAt      time.Time\n\tNotBefore      time.Time\n\tIssuer         string\n\tAudience       []string\n\tRaw            map[string]any\n}\n\n// Verify validates the token and returns an Identity.\n//\n// It tries local verification first (fast path), falling back to\n// Stytch API verification if local verification fails.\nfunc (v *TokenVerifier) Verify(ctx context.Context, token string) (*auth.Identity, error) {\n\t// Check for test mode (DANGEROUS - only for development)\n\tif v.cfg.DisableSessionVerification {\n\t\tv.logger.Warn(\"session verification disabled - test mode only\", logger.Fields{})\n\t\treturn v.verifyWithoutSignature(ctx, token)\n\t}\n\n\t// Fast path: Local JWT verification\n\tidentity, err := v.verifyLocally(ctx, token)\n\tif err == nil {\n\t\tv.logger.Debug(\"token verified locally (fast path)\", logger.Fields{\n\t\t\t\"user_id\": identity.UserID,\n\t\t\t\"email\":   identity.Email,\n\t\t})\n\t\treturn identity, nil\n\t}\n\n\t// Log fast path failure\n\tv.logger.Warn(\"local verification failed, trying Stytch API\", logger.Fields{\n\t\t\"error\": err.Error(),\n\t})\n\n\t// Slow path: Stytch API verification\n\treturn v.verifyViaAPI(ctx, token)\n}\n\n// verifyLocally verifies the token using cached JWKS (fast path).\nfunc (v *TokenVerifier) verifyLocally(ctx context.Context, token string) (*auth.Identity, error) {\n\t// 1. Parse token header to get key ID\n\tkid, err := v.jwtParser.ExtractKeyID(token)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to extract key ID: %w\", err)\n\t}\n\n\t// 2. Get public key from cache\n\tpublicKey, err := v.jwksCache.GetPublicKey(ctx, kid)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get public key: %w\", err)\n\t}\n\n\t// 3. Verify token signature\n\tjwtToken, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {\n\t\tif _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", t.Header[\"alg\"])\n\t\t}\n\t\treturn publicKey, nil\n\t})\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"expired\") {\n\t\t\treturn nil, auth.ErrTokenExpired\n\t\t}\n\t\treturn nil, auth.ErrInvalidToken\n\t}\n\n\tif !jwtToken.Valid {\n\t\treturn nil, auth.ErrInvalidToken\n\t}\n\n\t// 4. Parse claims from token\n\t_, claimsMap, err := v.jwtParser.ParseWithoutVerification(token)\n\tif err != nil {\n\t\treturn nil, auth.ErrInvalidToken\n\t}\n\n\tclaims := v.parseClaimsFromMap(claimsMap)\n\n\t// 5. Validate claims\n\tif err := v.validateClaims(claims); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 6. Derive permissions from roles\n\tpermissions := v.derivePermissions(ctx, claims.Roles)\n\n\t// 7. Convert to Identity\n\treturn &auth.Identity{\n\t\tUserID:         claims.Subject,\n\t\tEmail:          claims.Email,\n\t\tEmailVerified:  claims.EmailVerified,\n\t\tOrganizationID: claims.OrganizationID,\n\t\tRoles:          v.convertRoles(claims.Roles),\n\t\tPermissions:    permissions,\n\t\tExpiresAt:      claims.ExpiresAt,\n\t\tRaw:            claims.Raw,\n\t}, nil\n}\n\n// verifyViaAPI verifies the token using Stytch API (slow path).\nfunc (v *TokenVerifier) verifyViaAPI(ctx context.Context, token string) (*auth.Identity, error) {\n\t// Add timeout\n\tctx, cancel := context.WithTimeout(ctx, v.cfg.APITimeout)\n\tdefer cancel()\n\n\treq := &sessions.AuthenticateParams{\n\t\tSessionJWT: token,\n\t}\n\tif v.cfg.SessionDurationMinutes > 0 {\n\t\treq.SessionDurationMinutes = v.cfg.SessionDurationMinutes\n\t}\n\n\tresp, err := v.client.Sessions.Authenticate(ctx, req)\n\tif err != nil {\n\t\treturn nil, v.translateStytchError(err)\n\t}\n\n\tmember := resp.Member\n\tsession := resp.MemberSession\n\n\t// Check email verification\n\tif !member.EmailAddressVerified {\n\t\treturn nil, auth.ErrEmailNotVerified\n\t}\n\n\t// Derive permissions from roles\n\tpermissions := v.derivePermissions(ctx, session.Roles)\n\n\t// Build identity\n\tidentity := &auth.Identity{\n\t\tUserID:         session.MemberID,\n\t\tEmail:          member.EmailAddress,\n\t\tEmailVerified:  member.EmailAddressVerified,\n\t\tOrganizationID: session.OrganizationID,\n\t\tRoles:          v.convertRoles(session.Roles),\n\t\tPermissions:    permissions,\n\t\tExpiresAt:      timeValue(session.ExpiresAt),\n\t\tRaw: map[string]any{\n\t\t\t\"member_session\": session,\n\t\t\t\"member\":         member,\n\t\t\t\"custom_claims\":  session.CustomClaims,\n\t\t},\n\t}\n\n\tv.logger.Debug(\"token verified via Stytch API (slow path)\", logger.Fields{\n\t\t\"user_id\": identity.UserID,\n\t\t\"email\":   identity.Email,\n\t})\n\n\treturn identity, nil\n}\n\n// verifyWithoutSignature parses the token without verifying signature (test mode only).\nfunc (v *TokenVerifier) verifyWithoutSignature(ctx context.Context, token string) (*auth.Identity, error) {\n\t_, claimsMap, err := v.jwtParser.ParseWithoutVerification(token)\n\tif err != nil {\n\t\treturn nil, auth.ErrInvalidToken\n\t}\n\n\tclaims := v.parseClaimsFromMap(claimsMap)\n\tpermissions := v.derivePermissions(ctx, claims.Roles)\n\n\treturn &auth.Identity{\n\t\tUserID:         claims.Subject,\n\t\tEmail:          claims.Email,\n\t\tEmailVerified:  claims.EmailVerified,\n\t\tOrganizationID: claims.OrganizationID,\n\t\tRoles:          v.convertRoles(claims.Roles),\n\t\tPermissions:    permissions,\n\t\tExpiresAt:      claims.ExpiresAt,\n\t\tRaw:            claims.Raw,\n\t}, nil\n}\n\n// parseClaimsFromMap extracts claims from JWT payload.\nfunc (v *TokenVerifier) parseClaimsFromMap(claimsMap map[string]any) *internalClaims {\n\tclaims := &internalClaims{\n\t\tRaw: claimsMap,\n\t}\n\n\t// Extract subject\n\tif sub, ok := claimsMap[\"sub\"].(string); ok {\n\t\tclaims.Subject = sub\n\t}\n\n\t// Extract email from Stytch session authentication factors\n\t// Format: https://stytch.com/session.authentication_factors[].email_factor.email_address\n\tif sessionObj, ok := claimsMap[\"https://stytch.com/session\"].(map[string]any); ok {\n\t\tif factors, ok := sessionObj[\"authentication_factors\"].([]any); ok {\n\t\t\tfor _, factor := range factors {\n\t\t\t\tif factorMap, ok := factor.(map[string]any); ok {\n\t\t\t\t\tif emailFactor, ok := factorMap[\"email_factor\"].(map[string]any); ok {\n\t\t\t\t\t\tif emailAddr, ok := emailFactor[\"email_address\"].(string); ok {\n\t\t\t\t\t\t\tclaims.Email = emailAddr\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Extract roles from session\n\t\tif rolesIface, ok := sessionObj[\"roles\"].([]any); ok {\n\t\t\troles := make([]string, 0, len(rolesIface))\n\t\t\tfor _, r := range rolesIface {\n\t\t\t\tif roleStr, ok := r.(string); ok {\n\t\t\t\t\troles = append(roles, roleStr)\n\t\t\t\t}\n\t\t\t}\n\t\t\tclaims.Roles = roles\n\t\t}\n\t}\n\n\t// Fallback to standard email claim\n\tif claims.Email == \"\" {\n\t\tif email, ok := claimsMap[\"email\"].(string); ok {\n\t\t\tclaims.Email = email\n\t\t}\n\t}\n\n\t// Extract email_verified\n\tif verified, ok := claimsMap[\"email_verified\"].(bool); ok {\n\t\tclaims.EmailVerified = verified\n\t} else if verified, ok := claimsMap[\"https://stytch.com/email_verified\"].(bool); ok {\n\t\tclaims.EmailVerified = verified\n\t}\n\n\t// Extract organization ID from Stytch custom claim\n\t// Format: https://stytch.com/organization.organization_id\n\tif orgObj, ok := claimsMap[\"https://stytch.com/organization\"].(map[string]any); ok {\n\t\tif orgID, ok := orgObj[\"organization_id\"].(string); ok {\n\t\t\tclaims.OrganizationID = orgID\n\t\t}\n\t}\n\t// Fallback to standard claims\n\tif claims.OrganizationID == \"\" {\n\t\tif orgID, ok := claimsMap[\"organization_id\"].(string); ok {\n\t\t\tclaims.OrganizationID = orgID\n\t\t} else if orgID, ok := claimsMap[\"org_id\"].(string); ok {\n\t\t\tclaims.OrganizationID = orgID\n\t\t}\n\t}\n\n\t// Parse timestamps\n\tclaims.IssuedAt = parseNumericTime(claimsMap[\"iat\"])\n\tclaims.ExpiresAt = parseNumericTime(claimsMap[\"exp\"])\n\tclaims.NotBefore = parseNumericTime(claimsMap[\"nbf\"])\n\n\t// Parse issuer\n\tif iss, ok := claimsMap[\"iss\"].(string); ok {\n\t\tclaims.Issuer = iss\n\t}\n\n\t// Parse audience\n\tclaims.Audience = parseStringSlice(claimsMap[\"aud\"])\n\n\t// Fallback for roles\n\tif len(claims.Roles) == 0 {\n\t\tclaims.Roles = parseStringSlice(claimsMap[\"roles\"])\n\t}\n\n\treturn claims\n}\n\n// validateClaims validates security-critical claims.\nfunc (v *TokenVerifier) validateClaims(claims *internalClaims) error {\n\tnow := time.Now()\n\n\t// Check expiry\n\tif !claims.ExpiresAt.IsZero() && now.After(claims.ExpiresAt) {\n\t\treturn auth.ErrTokenExpired\n\t}\n\n\t// Check not before\n\tif !claims.NotBefore.IsZero() && now.Before(claims.NotBefore) {\n\t\treturn auth.ErrInvalidToken\n\t}\n\n\t// Validate issuer (must be from Stytch)\n\tif claims.Issuer != \"\" && !strings.Contains(strings.ToLower(claims.Issuer), \"stytch.com\") {\n\t\treturn auth.ErrIssuerMismatch\n\t}\n\n\t// Check email verification if claim is present\n\tif _, hasEmailVerified := claims.Raw[\"email_verified\"]; hasEmailVerified {\n\t\tif !claims.EmailVerified {\n\t\t\treturn auth.ErrEmailNotVerified\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// derivePermissions derives permissions from roles.\n//\n// Fast path: Use hardcoded permissions for standard roles (no API calls).\n// Slow path: Fetch from Stytch RBAC policy for custom roles.\nfunc (v *TokenVerifier) derivePermissions(ctx context.Context, roles []string) []auth.Permission {\n\tpermSet := make(map[auth.Permission]struct{})\n\n\tfor _, roleStr := range roles {\n\t\tif roleStr == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Normalize role\n\t\tnormalizedRole := auth.NormalizeRole(roleStr)\n\n\t\t// Fast path: Use hardcoded permissions for standard roles\n\t\tif perms := auth.GetRolePermissions(normalizedRole); len(perms) > 0 {\n\t\t\tfor _, p := range perms {\n\t\t\t\tpermSet[p] = struct{}{}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Slow path: Fetch from Stytch RBAC policy\n\t\tif v.policyService != nil {\n\t\t\tstytchPerms, err := v.policyService.GetRolePermissions(ctx, roleStr)\n\t\t\tif err != nil {\n\t\t\t\tv.logger.Warn(\"failed to get role permissions from Stytch\", logger.Fields{\n\t\t\t\t\t\"role\":  roleStr,\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfor _, p := range stytchPerms {\n\t\t\t\tpermSet[p] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Convert set to slice\n\tif len(permSet) == 0 {\n\t\treturn nil\n\t}\n\n\tpermissions := make([]auth.Permission, 0, len(permSet))\n\tfor p := range permSet {\n\t\tpermissions = append(permissions, p)\n\t}\n\n\treturn permissions\n}\n\n// convertRoles converts string role names to auth.Role.\nfunc (v *TokenVerifier) convertRoles(roles []string) []auth.Role {\n\tif len(roles) == 0 {\n\t\treturn nil\n\t}\n\n\tresult := make([]auth.Role, 0, len(roles))\n\tseen := make(map[auth.Role]struct{})\n\n\tfor _, roleStr := range roles {\n\t\trole := auth.NormalizeRole(roleStr)\n\t\tif _, exists := seen[role]; !exists {\n\t\t\tseen[role] = struct{}{}\n\t\t\tresult = append(result, role)\n\t\t}\n\t}\n\n\treturn result\n}\n\n// translateStytchError converts Stytch errors to auth errors.\nfunc (v *TokenVerifier) translateStytchError(err error) error {\n\tvar stErr *stytcherror.Error\n\tif errors.As(err, &stErr) {\n\t\tif strings.Contains(strings.ToLower(string(stErr.ErrorType)), \"expired\") {\n\t\t\treturn auth.ErrTokenExpired\n\t\t}\n\t\tswitch stErr.StatusCode {\n\t\tcase 401, 403, 404:\n\t\t\treturn auth.ErrInvalidToken\n\t\tdefault:\n\t\t\tif stErr.StatusCode >= 500 {\n\t\t\t\treturn fmt.Errorf(\"stytch service error: %w\", err)\n\t\t\t}\n\t\t\treturn auth.ErrInvalidToken\n\t\t}\n\t}\n\treturn fmt.Errorf(\"stytch error: %w\", err)\n}\n\n// Helper functions\n\nfunc parseStringSlice(value any) []string {\n\tswitch v := value.(type) {\n\tcase string:\n\t\tif v == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn []string{v}\n\tcase []string:\n\t\treturn v\n\tcase []any:\n\t\tres := make([]string, 0, len(v))\n\t\tfor _, item := range v {\n\t\t\tif s, ok := item.(string); ok && s != \"\" {\n\t\t\t\tres = append(res, s)\n\t\t\t}\n\t\t}\n\t\treturn res\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc parseNumericTime(value any) time.Time {\n\tswitch v := value.(type) {\n\tcase float64:\n\t\treturn time.Unix(int64(v), 0)\n\tcase int64:\n\t\treturn time.Unix(v, 0)\n\tcase int:\n\t\treturn time.Unix(int64(v), 0)\n\tdefault:\n\t\treturn time.Time{}\n\t}\n}\n\nfunc timeValue(ts *time.Time) time.Time {\n\tif ts == nil {\n\t\treturn time.Time{}\n\t}\n\treturn ts.UTC()\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/auth.go",
    "content": "// Package auth provides a unified authentication and authorization layer.\n//\n// This package abstracts away the authentication provider (Stytch, Auth0, etc.)\n// and provides a clean interface for the rest of the application to use.\n//\n// # Architecture\n//\n// The auth package follows the adapter pattern:\n//\n//\t┌─────────────────────────────────────────────────────────────────┐\n//\t│                        Application Layer                        │\n//\t│  (handlers, services - use auth.GetRequestContext, auth.RequirePermission) │\n//\t└─────────────────────────────────────────────────────────────────┘\n//\t                              │\n//\t                              ▼\n//\t┌─────────────────────────────────────────────────────────────────┐\n//\t│                         auth package                            │\n//\t│  • AuthProvider interface                                       │\n//\t│  • Identity (provider-agnostic user representation)            │\n//\t│  • RequestContext (resolved database IDs)                      │\n//\t│  • Middleware (RequireAuth, RequireOrganization, RequirePermission) │\n//\t│  • Type-safe context helpers                                   │\n//\t└─────────────────────────────────────────────────────────────────┘\n//\t                              │\n//\t                              ▼\n//\t┌─────────────────────────────────────────────────────────────────┐\n//\t│                    auth/adapters/stytch                         │\n//\t│  (Stytch-specific implementation - hidden from app layer)      │\n//\t└─────────────────────────────────────────────────────────────────┘\n//\n// # Usage\n//\n// In routes:\n//\n//\trouter.Use(\n//\t    auth.RequireAuth(authProvider),\n//\t    auth.RequireOrganization(orgRepo, accountRepo, logger),\n//\t)\n//\trouter.GET(\"/resource\", auth.RequirePermission(\"resource\", \"view\"), handler)\n//\n// In handlers:\n//\n//\tfunc Handler(c *gin.Context) {\n//\t    reqCtx := auth.GetRequestContext(c)\n//\t    orgID := reqCtx.OrganizationID  // int32, type-safe\n//\t    accountID := reqCtx.AccountID   // int32, type-safe\n//\t}\n//\n// # Adding a New Auth Provider\n//\n// To add a new authentication provider (e.g., Auth0, Firebase):\n//\n//  1. Create a new adapter in auth/adapters/<provider>/\n//  2. Implement the AuthProvider interface\n//  3. Map provider-specific claims to auth.Identity\n//  4. Register the adapter in the DI container\n//\n// See auth/adapters/stytch/ for a reference implementation.\npackage auth\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\n// AuthProvider abstracts the authentication provider (Stytch, Auth0, Firebase, etc.).\n//\n// Implementations must:\n//   - Verify the token signature and validity\n//   - Extract user identity information\n//   - Derive permissions from roles (if applicable)\n//   - Return appropriate errors for invalid/expired tokens\n//\n// The application layer should only depend on this interface, never on\n// provider-specific implementations.\ntype AuthProvider interface {\n\t// VerifyToken validates the provided token and returns the user's identity.\n\t//\n\t// The token is typically a JWT from the Authorization header.\n\t// Returns ErrInvalidToken, ErrTokenExpired, or other auth errors on failure.\n\tVerifyToken(ctx context.Context, token string) (*Identity, error)\n}\n\n// Identity represents an authenticated user in a provider-agnostic way.\n//\n// This struct contains all the information needed by the application\n// after a user has been authenticated. Provider-specific data is stored\n// in the Raw field for debugging or advanced use cases.\ntype Identity struct {\n\t// UserID is the unique identifier for the user from the auth provider.\n\t// For Stytch, this is the member_id. For Auth0, this is the sub claim.\n\tUserID string `json:\"user_id\"`\n\n\t// Email is the user's email address.\n\tEmail string `json:\"email\"`\n\n\t// EmailVerified indicates whether the email has been verified.\n\tEmailVerified bool `json:\"email_verified\"`\n\n\t// OrganizationID is the auth provider's organization/tenant identifier.\n\t// This is a string UUID from the provider, NOT the database int32 ID.\n\t// Use RequestContext.OrganizationID for the database ID.\n\tOrganizationID string `json:\"organization_id\"`\n\n\t// Roles contains the user's role assignments (e.g., \"admin\", \"member\").\n\tRoles []Role `json:\"roles\"`\n\n\t// Permissions contains the derived permissions in \"resource:action\" format.\n\t// These are derived from roles by the auth provider or adapter.\n\tPermissions []Permission `json:\"permissions\"`\n\n\t// ExpiresAt is when the token/session expires.\n\tExpiresAt time.Time `json:\"expires_at\"`\n\n\t// Raw contains provider-specific data for debugging or advanced use cases.\n\t// This should NOT be used in normal application logic.\n\tRaw map[string]any `json:\"raw,omitempty\"`\n}\n\n// HasRole checks if the identity has a specific role.\nfunc (i *Identity) HasRole(role Role) bool {\n\tfor _, r := range i.Roles {\n\t\tif r == role {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// HasPermission checks if the identity has a specific permission.\nfunc (i *Identity) HasPermission(permission Permission) bool {\n\tfor _, p := range i.Permissions {\n\t\tif p == permission {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// HasResourcePermission checks if the identity has permission for a resource and action.\nfunc (i *Identity) HasResourcePermission(resource, action string) bool {\n\treturn i.HasPermission(NewPermission(resource, action))\n}\n\n// RequestContext holds the resolved database IDs for the current request.\n//\n// This is set by the RequireOrganization middleware after looking up\n// the organization and account in the database using the Identity's\n// provider-specific IDs.\n//\n// Use auth.GetRequestContext(c) to retrieve this in handlers.\ntype RequestContext struct {\n\t// Identity contains the authenticated user information from the auth provider.\n\tIdentity *Identity `json:\"identity\"`\n\n\t// OrganizationID is the database primary key (int32) for the organization.\n\t// This is resolved from Identity.OrganizationID by the middleware.\n\tOrganizationID int32 `json:\"organization_id\"`\n\n\t// AccountID is the database primary key (int32) for the user's account.\n\t// This is resolved from Identity.Email by the middleware.\n\tAccountID int32 `json:\"account_id\"`\n\n\t// ProviderOrgID preserves the original provider organization ID for reference.\n\t// Use this when making calls back to the auth provider.\n\tProviderOrgID string `json:\"provider_org_id,omitempty\"`\n}\n\n// OrganizationRepository defines the interface for looking up organizations.\n//\n// This is used by the RequireOrganization middleware to resolve\n// the auth provider's organization ID to a database ID.\ntype OrganizationRepository interface {\n\t// GetByProviderID looks up an organization by the auth provider's organization ID.\n\t// Returns the organization with its database ID, or an error if not found.\n\tGetByProviderID(ctx context.Context, providerOrgID string) (*Organization, error)\n}\n\n// AccountRepository defines the interface for looking up accounts.\n//\n// This is used by the RequireOrganization middleware to resolve\n// the user's email to a database account ID within an organization.\ntype AccountRepository interface {\n\t// GetByEmail looks up an account by email within an organization.\n\t// Returns the account with its database ID, or an error if not found.\n\tGetByEmail(ctx context.Context, orgID int32, email string) (*Account, error)\n}\n\n// Organization represents the minimal organization data needed by the auth package.\ntype Organization struct {\n\tID   int32  `json:\"id\"`\n\tName string `json:\"name\"`\n}\n\n// Account represents the minimal account data needed by the auth package.\ntype Account struct {\n\tID    int32  `json:\"id\"`\n\tEmail string `json:\"email\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/cmd/init.go",
    "content": "// Package cmd provides initialization for the auth module.\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth/adapters/stytch\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"go.uber.org/dig\"\n)\n\n//\n// This sets up:\n//   - stytch.Config\n//   - auth.AuthProvider (Stytch adapter)\n//\n// Note: The auth middleware is NOT initialized here because it requires\n// organization/account resolvers from the organizations module.\n// Use InitMiddleware after the organizations module is initialized.\n//\n// # Prerequisites\n//\n// The following modules must be initialized first:\n//   - redis (for caching)\n//   - logger\n//\n// # Usage\n//\n//\t// In main/cmd/init_mods.go:\n//\tif err := authCmd.Init(container); err != nil {\n//\t    panic(err)\n//\t}\nfunc Init(container *dig.Container) error {\n\t// Stytch configuration\n\tif err := container.Provide(func() (*stytch.Config, error) {\n\t\treturn stytch.LoadConfig()\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide stytch config: %w\", err)\n\t}\n\n\t// Stytch Auth Adapter (implements auth.AuthProvider)\n\tif err := container.Provide(func(\n\t\tcfg *stytch.Config,\n\t\tredisClient redis.Client,\n\t\tlog logger.Logger,\n\t) (auth.AuthProvider, error) {\n\t\t// Check for placeholder credentials\n\t\tif isPlaceholderCredentials(cfg) {\n\t\t\tlog.Warn(\"Stytch credentials are placeholders - using development mode\", map[string]any{\n\t\t\t\t\"project_id\": cfg.ProjectID,\n\t\t\t\t\"message\":    \"Update STYTCH_PROJECT_ID and STYTCH_SECRET in app.env with real credentials\",\n\t\t\t})\n\t\t\treturn stytch.NewMockAuthAdapter(log), nil\n\t\t}\n\n\t\tadapter, err := stytch.NewStytchAuthAdapter(cfg, redisClient, log)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create stytch adapter: %w\", err)\n\t\t}\n\t\treturn adapter, nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide auth provider: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// InitMiddleware initializes the auth middleware with resolvers.\n//\n// This must be called after the organizations module is initialized,\n// as it depends on organization and account repositories.\n//\n// # Prerequisites\n//\n// The following must be available in the container:\n//   - auth.AuthProvider (from Init)\n//   - auth.OrganizationResolver\n//   - auth.AccountResolver\n//   - serverDomain.Server (for registering named middlewares)\n//\n// # Usage\n//\n//\t// After organizations module init:\n//\tif err := authCmd.InitMiddleware(container); err != nil {\n//\t    panic(err)\n//\t}\nfunc InitMiddleware(container *dig.Container) error {\n\tif err := auth.SetupMiddleware(container); err != nil {\n\t\treturn fmt.Errorf(\"failed to setup auth middleware: %w\", err)\n\t}\n\treturn nil\n}\n\n// isPlaceholderCredentials checks if the Stytch credentials are placeholder values.\nfunc isPlaceholderCredentials(cfg *stytch.Config) bool {\n\treturn strings.Contains(cfg.ProjectID, \"REPLACE\") ||\n\t\tstrings.Contains(cfg.Secret, \"REPLACE\") ||\n\t\tcfg.ProjectID == \"\" ||\n\t\tcfg.Secret == \"\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/context.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Context keys for storing auth data.\n// Using unexported type to prevent collisions with other packages.\ntype contextKey string\n\nconst (\n\t// identityKey is the context key for storing the authenticated Identity.\n\tidentityKey contextKey = \"auth_identity\"\n\n\t// requestContextKey is the context key for storing the RequestContext.\n\trequestContextKey contextKey = \"auth_request_context\"\n)\n\n// SetIdentity stores the Identity in the Gin context.\n//\n// This is called by the RequireAuth middleware after successful authentication.\n// Application code should not call this directly.\nfunc SetIdentity(c *gin.Context, identity *Identity) {\n\tc.Set(string(identityKey), identity)\n}\n\n// GetIdentity retrieves the Identity from the Gin context.\n//\n// Returns nil if no identity is set (user not authenticated).\n// Use MustGetIdentity if you expect authentication middleware to have run.\n//\n// Example:\n//\n//\tidentity := auth.GetIdentity(c)\n//\tif identity == nil {\n//\t    // Handle unauthenticated request\n//\t}\nfunc GetIdentity(c *gin.Context) *Identity {\n\tif val, exists := c.Get(string(identityKey)); exists {\n\t\tif identity, ok := val.(*Identity); ok {\n\t\t\treturn identity\n\t\t}\n\t}\n\treturn nil\n}\n\n// MustGetIdentity retrieves the Identity from the Gin context.\n//\n// Panics if no identity is set. Only use this after RequireAuth middleware.\n// For handlers where authentication is optional, use GetIdentity instead.\nfunc MustGetIdentity(c *gin.Context) *Identity {\n\tidentity := GetIdentity(c)\n\tif identity == nil {\n\t\tpanic(\"auth: MustGetIdentity called without Identity in context - ensure RequireAuth middleware is applied\")\n\t}\n\treturn identity\n}\n\n// SetRequestContext stores the RequestContext in the Gin context.\n//\n// This is called by the RequireOrganization middleware after resolving\n// the database IDs. Application code should not call this directly.\nfunc SetRequestContext(c *gin.Context, reqCtx *RequestContext) {\n\tc.Set(string(requestContextKey), reqCtx)\n}\n\n// GetRequestContext retrieves the RequestContext from the Gin context.\n//\n// Returns nil if no request context is set.\n// Use MustGetRequestContext if you expect the organization middleware to have run.\n//\n// Example:\n//\n//\treqCtx := auth.GetRequestContext(c)\n//\tif reqCtx == nil {\n//\t    // Handle request without organization context\n//\t}\n//\torgID := reqCtx.OrganizationID  // int32, type-safe\nfunc GetRequestContext(c *gin.Context) *RequestContext {\n\tif val, exists := c.Get(string(requestContextKey)); exists {\n\t\tif reqCtx, ok := val.(*RequestContext); ok {\n\t\t\treturn reqCtx\n\t\t}\n\t}\n\treturn nil\n}\n\n// MustGetRequestContext retrieves the RequestContext from the Gin context.\n//\n// Panics if no request context is set. Only use this after RequireOrganization middleware.\n// For handlers where organization context is optional, use GetRequestContext instead.\n//\n// Example:\n//\n//\treqCtx := auth.MustGetRequestContext(c)\n//\torgID := reqCtx.OrganizationID\n//\taccountID := reqCtx.AccountID\nfunc MustGetRequestContext(c *gin.Context) *RequestContext {\n\treqCtx := GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tpanic(\"auth: MustGetRequestContext called without RequestContext in context - ensure RequireOrganization middleware is applied\")\n\t}\n\treturn reqCtx\n}\n\n// GetOrganizationID is a convenience function to get the database organization ID.\n//\n// Returns 0 if no request context is set.\n// Use MustGetRequestContext().OrganizationID if you expect the middleware to have run.\nfunc GetOrganizationID(c *gin.Context) int32 {\n\tif reqCtx := GetRequestContext(c); reqCtx != nil {\n\t\treturn reqCtx.OrganizationID\n\t}\n\treturn 0\n}\n\n// GetAccountID is a convenience function to get the database account ID.\n//\n// Returns 0 if no request context is set.\n// Use MustGetRequestContext().AccountID if you expect the middleware to have run.\nfunc GetAccountID(c *gin.Context) int32 {\n\tif reqCtx := GetRequestContext(c); reqCtx != nil {\n\t\treturn reqCtx.AccountID\n\t}\n\treturn 0\n}\n\n// WithIdentity adds the Identity to a context.Context.\n//\n// This is useful for passing auth context through service layers\n// that don't use Gin context directly.\nfunc WithIdentity(ctx context.Context, identity *Identity) context.Context {\n\treturn context.WithValue(ctx, identityKey, identity)\n}\n\n// IdentityFromContext retrieves the Identity from a context.Context.\n//\n// Returns nil if no identity is set.\nfunc IdentityFromContext(ctx context.Context) *Identity {\n\tif val := ctx.Value(identityKey); val != nil {\n\t\tif identity, ok := val.(*Identity); ok {\n\t\t\treturn identity\n\t\t}\n\t}\n\treturn nil\n}\n\n// WithRequestContext adds the RequestContext to a context.Context.\n//\n// This is useful for passing auth context through service layers\n// that don't use Gin context directly.\nfunc WithRequestContext(ctx context.Context, reqCtx *RequestContext) context.Context {\n\treturn context.WithValue(ctx, requestContextKey, reqCtx)\n}\n\n// RequestContextFromContext retrieves the RequestContext from a context.Context.\n//\n// Returns nil if no request context is set.\nfunc RequestContextFromContext(ctx context.Context) *RequestContext {\n\tif val := ctx.Value(requestContextKey); val != nil {\n\t\tif reqCtx, ok := val.(*RequestContext); ok {\n\t\t\treturn reqCtx\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/errors.go",
    "content": "package auth\n\nimport \"errors\"\n\n// Authentication and authorization errors.\n//\n// These errors are returned by the auth package and can be checked\n// by application code to handle specific error cases.\nvar (\n\t// ErrUnauthorized is returned when authentication is required but not provided.\n\t// HTTP status: 401 Unauthorized\n\tErrUnauthorized = errors.New(\"authentication required\")\n\n\t// ErrInvalidToken is returned when the provided token is malformed or invalid.\n\t// HTTP status: 401 Unauthorized\n\tErrInvalidToken = errors.New(\"invalid token\")\n\n\t// ErrTokenExpired is returned when the token has expired.\n\t// HTTP status: 401 Unauthorized\n\tErrTokenExpired = errors.New(\"token expired\")\n\n\t// ErrEmailNotVerified is returned when the user's email is not verified.\n\t// HTTP status: 403 Forbidden\n\tErrEmailNotVerified = errors.New(\"email not verified\")\n\n\t// ErrForbidden is returned when the user lacks required permissions.\n\t// HTTP status: 403 Forbidden\n\tErrForbidden = errors.New(\"insufficient permissions\")\n\n\t// ErrOrganizationNotFound is returned when the organization cannot be found.\n\t// This typically means the organization in the token doesn't exist in our database.\n\t// HTTP status: 403 Forbidden\n\tErrOrganizationNotFound = errors.New(\"organization not found\")\n\n\t// ErrAccountNotFound is returned when the user's account cannot be found.\n\t// This typically means the user exists in the auth provider but not in our database.\n\t// HTTP status: 403 Forbidden\n\tErrAccountNotFound = errors.New(\"account not found\")\n\n\t// ErrMissingOrganization is returned when the token doesn't contain an organization ID.\n\t// HTTP status: 403 Forbidden\n\tErrMissingOrganization = errors.New(\"no organization in token\")\n\n\t// ErrMissingEmail is returned when the token doesn't contain an email.\n\t// HTTP status: 403 Forbidden\n\tErrMissingEmail = errors.New(\"no email in token\")\n\n\t// ErrAudienceMismatch is returned when the token audience doesn't match.\n\t// HTTP status: 401 Unauthorized\n\tErrAudienceMismatch = errors.New(\"token audience mismatch\")\n\n\t// ErrIssuerMismatch is returned when the token issuer doesn't match.\n\t// HTTP status: 401 Unauthorized\n\tErrIssuerMismatch = errors.New(\"token issuer mismatch\")\n)\n\n// IsAuthError returns true if the error is an authentication error (401).\nfunc IsAuthError(err error) bool {\n\treturn errors.Is(err, ErrUnauthorized) ||\n\t\terrors.Is(err, ErrInvalidToken) ||\n\t\terrors.Is(err, ErrTokenExpired) ||\n\t\terrors.Is(err, ErrAudienceMismatch) ||\n\t\terrors.Is(err, ErrIssuerMismatch)\n}\n\n// IsForbiddenError returns true if the error is an authorization error (403).\nfunc IsForbiddenError(err error) bool {\n\treturn errors.Is(err, ErrForbidden) ||\n\t\terrors.Is(err, ErrEmailNotVerified) ||\n\t\terrors.Is(err, ErrOrganizationNotFound) ||\n\t\terrors.Is(err, ErrAccountNotFound) ||\n\t\terrors.Is(err, ErrMissingOrganization) ||\n\t\terrors.Is(err, ErrMissingEmail)\n}\n\n// HTTPStatusCode returns the appropriate HTTP status code for an auth error.\n//\n// Returns:\n//   - 401 for authentication errors\n//   - 403 for authorization errors\n//   - 500 for unknown errors\nfunc HTTPStatusCode(err error) int {\n\tif IsAuthError(err) {\n\t\treturn 401\n\t}\n\tif IsForbiddenError(err) {\n\t\treturn 403\n\t}\n\treturn 500\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/handler.go",
    "content": "package auth\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/pkg/response\"\n)\n\n// Handler handles RBAC API endpoints\ntype Handler struct {\n\tservice RBACService\n}\n\nfunc NewHandler(service RBACService) *Handler {\n\treturn &Handler{\n\t\tservice: service,\n\t}\n}\n\n// GetRoles godoc\n// @Summary Get all roles with permissions\n// @Description Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.\n// @Tags RBAC\n// @Produce json\n// @Success 200 {object} RolesResponse \"Roles with permissions\"\n// @Failure 500 {object} map[string]string \"Internal error\"\n// @Router /rbac/roles [get]\nfunc (h *Handler) GetRoles(c *gin.Context) {\n\troles := h.service.GetAllRoles()\n\n\troleDTOs := make([]RoleDTO, len(roles))\n\tfor i, role := range roles {\n\t\troleDTOs[i] = NewRoleDTO(role)\n\t}\n\n\tresponse.Success(c, http.StatusOK, RolesResponse{\n\t\tRoles: roleDTOs,\n\t})\n}\n\n// GetPermissions godoc\n// @Summary Get all permissions\n// @Description Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.\n// @Tags RBAC\n// @Produce json\n// @Success 200 {object} PermissionsResponse \"All permissions\"\n// @Failure 500 {object} map[string]string \"Internal error\"\n// @Router /rbac/permissions [get]\nfunc (h *Handler) GetPermissions(c *gin.Context) {\n\tpermissions := h.service.GetAllPermissions()\n\n\tpermDTOs := make([]PermissionDTO, len(permissions))\n\tfor i, perm := range permissions {\n\t\tpermDTOs[i] = NewPermissionDTO(perm)\n\t}\n\n\tresponse.Success(c, http.StatusOK, PermissionsResponse{\n\t\tPermissions: permDTOs,\n\t})\n}\n\n// GetPermissionsByCategory godoc\n// @Summary Get permissions grouped by category\n// @Description Returns all permissions organized by their category for better UI organization.\n// @Tags RBAC\n// @Produce json\n// @Success 200 {object} PermissionsByCategoryResponse \"Permissions by category\"\n// @Failure 500 {object} map[string]string \"Internal error\"\n// @Router /rbac/permissions/by-category [get]\nfunc (h *Handler) GetPermissionsByCategory(c *gin.Context) {\n\tcategoriesMap := h.service.GetPermissionsByCategory()\n\n\t// Convert to DTO format\n\tresult := make(map[string][]PermissionDTO)\n\tfor category, perms := range categoriesMap {\n\t\tpermDTOs := make([]PermissionDTO, len(perms))\n\t\tfor i, perm := range perms {\n\t\t\tpermDTOs[i] = NewPermissionDTO(perm)\n\t\t}\n\t\tresult[category] = permDTOs\n\t}\n\n\tresponse.Success(c, http.StatusOK, PermissionsByCategoryResponse{\n\t\tCategories: result,\n\t})\n}\n\n// GetRoleDetails godoc\n// @Summary Get detailed information about a specific role\n// @Description Returns comprehensive information about a role including permissions, statistics, and restrictions.\n// @Tags RBAC\n// @Produce json\n// @Param role_id path string true \"Role ID (member, approver, admin)\"\n// @Success 200 {object} RolePermissionsResponse \"Role details with statistics\"\n// @Failure 400 {object} map[string]string \"Invalid role ID\"\n// @Failure 404 {object} map[string]string \"Role not found\"\n// @Router /rbac/roles/{role_id} [get]\nfunc (h *Handler) GetRoleDetails(c *gin.Context) {\n\troleID := c.Param(\"role_id\")\n\n\tif roleID == \"\" {\n\t\tresponse.Error(c, http.StatusBadRequest, \"role_id_required\", nil)\n\t\treturn\n\t}\n\n\troleResp := NewRolePermissionsResponse(roleID)\n\tif roleResp == nil {\n\t\tresponse.Error(c, http.StatusNotFound, \"role_not_found\", nil)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, roleResp)\n}\n\n// CheckPermission godoc\n// @Summary Check if a role has a specific permission\n// @Description Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.\n// @Tags RBAC\n// @Accept json\n// @Produce json\n// @Param body body PermissionCheckRequest true \"Role and permission to check\"\n// @Success 200 {object} PermissionCheckResponse \"Permission check result\"\n// @Failure 400 {object} map[string]string \"Invalid request\"\n// @Router /rbac/check-permission [post]\nfunc (h *Handler) CheckPermission(c *gin.Context) {\n\tvar req PermissionCheckRequest\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid_request\", err)\n\t\treturn\n\t}\n\n\tif req.RoleID == \"\" || req.PermissionID == \"\" {\n\t\tresponse.Error(c, http.StatusBadRequest, \"missing_parameters\", nil)\n\t\treturn\n\t}\n\n\thasPermission := h.service.HasPermission(req.RoleID, req.PermissionID)\n\n\tresponse.Success(c, http.StatusOK, PermissionCheckResponse{\n\t\tRoleID:        req.RoleID,\n\t\tPermissionID:  req.PermissionID,\n\t\tHasPermission: hasPermission,\n\t})\n}\n\n// GetMetadata godoc\n// @Summary Get RBAC system metadata\n// @Description Returns summary information about the RBAC system including total roles, permissions, and categories.\n// @Tags RBAC\n// @Produce json\n// @Success 200 {object} RBACMetadata \"RBAC system metadata\"\n// @Router /rbac/metadata [get]\nfunc (h *Handler) GetMetadata(c *gin.Context) {\n\tmetadata := h.service.GetRBACMetadata()\n\tresponse.Success(c, http.StatusOK, metadata)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/middleware.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// OrganizationResolver looks up organization by provider org ID.\n//\n// This interface decouples auth middleware from the organizations domain.\n// Implement this interface by wrapping your organization repository.\ntype OrganizationResolver interface {\n\t// ResolveByProviderID looks up organization by the auth provider's org ID (e.g., Stytch org UUID).\n\t// Returns the database organization ID (int32) or error if not found.\n\tResolveByProviderID(ctx context.Context, providerOrgID string) (int32, error)\n}\n\n// AccountResolver looks up account by email within an organization.\n//\n// This interface decouples auth middleware from the organizations domain.\n// Implement this interface by wrapping your account repository.\ntype AccountResolver interface {\n\t// ResolveByEmail looks up account by email within the given organization.\n\t// Returns the database account ID (int32) or error if not found.\n\tResolveByEmail(ctx context.Context, orgID int32, email string) (int32, error)\n}\n\n// MiddlewareConfig configures the auth middleware behavior.\ntype MiddlewareConfig struct {\n\t// ErrorHandler is called when an error occurs. If nil, default JSON responses are used.\n\tErrorHandler func(c *gin.Context, statusCode int, message string, err error)\n}\n\n// DefaultMiddlewareConfig returns the default middleware configuration.\nfunc DefaultMiddlewareConfig() *MiddlewareConfig {\n\treturn &MiddlewareConfig{\n\t\tErrorHandler: defaultErrorHandler,\n\t}\n}\n\n// defaultErrorHandler sends JSON error responses.\nfunc defaultErrorHandler(c *gin.Context, statusCode int, message string, err error) {\n\tresponse := gin.H{\n\t\t\"error\":   message,\n\t\t\"success\": false,\n\t}\n\tif err != nil && statusCode >= 500 {\n\t\tresponse[\"detail\"] = err.Error()\n\t}\n\tc.JSON(statusCode, response)\n}\n\n// Middleware provides auth middleware functions.\n//\n// Use NewMiddleware to create an instance with proper dependencies.\ntype Middleware struct {\n\tprovider    AuthProvider\n\torgResolver OrganizationResolver\n\taccResolver AccountResolver\n\tconfig      *MiddlewareConfig\n}\n\n// Parameters:\n//   - provider: The auth provider for token verification (e.g., Stytch adapter)\n//   - orgResolver: Resolves org by provider ID (optional, required for RequireOrganization)\n//   - accResolver: Resolves account by email (optional, required for RequireOrganization)\n//   - config: Middleware configuration (optional, uses defaults if nil)\nfunc NewMiddleware(\n\tprovider AuthProvider,\n\torgResolver OrganizationResolver,\n\taccResolver AccountResolver,\n\tconfig *MiddlewareConfig,\n) *Middleware {\n\tif config == nil {\n\t\tconfig = DefaultMiddlewareConfig()\n\t}\n\treturn &Middleware{\n\t\tprovider:    provider,\n\t\torgResolver: orgResolver,\n\t\taccResolver: accResolver,\n\t\tconfig:      config,\n\t}\n}\n\n// RequireAuth returns middleware that verifies the JWT token.\n//\n// This middleware:\n//  1. Extracts Bearer token from Authorization header\n//  2. Verifies token using the AuthProvider\n//  3. Sets Identity in Gin context (accessible via GetIdentity)\n//\n// Must be called before any middleware that requires authentication.\n//\n// Usage:\n//\n//\trouter.Use(authMiddleware.RequireAuth())\nfunc (m *Middleware) RequireAuth() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Skip OPTIONS requests (CORS preflight)\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Extract Bearer token\n\t\ttoken, err := extractBearerToken(c)\n\t\tif err != nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"missing or invalid authorization header\", err)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Verify token\n\t\tidentity, err := m.provider.VerifyToken(c.Request.Context(), token)\n\t\tif err != nil {\n\t\t\tstatusCode := HTTPStatusCode(err)\n\t\t\tmessage := errorMessage(err)\n\t\t\tm.config.ErrorHandler(c, statusCode, message, err)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Set identity in context\n\t\tSetIdentity(c, identity)\n\n\t\tc.Next()\n\t}\n}\n\n// RequireOrganization returns middleware that resolves org/account from Identity.\n//\n// This middleware:\n//  1. Gets Identity from context (requires RequireAuth to run first)\n//  2. Looks up organization by provider org ID\n//  3. Looks up account by email within organization\n//  4. Sets RequestContext in Gin context (accessible via GetRequestContext)\n//\n// Must be called after RequireAuth middleware.\n//\n// Usage:\n//\n//\trouter.Use(authMiddleware.RequireAuth())\n//\trouter.Use(authMiddleware.RequireOrganization())\nfunc (m *Middleware) RequireOrganization() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Get identity from context\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Validate required fields\n\t\tif identity.OrganizationID == \"\" {\n\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"no organization in token\", ErrMissingOrganization)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tif identity.Email == \"\" {\n\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"no email in token\", ErrMissingEmail)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Resolve organization\n\t\torgID, err := m.orgResolver.ResolveByProviderID(c.Request.Context(), identity.OrganizationID)\n\t\tif err != nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"organization not found\", err)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Resolve account\n\t\taccountID, err := m.accResolver.ResolveByEmail(c.Request.Context(), orgID, identity.Email)\n\t\tif err != nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"account not found\", err)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Set request context\n\t\treqCtx := &RequestContext{\n\t\t\tIdentity:       identity,\n\t\t\tOrganizationID: orgID,\n\t\t\tAccountID:      accountID,\n\t\t\tProviderOrgID:  identity.OrganizationID,\n\t\t}\n\t\tSetRequestContext(c, reqCtx)\n\n\t\t// Also set individual values for backward compatibility\n\t\tc.Set(\"organization_id\", orgID)\n\t\tc.Set(\"account_id\", accountID)\n\t\tc.Set(\"stytch_org_id\", identity.OrganizationID)\n\n\t\tc.Next()\n\t}\n}\n\n// RequirePermission returns middleware that checks for a specific permission.\n//\n// This middleware:\n//  1. Gets Identity from context (requires RequireAuth to run first)\n//  2. Checks if user has the required permission\n//  3. Falls back to role-based permissions if not found in Identity\n//\n// Must be called after RequireAuth middleware.\n//\n// Usage:\n//\n//\trouter.GET(\"/invoices\", authMiddleware.RequirePermission(\"invoice\", \"view\"), handler)\n//\trouter.POST(\"/invoices\", authMiddleware.RequirePermission(\"invoice\", \"create\"), handler)\nfunc (m *Middleware) RequirePermission(resource, action string) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tif !hasPermission(identity, resource, action) {\n\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"insufficient permissions\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// RequireAnyPermission returns middleware that checks for any of the given permissions.\n//\n// This middleware succeeds if the user has at least one of the specified permissions.\n// Useful when multiple permissions can grant access to the same resource.\n//\n// Must be called after RequireAuth middleware.\n//\n// Usage:\n//\n//\trouter.GET(\"/reports\", authMiddleware.RequireAnyPermission(\n//\t    auth.PermPaymentOptSchedule,\n//\t    auth.PermPaymentOptExport,\n//\t), handler)\nfunc (m *Middleware) RequireAnyPermission(permissions ...Permission) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tfor _, perm := range permissions {\n\t\t\tif hasPermission(identity, perm.Resource(), perm.Action()) {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"insufficient permissions\", nil)\n\t\tc.Abort()\n\t}\n}\n\n// RequireAllPermissions returns middleware that checks for all given permissions.\n//\n// This middleware succeeds only if the user has all of the specified permissions.\n//\n// Must be called after RequireAuth middleware.\n//\n// Usage:\n//\n//\trouter.DELETE(\"/org\", authMiddleware.RequireAllPermissions(\n//\t    auth.NewPermission(\"org\", \"view\"),\n//\t    auth.NewPermission(\"org\", \"manage\"),\n//\t), handler)\nfunc (m *Middleware) RequireAllPermissions(permissions ...Permission) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tfor _, perm := range permissions {\n\t\t\tif !hasPermission(identity, perm.Resource(), perm.Action()) {\n\t\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"insufficient permissions\", nil)\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// RequireRole returns middleware that checks for a specific role.\n//\n// Must be called after RequireAuth middleware.\n//\n// Usage:\n//\n//\trouter.POST(\"/admin\", authMiddleware.RequireRole(auth.RoleAdmin), handler)\nfunc (m *Middleware) RequireRole(role Role) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tif !hasRole(identity, role) {\n\t\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"insufficient role\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// RequireAnyRole returns middleware that checks for any of the given roles.\n//\n// Must be called after RequireAuth middleware.\n//\n// Usage:\n//\n//\trouter.GET(\"/dashboard\", authMiddleware.RequireAnyRole(auth.RoleAdmin, auth.RoleApprover), handler)\nfunc (m *Middleware) RequireAnyRole(roles ...Role) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tm.config.ErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tfor _, role := range roles {\n\t\t\tif hasRole(identity, role) {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tm.config.ErrorHandler(c, http.StatusForbidden, \"insufficient role\", nil)\n\t\tc.Abort()\n\t}\n}\n\n// Helper functions\n\n// extractBearerToken extracts the JWT from Authorization header.\nfunc extractBearerToken(c *gin.Context) (string, error) {\n\theader := c.GetHeader(\"Authorization\")\n\tif header == \"\" {\n\t\treturn \"\", ErrUnauthorized\n\t}\n\n\tfields := strings.Fields(header)\n\tif len(fields) != 2 || !strings.EqualFold(fields[0], \"bearer\") {\n\t\treturn \"\", ErrInvalidToken\n\t}\n\n\treturn fields[1], nil\n}\n\n// hasPermission checks if identity has the required permission.\nfunc hasPermission(identity *Identity, resource, action string) bool {\n\tperm := NewPermission(resource, action)\n\n\t// Check explicit permissions in identity\n\tfor _, p := range identity.Permissions {\n\t\tif p == perm || p.MatchesWithWildcard(perm) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Fallback: Check role-based permissions\n\tfor _, role := range identity.Roles {\n\t\tif HasRolePermission(role, resource, action) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// hasRole checks if identity has the required role.\nfunc hasRole(identity *Identity, role Role) bool {\n\tnormalized := NormalizeRole(string(role))\n\tfor _, r := range identity.Roles {\n\t\tif NormalizeRole(string(r)) == normalized {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// errorMessage returns a user-friendly message for auth errors.\nfunc errorMessage(err error) string {\n\tswitch err {\n\tcase ErrTokenExpired:\n\t\treturn \"token expired\"\n\tcase ErrInvalidToken:\n\t\treturn \"invalid token\"\n\tcase ErrEmailNotVerified:\n\t\treturn \"email not verified\"\n\tcase ErrAudienceMismatch:\n\t\treturn \"invalid token audience\"\n\tcase ErrIssuerMismatch:\n\t\treturn \"invalid token issuer\"\n\tdefault:\n\t\treturn \"authentication failed\"\n\t}\n}\n\n// Standalone middleware functions for simpler usage\n\n// RequirePermissionFunc returns a standalone middleware that checks permissions.\n//\n// This is a convenience function that doesn't require a Middleware instance.\n// It reads Identity directly from Gin context.\n//\n// Usage:\n//\n//\trouter.GET(\"/invoices\", auth.RequirePermissionFunc(\"invoice\", \"view\"), handler)\nfunc RequirePermissionFunc(resource, action string) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tdefaultErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tif !hasPermission(identity, resource, action) {\n\t\t\tdefaultErrorHandler(c, http.StatusForbidden, \"insufficient permissions\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// RequireAnyPermissionFunc returns a standalone middleware that checks for any permission.\n//\n// Usage:\n//\n//\trouter.GET(\"/reports\", auth.RequireAnyPermissionFunc(\n//\t    auth.PermPaymentOptSchedule,\n//\t    auth.PermPaymentOptExport,\n//\t), handler)\nfunc RequireAnyPermissionFunc(permissions ...Permission) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidentity := GetIdentity(c)\n\t\tif identity == nil {\n\t\t\tdefaultErrorHandler(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tfor _, perm := range permissions {\n\t\t\tif hasPermission(identity, perm.Resource(), perm.Action()) {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tdefaultErrorHandler(c, http.StatusForbidden, \"insufficient permissions\", nil)\n\t\tc.Abort()\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/permissions.go",
    "content": "package auth\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// Permission represents an authorization permission in \"resource:action\" format.\n//\n// Permissions follow the pattern \"resource:action\" where:\n//   - resource: The entity being accessed (e.g., \"invoice\", \"org\", \"approval\")\n//   - action: The operation being performed (e.g., \"view\", \"create\", \"manage\")\n//\n// # Examples\n//\n//\tauth.NewPermission(\"invoice\", \"create\")  // \"invoice:create\"\n//\tauth.NewPermission(\"org\", \"manage\")      // \"org:manage\"\n//\tauth.NewPermission(\"approval\", \"approve\") // \"approval:approve\"\n//\n// # Adding New Permissions\n//\n// To add a new permission:\n//  1. Add it to the appropriate role in DefaultRolePermissions (roles.go)\n//  2. Configure it in your auth provider (e.g., Stytch RBAC policy)\n//  3. Use RequirePermission(\"resource\", \"action\") in your routes\ntype Permission string\n\n// NewPermission creates a permission from resource and action.\n//\n// Example:\n//\n//\tperm := auth.NewPermission(\"invoice\", \"create\")\n//\t// perm = \"invoice:create\"\nfunc NewPermission(resource, action string) Permission {\n\treturn Permission(fmt.Sprintf(\"%s:%s\", resource, action))\n}\n\n// String returns the string representation of the permission.\nfunc (p Permission) String() string {\n\treturn string(p)\n}\n\n// Resource returns the resource part of the permission.\n//\n// Example:\n//\n//\tperm := auth.NewPermission(\"invoice\", \"create\")\n//\tperm.Resource() // \"invoice\"\nfunc (p Permission) Resource() string {\n\tparts := strings.SplitN(string(p), \":\", 2)\n\tif len(parts) > 0 {\n\t\treturn parts[0]\n\t}\n\treturn \"\"\n}\n\n// Action returns the action part of the permission.\n//\n// Example:\n//\n//\tperm := auth.NewPermission(\"invoice\", \"create\")\n//\tperm.Action() // \"create\"\nfunc (p Permission) Action() string {\n\tparts := strings.SplitN(string(p), \":\", 2)\n\tif len(parts) > 1 {\n\t\treturn parts[1]\n\t}\n\treturn \"\"\n}\n\n// IsValid checks if the permission has both resource and action parts.\nfunc (p Permission) IsValid() bool {\n\tparts := strings.SplitN(string(p), \":\", 2)\n\treturn len(parts) == 2 && parts[0] != \"\" && parts[1] != \"\"\n}\n\n// Matches checks if this permission matches another permission.\n//\n// This is a simple equality check. For wildcard matching,\n// use MatchesWithWildcard.\nfunc (p Permission) Matches(other Permission) bool {\n\treturn p == other\n}\n\n// MatchesWithWildcard checks if this permission matches another,\n// supporting wildcards.\n//\n// Wildcards:\n//   - \"*:*\" matches any permission\n//   - \"resource:*\" matches any action on that resource\n//   - \"*:action\" matches that action on any resource\n//\n// Example:\n//\n//\tauth.Permission(\"invoice:*\").MatchesWithWildcard(\"invoice:create\") // true\n//\tauth.Permission(\"*:view\").MatchesWithWildcard(\"invoice:view\")      // true\nfunc (p Permission) MatchesWithWildcard(other Permission) bool {\n\tif p == other {\n\t\treturn true\n\t}\n\n\tmyResource := p.Resource()\n\tmyAction := p.Action()\n\totherResource := other.Resource()\n\totherAction := other.Action()\n\n\t// Check for wildcards\n\tresourceMatch := myResource == \"*\" || myResource == otherResource\n\tactionMatch := myAction == \"*\" || myAction == otherAction\n\n\treturn resourceMatch && actionMatch\n}\n\n// PermissionSet is a helper for checking multiple permissions efficiently.\ntype PermissionSet map[Permission]struct{}\n\n// NewPermissionSet creates a permission set from a slice of permissions.\nfunc NewPermissionSet(permissions []Permission) PermissionSet {\n\tset := make(PermissionSet, len(permissions))\n\tfor _, p := range permissions {\n\t\tset[p] = struct{}{}\n\t}\n\treturn set\n}\n\n// NewPermissionSetFromStrings creates a permission set from string permissions.\nfunc NewPermissionSetFromStrings(permissions []string) PermissionSet {\n\tset := make(PermissionSet, len(permissions))\n\tfor _, p := range permissions {\n\t\tset[Permission(p)] = struct{}{}\n\t}\n\treturn set\n}\n\n// Contains checks if the set contains a permission.\nfunc (ps PermissionSet) Contains(permission Permission) bool {\n\t_, exists := ps[permission]\n\treturn exists\n}\n\n// ContainsResourceAction checks if the set contains a resource:action permission.\nfunc (ps PermissionSet) ContainsResourceAction(resource, action string) bool {\n\treturn ps.Contains(NewPermission(resource, action))\n}\n\n// ContainsAny checks if the set contains any of the given permissions.\nfunc (ps PermissionSet) ContainsAny(permissions ...Permission) bool {\n\tfor _, p := range permissions {\n\t\tif ps.Contains(p) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ContainsAll checks if the set contains all of the given permissions.\nfunc (ps PermissionSet) ContainsAll(permissions ...Permission) bool {\n\tfor _, p := range permissions {\n\t\tif !ps.Contains(p) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ToSlice converts the permission set to a slice.\nfunc (ps PermissionSet) ToSlice() []Permission {\n\tresult := make([]Permission, 0, len(ps))\n\tfor p := range ps {\n\t\tresult = append(result, p)\n\t}\n\treturn result\n}\n\n// PermissionsToStrings converts a slice of Permission to a slice of strings.\nfunc PermissionsToStrings(permissions []Permission) []string {\n\tresult := make([]string, len(permissions))\n\tfor i, p := range permissions {\n\t\tresult[i] = string(p)\n\t}\n\treturn result\n}\n\n// StringsToPermissions converts a slice of strings to a slice of Permission.\nfunc StringsToPermissions(permissions []string) []Permission {\n\tresult := make([]Permission, len(permissions))\n\tfor i, p := range permissions {\n\t\tresult[i] = Permission(p)\n\t}\n\treturn result\n}\n\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/provider.go",
    "content": "package auth\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/dig\"\n)\n\n// ServerMiddlewareRegistrar is the interface for registering named middleware.\n// This matches the server.Server interface's RegisterNamedMiddleware method.\ntype ServerMiddlewareRegistrar interface {\n\tRegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc)\n}\n\n// Provider handles dependency injection for the RBAC module\ntype Provider struct {\n\tcontainer *dig.Container\n}\n\nfunc NewProvider(container *dig.Container) *Provider {\n\treturn &Provider{\n\t\tcontainer: container,\n\t}\n}\n\n// RegisterDependencies registers all RBAC dependencies in the container\nfunc (p *Provider) RegisterDependencies() error {\n\t// Provide RBAC Service\n\tif err := p.container.Provide(func() RBACService {\n\t\treturn NewRBACService()\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide rbac service: %w\", err)\n\t}\n\n\t// Provide RBAC Handler\n\tif err := p.container.Provide(func(service RBACService) *Handler {\n\t\treturn NewHandler(service)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide rbac handler: %w\", err)\n\t}\n\n\t// Provide RBAC Routes\n\tif err := p.container.Provide(func(handler *Handler) *Routes {\n\t\treturn NewRoutes(handler)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide rbac routes: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// SetupMiddleware wires the auth middleware into the DI container.\n//\n// This must be called after the auth provider and resolvers are available.\n//\n// # Prerequisites\n//\n// The following must be available in the container:\n//   - auth.AuthProvider\n//   - auth.OrganizationResolver\n//   - auth.AccountResolver\n//\n// # Usage\n//\n//\tif err := auth.SetupMiddleware(container); err != nil {\n//\t    return err\n//\t}\nfunc SetupMiddleware(container *dig.Container) error {\n\tif err := container.Provide(func(\n\t\tprovider AuthProvider,\n\t\torgResolver OrganizationResolver,\n\t\taccResolver AccountResolver,\n\t) *Middleware {\n\t\treturn NewMiddleware(provider, orgResolver, accResolver, nil)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide auth middleware: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// RegisterNamedMiddlewares registers the auth middleware functions with the server.\n//\n// This should be called after SetupMiddleware and the server is available.\n// It registers the following named middlewares:\n//   - \"auth\": RequireAuth middleware (verifies JWT token)\n//   - \"org_context\": RequireOrganization middleware (resolves org/account IDs)\n//\n// # Usage\n//\n//\tif err := auth.RegisterNamedMiddlewares(container); err != nil {\n//\t    return err\n//\t}\nfunc RegisterNamedMiddlewares(container *dig.Container) error {\n\treturn container.Invoke(func(\n\t\tmiddleware *Middleware,\n\t\tserver ServerMiddlewareRegistrar,\n\t) {\n\t\t// Register auth middleware (verifies JWT and sets Identity)\n\t\tserver.RegisterNamedMiddleware(\"auth\", func() gin.HandlerFunc {\n\t\t\treturn middleware.RequireAuth()\n\t\t})\n\n\t\t// Register organization context middleware (resolves database IDs)\n\t\tserver.RegisterNamedMiddleware(\"org_context\", func() gin.HandlerFunc {\n\t\t\treturn middleware.RequireOrganization()\n\t\t})\n\t})\n}\n\n// ProvideResolvers provides Organization and Account resolvers.\n//\n// This is a convenience function for when you have repositories that\n// need to be adapted to the resolver interfaces.\n//\n// # Example\n//\n//\tauth.ProvideResolvers(container, func(orgRepo domain.OrganizationRepository) auth.OrganizationResolver {\n//\t    return auth.NewOrganizationResolver(repo)\n//\t}, func(accRepo domain.AccountRepository) auth.AccountResolver {\n//\t    return auth.NewAccountResolver(repo)\n//\t})\nfunc ProvideResolvers(\n\tcontainer *dig.Container,\n\torgResolverProvider any,\n\taccResolverProvider any,\n) error {\n\tif err := container.Provide(orgResolverProvider); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide organization resolver: %w\", err)\n\t}\n\tif err := container.Provide(accResolverProvider); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide account resolver: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/rbac.go",
    "content": "package auth\n\n// =============================================================================\n// RBAC DEFINITIONS - Roles and Permissions\n// =============================================================================\n//\n// This file is the SINGLE SOURCE OF TRUTH for all role and permission definitions.\n// Customize these for your business domain.\n//\n// =============================================================================\n\n// =============================================================================\n// PERMISSIONS - Customize for your business domain\n// =============================================================================\n//\n// The default uses \"resource\" as a generic placeholder.\n// Change \"resource\" to match your domain entity:\n//\n//   E-commerce:     \"product:view\", \"product:create\", \"order:manage\"\n//   Healthcare:     \"patient:view\", \"records:manage\", \"prescription:create\"\n//   Project Mgmt:   \"project:view\", \"task:create\", \"task:assign\"\n//   CRM:            \"contact:view\", \"deal:create\", \"deal:close\"\n//   Invoice System: \"invoice:view\", \"invoice:create\", \"invoice:approve\"\n//\n// Simply rename \"resource\" to your domain entity!\n//\n// =============================================================================\n\nvar (\n\t// Resource permissions - rename \"resource\" to your domain entity\n\tPermResourceView    = NewPermission(\"resource\", \"view\")\n\tPermResourceCreate  = NewPermission(\"resource\", \"create\")\n\tPermResourceEdit    = NewPermission(\"resource\", \"edit\")\n\tPermResourceDelete  = NewPermission(\"resource\", \"delete\")\n\tPermResourceApprove = NewPermission(\"resource\", \"approve\")\n\n\t// Organization permissions\n\tPermOrgView   = NewPermission(\"org\", \"view\")\n\tPermOrgManage = NewPermission(\"org\", \"manage\")\n)\n\n// AllPermissions is the complete list of all permissions in the system.\n// Update this when you add or remove permissions.\nvar AllPermissions = []Permission{\n\tPermResourceView,\n\tPermResourceCreate,\n\tPermResourceEdit,\n\tPermResourceDelete,\n\tPermResourceApprove,\n\tPermOrgView,\n\tPermOrgManage,\n}\n\n// =============================================================================\n// ROLES - Customize role names and permissions as needed\n// =============================================================================\n//\n// Default roles follow a simple hierarchy:\n//   - Member: Basic access (view, create)\n//   - Manager: Elevated access (edit, delete, approve)\n//   - Admin: Full control (everything + org management)\n//\n// To customize:\n//   1. Change role IDs if needed (must match Stytch configuration)\n//   2. Adjust permissions for each role\n//   3. Add new roles if needed\n//\n// =============================================================================\n\n// RoleInfo contains complete information about a role including its permissions.\n// Used for API responses and role lookups.\ntype RoleInfo struct {\n\t// ID is the unique identifier for the role (e.g., \"member\", \"manager\", \"admin\")\n\tID string\n\t// Name is the display name for the role\n\tName string\n\t// Description explains the purpose and scope of the role\n\tDescription string\n\t// Permissions is the list of permissions granted to this role\n\tPermissions []Permission\n}\n\nvar (\n\t// RoleMemberInfo - Basic user access\n\t// Typical users: Employees, staff, basic users\n\tRoleMemberInfo = RoleInfo{\n\t\tID:          \"member\",\n\t\tName:        \"Member\",\n\t\tDescription: \"Basic access. Can view and create resources.\",\n\t\tPermissions: []Permission{\n\t\t\tPermResourceView,\n\t\t\tPermResourceCreate,\n\t\t},\n\t}\n\n\t// RoleManagerInfo - Elevated access with approval rights\n\t// Typical users: Team leads, supervisors, managers\n\tRoleManagerInfo = RoleInfo{\n\t\tID:          \"manager\",\n\t\tName:        \"Manager\",\n\t\tDescription: \"Elevated access. Can edit, delete, and approve resources.\",\n\t\tPermissions: []Permission{\n\t\t\tPermResourceView,\n\t\t\tPermResourceCreate,\n\t\t\tPermResourceEdit,\n\t\t\tPermResourceDelete,\n\t\t\tPermResourceApprove,\n\t\t\tPermOrgView,\n\t\t},\n\t}\n\n\t// RoleAdminInfo - Full system control\n\t// Typical users: Business owners, administrators\n\tRoleAdminInfo = RoleInfo{\n\t\tID:          \"admin\",\n\t\tName:        \"Admin\",\n\t\tDescription: \"Full control. Can manage organization settings and users.\",\n\t\tPermissions: []Permission{\n\t\t\tPermResourceView,\n\t\t\tPermResourceCreate,\n\t\t\tPermResourceEdit,\n\t\t\tPermResourceDelete,\n\t\t\tPermResourceApprove,\n\t\t\tPermOrgView,\n\t\t\tPermOrgManage,\n\t\t},\n\t}\n)\n\n// AllRoles is the complete list of all roles in the RBAC system.\n// Update this when you add or remove roles.\nvar AllRoles = []RoleInfo{\n\tRoleMemberInfo,\n\tRoleManagerInfo,\n\tRoleAdminInfo,\n}\n\n// =============================================================================\n// HELPER FUNCTIONS\n// =============================================================================\n\n// GetRoleInfo retrieves role information by role ID.\n// Returns nil if the role is not found.\nfunc GetRoleInfo(roleID string) *RoleInfo {\n\tfor i := range AllRoles {\n\t\tif AllRoles[i].ID == roleID {\n\t\t\treturn &AllRoles[i]\n\t\t}\n\t}\n\treturn nil\n}\n\n// GetRolePermissionIDs returns just the permission IDs (strings) for a given role.\n// Useful for Stytch integration and API responses.\nfunc GetRolePermissionIDs(roleID string) []string {\n\trole := GetRoleInfo(roleID)\n\tif role == nil {\n\t\treturn []string{}\n\t}\n\n\tids := make([]string, len(role.Permissions))\n\tfor i, perm := range role.Permissions {\n\t\tids[i] = string(perm)\n\t}\n\treturn ids\n}\n\n// HasPermission checks if a role has a specific permission.\nfunc HasPermission(roleID string, permission Permission) bool {\n\trole := GetRoleInfo(roleID)\n\tif role == nil {\n\t\treturn false\n\t}\n\n\tfor _, perm := range role.Permissions {\n\t\tif perm == permission {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// =============================================================================\n// PERMISSION MATRIX (for reference)\n// =============================================================================\n//\n// | Permission        | Member | Manager | Admin |\n// |-------------------|--------|---------|-------|\n// | resource:view     |   ✓    |    ✓    |   ✓   |\n// | resource:create   |   ✓    |    ✓    |   ✓   |\n// | resource:edit     |        |    ✓    |   ✓   |\n// | resource:delete   |        |    ✓    |   ✓   |\n// | resource:approve  |        |    ✓    |   ✓   |\n// | org:view          |        |    ✓    |   ✓   |\n// | org:manage        |        |         |   ✓   |\n//\n// Role totals:\n//   - Member: 2 permissions\n//   - Manager: 6 permissions\n//   - Admin: 7 permissions (all)\n//\n// =============================================================================\n\n// =============================================================================\n// API RESPONSE TYPES (DTOs)\n// =============================================================================\n\n// PermissionDTO represents a permission in API responses\ntype PermissionDTO struct {\n\tID          string `json:\"id\"`\n\tResource    string `json:\"resource\"`\n\tAction      string `json:\"action\"`\n\tDisplayName string `json:\"display_name\"`\n\tDescription string `json:\"description\"`\n\tCategory    string `json:\"category\"`\n}\n\n// NewPermissionDTO converts a Permission to a DTO\nfunc NewPermissionDTO(perm Permission) PermissionDTO {\n\treturn PermissionDTO{\n\t\tID:       string(perm),\n\t\tResource: perm.Resource(),\n\t\tAction:   perm.Action(),\n\t\t// Generic display name and description for simple permissions\n\t\tDisplayName: perm.Resource() + \" \" + perm.Action(),\n\t\tDescription: \"Can \" + perm.Action() + \" \" + perm.Resource(),\n\t\tCategory:    \"General\",\n\t}\n}\n\n// RoleDTO represents a role with its permissions in API responses\ntype RoleDTO struct {\n\tID          string          `json:\"id\"`\n\tName        string          `json:\"name\"`\n\tDescription string          `json:\"description\"`\n\tPermissions []PermissionDTO `json:\"permissions\"`\n}\n\n// NewRoleDTO converts a RoleInfo to a DTO\nfunc NewRoleDTO(role RoleInfo) RoleDTO {\n\tpermDTOs := make([]PermissionDTO, len(role.Permissions))\n\tfor i, perm := range role.Permissions {\n\t\tpermDTOs[i] = NewPermissionDTO(perm)\n\t}\n\n\treturn RoleDTO{\n\t\tID:          role.ID,\n\t\tName:        role.Name,\n\t\tDescription: role.Description,\n\t\tPermissions: permDTOs,\n\t}\n}\n\n// RolesResponse is the response body for GET /rbac/roles\ntype RolesResponse struct {\n\tRoles []RoleDTO `json:\"roles\"`\n}\n\n// PermissionsResponse is the response body for GET /rbac/permissions\ntype PermissionsResponse struct {\n\tPermissions []PermissionDTO `json:\"permissions\"`\n}\n\n// PermissionsByCategoryResponse is the response body for GET /rbac/permissions/by-category\ntype PermissionsByCategoryResponse struct {\n\tCategories map[string][]PermissionDTO `json:\"categories\"`\n}\n\n// RolePermissionsResponse contains role information with detailed metadata\ntype RolePermissionsResponse struct {\n\tRole         RoleDTO          `json:\"role\"`\n\tStatistics   RoleStatistics   `json:\"statistics\"`\n\tRestrictions RoleRestrictions `json:\"restrictions\"`\n}\n\n// RoleStatistics provides summary information about a role\ntype RoleStatistics struct {\n\tTotalPermissions int    `json:\"total_permissions\"`\n\tCanApprove       bool   `json:\"can_approve\"`\n\tCanManageOrg     bool   `json:\"can_manage_org\"`\n\tDescription      string `json:\"description\"`\n}\n\n// RoleRestrictions documents what a role cannot do\ntype RoleRestrictions struct {\n\tCannotDo        []string `json:\"cannot_do\"`\n\tDataAccessLevel string   `json:\"data_access_level\"`\n\tScope           string   `json:\"scope\"`\n}\n\n// NewRolePermissionsResponse creates a detailed response for a role\nfunc NewRolePermissionsResponse(roleID string) *RolePermissionsResponse {\n\trole := GetRoleInfo(roleID)\n\tif role == nil {\n\t\treturn nil\n\t}\n\n\tstats := RoleStatistics{\n\t\tTotalPermissions: len(role.Permissions),\n\t\tCanApprove:       HasPermission(roleID, PermResourceApprove),\n\t\tCanManageOrg:     HasPermission(roleID, PermOrgManage),\n\t\tDescription:      role.Description,\n\t}\n\n\t// Define restrictions based on role\n\tvar restrictions RoleRestrictions\n\tswitch roleID {\n\tcase \"member\":\n\t\trestrictions = RoleRestrictions{\n\t\t\tCannotDo:        []string{\"Edit resources\", \"Delete resources\", \"Approve requests\", \"Manage organization\"},\n\t\t\tDataAccessLevel: \"Basic - view and create only\",\n\t\t\tScope:           \"Limited to own resources\",\n\t\t}\n\tcase \"manager\":\n\t\trestrictions = RoleRestrictions{\n\t\t\tCannotDo:        []string{\"Manage organization settings\"},\n\t\t\tDataAccessLevel: \"Elevated - full resource access\",\n\t\t\tScope:           \"Team-wide access\",\n\t\t}\n\tcase \"admin\":\n\t\trestrictions = RoleRestrictions{\n\t\t\tCannotDo:        []string{},\n\t\t\tDataAccessLevel: \"Full - all data access\",\n\t\t\tScope:           \"Organization-wide\",\n\t\t}\n\tdefault:\n\t\trestrictions = RoleRestrictions{\n\t\t\tCannotDo:        []string{},\n\t\t\tDataAccessLevel: \"Unknown\",\n\t\t\tScope:           \"Unknown\",\n\t\t}\n\t}\n\n\treturn &RolePermissionsResponse{\n\t\tRole:         NewRoleDTO(*role),\n\t\tStatistics:   stats,\n\t\tRestrictions: restrictions,\n\t}\n}\n\n// PermissionCheckRequest is used to verify if a role has a permission\ntype PermissionCheckRequest struct {\n\tRoleID       string `json:\"role_id\" binding:\"required\"`\n\tPermissionID string `json:\"permission_id\" binding:\"required\"`\n}\n\n// PermissionCheckResponse indicates whether a role has a permission\ntype PermissionCheckResponse struct {\n\tRoleID        string `json:\"role_id\"`\n\tPermissionID  string `json:\"permission_id\"`\n\tHasPermission bool   `json:\"has_permission\"`\n}\n\n// RBACMetadata provides summary information about the RBAC system\ntype RBACMetadata struct {\n\tTotalRoles        int            `json:\"total_roles\"`\n\tTotalPermissions  int            `json:\"total_permissions\"`\n\tPermissionsByRole map[string]int `json:\"permissions_by_role\"`\n\tDescription       string         `json:\"description\"`\n}\n\n// NewRBACMetadata creates metadata about the RBAC system\nfunc NewRBACMetadata() RBACMetadata {\n\tpermsByRole := make(map[string]int)\n\tfor _, role := range AllRoles {\n\t\tpermsByRole[role.ID] = len(role.Permissions)\n\t}\n\n\treturn RBACMetadata{\n\t\tTotalRoles:        len(AllRoles),\n\t\tTotalPermissions:  len(AllPermissions),\n\t\tPermissionsByRole: permsByRole,\n\t\tDescription:       \"Simple RBAC system with 3 roles (Member, Manager, Admin) and 7 generic permissions\",\n\t}\n}\n\n// =============================================================================\n// SERVICE INTERFACE AND IMPLEMENTATION\n// =============================================================================\n\n// RBACService provides business logic for RBAC operations\ntype RBACService interface {\n\tGetAllRoles() []RoleInfo\n\tGetRoleInfo(roleID string) *RoleInfo\n\tGetAllPermissions() []Permission\n\tGetRolePermissions(roleID string) []Permission\n\tGetPermissionsByCategory() map[string][]Permission\n\tGetPermissionsByRoleID(roleID string) []string\n\tHasPermission(roleID string, permissionID string) bool\n\tGetRBACMetadata() RBACMetadata\n}\n\n// defaultRBACService implements the RBACService interface\ntype defaultRBACService struct{}\n\nfunc NewRBACService() RBACService {\n\treturn &defaultRBACService{}\n}\n\nfunc (s *defaultRBACService) GetAllRoles() []RoleInfo {\n\treturn AllRoles\n}\n\nfunc (s *defaultRBACService) GetRoleInfo(roleID string) *RoleInfo {\n\treturn GetRoleInfo(roleID)\n}\n\nfunc (s *defaultRBACService) GetAllPermissions() []Permission {\n\treturn AllPermissions\n}\n\nfunc (s *defaultRBACService) GetRolePermissions(roleID string) []Permission {\n\trole := GetRoleInfo(roleID)\n\tif role == nil {\n\t\treturn []Permission{}\n\t}\n\treturn role.Permissions\n}\n\nfunc (s *defaultRBACService) GetPermissionsByCategory() map[string][]Permission {\n\t// For simplicity, return all permissions in one \"General\" category\n\treturn map[string][]Permission{\n\t\t\"General\": AllPermissions,\n\t}\n}\n\nfunc (s *defaultRBACService) GetPermissionsByRoleID(roleID string) []string {\n\treturn GetRolePermissionIDs(roleID)\n}\n\nfunc (s *defaultRBACService) HasPermission(roleID string, permissionID string) bool {\n\treturn HasPermission(roleID, Permission(permissionID))\n}\n\nfunc (s *defaultRBACService) GetRBACMetadata() RBACMetadata {\n\treturn NewRBACMetadata()\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/resolvers.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\n// OrganizationLookup is the minimal interface needed to resolve organizations.\n//\n// This interface should be implemented by your organization repository.\n// It abstracts the specific repository implementation from the auth package.\ntype OrganizationLookup interface {\n\t// GetByStytchID returns an organization by Stytch organization ID.\n\t// The returned value must have an ID field (int32).\n\tGetByStytchID(ctx context.Context, stytchOrgID string) (OrganizationEntity, error)\n}\n\n// OrganizationEntity is the minimal interface for an organization entity.\ntype OrganizationEntity interface {\n\tGetID() int32\n}\n\n// AccountLookup is the minimal interface needed to resolve accounts.\n//\n// This interface should be implemented by your account repository.\n// It abstracts the specific repository implementation from the auth package.\ntype AccountLookup interface {\n\t// GetByEmail returns an account by email within an organization.\n\t// The returned value must have an ID field (int32).\n\tGetByEmail(ctx context.Context, orgID int32, email string) (AccountEntity, error)\n}\n\n// AccountEntity is the minimal interface for an account entity.\ntype AccountEntity interface {\n\tGetID() int32\n}\n\n// NewOrganizationResolver creates an OrganizationResolver from an OrganizationLookup.\n//\n// This is a convenience function for creating resolvers from repositories\n// that implement the OrganizationLookup interface.\n//\n// # Usage\n//\n//\t// In your organizations module provider:\n//\tcontainer.Provide(func(repo domain.OrganizationRepository) auth.OrganizationResolver {\n//\t    return auth.NewOrganizationResolver(repo)\n//\t})\nfunc NewOrganizationResolver(lookup OrganizationLookup) OrganizationResolver {\n\treturn &orgResolverAdapter{lookup: lookup}\n}\n\n// NewAccountResolver creates an AccountResolver from an AccountLookup.\n//\n// # Usage\n//\n//\t// In your organizations module provider:\n//\tcontainer.Provide(func(repo domain.AccountRepository) auth.AccountResolver {\n//\t    return auth.NewAccountResolver(repo)\n//\t})\nfunc NewAccountResolver(lookup AccountLookup) AccountResolver {\n\treturn &accResolverAdapter{lookup: lookup}\n}\n\n// orgResolverAdapter adapts OrganizationLookup to OrganizationResolver.\ntype orgResolverAdapter struct {\n\tlookup OrganizationLookup\n}\n\nfunc (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, providerOrgID string) (int32, error) {\n\torg, err := a.lookup.GetByStytchID(ctx, providerOrgID)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"organization not found for provider ID %s: %w\", providerOrgID, err)\n\t}\n\treturn org.GetID(), nil\n}\n\n// accResolverAdapter adapts AccountLookup to AccountResolver.\ntype accResolverAdapter struct {\n\tlookup AccountLookup\n}\n\nfunc (a *accResolverAdapter) ResolveByEmail(ctx context.Context, orgID int32, email string) (int32, error) {\n\tacc, err := a.lookup.GetByEmail(ctx, orgID, email)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"account not found for email %s in org %d: %w\", email, orgID, err)\n\t}\n\treturn acc.GetID(), nil\n}\n\n// SimpleOrganization is a simple implementation of OrganizationEntity.\n// Use this if your domain entity doesn't already implement GetID().\ntype SimpleOrganization struct {\n\tID int32\n}\n\nfunc (o *SimpleOrganization) GetID() int32 { return o.ID }\n\n// SimpleAccount is a simple implementation of AccountEntity.\n// Use this if your domain entity doesn't already implement GetID().\ntype SimpleAccount struct {\n\tID int32\n}\n\nfunc (a *SimpleAccount) GetID() int32 { return a.ID }\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/roles.go",
    "content": "package auth\n\n// Role represents a user role in the system.\n//\n// Roles are assigned to users and determine their base permissions.\n// The application uses a three-tier RBAC system:\n//\n//   - Member: Basic access (view and create)\n//   - Manager: Elevated access (edit, delete, approve)\n//   - Admin: Full system control\n//\n// # Adding New Roles\n//\n// To add a new role:\n//  1. Add the role constant below\n//  2. Add role info in rbac.go (AllRoles)\n//  3. Configure the role in your auth provider (e.g., Stytch dashboard)\n//\n// # Role Source of Truth\n//\n// The auth provider (e.g., Stytch) is the source of truth for roles at runtime.\n// The definitions in rbac.go are used as a fallback when the auth provider\n// doesn't provide explicit permissions.\ntype Role string\n\n// Core RBAC roles.\n//\n// These must match the roles configured in the auth provider.\nconst (\n\t// RoleMember is for basic users with view and create access.\n\t// Can: View resources, create resources\n\t// Cannot: Edit, delete, approve, manage org\n\tRoleMember Role = \"member\"\n\n\t// RoleManager is for users with elevated access.\n\t// Can: View, create, edit, delete, approve resources\n\t// Cannot: Manage organization settings\n\tRoleManager Role = \"manager\"\n\n\t// RoleAdmin has full system control.\n\t// Can: Everything - no restrictions\n\tRoleAdmin Role = \"admin\"\n)\n\n// Legacy role aliases for backward compatibility.\n//\n// These map to the new role constants and will be removed in a future version.\nconst (\n\t// RoleOwner is a legacy alias for RoleAdmin.\n\t// Deprecated: Use RoleAdmin instead.\n\tRoleOwner Role = \"owner\"\n\n\t// RoleApprover is a legacy alias for RoleManager.\n\t// Deprecated: Use RoleManager instead.\n\tRoleApprover Role = \"approver\"\n\n\t// RoleReviewer is a legacy alias for RoleManager.\n\t// Deprecated: Use RoleManager instead.\n\tRoleReviewer Role = \"reviewer\"\n\n\t// RoleEmployee is a legacy alias for RoleMember.\n\t// Deprecated: Use RoleMember instead.\n\tRoleEmployee Role = \"employee\"\n)\n\n// String returns the string representation of the role.\nfunc (r Role) String() string {\n\treturn string(r)\n}\n\n// IsValid checks if the role is a known role.\nfunc (r Role) IsValid() bool {\n\tnormalized := NormalizeRole(string(r))\n\troleInfo := GetRoleInfo(string(normalized))\n\treturn roleInfo != nil\n}\n\n// NormalizeRole converts legacy role names to current ones.\n//\n// This handles backward compatibility for old role assignments:\n//   - \"owner\" -> \"admin\"\n//   - \"approver\" -> \"manager\"\n//   - \"reviewer\" -> \"manager\"\n//   - \"employee\" -> \"member\"\n//   - \"stytch_member\" -> \"member\" (strips provider prefix)\nfunc NormalizeRole(roleStr string) Role {\n\trole := Role(roleStr)\n\n\t// Handle provider-prefixed roles (e.g., \"stytch_member\")\n\tswitch role {\n\tcase \"stytch_member\":\n\t\treturn RoleMember\n\tcase \"stytch_admin\":\n\t\treturn RoleAdmin\n\tcase \"stytch_manager\":\n\t\treturn RoleManager\n\t}\n\n\t// Handle legacy roles\n\tswitch role {\n\tcase RoleOwner:\n\t\treturn RoleAdmin\n\tcase RoleApprover, RoleReviewer:\n\t\treturn RoleManager\n\tcase RoleEmployee:\n\t\treturn RoleMember\n\t}\n\n\treturn role\n}\n\n// GetRolePermissions returns the default permissions for a role.\n//\n// Returns nil if the role is not recognized.\n// This is used as a fallback when the auth provider doesn't provide permissions.\n//\n// Permissions are defined in rbac.go which is the single source of truth.\nfunc GetRolePermissions(role Role) []Permission {\n\t// Normalize the role to handle legacy names\n\tnormalized := NormalizeRole(string(role))\n\n\t// Get role info from rbac.go (single source of truth)\n\troleInfo := GetRoleInfo(string(normalized))\n\tif roleInfo == nil {\n\t\treturn nil\n\t}\n\n\treturn roleInfo.Permissions\n}\n\n// HasRolePermission checks if a role has a specific permission.\n//\n// This uses the role definitions from rbac.go and is used as a fallback\n// when the auth provider doesn't include explicit permissions.\nfunc HasRolePermission(role Role, resource, action string) bool {\n\tperms := GetRolePermissions(role)\n\ttarget := NewPermission(resource, action)\n\tfor _, p := range perms {\n\t\tif p == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/auth/routes.go",
    "content": "package auth\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\tserverDomain \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\n// Routes handles RBAC API routes registration\ntype Routes struct {\n\thandler *Handler\n}\n\nfunc NewRoutes(handler *Handler) *Routes {\n\treturn &Routes{\n\t\thandler: handler,\n\t}\n}\n\n// RegisterRoutes registers RBAC routes on the router\n// Note: RBAC endpoints are public and do NOT require authentication\n// These endpoints are used by frontend for role/permission discovery\nfunc (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\t// RBAC info endpoints - NO authentication required for role/permission discovery\n\trbacGroup := router.Group(\"/rbac\")\n\t{\n\t\t// Get all roles with their permissions - single source of truth for frontend\n\t\t// GET /api/rbac/roles\n\t\trbacGroup.GET(\"/roles\",\n\t\t\tr.handler.GetRoles)\n\n\t\t// Get all permissions - useful for permission checkers\n\t\t// GET /api/rbac/permissions\n\t\trbacGroup.GET(\"/permissions\",\n\t\t\tr.handler.GetPermissions)\n\n\t\t// Get permissions organized by category - for structured UI display\n\t\t// GET /api/rbac/permissions/by-category\n\t\trbacGroup.GET(\"/permissions/by-category\",\n\t\t\tr.handler.GetPermissionsByCategory)\n\n\t\t// Get detailed information about a specific role with statistics\n\t\t// GET /api/rbac/roles/{role_id}\n\t\trbacGroup.GET(\"/roles/:role_id\",\n\t\t\tr.handler.GetRoleDetails)\n\n\t\t// Check if a role has a specific permission - for conditional UI rendering\n\t\t// POST /api/rbac/check-permission\n\t\trbacGroup.POST(\"/check-permission\",\n\t\t\tr.handler.CheckPermission)\n\n\t\t// Get RBAC system metadata\n\t\t// GET /api/rbac/metadata\n\t\trbacGroup.GET(\"/metadata\",\n\t\t\tr.handler.GetMetadata)\n\t}\n}\n\n// Routes satisfies the RouteRegistrar interface\n// This allows the routes to be registered by the server\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\tr.RegisterRoutes(router, resolver)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/README.md",
    "content": "# Billing Module\n\nHybrid subscription lifecycle management for B2B SaaS applications. This module combines **event-driven webhooks** with **active verification** for maximum reliability.\n\n## Key Principle: Hybrid Synchronization Strategy\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                     HYBRID BILLING SYNC (This Module)                       │\n│                                                                             │\n│  \"Primary: Webhooks + Fallback: Active Verification + Self-Healing\"        │\n│                                                                             │\n│  1. VERIFICATION ON REDIRECT (Initial Payment):                             │\n│     User pays → Frontend calls /verify-payment → Instant access            │\n│                                                                             │\n│  2. WEBHOOKS (Renewals):                                                    │\n│     Polar.sh sends webhook → Billing module processes → DB updated         │\n│                                                                             │\n│  3. LAZY GUARDING (Missed Webhooks):                                        │\n│     DB says expired → Middleware checks Polar API → Self-healing           │\n│                                                                             │\n│  Result: Fast, reliable, self-healing subscription management               │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n## Architecture\n\n```\n                                EXTERNAL\n┌────────────────────────────────────────────────────────────────────────────┐\n│                              Polar.sh                                      │\n│                                                                            │\n│   - Handles checkout, payment processing, subscription management          │\n│   - Sends webhooks on state changes                                        │\n└────────────────────────────────────────────────────────────────────────────┘\n                    │\n                    │ Webhooks (subscription.created, subscription.updated, etc.)\n                    ▼\n┌────────────────────────────────────────────────────────────────────────────┐\n│                         BILLING MODULE                                     │\n├────────────────────────────────────────────────────────────────────────────┤\n│                                                                            │\n│  ┌──────────────────┐     ┌──────────────────┐     ┌──────────────────┐   │\n│  │  Webhook Handler │ ──► │  BillingService  │ ──► │   Repository     │   │\n│  │   (API Layer)    │     │  (Domain Logic)  │     │  (Infra Layer)   │   │\n│  └──────────────────┘     └──────────────────┘     └──────────────────┘   │\n│                                    │                        │              │\n│                                    ▼                        ▼              │\n│                           ┌──────────────────┐     ┌──────────────────┐   │\n│                           │  Quota Tracking  │     │    Local DB      │   │\n│                           └──────────────────┘     └──────────────────┘   │\n│                                                                            │\n└────────────────────────────────────────────────────────────────────────────┘\n                    │\n                    │ Reads subscription status\n                    ▼\n┌────────────────────────────────────────────────────────────────────────────┐\n│                      PAYWALL MIDDLEWARE (pkg/paywall)                      │\n│                                                                            │\n│   - Reads from local DB (fast, no external calls)                          │\n│   - Blocks requests if subscription inactive (402)                         │\n│   - Provider-agnostic access gating                                        │\n└────────────────────────────────────────────────────────────────────────────┘\n```\n\n## Synchronization Mechanisms\n\n### 1. Verification on Redirect (Initial Payment)\n\n**Use Case:** User completes payment and returns to app\n\n**Problem:** Webhooks may not arrive immediately (delays, failures)\n\n**Solution:** Frontend triggers backend verification\n\n```\nUser Pays → Polar Redirects → Frontend → POST /verify-payment → Backend → Polar API → DB Updated → Instant Access\n```\n\n**Benefits:**\n- ✅ Instant access (5 seconds vs. minutes)\n- ✅ No webhook dependency\n- ✅ User sees immediate result\n\n**Implementation:**\n- Endpoint: `POST /api/subscriptions/verify-payment`\n- Service: `src/app/billing/app/services/verify_payment_service.go`\n- Adapter: `src/app/billing/infra/polar/polar_adapter.go` → `GetCheckoutSession()`\n\n### 2. Webhooks (Renewals & Updates)\n\n**Use Case:** Monthly renewals, cancellations, plan changes\n\n**Standard Flow:** Polar sends webhook → Backend processes → DB updated\n\n**Supported Events:**\n- `subscription.created`, `subscription.updated`, `subscription.canceled`\n- `customer.updated`, `meter.grant.updated`\n\n### 3. Lazy Guarding (Self-Healing)\n\n**Use Case:** Webhook failed or delayed for renewal\n\n**How It Works:**\n1. User makes request\n2. Middleware checks DB → Status: \"expired\"\n3. Middleware calls Polar API to verify\n4. If Polar says \"active\" → Grant access + Update DB\n5. If Polar says \"inactive\" → Block access (truly expired)\n\n**Code (Automatic in Middleware):**\n```go\nif !status.IsActive && status.Status != StatusNone {\n    freshStatus, err := provider.RefreshSubscriptionStatus(ctx, orgID)\n    if err == nil && freshStatus.IsActive {\n        status = freshStatus  // Self-healed!\n    }\n}\n```\n\n**Benefits:**\n- ✅ Self-healing: No manual intervention\n- ✅ Fast: Only calls API in edge cases (<1% of requests)\n- ✅ Reliable: Paying users never locked out\n\n## Why Hybrid Approach?\n\n| Scenario | Mechanism | Benefit |\n|----------|-----------|---------|\n| Initial Payment | Verification on Redirect | Instant access |\n| Monthly Renewal | Webhooks | No user action needed |\n| Missed Webhook | Lazy Guarding | Self-healing |\n| Normal Requests | Database Read | Fast (no API calls) |\n\n## Module Structure\n\n```\nsrc/app/billing/\n├── domain/\n│   ├── subscription.go      # Subscription entity\n│   ├── quota.go             # Quota tracking entity\n│   ├── billing_status.go    # Combined status for API responses\n│   ├── repository.go        # Repository interfaces\n│   ├── service.go           # Service interface\n│   └── errors.go            # Domain errors\n│\n├── app/services/\n│   ├── subscription_service_dec.go  # BillingService interface\n│   ├── sync_service.go              # Sync subscription from Polar\n│   ├── webhook_service.go           # Process webhook events\n│   └── quota_service.go             # Quota management\n│\n├── infra/\n│   ├── adapters/\n│   │   └── status_provider.go       # Bridge to paywall middleware\n│   ├── repositories/\n│   │   ├── subscription_repository.go   # Subscription DB operations\n│   │   └── organization_adapter.go      # Org ID lookups\n│   └── polar/\n│       └── polar_adapter.go         # Polar API client (webhook only)\n│\n└── cmd/\n    └── init.go              # DI initialization\n```\n\n## Data Flow\n\n### 1. Subscription Created (Webhook)\n\n```\nPolar.sh                    Billing Module                Local DB\n    │                            │                            │\n    │  subscription.created      │                            │\n    │ ────────────────────────►  │                            │\n    │                            │  UpsertSubscription()      │\n    │                            │ ────────────────────────►  │\n    │                            │                            │\n    │                            │  UpsertQuota()             │\n    │                            │ ────────────────────────►  │\n    │                            │                            │\n    │         200 OK             │                            │\n    │ ◄────────────────────────  │                            │\n```\n\n### 2. User Accesses Premium Feature\n\n```\nUser                        Paywall                    Local DB\n  │                            │                          │\n  │  GET /ai/generate          │                          │\n  │ ────────────────────────►  │                          │\n  │                            │  GetSubscriptionStatus() │\n  │                            │ ──────────────────────►  │\n  │                            │                          │\n  │                            │  {status: \"active\"}      │\n  │                            │ ◄──────────────────────  │\n  │                            │                          │\n  │         Pass through       │                          │\n  │ ◄────────────────────────  │                          │\n```\n\n### 3. Quota Consumption (Invoice Processing)\n\n```\nUser                      BillingService                Local DB\n  │                            │                           │\n  │  POST /invoices/process    │                           │\n  │ ────────────────────────►  │                           │\n  │                            │  DecrementInvoiceCount()  │\n  │                            │ ──────────────────────►   │\n  │                            │                           │\n  │                            │  {remaining: 42}          │\n  │                            │ ◄──────────────────────   │\n  │                            │                           │\n  │         200 OK             │                           │\n  │ ◄────────────────────────  │                           │\n```\n\n## Key Components\n\n### BillingService\n\n```go\n// BillingService handles subscription management and quota verification.\n//\n// This service manages the billing lifecycle with Polar.sh via event-driven webhooks.\n// It does NOT expose direct API calls to Polar during request handling:\n//\n//  1. WEBHOOK PROCESSING (async, event-driven):\n//     - subscription.created, subscription.updated, subscription.canceled\n//     - Updates local database with subscription state\n//\n//  2. LOCAL DB QUERIES (sync, during requests):\n//     - GetBillingStatus: Check subscription status from local DB\n//     - GetQuotaStatus: Check quota limits from local DB\n//\n//  3. QUOTA CONSUMPTION (sync, during requests):\n//     - ConsumeInvoiceQuota: Decrement invoice count in local DB\ntype BillingService interface {\n    // Webhook processing (called by webhook handler)\n    ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error\n\n    // Status queries (from local DB only)\n    GetBillingStatus(ctx context.Context, organizationID int32) (*BillingStatus, error)\n    CheckQuotaAvailability(ctx context.Context, organizationID int32) (*BillingStatus, error)\n\n    // Quota consumption (local DB update)\n    ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*BillingStatus, error)\n\n    // NEW: Verification on Redirect (makes Polar API call)\n    VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*BillingStatus, error)\n\n    // NEW: Lazy Guarding (makes Polar API call when DB says expired)\n    RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*BillingStatus, error)\n\n    // Manual sync (for admin/debug - makes Polar API call)\n    SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error\n}\n```\n\n### StatusProviderAdapter\n\nBridges the billing module to the paywall middleware:\n\n```go\n// In app/billing/infra/adapters/status_provider.go\ntype StatusProviderAdapter struct {\n    service services.BillingService\n}\n\n// Implements paywall.SubscriptionStatusProvider\nfunc (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) {\n    billingStatus, err := a.service.GetBillingStatus(ctx, orgID)\n    if err != nil {\n        return nil, err\n    }\n\n    return &paywall.SubscriptionStatus{\n        OrganizationID: orgID,\n        Status:         billingStatus.SubscriptionStatus,\n        IsActive:       billingStatus.HasActiveSubscription,\n        // Maps billing status to access status\n    }, nil\n}\n\n// NEW: Implements lazy guarding - refreshes from Polar API when DB says expired\nfunc (a *StatusProviderAdapter) RefreshSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) {\n    billingStatus, err := a.service.RefreshSubscriptionStatus(ctx, orgID)\n    if err != nil {\n        return nil, err\n    }\n\n    return &paywall.SubscriptionStatus{\n        OrganizationID: orgID,\n        Status:         billingStatus.SubscriptionStatus,\n        IsActive:       billingStatus.HasActiveSubscription,\n    }, nil\n}\n```\n\n### Webhook Events\n\nSupported Polar.sh webhook events:\n\n| Event | Description | Action |\n|-------|-------------|--------|\n| `subscription.created` | New subscription | Create/update subscription + quota |\n| `subscription.updated` | Status change | Update subscription status |\n| `subscription.canceled` | Subscription canceled | Mark as canceled |\n| `checkout.completed` | Checkout finished | Trigger subscription sync |\n\n## Usage\n\n### Webhook Handler (API Layer)\n\n```go\n// POST /api/webhooks/polar\nfunc (h *Handler) HandlePolarWebhook(c *gin.Context) {\n    var event domain.WebhookEvent\n    if err := c.ShouldBindJSON(&event); err != nil {\n        c.JSON(400, gin.H{\"error\": \"invalid payload\"})\n        return\n    }\n\n    if err := h.billingService.ProcessSubscriptionWebhook(c.Request.Context(), &event); err != nil {\n        c.JSON(500, gin.H{\"error\": err.Error()})\n        return\n    }\n\n    c.JSON(200, gin.H{\"status\": \"processed\"})\n}\n```\n\n### Getting Billing Status\n\n```go\n// In any handler that needs billing info\nfunc (h *Handler) GetBillingStatus(c *gin.Context) {\n    reqCtx := auth.GetRequestContext(c)\n\n    status, err := h.billingService.GetBillingStatus(c.Request.Context(), reqCtx.OrganizationID)\n    if err != nil {\n        if err == domain.ErrSubscriptionNotFound {\n            // No subscription yet - return appropriate response\n            c.JSON(200, domain.BillingStatus{\n                HasActiveSubscription: false,\n                CanProcessInvoices:    false,\n                Reason:                \"No active subscription\",\n            })\n            return\n        }\n        c.JSON(500, gin.H{\"error\": err.Error()})\n        return\n    }\n\n    c.JSON(200, status)\n}\n```\n\n### Consuming Quota\n\n```go\n// Before processing an invoice\nfunc (h *Handler) ProcessInvoice(c *gin.Context) {\n    reqCtx := auth.GetRequestContext(c)\n\n    // Check quota\n    quotaStatus, err := h.billingService.GetQuotaStatus(c.Request.Context(), reqCtx.OrganizationID)\n    if err != nil {\n        c.JSON(500, gin.H{\"error\": err.Error()})\n        return\n    }\n\n    if !quotaStatus.CanProcessInvoice {\n        c.JSON(402, gin.H{\n            \"error\": \"quota_exceeded\",\n            \"message\": \"Invoice processing quota exhausted\",\n            \"upgrade_url\": \"/billing\",\n        })\n        return\n    }\n\n    // Process the invoice...\n\n    // Consume quota\n    if err := h.billingService.ConsumeInvoiceQuota(c.Request.Context(), reqCtx.OrganizationID); err != nil {\n        // Log but don't fail - invoice was processed\n        log.Printf(\"failed to consume quota: %v\", err)\n    }\n}\n```\n\n## Configuration\n\nEnvironment variables for Polar.sh integration:\n\n```env\nPOLAR_API_KEY=your_polar_api_key\nPOLAR_WEBHOOK_SECRET=your_webhook_secret\nPOLAR_ORGANIZATION_ID=your_polar_org_id\n```\n\n## Database Schema\n\n```sql\n-- Subscription tracking\nCREATE TABLE subscription_billing.subscriptions (\n    id SERIAL PRIMARY KEY,\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id),\n    external_customer_id TEXT NOT NULL,      -- Polar customer ID\n    subscription_id TEXT NOT NULL,           -- Polar subscription ID\n    subscription_status TEXT NOT NULL,       -- active, trialing, past_due, canceled, unpaid\n    product_id TEXT NOT NULL,\n    product_name TEXT,\n    plan_name TEXT,\n    current_period_start TIMESTAMP,\n    current_period_end TIMESTAMP,\n    cancel_at_period_end BOOLEAN DEFAULT FALSE,\n    canceled_at TIMESTAMP,\n    metadata JSONB DEFAULT '{}',\n    created_at TIMESTAMP DEFAULT NOW(),\n    updated_at TIMESTAMP DEFAULT NOW()\n);\n\n-- Quota tracking\nCREATE TABLE subscription_billing.quota_tracking (\n    id SERIAL PRIMARY KEY,\n    organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id),\n    invoice_count INTEGER DEFAULT 0,         -- Remaining invoices\n    max_seats INTEGER,                       -- Seat limit\n    period_start TIMESTAMP,\n    period_end TIMESTAMP,\n    last_synced_at TIMESTAMP,\n    created_at TIMESTAMP DEFAULT NOW(),\n    updated_at TIMESTAMP DEFAULT NOW()\n);\n```\n\n## Related Modules\n\n- **pkg/paywall**: Access gating middleware (reads from this module's DB)\n- **pkg/polar**: Polar.sh API client (used for webhook validation)\n- **app/organizations**: Organization management (links subscription to org)\n\n## Testing\n\n```go\n// Mock the billing service for unit tests\ntype MockBillingService struct {\n    mock.Mock\n}\n\nfunc (m *MockBillingService) GetBillingStatus(ctx context.Context, orgID int32) (*domain.BillingStatus, error) {\n    args := m.Called(ctx, orgID)\n    if args.Get(0) == nil {\n        return nil, args.Error(1)\n    }\n    return args.Get(0).(*domain.BillingStatus), args.Error(1)\n}\n\n// In your test\nfunc TestHandler_RequiresActiveSubscription(t *testing.T) {\n    mockService := new(MockBillingService)\n    mockService.On(\"GetBillingStatus\", mock.Anything, int32(1)).Return(&domain.BillingStatus{\n        HasActiveSubscription: false,\n    }, nil)\n\n    // Test that handler returns 402\n}\n```\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/check_quota_availability_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\n// CheckQuotaAvailability performs a read-only verification of quota availability\n// This method does NOT consume quota - it only checks if processing is allowed\n// Use ConsumeInvoiceQuota after successful invoice processing to actually decrement the quota\nfunc (s *billingService) CheckQuotaAvailability(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) {\n\t// Step 1: Check database quota status (read-only)\n\tquotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID)\n\tif err != nil {\n\t\treturn &domain.BillingStatus{\n\t\t\tOrganizationID:        organizationID,\n\t\t\tHasActiveSubscription: false,\n\t\t\tCanProcessInvoices:    false,\n\t\t\tReason:                \"no active subscription\",\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, domain.ErrSubscriptionNotFound\n\t}\n\n\t// Step 2: Check if we need fallback API verification\n\tneedsFallback := s.needsFallbackVerification(quotaStatus)\n\tif needsFallback {\n\t\ts.logger.Info(\"Quota near limit or stale, performing fallback API verification\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"invoice_count\":   quotaStatus.InvoiceCount,\n\t\t})\n\n\t\t// Sync from Polar and re-check\n\t\tif err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil {\n\t\t\ts.logger.Error(\"Fallback sync failed, using database data\", map[string]any{\n\t\t\t\t\"organization_id\": organizationID,\n\t\t\t\t\"error\":           err.Error(),\n\t\t\t})\n\t\t} else {\n\t\t\t// Re-fetch quota status after sync\n\t\t\tquotaStatus, err = s.repo.GetQuotaStatus(ctx, organizationID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to get quota after sync: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Step 3: Verify quota is available (NO consumption here)\n\tif !quotaStatus.CanProcessInvoice {\n\t\treturn &domain.BillingStatus{\n\t\t\tOrganizationID:        organizationID,\n\t\t\tHasActiveSubscription: quotaStatus.SubscriptionStatus == \"active\",\n\t\t\tCanProcessInvoices:    false,\n\t\t\tInvoiceCount:          quotaStatus.InvoiceCount,\n\t\t\tReason:                \"quota exceeded or subscription inactive\",\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, domain.ErrQuotaExceeded\n\t}\n\n\t// Step 4: Return success status (quota NOT consumed yet)\n\treturn &domain.BillingStatus{\n\t\tOrganizationID:        organizationID,\n\t\tHasActiveSubscription: true,\n\t\tCanProcessInvoices:    true,\n\t\tInvoiceCount:          quotaStatus.InvoiceCount, // Current count, NOT decremented\n\t\tReason:                \"quota available\",\n\t\tCheckedAt:             time.Now(),\n\t}, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/consume_invoice_quota_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\n// ConsumeInvoiceQuota explicitly consumes one invoice quota after successful processing\n// This should be called after the invoice has been successfully processed\n// Can be safely called in a background goroutine for better performance\nfunc (s *billingService) ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) {\n\ts.logger.Info(\"Consuming invoice quota for organization\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t})\n\n\t// Step 1: Get current quota status before consumption\n\tquotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID)\n\tif err != nil {\n\t\ts.logger.Error(\"Failed to get quota status before consumption\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"error\":           err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to get quota status: %w\", err)\n\t}\n\n\t// Step 2: Decrement quota count (atomic database operation)\n\tupdatedQuota, err := s.repo.DecrementInvoiceCount(ctx, organizationID)\n\tif err != nil {\n\t\ts.logger.Error(\"Failed to decrement invoice count\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"error\":           err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to decrement invoice count: %w\", err)\n\t}\n\n\ts.logger.Info(\"Successfully consumed invoice quota locally\", map[string]any{\n\t\t\"organization_id\":    organizationID,\n\t\t\"previous_count\":     quotaStatus.InvoiceCount,\n\t\t\"new_count\":          updatedQuota.InvoiceCount,\n\t\t\"remaining_invoices\": updatedQuota.InvoiceCount,\n\t})\n\n\t// Step 3: Ingest meter event to Polar to consume credits (best-effort)\n\t// This notifies Polar about the invoice processing usage\n\t// Local tracking is maintained for fast quota checks, Polar tracks actual billing\n\tgo s.ingestMeterEventToPolar(context.Background(), organizationID)\n\n\t// Step 4: Return updated billing status\n\treturn &domain.BillingStatus{\n\t\tOrganizationID:        organizationID,\n\t\tHasActiveSubscription: quotaStatus.SubscriptionStatus == \"active\",\n\t\tCanProcessInvoices:    updatedQuota.InvoiceCount > 0,\n\t\tInvoiceCount:          updatedQuota.InvoiceCount,\n\t\tReason:                \"quota consumed successfully\",\n\t\tCheckedAt:             time.Now(),\n\t}, nil\n}\n\n// ingestMeterEventToPolar ingests a meter event to Polar for usage-based billing\n// This runs in a background goroutine and uses best-effort approach\n// Failures are logged but don't affect the main operation since local tracking is maintained\nfunc (s *billingService) ingestMeterEventToPolar(ctx context.Context, organizationID int32) {\n\t// Use background context with timeout (independent of request context)\n\tctx, cancel := context.WithTimeout(ctx, 10*time.Second)\n\tdefer cancel()\n\n\t// Get organization's external customer ID (Stytch org ID)\n\texternalID, err := s.orgAdapter.GetStytchOrgID(ctx, organizationID)\n\tif err != nil {\n\t\ts.logger.Error(\"Failed to get external customer ID for Polar meter event\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"error\":           err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Ingest meter event to Polar\n\t// Meter: \"Invoice Processing\" (configured in Polar dashboard)\n\t// Filter: name equals \"invoice.processed\"\n\t// Amount: 1 (one invoice processed)\n\tmeterSlug := invoicesProcessedMeterSlug // Event name MUST match meter filter exactly (with dot)\n\tif err := s.billingProvider.IngestMeterEvent(ctx, externalID, meterSlug, 1); err != nil {\n\t\ts.logger.Error(\"Failed to ingest meter event to Polar\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"external_id\":     externalID,\n\t\t\t\"meter_slug\":      meterSlug,\n\t\t\t\"error\":           err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Log success\n\ts.logger.Info(\"Successfully ingested event to Polar\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"external_id\":     externalID,\n\t\t\"event_name\":      meterSlug,\n\t\t\"amount\":          1,\n\t})\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/get_billing_status_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\nfunc (s *billingService) GetBillingStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) {\n\t// Get quota status from database\n\tquotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID)\n\tif err != nil {\n\t\t// No subscription found\n\t\treturn &domain.BillingStatus{\n\t\t\tOrganizationID:        organizationID,\n\t\t\tHasActiveSubscription: false,\n\t\t\tCanProcessInvoices:    false,\n\t\t\tInvoiceCount:          0,\n\t\t\tReason:                \"no active subscription found\",\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, nil\n\t}\n\n\t// Build billing status from quota status\n\treturn &domain.BillingStatus{\n\t\tOrganizationID:        organizationID,\n\t\tHasActiveSubscription: quotaStatus.SubscriptionStatus == \"active\",\n\t\tCanProcessInvoices:    quotaStatus.CanProcessInvoice,\n\t\tInvoiceCount:          quotaStatus.InvoiceCount,\n\t\tReason:                s.buildStatusReason(quotaStatus),\n\t\tCheckedAt:             time.Now(),\n\t}, nil\n}\n\nfunc (s *billingService) buildStatusReason(status *domain.QuotaStatus) string {\n\tif !status.CanProcessInvoice {\n\t\tif status.SubscriptionStatus != \"active\" {\n\t\t\treturn fmt.Sprintf(\"subscription status: %s\", status.SubscriptionStatus)\n\t\t}\n\t\treturn \"invoice quota exceeded\"\n\t}\n\treturn \"ok\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/module.go",
    "content": "package services\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/infra/polar\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/infra/repositories\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\tlogger \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tpolarpkg \"github.com/moasq/go-b2b-starter/internal/platform/polar\"\n)\n\n// Module handles dependency injection for billing services\n// Note: SubscriptionRepository is registered in internal/db/inject.go\ntype Module struct{}\n\nfunc NewModule() *Module {\n\treturn &Module{}\n}\n\n// Configure registers all services in the dependency container\nfunc (m *Module) Configure(container *dig.Container) error {\n\t// Register OrganizationAdapter (uses legacy adapter store for now)\n\tif err := container.Provide(func(orgStore adapters.OrganizationStore) domain.OrganizationAdapter {\n\t\treturn repositories.NewOrganizationAdapter(orgStore)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register BillingProvider (Polar implementation)\n\tif err := container.Provide(func(client *polarpkg.Client, log logger.Logger) domain.BillingProvider {\n\t\treturn polar.NewPolarAdapter(client, log)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register BillingService\n\tif err := container.Provide(func(\n\t\trepo domain.SubscriptionRepository,\n\t\torgAdapter domain.OrganizationAdapter,\n\t\tbillingProvider domain.BillingProvider,\n\t\tlogger logger.Logger,\n\t) BillingService {\n\t\treturn NewBillingService(repo, orgAdapter, billingProvider, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/process_webhook_event_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\nconst invoicesProcessedMeterSlug = \"invoice.processed\"\n\nfunc (s *billingService) ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error {\n\ts.logger.Info(\"Processing webhook event\", map[string]any{\n\t\t\"event_type\":   eventType,\n\t\t\"payload_keys\": mapKeys(payload),\n\t})\n\n\t// Update subscription based on event type\n\tswitch eventType {\n\tcase \"subscription.created\", \"subscription.updated\":\n\t\teventData, err := s.parseSubscriptionWebhookPayload(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse subscription webhook payload: %w\", err)\n\t\t}\n\t\treturn s.handleSubscriptionUpsert(ctx, eventData)\n\tcase \"subscription.canceled\":\n\t\teventData, err := s.parseSubscriptionWebhookPayload(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse subscription webhook payload: %w\", err)\n\t\t}\n\t\treturn s.handleSubscriptionCanceled(ctx, eventData)\n\tcase \"customer.updated\":\n\t\teventData, err := s.parseSubscriptionWebhookPayload(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse subscription webhook payload: %w\", err)\n\t\t}\n\t\treturn s.handleCustomerUpdated(ctx, eventData)\n\tcase \"meter.grant.updated\", \"meter.grant.created\", \"entitlement.grant.updated\":\n\t\tif err := s.handleMeterGrantEvent(ctx, payload); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to handle meter grant webhook: %w\", err)\n\t\t}\n\t\treturn nil\n\tdefault:\n\t\ts.logger.Warn(\"Unhandled webhook event type\", map[string]any{\n\t\t\t\"event_type\": eventType,\n\t\t})\n\t\treturn nil // Don't fail on unknown events\n\t}\n}\n\nfunc (s *billingService) parseSubscriptionWebhookPayload(payload map[string]any) (*domain.SubscriptionEventData, error) {\n\tnormalized := normalizePolarObject(payload)\n\tif normalized == nil {\n\t\treturn nil, fmt.Errorf(\"webhook payload missing subscription object\")\n\t}\n\n\tdata := &domain.SubscriptionEventData{}\n\n\tif subID, ok := normalized[\"id\"].(string); ok {\n\t\tdata.SubscriptionID = subID\n\t} else if subID, ok := normalized[\"subscription_id\"].(string); ok {\n\t\tdata.SubscriptionID = subID\n\t}\n\n\tif status, ok := normalized[\"status\"].(string); ok {\n\t\tdata.Status = status\n\t}\n\n\tif t, ok := parseISOTime(normalized[\"current_period_start\"]); ok {\n\t\tdata.CurrentPeriodStart = t\n\t} else if t, ok := parseISOTime(normalized[\"current_period_start_at\"]); ok {\n\t\tdata.CurrentPeriodStart = t\n\t}\n\n\tif t, ok := parseISOTime(normalized[\"current_period_end\"]); ok {\n\t\tdata.CurrentPeriodEnd = t\n\t} else if t, ok := parseISOTime(normalized[\"current_period_end_at\"]); ok {\n\t\tdata.CurrentPeriodEnd = t\n\t}\n\n\tif value, exists := normalized[\"cancel_at_period_end\"]; exists {\n\t\tif v, ok := toBool(value); ok {\n\t\t\tdata.CancelAtPeriodEnd = v\n\t\t}\n\t}\n\n\tif value, exists := normalized[\"canceled_at\"]; exists {\n\t\tif t, ok := parseISOTime(value); ok {\n\t\t\tdata.CanceledAt = &t\n\t\t}\n\t}\n\n\tproduct := extractProductMap(normalized)\n\tif product == nil {\n\t\tproduct = extractProductMap(payload)\n\t}\n\n\tif product != nil {\n\t\tif productID, ok := product[\"id\"].(string); ok && data.ProductID == \"\" {\n\t\t\tdata.ProductID = productID\n\t\t}\n\t\tif productName, ok := product[\"name\"].(string); ok && data.ProductName == \"\" {\n\t\t\tdata.ProductName = productName\n\t\t}\n\t\tif metadata := stringMapFrom(product[\"metadata\"]); len(metadata) > 0 {\n\t\t\tdata.ProductMetadata = metadata\n\t\t}\n\t}\n\n\tif data.ProductID == \"\" {\n\t\tif productID, ok := normalized[\"product_id\"].(string); ok {\n\t\t\tdata.ProductID = productID\n\t\t} else if productID, ok := payload[\"product_id\"].(string); ok {\n\t\t\tdata.ProductID = productID\n\t\t}\n\t}\n\n\tif data.ProductName == \"\" {\n\t\tif productName, ok := normalized[\"product_name\"].(string); ok {\n\t\t\tdata.ProductName = productName\n\t\t}\n\t}\n\n\tif len(data.ProductMetadata) == 0 {\n\t\tif metadata := stringMapFrom(normalized[\"product_metadata\"]); len(metadata) > 0 {\n\t\t\tdata.ProductMetadata = metadata\n\t\t} else if metadata := stringMapFrom(payload[\"product_metadata\"]); len(metadata) > 0 {\n\t\t\tdata.ProductMetadata = metadata\n\t\t}\n\t}\n\n\tif product != nil {\n\t\tif invoiceCount := extractInvoiceCountFromProduct(product); invoiceCount != \"\" {\n\t\t\tif data.ProductMetadata == nil {\n\t\t\t\tdata.ProductMetadata = make(map[string]string)\n\t\t\t}\n\t\t\tif existing, ok := data.ProductMetadata[\"invoice_count\"]; !ok || existing == \"\" {\n\t\t\t\tdata.ProductMetadata[\"invoice_count\"] = invoiceCount\n\t\t\t}\n\t\t}\n\t}\n\n\tif metadata := stringMapFrom(normalized[\"metadata\"]); len(metadata) > 0 {\n\t\tdata.CustomerMetadata = metadata\n\t}\n\n\tif len(data.CustomerMetadata) == 0 {\n\t\tif customer, ok := normalized[\"customer\"].(map[string]any); ok {\n\t\t\tif metadata := stringMapFrom(customer[\"metadata\"]); len(metadata) > 0 {\n\t\t\t\tdata.CustomerMetadata = metadata\n\t\t\t}\n\n\t\t\tif data.ExternalCustomerID == \"\" {\n\t\t\t\tif externalID, ok := customer[\"external_id\"].(string); ok && externalID != \"\" {\n\t\t\t\t\tdata.ExternalCustomerID = externalID\n\t\t\t\t} else if externalID, ok := customer[\"id\"].(string); ok && externalID != \"\" {\n\t\t\t\t\tdata.ExternalCustomerID = externalID\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(data.CustomerMetadata) == 0 {\n\t\tif metadata := stringMapFrom(payload[\"metadata\"]); len(metadata) > 0 {\n\t\t\tdata.CustomerMetadata = metadata\n\t\t}\n\t}\n\n\tif data.ExternalCustomerID == \"\" {\n\t\tif externalID, ok := normalized[\"customer_external_id\"].(string); ok && externalID != \"\" {\n\t\t\tdata.ExternalCustomerID = externalID\n\t\t} else if externalID, ok := normalized[\"external_customer_id\"].(string); ok && externalID != \"\" {\n\t\t\tdata.ExternalCustomerID = externalID\n\t\t} else if externalID, ok := payload[\"customer_external_id\"].(string); ok && externalID != \"\" {\n\t\t\tdata.ExternalCustomerID = externalID\n\t\t}\n\t}\n\n\tif data.ExternalCustomerID == \"\" && len(data.CustomerMetadata) > 0 {\n\t\tif externalID, ok := data.CustomerMetadata[\"organization_id\"]; ok && externalID != \"\" {\n\t\t\tdata.ExternalCustomerID = externalID\n\t\t} else if externalID, ok := data.CustomerMetadata[\"external_customer_id\"]; ok && externalID != \"\" {\n\t\t\tdata.ExternalCustomerID = externalID\n\t\t}\n\t}\n\n\tif data.ExternalCustomerID == \"\" {\n\t\ts.logger.Warn(\"Subscription webhook payload missing external customer identifier\", map[string]any{\n\t\t\t\"payload_keys\": mapKeys(normalized),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"webhook payload missing organization_id\")\n\t}\n\n\ts.logger.Info(\"Parsed subscription webhook payload\", map[string]any{\n\t\t\"subscription_id\":        data.SubscriptionID,\n\t\t\"external_customer_id\":   data.ExternalCustomerID,\n\t\t\"status\":                 data.Status,\n\t\t\"product_id\":             data.ProductID,\n\t\t\"product_metadata_keys\":  len(data.ProductMetadata),\n\t\t\"customer_metadata_keys\": len(data.CustomerMetadata),\n\t})\n\n\treturn data, nil\n}\n\nfunc (s *billingService) handleSubscriptionUpsert(ctx context.Context, eventData *domain.SubscriptionEventData) error {\n\t// Step 1: Map Polar organization_id to internal organization ID\n\torganizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to map organization: %w\", err)\n\t}\n\n\ts.logger.Info(\"Mapped organization\", map[string]any{\n\t\t\"external_customer_id\": eventData.ExternalCustomerID,\n\t\t\"organization_id\":      organizationID,\n\t})\n\n\t// Step 2: Parse quota limits from product metadata (remaining invoices)\n\tvar invoiceCount int32 = 0\n\tif val, ok := eventData.ProductMetadata[\"invoice_count\"]; ok {\n\t\tif count, err := strconv.ParseInt(val, 10, 32); err == nil {\n\t\t\tinvoiceCount = int32(count)\n\t\t} else {\n\t\t\ts.logger.Warn(\"Failed to parse invoice_count from product metadata\", map[string]any{\n\t\t\t\t\"value\": val,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t} else {\n\t\ts.logger.Warn(\"invoice_count not found in product metadata\", map[string]any{\n\t\t\t\"product_metadata\": eventData.ProductMetadata,\n\t\t})\n\t}\n\n\tvar maxSeats int32 = 0\n\tif val, ok := eventData.ProductMetadata[\"max_seats\"]; ok {\n\t\tif count, err := strconv.ParseInt(val, 10, 32); err == nil {\n\t\t\tmaxSeats = int32(count)\n\t\t}\n\t}\n\n\ts.logger.Info(\"Parsed quota limits from metadata\", map[string]any{\n\t\t\"invoice_count\": invoiceCount,\n\t\t\"max_seats\":     maxSeats,\n\t})\n\n\t// Step 4: Create subscription domain object\n\tsubscription := &domain.Subscription{\n\t\tOrganizationID:     organizationID,\n\t\tExternalCustomerID: eventData.ExternalCustomerID,\n\t\tSubscriptionID:     eventData.SubscriptionID,\n\t\tSubscriptionStatus: eventData.Status,\n\t\tProductID:          eventData.ProductID,\n\t\tProductName:        eventData.ProductName,\n\t\tCurrentPeriodStart: eventData.CurrentPeriodStart,\n\t\tCurrentPeriodEnd:   eventData.CurrentPeriodEnd,\n\t\tCancelAtPeriodEnd:  eventData.CancelAtPeriodEnd,\n\t\tCanceledAt:         eventData.CanceledAt,\n\t}\n\n\t// Step 5: Upsert subscription to database\n\t_, err = s.repo.UpsertSubscription(ctx, subscription)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to upsert subscription: %w\", err)\n\t}\n\n\ts.logger.Info(\"Upserted subscription\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"subscription_id\": eventData.SubscriptionID,\n\t\t\"status\":          eventData.Status,\n\t})\n\n\t// Step 6: Create quota tracking domain object\n\tnow := time.Now()\n\tquota := &domain.QuotaTracking{\n\t\tOrganizationID: organizationID,\n\t\tInvoiceCount:   invoiceCount,\n\t\tMaxSeats:       maxSeats,\n\t\tPeriodStart:    eventData.CurrentPeriodStart,\n\t\tPeriodEnd:      eventData.CurrentPeriodEnd,\n\t\tLastSyncedAt:   &now,\n\t}\n\n\t// Step 7: Upsert quota tracking to database\n\t_, err = s.repo.UpsertQuota(ctx, quota)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to upsert quota: %w\", err)\n\t}\n\n\ts.logger.Info(\"Upserted quota tracking\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"invoice_count\":   invoiceCount,\n\t\t\"max_seats\":       maxSeats,\n\t})\n\n\treturn nil\n}\n\nfunc (s *billingService) handleSubscriptionCanceled(ctx context.Context, eventData *domain.SubscriptionEventData) error {\n\t// Step 1: Map Polar organization_id to internal organization ID\n\torganizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to map organization: %w\", err)\n\t}\n\n\ts.logger.Info(\"Processing subscription cancellation\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"subscription_id\": eventData.SubscriptionID,\n\t})\n\n\t// Step 2: Create subscription object with canceled status\n\tnow := time.Now()\n\tsubscription := &domain.Subscription{\n\t\tOrganizationID:     organizationID,\n\t\tExternalCustomerID: eventData.ExternalCustomerID,\n\t\tSubscriptionID:     eventData.SubscriptionID,\n\t\tSubscriptionStatus: \"canceled\",\n\t\tProductID:          eventData.ProductID,\n\t\tProductName:        eventData.ProductName,\n\t\tCurrentPeriodStart: eventData.CurrentPeriodStart,\n\t\tCurrentPeriodEnd:   eventData.CurrentPeriodEnd,\n\t\tCancelAtPeriodEnd:  false, // Already canceled\n\t\tCanceledAt:         &now,\n\t}\n\n\t// If webhook includes canceled_at timestamp, use it\n\tif eventData.CanceledAt != nil {\n\t\tsubscription.CanceledAt = eventData.CanceledAt\n\t}\n\n\t// Step 3: Upsert subscription with canceled status\n\t_, err = s.repo.UpsertSubscription(ctx, subscription)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to update subscription to canceled: %w\", err)\n\t}\n\n\ts.logger.Info(\"Subscription marked as canceled\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"subscription_id\": eventData.SubscriptionID,\n\t\t\"canceled_at\":     subscription.CanceledAt,\n\t})\n\n\treturn nil\n}\n\nfunc (s *billingService) handleCustomerUpdated(ctx context.Context, eventData *domain.SubscriptionEventData) error {\n\t// Step 1: Map Polar organization_id to internal organization ID\n\torganizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to map organization: %w\", err)\n\t}\n\n\ts.logger.Info(\"Processing customer update\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"metadata_keys\":   len(eventData.CustomerMetadata),\n\t})\n\n\t// Step 2: Parse invoice count from customer metadata (remaining count)\n\tvar invoiceCount int32 = 0\n\tif val, ok := eventData.CustomerMetadata[\"invoice_count\"]; ok {\n\t\tif count, err := strconv.ParseInt(val, 10, 32); err == nil {\n\t\t\tinvoiceCount = int32(count)\n\t\t}\n\t}\n\n\t// Step 3: Get existing quota to preserve other fields\n\texistingQuota, err := s.repo.GetQuotaByOrgID(ctx, organizationID)\n\tif err != nil {\n\t\t// If no quota exists, create a minimal one with just the invoice count\n\t\ts.logger.Warn(\"No existing quota found, creating new quota entry\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t})\n\n\t\tnow := time.Now()\n\t\tquota := &domain.QuotaTracking{\n\t\t\tOrganizationID: organizationID,\n\t\t\tInvoiceCount:   invoiceCount,\n\t\t\tMaxSeats:       0,\n\t\t\tPeriodStart:    now,\n\t\t\tPeriodEnd:      now,\n\t\t\tLastSyncedAt:   &now,\n\t\t}\n\n\t\t_, err = s.repo.UpsertQuota(ctx, quota)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create quota: %w\", err)\n\t\t}\n\n\t\ts.logger.Info(\"Created new quota with invoice count\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"invoice_count\":   invoiceCount,\n\t\t})\n\n\t\treturn nil\n\t}\n\n\t// Step 4: Update existing quota with new invoice count\n\tnow := time.Now()\n\t_ = existingQuota.InvoiceCount\n\texistingQuota.InvoiceCount = invoiceCount\n\texistingQuota.LastSyncedAt = &now\n\n\t_, err = s.repo.UpsertQuota(ctx, existingQuota)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to update quota: %w\", err)\n\t}\n\n\ts.logger.Info(\"Updated quota with invoice count from customer metadata\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"invoice_count\":   invoiceCount,\n\t})\n\n\treturn nil\n}\n\nfunc (s *billingService) handleMeterGrantEvent(ctx context.Context, payload map[string]any) error {\n\teventData, err := s.parseMeterGrantPayload(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse meter grant payload: %w\", err)\n\t}\n\n\tif !strings.EqualFold(eventData.MeterSlug, invoicesProcessedMeterSlug) {\n\t\ts.logger.Info(\"Ignoring meter grant event for unrelated meter\", map[string]any{\n\t\t\t\"meter_slug\": eventData.MeterSlug,\n\t\t})\n\t\treturn nil\n\t}\n\n\torganizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to map organization for meter grant: %w\", err)\n\t}\n\n\tnow := time.Now()\n\tquota, err := s.repo.GetQuotaByOrgID(ctx, organizationID)\n\tif err != nil {\n\t\tif errors.Is(err, domain.ErrQuotaNotFound) {\n\t\t\tnewQuota := &domain.QuotaTracking{\n\t\t\t\tOrganizationID: organizationID,\n\t\t\t\tInvoiceCount:   eventData.AvailableCredits,\n\t\t\t\tMaxSeats:       0,\n\t\t\t\tPeriodStart:    now,\n\t\t\t\tPeriodEnd:      now,\n\t\t\t\tLastSyncedAt:   &now,\n\t\t\t}\n\n\t\t\tif _, err := s.repo.UpsertQuota(ctx, newQuota); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to create quota from meter grant: %w\", err)\n\t\t\t}\n\n\t\t\ts.logger.Info(\"Created quota from meter grant event\", map[string]any{\n\t\t\t\t\"organization_id\": organizationID,\n\t\t\t\t\"meter_slug\":      eventData.MeterSlug,\n\t\t\t\t\"invoice_count\":   eventData.AvailableCredits,\n\t\t\t})\n\n\t\t\treturn nil\n\t\t}\n\n\t\treturn fmt.Errorf(\"failed to get quota for meter grant: %w\", err)\n\t}\n\n\tprevious := quota.InvoiceCount\n\tquota.InvoiceCount = eventData.AvailableCredits\n\tquota.LastSyncedAt = &now\n\n\tif _, err := s.repo.UpsertQuota(ctx, quota); err != nil {\n\t\treturn fmt.Errorf(\"failed to update quota from meter grant: %w\", err)\n\t}\n\n\ts.logger.Info(\"Updated quota from meter grant event\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"meter_slug\":      eventData.MeterSlug,\n\t\t\"invoice_count\":   quota.InvoiceCount,\n\t\t\"previous_count\":  previous,\n\t})\n\n\treturn nil\n}\n\nfunc (s *billingService) parseMeterGrantPayload(payload map[string]any) (*domain.MeterGrantEventData, error) {\n\tnormalized := normalizePolarObject(payload)\n\tif normalized == nil {\n\t\treturn nil, fmt.Errorf(\"meter grant payload missing object\")\n\t}\n\n\tdata := &domain.MeterGrantEventData{}\n\n\tif slug, ok := toString(normalized[\"meter_slug\"]); ok {\n\t\tdata.MeterSlug = strings.TrimSpace(slug)\n\t}\n\tif data.MeterSlug == \"\" {\n\t\tif slug, ok := toString(normalized[\"slug\"]); ok {\n\t\t\tdata.MeterSlug = strings.TrimSpace(slug)\n\t\t}\n\t}\n\tif data.MeterSlug == \"\" {\n\t\tif meter, ok := normalized[\"meter\"].(map[string]any); ok {\n\t\t\tif slug, ok := toString(meter[\"slug\"]); ok {\n\t\t\t\tdata.MeterSlug = strings.TrimSpace(slug)\n\t\t\t} else if slug, ok := toString(meter[\"meter_slug\"]); ok {\n\t\t\t\tdata.MeterSlug = strings.TrimSpace(slug)\n\t\t\t} else if slug, ok := toString(meter[\"name\"]); ok {\n\t\t\t\tdata.MeterSlug = strings.TrimSpace(slug)\n\t\t\t}\n\t\t}\n\t}\n\n\tif externalID, ok := toString(normalized[\"external_customer_id\"]); ok && strings.TrimSpace(externalID) != \"\" {\n\t\tdata.ExternalCustomerID = strings.TrimSpace(externalID)\n\t}\n\tif data.ExternalCustomerID == \"\" {\n\t\tif externalID, ok := toString(normalized[\"customer_external_id\"]); ok && strings.TrimSpace(externalID) != \"\" {\n\t\t\tdata.ExternalCustomerID = strings.TrimSpace(externalID)\n\t\t}\n\t}\n\tif data.ExternalCustomerID == \"\" {\n\t\tif customer, ok := normalized[\"customer\"].(map[string]any); ok {\n\t\t\tif externalID, ok := toString(customer[\"external_id\"]); ok && strings.TrimSpace(externalID) != \"\" {\n\t\t\t\tdata.ExternalCustomerID = strings.TrimSpace(externalID)\n\t\t\t} else if externalID, ok := toString(customer[\"id\"]); ok && strings.TrimSpace(externalID) != \"\" {\n\t\t\t\tdata.ExternalCustomerID = strings.TrimSpace(externalID)\n\t\t\t} else if metadata := stringMapFrom(customer[\"metadata\"]); len(metadata) > 0 {\n\t\t\t\tif externalID := strings.TrimSpace(metadata[\"organization_id\"]); externalID != \"\" {\n\t\t\t\t\tdata.ExternalCustomerID = externalID\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif data.ExternalCustomerID == \"\" {\n\t\tif metadata := stringMapFrom(normalized[\"metadata\"]); len(metadata) > 0 {\n\t\t\tif externalID := strings.TrimSpace(metadata[\"organization_id\"]); externalID != \"\" {\n\t\t\t\tdata.ExternalCustomerID = externalID\n\t\t\t}\n\t\t}\n\t}\n\n\tvar (\n\t\tavailable  int32\n\t\thasBalance bool\n\t)\n\n\tif balanceMap, ok := normalized[\"balance\"].(map[string]any); ok {\n\t\tfor _, key := range []string{\"available\", \"remaining\", \"quantity\", \"value\"} {\n\t\t\tif value, exists := balanceMap[key]; exists {\n\t\t\t\tif count, ok := toInt32(value); ok {\n\t\t\t\t\tavailable = count\n\t\t\t\t\thasBalance = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !hasBalance {\n\t\tif creditBalance, ok := normalized[\"credit_balance\"].(map[string]any); ok {\n\t\t\tfor _, key := range []string{\"available\", \"remaining\", \"quantity\"} {\n\t\t\t\tif value, exists := creditBalance[key]; exists {\n\t\t\t\t\tif count, ok := toInt32(value); ok {\n\t\t\t\t\t\tavailable = count\n\t\t\t\t\t\thasBalance = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !hasBalance {\n\t\tfor _, key := range []string{\"available\", \"remaining\", \"balance\", \"quantity\"} {\n\t\t\tif value, exists := normalized[key]; exists {\n\t\t\t\tif count, ok := toInt32(value); ok {\n\t\t\t\t\tavailable = count\n\t\t\t\t\thasBalance = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !hasBalance {\n\t\ts.logger.Warn(\"Meter grant payload missing available balance\", map[string]any{\n\t\t\t\"payload_keys\": mapKeys(normalized),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"meter grant payload missing available balance\")\n\t}\n\n\tdata.AvailableCredits = available\n\n\tif data.MeterSlug == \"\" {\n\t\ts.logger.Warn(\"Meter grant payload missing meter slug\", map[string]any{\n\t\t\t\"payload_keys\": mapKeys(normalized),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"meter grant payload missing meter slug\")\n\t}\n\n\tif data.ExternalCustomerID == \"\" {\n\t\ts.logger.Warn(\"Meter grant payload missing external customer identifier\", map[string]any{\n\t\t\t\"payload_keys\": mapKeys(normalized),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"meter grant payload missing external customer id\")\n\t}\n\n\ts.logger.Info(\"Parsed meter grant payload\", map[string]any{\n\t\t\"meter_slug\":            data.MeterSlug,\n\t\t\"external_customer_id\":  data.ExternalCustomerID,\n\t\t\"available_invoice_cnt\": data.AvailableCredits,\n\t})\n\n\treturn data, nil\n}\n\nfunc normalizePolarObject(payload map[string]any) map[string]any {\n\tif payload == nil {\n\t\treturn nil\n\t}\n\n\tif object, ok := payload[\"object\"].(map[string]any); ok && len(object) > 0 {\n\t\treturn object\n\t}\n\n\tif data, ok := payload[\"data\"].(map[string]any); ok {\n\t\tif object, ok := data[\"object\"].(map[string]any); ok && len(object) > 0 {\n\t\t\treturn object\n\t\t}\n\t}\n\n\tif dataSlice, ok := payload[\"data\"].([]any); ok && len(dataSlice) > 0 {\n\t\tfor _, item := range dataSlice {\n\t\t\tif itemMap, ok := item.(map[string]any); ok {\n\t\t\t\tif object, ok := itemMap[\"object\"].(map[string]any); ok && len(object) > 0 {\n\t\t\t\t\treturn object\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn payload\n}\n\nfunc extractProductMap(input map[string]any) map[string]any {\n\tif input == nil {\n\t\treturn nil\n\t}\n\n\tif product, ok := input[\"product\"].(map[string]any); ok {\n\t\treturn product\n\t}\n\n\tif price, ok := input[\"price\"].(map[string]any); ok {\n\t\tif product, ok := price[\"product\"].(map[string]any); ok {\n\t\t\treturn product\n\t\t}\n\t}\n\n\tif plan, ok := input[\"plan\"].(map[string]any); ok {\n\t\tif product, ok := plan[\"product\"].(map[string]any); ok {\n\t\t\treturn product\n\t\t}\n\t}\n\n\tif itemsMap := firstMapFromSlice(input[\"items\"]); itemsMap != nil {\n\t\tif product, ok := itemsMap[\"product\"].(map[string]any); ok {\n\t\t\treturn product\n\t\t}\n\t\tif price, ok := itemsMap[\"price\"].(map[string]any); ok {\n\t\t\tif product, ok := price[\"product\"].(map[string]any); ok {\n\t\t\t\treturn product\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc firstMapFromSlice(value any) map[string]any {\n\titems, ok := value.([]any)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tfor _, item := range items {\n\t\tif itemMap, ok := item.(map[string]any); ok {\n\t\t\treturn itemMap\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc stringMapFrom(value any) map[string]string {\n\tsource, ok := value.(map[string]any)\n\tif !ok || len(source) == 0 {\n\t\treturn nil\n\t}\n\n\tresult := toStringMap(source)\n\tif len(result) == 0 {\n\t\treturn nil\n\t}\n\n\treturn result\n}\n\nfunc toStringMap(input map[string]any) map[string]string {\n\tresult := make(map[string]string, len(input))\n\tfor key, value := range input {\n\t\tif str, ok := toString(value); ok {\n\t\t\tresult[key] = str\n\t\t}\n\t}\n\treturn result\n}\n\nfunc toString(value any) (string, bool) {\n\tswitch v := value.(type) {\n\tcase string:\n\t\treturn v, true\n\tcase fmt.Stringer:\n\t\treturn v.String(), true\n\tcase bool:\n\t\treturn strconv.FormatBool(v), true\n\tcase int:\n\t\tif v > math.MaxInt32 || v < math.MinInt32 {\n\t\t\treturn \"\", false\n\t\t}\n\t\treturn strconv.Itoa(v), true\n\tcase int8:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int16:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int32:\n\t\treturn strconv.FormatInt(int64(v), 10), true\n\tcase int64:\n\t\treturn strconv.FormatInt(v, 10), true\n\tcase uint:\n\t\tif v > uint(math.MaxInt32) {\n\t\t\treturn \"\", false\n\t\t}\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint8:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint16:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint32:\n\t\treturn strconv.FormatUint(uint64(v), 10), true\n\tcase uint64:\n\t\treturn strconv.FormatUint(v, 10), true\n\tcase float32:\n\t\tf := float64(v)\n\t\tif math.Mod(f, 1) == 0 {\n\t\t\treturn strconv.FormatInt(int64(f), 10), true\n\t\t}\n\t\treturn strconv.FormatFloat(f, 'f', -1, 32), true\n\tcase float64:\n\t\tif math.Mod(v, 1) == 0 {\n\t\t\treturn strconv.FormatInt(int64(v), 10), true\n\t\t}\n\t\treturn strconv.FormatFloat(v, 'f', -1, 64), true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\nfunc toInt32(value any) (int32, bool) {\n\tswitch v := value.(type) {\n\tcase int:\n\t\tif v > math.MaxInt32 || v < math.MinInt32 {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(v), true\n\tcase int8:\n\t\treturn int32(v), true\n\tcase int16:\n\t\treturn int32(v), true\n\tcase int32:\n\t\treturn v, true\n\tcase int64:\n\t\tif v > int64(math.MaxInt32) || v < int64(math.MinInt32) {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(v), true\n\tcase uint:\n\t\tif v > uint(math.MaxInt32) {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(v), true\n\tcase uint8:\n\t\treturn int32(v), true\n\tcase uint16:\n\t\treturn int32(v), true\n\tcase uint32:\n\t\tif v > uint32(math.MaxInt32) {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(v), true\n\tcase uint64:\n\t\tif v > uint64(math.MaxInt32) {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(v), true\n\tcase float32:\n\t\tf := float64(v)\n\t\tif math.Mod(f, 1) != 0 {\n\t\t\treturn 0, false\n\t\t}\n\t\tif f > float64(math.MaxInt32) || f < float64(math.MinInt32) {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(f), true\n\tcase float64:\n\t\tif math.Mod(v, 1) != 0 {\n\t\t\treturn 0, false\n\t\t}\n\t\tif v > float64(math.MaxInt32) || v < float64(math.MinInt32) {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(v), true\n\tcase string:\n\t\tif strings.TrimSpace(v) == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif strings.Contains(v, \".\") {\n\t\t\tf, err := strconv.ParseFloat(v, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, false\n\t\t\t}\n\t\t\tif math.Mod(f, 1) != 0 {\n\t\t\t\treturn 0, false\n\t\t\t}\n\t\t\tif f > float64(math.MaxInt32) || f < float64(math.MinInt32) {\n\t\t\t\treturn 0, false\n\t\t\t}\n\t\t\treturn int32(f), true\n\t\t}\n\t\ti, err := strconv.ParseInt(v, 10, 32)\n\t\tif err != nil {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn int32(i), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc parseISOTime(value any) (time.Time, bool) {\n\tswitch v := value.(type) {\n\tcase string:\n\t\tif strings.TrimSpace(v) == \"\" {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\tt, err := time.Parse(time.RFC3339, v)\n\t\tif err != nil {\n\t\t\treturn time.Time{}, false\n\t\t}\n\t\treturn t, true\n\tcase time.Time:\n\t\treturn v, true\n\tdefault:\n\t\treturn time.Time{}, false\n\t}\n}\n\nfunc toBool(value any) (bool, bool) {\n\tswitch v := value.(type) {\n\tcase bool:\n\t\treturn v, true\n\tcase string:\n\t\tif strings.TrimSpace(v) == \"\" {\n\t\t\treturn false, false\n\t\t}\n\t\tparsed, err := strconv.ParseBool(v)\n\t\tif err != nil {\n\t\t\treturn false, false\n\t\t}\n\t\treturn parsed, true\n\tcase int:\n\t\treturn v != 0, true\n\tcase int32:\n\t\treturn v != 0, true\n\tcase int64:\n\t\treturn v != 0, true\n\tcase float32:\n\t\treturn v != 0, true\n\tcase float64:\n\t\treturn v != 0, true\n\tdefault:\n\t\treturn false, false\n\t}\n}\n\nfunc mapKeys(m map[string]any) []string {\n\tif m == nil {\n\t\treturn nil\n\t}\n\n\tkeys := make([]string, 0, len(m))\n\tfor key := range m {\n\t\tkeys = append(keys, key)\n\t}\n\treturn keys\n}\n\nfunc extractInvoiceCountFromProduct(product map[string]any) string {\n\tif product == nil {\n\t\treturn \"\"\n\t}\n\n\tif metadata := stringMapFrom(product[\"metadata\"]); len(metadata) > 0 {\n\t\tif value := strings.TrimSpace(metadata[\"invoice_count\"]); value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\n\tbenefits, ok := product[\"benefits\"].([]any)\n\tif !ok || len(benefits) == 0 {\n\t\treturn \"\"\n\t}\n\n\tfor _, item := range benefits {\n\t\tbenefit, ok := item.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tbenefitType, _ := toString(benefit[\"type\"])\n\t\tif !strings.EqualFold(strings.TrimSpace(benefitType), \"meter_credit\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tif properties, ok := benefit[\"properties\"].(map[string]any); ok {\n\t\t\tif count, ok := toInt32(properties[\"units\"]); ok && count > 0 {\n\t\t\t\treturn strconv.FormatInt(int64(count), 10)\n\t\t\t}\n\t\t}\n\n\t\tif metadata := stringMapFrom(benefit[\"metadata\"]); len(metadata) > 0 {\n\t\t\tif value := strings.TrimSpace(metadata[\"units\"]); value != \"\" {\n\t\t\t\treturn value\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/refresh_subscription_status_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\n// RefreshSubscriptionStatus forces a sync with Polar API and returns updated status.\n// This is the lazy guarding mechanism - used when DB says expired but we want\n// to double-check with the provider in case we missed a webhook.\nfunc (s *billingService) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) {\n\t// Step 1: Check if subscription exists in database\n\t_, err := s.repo.GetSubscriptionByOrgID(ctx, organizationID)\n\tif err != nil {\n\t\t// No subscription exists - don't call Polar API\n\t\ts.logger.Info(\"No subscription found for refresh\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t})\n\n\t\treturn &domain.BillingStatus{\n\t\t\tOrganizationID:        organizationID,\n\t\t\tHasActiveSubscription: false,\n\t\t\tCanProcessInvoices:    false,\n\t\t\tInvoiceCount:          0,\n\t\t\tReason:                \"no active subscription found\",\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, nil\n\t}\n\n\t// Step 2: Sync subscription from Polar API\n\tif err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil {\n\t\t// Sync failed - return error\n\t\treturn nil, fmt.Errorf(\"failed to refresh subscription from Polar: %w\", err)\n\t}\n\n\t// Step 3: Get fresh billing status from database (after sync)\n\tbillingStatus, err := s.GetBillingStatus(ctx, organizationID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get billing status after refresh: %w\", err)\n\t}\n\n\ts.logger.Info(\"Subscription status refreshed\", map[string]any{\n\t\t\"organization_id\":         organizationID,\n\t\t\"has_active_subscription\": billingStatus.HasActiveSubscription,\n\t\t\"invoice_count\":           billingStatus.InvoiceCount,\n\t})\n\n\t// Console log for refresh completion\n\tfmt.Printf(\"🔄 SUBSCRIPTION REFRESHED - Org: %d | Active: %v | Invoice Count: %d | Reason: %s\\n\",\n\t\torganizationID, billingStatus.HasActiveSubscription, billingStatus.InvoiceCount, billingStatus.Reason)\n\n\treturn billingStatus, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/subscription_service_dec.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n\tlogger \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\n// BillingService handles subscription management and quota verification.\n//\n// This service manages the billing lifecycle with Polar.sh via event-driven webhooks.\n// It does NOT expose direct API calls to Polar during request handling - instead,\n// subscription state is synced via webhooks and stored locally for fast reads.\n//\n// Architecture:\n//\n//\t┌───────────────┐    webhooks    ┌─────────────────┐    reads    ┌─────────────┐\n//\t│   Polar.sh    │ ─────────────► │  BillingService │ ──────────► │  Local DB   │\n//\t└───────────────┘                └─────────────────┘             └─────────────┘\n//\t                                          │\n//\t                                          ▼\n//\t                                 ┌─────────────────┐\n//\t                                 │ Paywall reads   │\n//\t                                 │ from local DB   │\n//\t                                 └─────────────────┘\ntype BillingService interface {\n\t// ProcessWebhookEvent processes a Polar webhook event and updates local database\n\t// Handles: subscription.created, subscription.updated, subscription.canceled, customer.updated\n\tProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error\n\n\t// GetBillingStatus retrieves the current billing and quota status for an organization\n\t// This is a read-only operation from the local database\n\tGetBillingStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error)\n\n\t// CheckQuotaAvailability performs a read-only check of quota availability\n\t// Does NOT consume quota - use ConsumeInvoiceQuota after successful processing\n\t// Performs database-first check with fallback to Polar API if needed\n\t// Returns BillingStatus indicating if invoice processing is allowed\n\tCheckQuotaAvailability(ctx context.Context, organizationID int32) (*domain.BillingStatus, error)\n\n\t// ConsumeInvoiceQuota explicitly consumes one invoice quota after successful processing\n\t// Should be called after invoice has been successfully processed\n\t// Can be called asynchronously in background for better performance\n\t// Returns updated quota status\n\tConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error)\n\n\t// VerifyAndConsumeQuota verifies quota availability and consumes one invoice quota\n\t// Performs database-first check with fallback to Polar API if needed\n\t// Returns BillingStatus with detailed verification result\n\t// Automatically increments quota count on success\n\t// DEPRECATED: Use CheckQuotaAvailability + ConsumeInvoiceQuota pattern for better control\n\tVerifyAndConsumeQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error)\n\n\t// SyncSubscriptionFromPolar forces a sync of subscription data from Polar API\n\t// Used as fallback when webhook data is missing or stale\n\t// TODO: Implement periodic background sync job for all subscriptions\n\tSyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error\n\n\t// VerifyPaymentFromCheckout verifies a payment by checking the Polar checkout session\n\t// This is the primary mechanism for \"Verification on Redirect\" pattern\n\t// Called when user returns from payment page with session_id\n\t// Returns BillingStatus after updating database with latest subscription info\n\tVerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*domain.BillingStatus, error)\n\n\t// RefreshSubscriptionStatus forces a sync with Polar API and returns updated status\n\t// This is the lazy guarding mechanism - used when DB says expired but we want\n\t// to double-check with the provider in case we missed a webhook\n\t// Returns updated BillingStatus after syncing with provider\n\tRefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error)\n}\n\ntype billingService struct {\n\trepo            domain.SubscriptionRepository\n\torgAdapter      domain.OrganizationAdapter\n\tbillingProvider domain.BillingProvider\n\tlogger          logger.Logger\n}\n\nfunc NewBillingService(\n\trepo domain.SubscriptionRepository,\n\torgAdapter domain.OrganizationAdapter,\n\tbillingProvider domain.BillingProvider,\n\tlogger logger.Logger,\n) BillingService {\n\treturn &billingService{\n\t\trepo:            repo,\n\t\torgAdapter:      orgAdapter,\n\t\tbillingProvider: billingProvider,\n\t\tlogger:          logger,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/sync_subscription_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\nfunc (s *billingService) SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error {\n\t// Get organization's external customer ID\n\texternalID, err := s.orgAdapter.GetStytchOrgID(ctx, organizationID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get organization external ID: %w\", err)\n\t}\n\n\t// Fetch subscription from Polar\n\tsubscription, err := s.billingProvider.GetSubscription(ctx, externalID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to fetch subscription from Polar: %w\", err)\n\t}\n\n\t// Upsert subscription to database\n\tsubscription.OrganizationID = organizationID\n\t_, err = s.repo.UpsertSubscription(ctx, subscription)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to save subscription: %w\", err)\n\t}\n\n\t// Extract and upsert quota information\n\tinvoiceCountMax := int32(0)\n\tif metadata, ok := subscription.Metadata[\"invoice_count_max\"].(int32); ok {\n\t\tinvoiceCountMax = metadata\n\t} else if val, ok := subscription.Metadata[\"invoice_count_max\"].(string); ok {\n\t\tif count, err := strconv.ParseInt(val, 10, 32); err == nil {\n\t\t\tinvoiceCountMax = int32(count)\n\t\t}\n\t}\n\n\t// Create or update quota tracking with synced data\n\tquota := &domain.QuotaTracking{\n\t\tOrganizationID: organizationID,\n\t\tInvoiceCount:   invoiceCountMax,\n\t\tPeriodStart:    subscription.CurrentPeriodStart,\n\t\tPeriodEnd:      subscription.CurrentPeriodEnd,\n\t\tLastSyncedAt:   &time.Time{},\n\t}\n\t*quota.LastSyncedAt = time.Now()\n\n\t_, err = s.repo.UpsertQuota(ctx, quota)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to save quota: %w\", err)\n\t}\n\n\ts.logger.Info(\"Synced subscription and quota from Polar\", map[string]any{\n\t\t\"organization_id\": organizationID,\n\t\t\"subscription_id\": subscription.SubscriptionID,\n\t\t\"invoice_count\":   invoiceCountMax,\n\t\t\"synced_at\":       quota.LastSyncedAt,\n\t})\n\n\t// Console log for sync completion\n\tfmt.Printf(\"🔄 SYNC COMPLETED - Org: %d | Subscription: %s | Invoice Count: %d | Status: %s | Synced at: %s\\n\",\n\t\torganizationID, subscription.SubscriptionID, invoiceCountMax, subscription.SubscriptionStatus, quota.LastSyncedAt.Format(time.RFC3339))\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/verify_and_consume_quota_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\nfunc (s *billingService) VerifyAndConsumeQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) {\n\t// Step 1: Check database quota status\n\tquotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID)\n\tif err != nil {\n\t\treturn &domain.BillingStatus{\n\t\t\tOrganizationID:        organizationID,\n\t\t\tHasActiveSubscription: false,\n\t\t\tCanProcessInvoices:    false,\n\t\t\tReason:                \"no active subscription\",\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, domain.ErrSubscriptionNotFound\n\t}\n\n\t// Step 2: Check if we need fallback API verification\n\tneedsFallback := s.needsFallbackVerification(quotaStatus)\n\n\tif needsFallback {\n\t\ts.logger.Info(\"Quota near limit or stale, performing fallback API verification\", map[string]any{\n\t\t\t\"organization_id\": organizationID,\n\t\t\t\"invoice_count\":   quotaStatus.InvoiceCount,\n\t\t})\n\n\t\t// Sync from Polar and re-check\n\t\tif err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil {\n\t\t\ts.logger.Error(\"Fallback sync failed, using database data\", map[string]any{\n\t\t\t\t\"organization_id\": organizationID,\n\t\t\t\t\"error\":           err.Error(),\n\t\t\t})\n\t\t} else {\n\t\t\t// Re-fetch quota status after sync\n\t\t\tquotaStatus, err = s.repo.GetQuotaStatus(ctx, organizationID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to get quota after sync: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Step 3: Verify quota is available\n\tif !quotaStatus.CanProcessInvoice {\n\t\treturn &domain.BillingStatus{\n\t\t\tOrganizationID:        organizationID,\n\t\t\tHasActiveSubscription: quotaStatus.SubscriptionStatus == \"active\",\n\t\t\tCanProcessInvoices:    false,\n\t\t\tInvoiceCount:          quotaStatus.InvoiceCount,\n\t\t\tReason:                \"quota exceeded or subscription inactive\",\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, domain.ErrQuotaExceeded\n\t}\n\n\t// Step 4: Decrement quota count (consume one invoice)\n\t_, err = s.repo.DecrementInvoiceCount(ctx, organizationID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decrement invoice count: %w\", err)\n\t}\n\n\t// Step 5: Return success status\n\treturn &domain.BillingStatus{\n\t\tOrganizationID:        organizationID,\n\t\tHasActiveSubscription: true,\n\t\tCanProcessInvoices:    true,\n\t\tInvoiceCount:          quotaStatus.InvoiceCount - 1, // Already decremented\n\t\tReason:                \"quota verified and consumed\",\n\t\tCheckedAt:             time.Now(),\n\t}, nil\n}\n\nfunc (s *billingService) needsFallbackVerification(status *domain.QuotaStatus) bool {\n\t// Perform fallback if:\n\t// 1. Very few invoices remaining (< 10)\n\t// 2. Subscription is inactive but we're checking\n\n\treturn status.InvoiceCount < 10 || status.SubscriptionStatus != \"active\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/app/services/verify_payment_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\nfunc (s *billingService) VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*domain.BillingStatus, error) {\n\t// Step 1: Get checkout session from Polar with polling\n\tcheckoutSession, err := s.billingProvider.GetCheckoutSessionWithPolling(ctx, sessionID)\n\tif err != nil {\n\t\tfmt.Printf(\"❌ [VerifyPayment] Failed to verify checkout session %s: %v\\n\", sessionID, err)\n\t\treturn nil, fmt.Errorf(\"failed to get checkout session: %w\", err)\n\t}\n\n\tfmt.Printf(\"✅ [VerifyPayment] Checkout session %s verified with status: %s\\n\", sessionID, checkoutSession.Status)\n\n\t// Step 2: Verify checkout status\n\tif checkoutSession.Status != \"succeeded\" {\n\t\treturn &domain.BillingStatus{\n\t\t\tHasActiveSubscription: false,\n\t\t\tCanProcessInvoices:    false,\n\t\t\tReason:                fmt.Sprintf(\"checkout session status is %s (expected: succeeded)\", checkoutSession.Status),\n\t\t\tCheckedAt:             time.Now(),\n\t\t}, nil\n\t}\n\n\t// Step 3: Extract customer ID (this is the Stytch org ID)\n\texternalCustomerID := checkoutSession.CustomerID\n\tif externalCustomerID == \"\" {\n\t\treturn nil, fmt.Errorf(\"checkout session has no customer_id\")\n\t}\n\n\t// Step 4: Map external customer ID to internal organization ID\n\torganizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, externalCustomerID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to map customer ID to organization: %w\", err)\n\t}\n\n\t// Step 5: Fetch full subscription details from Polar\n\tsubscription, err := s.billingProvider.GetSubscription(ctx, externalCustomerID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch subscription from Polar: %w\", err)\n\t}\n\n\t// Step 6: Upsert subscription to database\n\tsubscription.OrganizationID = organizationID\n\t_, err = s.repo.UpsertSubscription(ctx, subscription)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save subscription: %w\", err)\n\t}\n\n\t// Step 7: Extract and upsert quota information\n\tinvoiceCountMax := int32(0)\n\tif metadata, ok := subscription.Metadata[\"invoice_count_max\"].(int32); ok {\n\t\tinvoiceCountMax = metadata\n\t} else if val, ok := subscription.Metadata[\"invoice_count_max\"].(string); ok {\n\t\tif count, err := strconv.ParseInt(val, 10, 32); err == nil {\n\t\t\tinvoiceCountMax = int32(count)\n\t\t}\n\t}\n\n\t// Create or update quota tracking\n\tquota := &domain.QuotaTracking{\n\t\tOrganizationID: organizationID,\n\t\tInvoiceCount:   invoiceCountMax,\n\t\tPeriodStart:    subscription.CurrentPeriodStart,\n\t\tPeriodEnd:      subscription.CurrentPeriodEnd,\n\t\tLastSyncedAt:   &time.Time{},\n\t}\n\t*quota.LastSyncedAt = time.Now()\n\n\t_, err = s.repo.UpsertQuota(ctx, quota)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save quota: %w\", err)\n\t}\n\n\ts.logger.Info(\"Payment verified from checkout session\", map[string]any{\n\t\t\"session_id\":      sessionID,\n\t\t\"organization_id\": organizationID,\n\t\t\"subscription_id\": subscription.SubscriptionID,\n\t\t\"invoice_count\":   invoiceCountMax,\n\t})\n\n\t// Console log for verification completion\n\tfmt.Printf(\"✅ PAYMENT VERIFIED - Session: %s | Org: %d | Subscription: %s | Invoice Count: %d | Status: %s\\n\",\n\t\tsessionID, organizationID, subscription.SubscriptionID, invoiceCountMax, subscription.SubscriptionStatus)\n\n\t// Step 8: Return billing status\n\treturn &domain.BillingStatus{\n\t\tOrganizationID:        organizationID,\n\t\tExternalID:            externalCustomerID,\n\t\tHasActiveSubscription: subscription.SubscriptionStatus == \"active\" || subscription.SubscriptionStatus == \"trialing\",\n\t\tCanProcessInvoices:    (subscription.SubscriptionStatus == \"active\" || subscription.SubscriptionStatus == \"trialing\") && invoiceCountMax > 0,\n\t\tInvoiceCount:          invoiceCountMax,\n\t\tReason:                \"Payment verified successfully\",\n\t\tCheckedAt:             time.Now(),\n\t}, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n)\n\n//\n// The billing module handles subscription lifecycle management with Polar.sh:\n//   - Webhook processing for subscription events\n//   - Quota tracking and consumption\n//   - Billing status queries\n//\n// Communication is event-driven:\n//   - Polar sends webhook → billing processes event → updates local DB\n//   - Paywall middleware reads from local DB (no external API calls)\nfunc Init(container *dig.Container) error {\n\t// Register all dependencies\n\tif err := ProvideDependencies(container); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/cmd/provider.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/infra/adapters\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/paywall\"\n)\n\n// ProvideDependencies registers all billing module dependencies\nfunc ProvideDependencies(container *dig.Container) error {\n\t// Use the services module for dependency injection\n\tservicesModule := services.NewModule()\n\tif err := servicesModule.Configure(container); err != nil {\n\t\treturn fmt.Errorf(\"failed to configure billing services: %w\", err)\n\t}\n\n\t// Register SubscriptionStatusProvider for the paywall middleware\n\t// This adapter bridges the billing module to the pkg/paywall middleware\n\t// Communication is event-driven: webhooks → billing → DB → paywall reads\n\tif err := container.Provide(func(svc services.BillingService) paywall.SubscriptionStatusProvider {\n\t\treturn adapters.NewStatusProviderAdapter(svc)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide subscription status provider: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/domain/errors.go",
    "content": "package domain\n\nimport \"errors\"\n\nvar (\n\t// ErrSubscriptionNotFound is returned when a subscription cannot be found\n\tErrSubscriptionNotFound = errors.New(\"subscription not found\")\n\n\t// ErrSubscriptionNotActive is returned when a subscription exists but is not active\n\tErrSubscriptionNotActive = errors.New(\"subscription is not active\")\n\n\t// ErrQuotaNotFound is returned when quota tracking record cannot be found\n\tErrQuotaNotFound = errors.New(\"quota not found\")\n\n\t// ErrQuotaExceeded is returned when invoice quota has been exceeded\n\tErrQuotaExceeded = errors.New(\"invoice quota exceeded\")\n\n\t// ErrInvalidWebhookPayload is returned when webhook payload cannot be parsed\n\tErrInvalidWebhookPayload = errors.New(\"invalid webhook payload\")\n\n\t// ErrWebhookSignatureInvalid is returned when webhook signature verification fails\n\tErrWebhookSignatureInvalid = errors.New(\"webhook signature invalid\")\n\n\t// ErrQuotaDataStale is returned when quota data hasn't been synced recently\n\tErrQuotaDataStale = errors.New(\"quota data is stale\")\n\n\t// ErrCheckoutSessionNotFound is returned when a checkout session cannot be found\n\tErrCheckoutSessionNotFound = errors.New(\"checkout session not found\")\n)\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/domain/repository.go",
    "content": "package domain\n\nimport \"context\"\n\n// SubscriptionRepository provides database operations for subscriptions and quotas\ntype SubscriptionRepository interface {\n\t// Subscription operations\n\tGetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*Subscription, error)\n\tUpsertSubscription(ctx context.Context, subscription *Subscription) (*Subscription, error)\n\tDeleteSubscription(ctx context.Context, organizationID int32) error\n\n\t// Quota operations\n\tGetQuotaByOrgID(ctx context.Context, organizationID int32) (*QuotaTracking, error)\n\tUpsertQuota(ctx context.Context, quota *QuotaTracking) (*QuotaTracking, error)\n\tDecrementInvoiceCount(ctx context.Context, organizationID int32) (*QuotaTracking, error)\n\n\t// Combined operations\n\tGetQuotaStatus(ctx context.Context, organizationID int32) (*QuotaStatus, error)\n}\n\n// OrganizationAdapter provides access to organization data\ntype OrganizationAdapter interface {\n\tGetStytchOrgID(ctx context.Context, organizationID int32) (string, error)\n\tGetOrganizationIDByStytchOrgID(ctx context.Context, stytchOrgID string) (int32, error)\n}\n\n// BillingProvider defines operations for external billing providers\n// This interface abstracts the billing provider (e.g., Polar.sh) from the app layer\ntype BillingProvider interface {\n\tGetSubscription(ctx context.Context, externalCustomerID string) (*Subscription, error)\n\tGetCheckoutSession(ctx context.Context, sessionID string) (*CheckoutSessionResponse, error)\n\tGetCheckoutSessionWithPolling(ctx context.Context, sessionID string) (*CheckoutSessionResponse, error)\n\tIngestMeterEvent(ctx context.Context, externalCustomerID string, meterSlug string, amount int32) error\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/domain/types.go",
    "content": "package domain\n\nimport \"time\"\n\n// Subscription represents a billing subscription from Polar\ntype Subscription struct {\n\tID                 int32\n\tOrganizationID     int32\n\tExternalCustomerID string\n\tSubscriptionID     string\n\tSubscriptionStatus string\n\tProductID          string\n\tProductName        string\n\tPlanName           string\n\tCurrentPeriodStart time.Time\n\tCurrentPeriodEnd   time.Time\n\tCancelAtPeriodEnd  bool\n\tCanceledAt         *time.Time\n\tMetadata           map[string]any\n\tCreatedAt          time.Time\n\tUpdatedAt          time.Time\n}\n\n// QuotaTracking represents usage quota tracking for an organization\ntype QuotaTracking struct {\n\tID             int32\n\tOrganizationID int32\n\tInvoiceCount   int32 // Remaining invoices (decremented on use)\n\tMaxSeats       int32\n\tPeriodStart    time.Time\n\tPeriodEnd      time.Time\n\tLastSyncedAt   *time.Time\n\tCreatedAt      time.Time\n\tUpdatedAt      time.Time\n}\n\n// QuotaStatus represents the combined subscription and quota status\n// This is returned from the GetQuotaStatus database query\ntype QuotaStatus struct {\n\tSubscriptionStatus string\n\tCurrentPeriodStart time.Time\n\tCurrentPeriodEnd   time.Time\n\tCancelAtPeriodEnd  bool\n\tInvoiceCount       int32 // Remaining invoices\n\tMaxSeats           int32\n\tCanProcessInvoice  bool\n}\n\n// BillingStatus represents the overall billing status for quota verification\ntype BillingStatus struct {\n\tOrganizationID        int32\n\tExternalID            string\n\tHasActiveSubscription bool\n\tCanProcessInvoices    bool\n\tInvoiceCount          int32 // Remaining invoices\n\tReason                string\n\tCheckedAt             time.Time\n}\n\n// WebhookEvent represents a Polar webhook event\ntype WebhookEvent struct {\n\tEventType string\n\tPayload   map[string]any\n}\n\n// SubscriptionEventData represents parsed subscription data from webhook\ntype SubscriptionEventData struct {\n\tSubscriptionID     string\n\tExternalCustomerID string\n\tProductID          string\n\tProductName        string\n\tStatus             string\n\tCurrentPeriodStart time.Time\n\tCurrentPeriodEnd   time.Time\n\tCancelAtPeriodEnd  bool\n\tCanceledAt         *time.Time\n\tProductMetadata    map[string]string\n\tCustomerMetadata   map[string]string\n}\n\n// MeterGrantEventData represents meter grant payload details from Polar webhooks\ntype MeterGrantEventData struct {\n\tMeterSlug          string\n\tExternalCustomerID string\n\tAvailableCredits   int32\n}\n\n// CheckoutSessionResponse represents a Polar checkout session\ntype CheckoutSessionResponse struct {\n\tID             string\n\tStatus         string // \"succeeded\", \"pending\", \"expired\", \"failed\"\n\tCustomerID     string\n\tSubscriptionID string\n\tProductID      string\n\tAmount         int64\n\tCreatedAt      time.Time\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/handler.go",
    "content": "package billing\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\tbillingServices \"github.com/moasq/go-b2b-starter/internal/modules/billing/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/pkg/httperr\"\n)\n\ntype Handler struct {\n\tbillingService billingServices.BillingService\n\tlogger         logger.Logger\n}\n\nfunc NewHandler(billingService billingServices.BillingService, log logger.Logger) *Handler {\n\treturn &Handler{\n\t\tbillingService: billingService,\n\t\tlogger:         log,\n\t}\n}\n\n// GetBillingStatus godoc\n// @Summary Get current billing and quota status\n// @Description Retrieve the current subscription billing status and invoice quota information for the organization\n// @Tags subscriptions\n// @Accept json\n// @Produce json\n// @Success 200 {object} domain.BillingStatus \"Current billing and quota status\"\n// @Failure 400 {object} httperr.HTTPError \"Invalid request parameters or missing organization context\"\n// @Failure 500 {object} httperr.HTTPError \"Internal server error\"\n// @Router /api/subscriptions/status [get]\nfunc (h *Handler) GetBillingStatus(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\t// Call service layer to get billing status\n\tbillingStatus, err := h.billingService.GetBillingStatus(c.Request.Context(), reqCtx.OrganizationID)\n\tif err != nil {\n\t\t// Check if subscription not found - this is not necessarily an error\n\t\t// Organization might not have a subscription yet\n\t\tif err == domain.ErrSubscriptionNotFound {\n\t\t\t// Return a response indicating no active subscription\n\t\t\tc.JSON(http.StatusOK, domain.BillingStatus{\n\t\t\t\tOrganizationID:        reqCtx.OrganizationID,\n\t\t\t\tHasActiveSubscription: false,\n\t\t\t\tCanProcessInvoices:    false,\n\t\t\t\tInvoiceCount:          0,\n\t\t\t\tReason:                \"No active subscription found\",\n\t\t\t\tCheckedAt:             time.Now(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"billing_status_failed\",\n\t\t\tfmt.Sprintf(\"Failed to retrieve billing status: %v\", err),\n\t\t))\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, billingStatus)\n}\n\n// VerifyPaymentRequest represents the request payload for verifying a payment\ntype VerifyPaymentRequest struct {\n\tSessionID string `json:\"session_id\" binding:\"required\"`\n}\n\n// VerifyPayment godoc\n// @Summary Verify payment from checkout session\n// @Description Verifies a payment by checking the Polar checkout session and updates subscription status. This is the primary mechanism for \"Verification on Redirect\" pattern when user returns from payment page.\n// @Tags subscriptions\n// @Accept json\n// @Produce json\n// @Param request body VerifyPaymentRequest true \"Checkout session ID\"\n// @Success 200 {object} domain.BillingStatus \"Verification result with updated billing status\"\n// @Failure 400 {object} httperr.HTTPError \"Invalid request parameters or checkout session failed\"\n// @Failure 404 {object} httperr.HTTPError \"Checkout session not found\"\n// @Failure 500 {object} httperr.HTTPError \"Internal server error\"\n// @Router /api/subscriptions/verify-payment [post]\nfunc (h *Handler) VerifyPayment(c *gin.Context) {\n\th.logger.Info(\"[VerifyPayment] Starting payment verification request\", nil)\n\n\t// Bind request\n\tvar req VerifyPaymentRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"[VerifyPayment] Failed to bind request JSON\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"invalid_request\",\n\t\t\tfmt.Sprintf(\"Invalid request: %v\", err),\n\t\t))\n\t\treturn\n\t}\n\n\th.logger.Info(\"[VerifyPayment] Request parsed successfully\", map[string]any{\n\t\t\"session_id\": req.SessionID,\n\t})\n\n\t// Validate session_id is not empty\n\tif req.SessionID == \"\" {\n\t\th.logger.Warn(\"[VerifyPayment] Missing session_id in request\", nil)\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_session_id\",\n\t\t\t\"Checkout session ID is required\",\n\t\t))\n\t\treturn\n\t}\n\n\th.logger.Info(\"[VerifyPayment] Calling billing service to verify payment\", map[string]any{\n\t\t\"session_id\": req.SessionID,\n\t})\n\n\t// Call service to verify payment\n\tbillingStatus, err := h.billingService.VerifyPaymentFromCheckout(c.Request.Context(), req.SessionID)\n\tif err != nil {\n\t\t// Check if it's a checkout session not found error\n\t\tif errors.Is(err, domain.ErrCheckoutSessionNotFound) {\n\t\t\th.logger.Warn(\"[VerifyPayment] Checkout session not found\", map[string]any{\n\t\t\t\t\"session_id\": req.SessionID,\n\t\t\t})\n\t\t\tc.JSON(http.StatusNotFound, httperr.NewHTTPError(\n\t\t\t\thttp.StatusNotFound,\n\t\t\t\t\"session_not_found\",\n\t\t\t\tfmt.Sprintf(\"Checkout session not found: %s\", req.SessionID),\n\t\t\t))\n\t\t\treturn\n\t\t}\n\n\t\th.logger.Error(\"[VerifyPayment] Failed to verify payment\", map[string]any{\n\t\t\t\"session_id\": req.SessionID,\n\t\t\t\"error\":      err.Error(),\n\t\t})\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"verification_failed\",\n\t\t\tfmt.Sprintf(\"Failed to verify payment: %v\", err),\n\t\t))\n\t\treturn\n\t}\n\n\th.logger.Info(\"[VerifyPayment] Billing service returned status\", map[string]any{\n\t\t\"session_id\":              req.SessionID,\n\t\t\"has_active_subscription\": billingStatus.HasActiveSubscription,\n\t\t\"can_process_invoices\":    billingStatus.CanProcessInvoices,\n\t\t\"invoice_count\":           billingStatus.InvoiceCount,\n\t\t\"reason\":                  billingStatus.Reason,\n\t})\n\n\t// If checkout session is not succeeded, return 400 with reason\n\tif !billingStatus.HasActiveSubscription && billingStatus.Reason != \"Payment verified successfully\" {\n\t\th.logger.Warn(\"[VerifyPayment] Payment not completed\", map[string]any{\n\t\t\t\"session_id\": req.SessionID,\n\t\t\t\"reason\":     billingStatus.Reason,\n\t\t})\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"payment_not_completed\",\n\t\t\tbillingStatus.Reason,\n\t\t))\n\t\treturn\n\t}\n\n\th.logger.Info(\"[VerifyPayment] Payment verification completed successfully\", map[string]any{\n\t\t\"session_id\":      req.SessionID,\n\t\t\"organization_id\": billingStatus.OrganizationID,\n\t\t\"invoice_count\":   billingStatus.InvoiceCount,\n\t})\n\n\tc.JSON(http.StatusOK, billingStatus)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/infra/adapters/status_provider.go",
    "content": "// Package adapters provides adapter implementations for external interfaces.\npackage adapters\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/paywall\"\n)\n\n// StatusProviderAdapter adapts the BillingService to the SubscriptionStatusProvider interface.\n//\n// This adapter allows the paywall middleware to check subscription status\n// without depending directly on the billing service implementation.\n// Communication is event-driven: Polar webhooks → billing module → local DB → paywall reads.\ntype StatusProviderAdapter struct {\n\tservice services.BillingService\n}\n\nfunc NewStatusProviderAdapter(service services.BillingService) paywall.SubscriptionStatusProvider {\n\treturn &StatusProviderAdapter{service: service}\n}\n\n// GetSubscriptionStatus implements paywall.SubscriptionStatusProvider.\n//\n// It delegates to the BillingService.GetBillingStatus method and converts\n// the BillingStatus to a SubscriptionStatus for the middleware to use.\nfunc (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, organizationID int32) (*paywall.SubscriptionStatus, error) {\n\tbillingStatus, err := a.service.GetBillingStatus(ctx, organizationID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Map BillingStatus to SubscriptionStatus\n\tstatus := &paywall.SubscriptionStatus{\n\t\tOrganizationID: billingStatus.OrganizationID,\n\t\tIsActive:       billingStatus.HasActiveSubscription,\n\t\tReason:         billingStatus.Reason,\n\t}\n\n\t// Determine status string from reason\n\tif billingStatus.HasActiveSubscription {\n\t\tstatus.Status = paywall.StatusActive\n\t} else if billingStatus.Reason == \"no active subscription found\" {\n\t\tstatus.Status = paywall.StatusNone\n\t} else {\n\t\t// Parse status from reason if available, otherwise default to inactive\n\t\tstatus.Status = parseStatusFromReason(billingStatus.Reason)\n\t}\n\n\treturn status, nil\n}\n\n// RefreshSubscriptionStatus implements paywall.SubscriptionStatusProvider.\n//\n// It forces a sync with the payment provider API and returns the updated status.\n// This is the lazy guarding mechanism - used when DB says expired but we want\n// to double-check with the provider in case we missed a webhook.\nfunc (a *StatusProviderAdapter) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*paywall.SubscriptionStatus, error) {\n\t// Delegate to the BillingService.RefreshSubscriptionStatus method\n\tbillingStatus, err := a.service.RefreshSubscriptionStatus(ctx, organizationID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Map BillingStatus to SubscriptionStatus\n\tstatus := &paywall.SubscriptionStatus{\n\t\tOrganizationID: billingStatus.OrganizationID,\n\t\tIsActive:       billingStatus.HasActiveSubscription,\n\t\tReason:         billingStatus.Reason,\n\t}\n\n\t// Determine status string from reason\n\tif billingStatus.HasActiveSubscription {\n\t\tstatus.Status = paywall.StatusActive\n\t} else if billingStatus.Reason == \"no active subscription found\" {\n\t\tstatus.Status = paywall.StatusNone\n\t} else {\n\t\t// Parse status from reason if available, otherwise default to inactive\n\t\tstatus.Status = parseStatusFromReason(billingStatus.Reason)\n\t}\n\n\treturn status, nil\n}\n\n// parseStatusFromReason attempts to extract a subscription status from the reason string.\nfunc parseStatusFromReason(reason string) string {\n\t// Check for common status patterns in reason\n\tswitch {\n\tcase containsStatus(reason, \"past_due\"):\n\t\treturn paywall.StatusPastDue\n\tcase containsStatus(reason, \"canceled\"):\n\t\treturn paywall.StatusCanceled\n\tcase containsStatus(reason, \"unpaid\"):\n\t\treturn paywall.StatusUnpaid\n\tcase containsStatus(reason, \"trialing\"):\n\t\treturn paywall.StatusTrialing\n\tdefault:\n\t\treturn paywall.StatusNone\n\t}\n}\n\n// containsStatus checks if the reason contains a specific status.\nfunc containsStatus(reason, status string) bool {\n\treturn len(reason) >= len(status) && contains(reason, status)\n}\n\n// contains is a simple substring check.\nfunc contains(s, substr string) bool {\n\tfor i := 0; i <= len(s)-len(substr); i++ {\n\t\tif s[i:i+len(substr)] == substr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/infra/polar/polar_adapter.go",
    "content": "package polar\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\tloggerdomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tpolarpkg \"github.com/moasq/go-b2b-starter/internal/platform/polar\"\n)\n\n// Ensure polarAdapter implements domain.BillingProvider at compile time\nvar _ domain.BillingProvider = (*polarAdapter)(nil)\n\ntype polarAdapter struct {\n\tclient *polarpkg.Client\n\tlogger logger.Logger\n}\n\nfunc NewPolarAdapter(client *polarpkg.Client, log logger.Logger) domain.BillingProvider {\n\treturn &polarAdapter{\n\t\tclient: client,\n\t\tlogger: log,\n\t}\n}\n\nfunc (p *polarAdapter) GetSubscription(ctx context.Context, externalCustomerID string) (*domain.Subscription, error) {\n\t// Call Polar API to get subscription by customer external ID\n\tendpoint := fmt.Sprintf(\"/v1/subscriptions?customer_external_id=%s\", externalCustomerID)\n\n\tresp, err := p.client.Get(ctx, endpoint)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to call Polar API: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"polar API returned status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\t// Parse response\n\tvar result struct {\n\t\tItems []struct {\n\t\t\tID                 string `json:\"id\"`\n\t\t\tCustomerID         string `json:\"customer_id\"`\n\t\t\tProductID          string `json:\"product_id\"`\n\t\t\tStatus             string `json:\"status\"`\n\t\t\tCurrentPeriodStart string `json:\"current_period_start\"`\n\t\t\tCurrentPeriodEnd   string `json:\"current_period_end\"`\n\t\t\tCanceledAt         *string `json:\"canceled_at\"`\n\t\t\tCustomer           struct {\n\t\t\t\tID       string            `json:\"id\"`\n\t\t\t\tMetadata map[string]string `json:\"metadata\"`\n\t\t\t} `json:\"customer\"`\n\t\t\tProduct struct {\n\t\t\t\tID       string            `json:\"id\"`\n\t\t\t\tName     string            `json:\"name\"`\n\t\t\t\tMetadata map[string]string `json:\"metadata\"`\n\t\t\t} `json:\"product\"`\n\t\t} `json:\"items\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\tif len(result.Items) == 0 {\n\t\treturn nil, domain.ErrSubscriptionNotFound\n\t}\n\n\tpolarSub := result.Items[0]\n\n\t// Parse timestamps\n\tcurrentPeriodStart, _ := parseTime(polarSub.CurrentPeriodStart)\n\tcurrentPeriodEnd, _ := parseTime(polarSub.CurrentPeriodEnd)\n\n\tvar canceledAt *time.Time\n\tif polarSub.CanceledAt != nil {\n\t\tt, _ := parseTime(*polarSub.CanceledAt)\n\t\tcanceledAt = &t\n\t}\n\n\t// Parse quota limit from product metadata\n\tinvoiceCountMax := int32(0)\n\tif val, ok := polarSub.Product.Metadata[\"invoice_count\"]; ok {\n\t\tif count, err := strconv.ParseInt(val, 10, 32); err == nil {\n\t\t\tinvoiceCountMax = int32(count)\n\t\t}\n\t}\n\n\t// Log subscription sync\n\tp.logger.Info(\"polar subscription sync completed\", loggerdomain.Fields{\n\t\t\"customer_id\":       externalCustomerID,\n\t\t\"subscription_id\":   polarSub.ID,\n\t\t\"invoice_count_max\": invoiceCountMax,\n\t\t\"status\":            polarSub.Status,\n\t\t\"product_name\":      polarSub.Product.Name,\n\t})\n\n\t// Create domain subscription (organizationID will be set by caller)\n\tsubscription := &domain.Subscription{\n\t\tExternalCustomerID: externalCustomerID,\n\t\tSubscriptionID:     polarSub.ID,\n\t\tSubscriptionStatus: polarSub.Status,\n\t\tProductID:          polarSub.ProductID,\n\t\tProductName:        polarSub.Product.Name,\n\t\tCurrentPeriodStart: currentPeriodStart,\n\t\tCurrentPeriodEnd:   currentPeriodEnd,\n\t\tCanceledAt:         canceledAt,\n\t\tMetadata: map[string]any{\n\t\t\t\"invoice_count_max\":    invoiceCountMax,\n\t\t\t\"product_metadata\":     polarSub.Product.Metadata,\n\t\t\t\"customer_metadata\":    polarSub.Customer.Metadata,\n\t\t},\n\t}\n\n\treturn subscription, nil\n}\n\n// GetCheckoutSession retrieves checkout session details from Polar\nfunc (p *polarAdapter) GetCheckoutSession(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) {\n\t// Call Polar API to get checkout session details\n\tendpoint := fmt.Sprintf(\"/v1/checkouts/custom/%s\", sessionID)\n\n\tresp, err := p.client.Get(ctx, endpoint)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to call Polar checkout API: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode == 404 {\n\t\treturn nil, fmt.Errorf(\"%w: %s\", domain.ErrCheckoutSessionNotFound, sessionID)\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"polar checkout API returned status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\t// Parse response - Polar returns customer_external_id at root level\n\tvar result struct {\n\t\tID                 string `json:\"id\"`\n\t\tStatus             string `json:\"status\"`\n\t\tAmount             int64  `json:\"amount\"`\n\t\tCustomerExternalID string `json:\"customer_external_id\"` // The Stytch org ID we passed during checkout\n\t\tCustomerID         string `json:\"customer_id\"`          // Polar internal customer ID\n\t\tProduct            struct {\n\t\t\tID string `json:\"id\"`\n\t\t} `json:\"product\"`\n\t\tCustomer struct {\n\t\t\tID         string `json:\"id\"`\n\t\t\tExternalID string `json:\"external_id\"` // Also available in nested customer object\n\t\t} `json:\"customer\"`\n\t\tSubscription struct {\n\t\t\tID string `json:\"id\"`\n\t\t} `json:\"subscription\"`\n\t\tCreatedAt string `json:\"created_at\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode checkout response: %w\", err)\n\t}\n\n\t// Parse timestamp\n\tcreatedAt, _ := parseTime(result.CreatedAt)\n\n\t// Resolve external customer ID - try multiple fields\n\texternalCustomerID := result.CustomerExternalID\n\tif externalCustomerID == \"\" {\n\t\texternalCustomerID = result.Customer.ExternalID\n\t}\n\n\t// Log checkout session retrieval\n\tp.logger.Info(\"polar checkout session retrieved\", loggerdomain.Fields{\n\t\t\"session_id\":           result.ID,\n\t\t\"status\":               result.Status,\n\t\t\"external_customer_id\": externalCustomerID,\n\t\t\"customer_id\":          result.CustomerID,\n\t\t\"subscription_id\":      result.Subscription.ID,\n\t})\n\n\t// Create domain checkout session response\n\tcheckoutSession := &domain.CheckoutSessionResponse{\n\t\tID:             result.ID,\n\t\tStatus:         result.Status,\n\t\tCustomerID:     externalCustomerID, // Use external customer ID (Stytch org ID)\n\t\tSubscriptionID: result.Subscription.ID,\n\t\tProductID:      result.Product.ID,\n\t\tAmount:         result.Amount,\n\t\tCreatedAt:      createdAt,\n\t}\n\n\treturn checkoutSession, nil\n}\n\n// GetCheckoutSessionWithPolling retrieves checkout session with polling and retry logic\n// Polls every 2 seconds for up to 10 seconds (5 attempts total)\n// Continues polling when status is \"pending\" or on transient errors\n// Returns immediately on \"succeeded\" status or non-retryable errors\nfunc (p *polarAdapter) GetCheckoutSessionWithPolling(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) {\n\tconst (\n\t\tpollInterval = 2 * time.Second  // Poll every 2 seconds\n\t\tmaxDuration  = 10 * time.Second // Total timeout: 10 seconds\n\t)\n\n\tdeadline := time.Now().Add(maxDuration)\n\tticker := time.NewTicker(pollInterval)\n\tdefer ticker.Stop()\n\n\t// First attempt (immediate)\n\tsession, err := p.GetCheckoutSession(ctx, sessionID)\n\tif err == nil && session.Status == \"succeeded\" {\n\t\treturn session, nil\n\t}\n\n\t// Log initial status\n\tif err == nil {\n\t\tp.logger.Debug(\"polar checkout polling started\", loggerdomain.Fields{\n\t\t\t\"session_id\":   sessionID,\n\t\t\t\"status\":       session.Status,\n\t\t\t\"max_duration\": maxDuration.String(),\n\t\t})\n\t} else if !isRetryableError(err) {\n\t\t// Non-retryable error (e.g., 404) - fail immediately\n\t\treturn nil, err\n\t} else {\n\t\tp.logger.Debug(\"polar checkout initial attempt failed, will retry\", loggerdomain.Fields{\n\t\t\t\"session_id\": sessionID,\n\t\t\t\"error\":      err.Error(),\n\t\t})\n\t}\n\n\t// Polling loop\n\tattemptCount := 1\n\tfor time.Now().Before(deadline) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, ctx.Err()\n\t\tcase <-ticker.C:\n\t\t\tattemptCount++\n\t\t\tsession, err := p.GetCheckoutSession(ctx, sessionID)\n\n\t\t\tif err == nil {\n\t\t\t\tp.logger.Debug(\"polar checkout polling attempt\", loggerdomain.Fields{\n\t\t\t\t\t\"session_id\": sessionID,\n\t\t\t\t\t\"attempt\":    attemptCount,\n\t\t\t\t\t\"status\":     session.Status,\n\t\t\t\t})\n\t\t\t\tif session.Status == \"succeeded\" {\n\t\t\t\t\tp.logger.Info(\"polar checkout polling succeeded\", loggerdomain.Fields{\n\t\t\t\t\t\t\"session_id\": sessionID,\n\t\t\t\t\t\t\"attempts\":   attemptCount,\n\t\t\t\t\t})\n\t\t\t\t\treturn session, nil\n\t\t\t\t}\n\t\t\t\t// Continue polling for \"pending\", \"processing\", etc.\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Check if error is retryable\n\t\t\tif !isRetryableError(err) {\n\t\t\t\tp.logger.Warn(\"polar checkout polling non-retryable error\", loggerdomain.Fields{\n\t\t\t\t\t\"session_id\": sessionID,\n\t\t\t\t\t\"attempt\":    attemptCount,\n\t\t\t\t\t\"error\":      err.Error(),\n\t\t\t\t})\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tp.logger.Debug(\"polar checkout polling attempt failed, retrying\", loggerdomain.Fields{\n\t\t\t\t\"session_id\": sessionID,\n\t\t\t\t\"attempt\":    attemptCount,\n\t\t\t\t\"error\":      err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\t// Timeout reached - get last known status\n\tlastStatus := \"unknown\"\n\tif session != nil {\n\t\tlastStatus = session.Status\n\t}\n\tp.logger.Warn(\"polar checkout polling timeout\", loggerdomain.Fields{\n\t\t\"session_id\":  sessionID,\n\t\t\"attempts\":    attemptCount,\n\t\t\"last_status\": lastStatus,\n\t})\n\treturn nil, fmt.Errorf(\"checkout verification timed out after 10 seconds (last status: %s)\", lastStatus)\n}\n\n// isRetryableError determines if an error should trigger a retry\nfunc isRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\terrStr := err.Error()\n\n\t// Don't retry 404 (session not found)\n\tif strings.Contains(errStr, \"checkout session not found\") || strings.Contains(errStr, \"404\") {\n\t\treturn false\n\t}\n\n\t// Don't retry 4xx client errors (except 429)\n\tif strings.Contains(errStr, \"returned status 400\") ||\n\t\tstrings.Contains(errStr, \"returned status 401\") ||\n\t\tstrings.Contains(errStr, \"returned status 403\") {\n\t\treturn false\n\t}\n\n\t// Retry on:\n\t// - Network errors\n\t// - 5xx server errors\n\t// - 429 rate limit errors\n\t// - Timeout errors\n\t// - Connection errors\n\treturn true\n}\n\n// IngestMeterEvent ingests a meter event to Polar for usage-based billing\n// This notifies Polar about invoice processing to consume meter credits\n// Meter: \"Invoice Processing\"\nfunc (p *polarAdapter) IngestMeterEvent(ctx context.Context, externalCustomerID string, meterSlug string, amount int32) error {\n\t// Call Polar API to ingest meter event\n\t// POST /v1/events/ingest endpoint for event ingestion\n\tendpoint := \"/v1/events/ingest\"\n\n\t// Prepare request body for event ingestion\n\t// Events must be wrapped in \"events\" array\n\t// Meter will automatically aggregate events and decrement credits\n\tbody := map[string]any{\n\t\t\"events\": []map[string]any{\n\t\t\t{\n\t\t\t\t\"name\":                 meterSlug,\n\t\t\t\t\"external_customer_id\": externalCustomerID,\n\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\"count\": amount,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\t// Log the payload being sent to Polar for debugging\n\tbodyJSON, _ := json.Marshal(body)\n\tp.logger.Debug(\"sending meter event to polar\", loggerdomain.Fields{\n\t\t\"endpoint\": endpoint,\n\t\t\"payload\":  string(bodyJSON),\n\t})\n\n\tresp, err := p.client.Post(ctx, endpoint, body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to call Polar events API: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 && resp.StatusCode != 201 {\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"polar events API returned status %d: %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\t// Log successful event ingestion\n\tp.logger.Info(\"meter event ingested successfully\", loggerdomain.Fields{\n\t\t\"customer_id\": externalCustomerID,\n\t\t\"meter_slug\":  meterSlug,\n\t\t\"amount\":      amount,\n\t})\n\n\treturn nil\n}\n\nfunc parseTime(s string) (time.Time, error) {\n\t// Parse ISO 8601 timestamp\n\treturn time.Parse(time.RFC3339, s)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/infra/repositories/organization_adapter.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\t\"github.com/jackc/pgx/v5/pgtype\"\n)\n\ntype organizationAdapter struct {\n\torgStore adapters.OrganizationStore\n}\n\nfunc NewOrganizationAdapter(orgStore adapters.OrganizationStore) domain.OrganizationAdapter {\n\treturn &organizationAdapter{\n\t\torgStore: orgStore,\n\t}\n}\n\nfunc (a *organizationAdapter) GetStytchOrgID(ctx context.Context, organizationID int32) (string, error) {\n\torg, err := a.orgStore.GetOrganizationByID(ctx, organizationID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get organization: %w\", err)\n\t}\n\n\tif !org.StytchOrgID.Valid || org.StytchOrgID.String == \"\" {\n\t\treturn \"\", fmt.Errorf(\"organization has no Stytch org ID\")\n\t}\n\n\treturn org.StytchOrgID.String, nil\n}\n\nfunc (a *organizationAdapter) GetOrganizationIDByStytchOrgID(ctx context.Context, stytchOrgID string) (int32, error) {\n\tstytchOrgIDText := pgtype.Text{\n\t\tString: stytchOrgID,\n\t\tValid:  true,\n\t}\n\n\torg, err := a.orgStore.GetOrganizationByStytchID(ctx, stytchOrgIDText)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to get organization by Stytch org ID: %w\", err)\n\t}\n\n\treturn org.ID, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/infra/repositories/subscription_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/helpers\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/billing/domain\"\n)\n\n// subscriptionRepository implements domain.SubscriptionRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype subscriptionRepository struct {\n\tstore sqlc.Store\n}\n\n// NewSubscriptionRepository creates a new SubscriptionRepository implementation.\nfunc NewSubscriptionRepository(store sqlc.Store) domain.SubscriptionRepository {\n\treturn &subscriptionRepository{store: store}\n}\n\nfunc (r *subscriptionRepository) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*domain.Subscription, error) {\n\tresult, err := r.store.GetSubscriptionByOrgID(ctx, organizationID)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrSubscriptionNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get subscription: %w\", err)\n\t}\n\n\treturn r.mapToDomainSubscription(&result), nil\n}\n\nfunc (r *subscriptionRepository) UpsertSubscription(ctx context.Context, subscription *domain.Subscription) (*domain.Subscription, error) {\n\t// Marshal metadata to JSONB\n\tmetadataJSON, err := json.Marshal(subscription.Metadata)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal metadata: %w\", err)\n\t}\n\n\tparams := sqlc.UpsertSubscriptionParams{\n\t\tOrganizationID:     subscription.OrganizationID,\n\t\tExternalCustomerID: subscription.ExternalCustomerID,\n\t\tSubscriptionID:     subscription.SubscriptionID,\n\t\tSubscriptionStatus: subscription.SubscriptionStatus,\n\t\tProductID:          subscription.ProductID,\n\t\tProductName:        helpers.ToPgText(subscription.ProductName),\n\t\tPlanName:           helpers.ToPgText(subscription.PlanName),\n\t\tCurrentPeriodStart: toPgTimestamp(subscription.CurrentPeriodStart),\n\t\tCurrentPeriodEnd:   toPgTimestamp(subscription.CurrentPeriodEnd),\n\t\tCancelAtPeriodEnd:  helpers.ToPgBool(subscription.CancelAtPeriodEnd),\n\t\tCanceledAt:         toPgTimestampPtr(subscription.CanceledAt),\n\t\tMetadata:           metadataJSON,\n\t}\n\n\tresult, err := r.store.UpsertSubscription(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to upsert subscription: %w\", err)\n\t}\n\n\treturn r.mapToDomainSubscription(&result), nil\n}\n\nfunc (r *subscriptionRepository) DeleteSubscription(ctx context.Context, organizationID int32) error {\n\tif err := r.store.DeleteSubscription(ctx, organizationID); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete subscription: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (r *subscriptionRepository) GetQuotaByOrgID(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) {\n\tresult, err := r.store.GetQuotaByOrgID(ctx, organizationID)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrQuotaNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get quota: %w\", err)\n\t}\n\n\treturn r.mapToDomainQuota(&result), nil\n}\n\nfunc (r *subscriptionRepository) UpsertQuota(ctx context.Context, quota *domain.QuotaTracking) (*domain.QuotaTracking, error) {\n\tparams := sqlc.UpsertQuotaParams{\n\t\tOrganizationID: quota.OrganizationID,\n\t\tInvoiceCount:   quota.InvoiceCount,\n\t\tMaxSeats:       helpers.ToPgInt4(quota.MaxSeats),\n\t\tPeriodStart:    toPgTimestamp(quota.PeriodStart),\n\t\tPeriodEnd:      toPgTimestamp(quota.PeriodEnd),\n\t}\n\n\tresult, err := r.store.UpsertQuota(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to upsert quota: %w\", err)\n\t}\n\n\treturn r.mapToDomainQuota(&result), nil\n}\n\nfunc (r *subscriptionRepository) DecrementInvoiceCount(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) {\n\tresult, err := r.store.DecrementInvoiceCount(ctx, organizationID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decrement invoice count: %w\", err)\n\t}\n\n\treturn r.mapToDomainQuota(&result), nil\n}\n\nfunc (r *subscriptionRepository) GetQuotaStatus(ctx context.Context, organizationID int32) (*domain.QuotaStatus, error) {\n\tresult, err := r.store.GetQuotaStatus(ctx, organizationID)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrSubscriptionNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get quota status: %w\", err)\n\t}\n\n\treturn r.mapToDomainQuotaStatus(&result), nil\n}\n\n// Mapping functions\n\nfunc (r *subscriptionRepository) mapToDomainSubscription(s *sqlc.SubscriptionBillingSubscription) *domain.Subscription {\n\tvar metadata map[string]any\n\tif len(s.Metadata) > 0 {\n\t\tjson.Unmarshal(s.Metadata, &metadata)\n\t}\n\n\tsubscription := &domain.Subscription{\n\t\tID:                 s.ID,\n\t\tOrganizationID:     s.OrganizationID,\n\t\tExternalCustomerID: s.ExternalCustomerID,\n\t\tSubscriptionID:     s.SubscriptionID,\n\t\tSubscriptionStatus: s.SubscriptionStatus,\n\t\tProductID:          s.ProductID,\n\t\tProductName:        helpers.FromPgText(s.ProductName),\n\t\tPlanName:           helpers.FromPgText(s.PlanName),\n\t\tCurrentPeriodStart: s.CurrentPeriodStart.Time,\n\t\tCurrentPeriodEnd:   s.CurrentPeriodEnd.Time,\n\t\tMetadata:           metadata,\n\t\tCreatedAt:          s.CreatedAt.Time,\n\t\tUpdatedAt:          s.UpdatedAt.Time,\n\t}\n\n\t// Handle nullable fields\n\tif s.CancelAtPeriodEnd.Valid {\n\t\tsubscription.CancelAtPeriodEnd = s.CancelAtPeriodEnd.Bool\n\t}\n\tif s.CanceledAt.Valid {\n\t\tsubscription.CanceledAt = &s.CanceledAt.Time\n\t}\n\n\treturn subscription\n}\n\nfunc (r *subscriptionRepository) mapToDomainQuota(q *sqlc.SubscriptionBillingQuotaTracking) *domain.QuotaTracking {\n\tquota := &domain.QuotaTracking{\n\t\tID:             q.ID,\n\t\tOrganizationID: q.OrganizationID,\n\t\tInvoiceCount:   q.InvoiceCount,\n\t\tMaxSeats:       helpers.FromPgInt4(q.MaxSeats),\n\t\tPeriodStart:    q.PeriodStart.Time,\n\t\tPeriodEnd:      q.PeriodEnd.Time,\n\t\tCreatedAt:      q.CreatedAt.Time,\n\t\tUpdatedAt:      q.UpdatedAt.Time,\n\t}\n\n\t// Handle nullable LastSyncedAt\n\tif q.LastSyncedAt.Valid {\n\t\tquota.LastSyncedAt = &q.LastSyncedAt.Time\n\t}\n\n\treturn quota\n}\n\nfunc (r *subscriptionRepository) mapToDomainQuotaStatus(qs *sqlc.GetQuotaStatusRow) *domain.QuotaStatus {\n\tstatus := &domain.QuotaStatus{\n\t\tSubscriptionStatus: qs.SubscriptionStatus,\n\t\tCurrentPeriodStart: qs.CurrentPeriodStart.Time,\n\t\tCurrentPeriodEnd:   qs.CurrentPeriodEnd.Time,\n\t\tInvoiceCount:       qs.InvoiceCount,\n\t\tCanProcessInvoice:  qs.CanProcessInvoice,\n\t}\n\n\t// Handle nullable fields\n\tif qs.CancelAtPeriodEnd.Valid {\n\t\tstatus.CancelAtPeriodEnd = qs.CancelAtPeriodEnd.Bool\n\t}\n\tif qs.MaxSeats.Valid {\n\t\tstatus.MaxSeats = qs.MaxSeats.Int32\n\t}\n\n\treturn status\n}\n\n// Helper functions for timestamp conversion\n\nfunc toPgTimestamp(t time.Time) pgtype.Timestamp {\n\tif t.IsZero() {\n\t\treturn pgtype.Timestamp{Valid: false}\n\t}\n\treturn pgtype.Timestamp{Time: t, Valid: true}\n}\n\nfunc toPgTimestampPtr(t *time.Time) pgtype.Timestamp {\n\tif t == nil {\n\t\treturn pgtype.Timestamp{Valid: false}\n\t}\n\treturn pgtype.Timestamp{Time: *t, Valid: true}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/provider.go",
    "content": "package billing\n\nimport (\n\t\"go.uber.org/dig\"\n)\n\n// RegisterHandlers registers subscription API handlers in the DI container\nfunc RegisterHandlers(container *dig.Container) error {\n\tif err := container.Provide(NewHandler); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ProvideHandler is an alias for RegisterHandlers for consistency\nfunc ProvideHandler(container *dig.Container) error {\n\treturn RegisterHandlers(container)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/billing/routes.go",
    "content": "package billing\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\tserverDomain \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\n// Routes registers subscription endpoints\nfunc (h *Handler) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\t// Subscription endpoints\n\tsubscriptions := router.Group(\"/subscriptions\")\n\tsubscriptions.Use(\n\t\tresolver.Get(\"auth\"),\n\t\tresolver.Get(\"org_context\"),\n\t)\n\t{\n\t\t// Get billing status - requires resource:view permission\n\t\tsubscriptions.GET(\"/status\",\n\t\t\tauth.RequirePermissionFunc(\"resource\", \"view\"),\n\t\t\th.GetBillingStatus)\n\t}\n\n\t// Verify payment endpoint - auth only (session_id identifies org)\n\t// This is separate from the main group to avoid requiring org_context middleware\n\t// The session_id from the checkout contains the customer_id which maps to the org\n\trouter.POST(\"/subscriptions/verify-payment\",\n\t\tresolver.Get(\"auth\"),\n\t\th.VerifyPayment)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/app/services/document_listener.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\ntype documentListener struct {\n\tembeddingService EmbeddingService\n}\n\nfunc NewDocumentListener(\n\tembeddingService EmbeddingService,\n) DocumentListener {\n\treturn &documentListener{\n\t\tembeddingService: embeddingService,\n\t}\n}\n\nfunc (l *documentListener) HandleDocumentUploaded(ctx context.Context, documentID, orgID int32, text string) error {\n\t// Skip if no text to embed\n\tif text == \"\" {\n\t\treturn nil\n\t}\n\n\t// Create embedding for the document\n\t_, err := l.embeddingService.EmbedDocument(ctx, orgID, documentID, text)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to embed document: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/app/services/embedding_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n)\n\nconst (\n\t// MaxChunkSize is the maximum number of characters per chunk\n\tMaxChunkSize = 8000\n\t// ContentPreviewLength is the length of content preview to store\n\tContentPreviewLength = 500\n)\n\ntype embeddingService struct {\n\tembeddingRepo  domain.EmbeddingRepository\n\ttextVectorizer domain.TextVectorizer\n}\n\nfunc NewEmbeddingService(\n\tembeddingRepo domain.EmbeddingRepository,\n\ttextVectorizer domain.TextVectorizer,\n) EmbeddingService {\n\treturn &embeddingService{\n\t\tembeddingRepo:  embeddingRepo,\n\t\ttextVectorizer: textVectorizer,\n\t}\n}\n\nfunc (s *embeddingService) EmbedDocument(ctx context.Context, orgID, documentID int32, text string) (*domain.DocumentEmbedding, error) {\n\t// Generate embedding using text vectorizer\n\tembedding, err := s.textVectorizer.Vectorize(ctx, text)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %v\", domain.ErrEmbeddingGenerationFailed, err)\n\t}\n\n\t// Create content hash for deduplication\n\tcontentHash := s.hashContent(text)\n\n\t// Create content preview\n\tcontentPreview := text\n\tif len(contentPreview) > ContentPreviewLength {\n\t\tcontentPreview = contentPreview[:ContentPreviewLength]\n\t}\n\n\t// Create embedding record\n\tdocEmbedding := &domain.DocumentEmbedding{\n\t\tDocumentID:     documentID,\n\t\tOrganizationID: orgID,\n\t\tEmbedding:      embedding,\n\t\tContentHash:    contentHash,\n\t\tContentPreview: contentPreview,\n\t\tChunkIndex:     0, // Single chunk for now\n\t}\n\n\tresult, err := s.embeddingRepo.Create(ctx, docEmbedding)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to store embedding: %w\", err)\n\t}\n\n\treturn result, nil\n}\n\nfunc (s *embeddingService) GetDocumentEmbeddings(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) {\n\treturn s.embeddingRepo.GetByDocumentID(ctx, orgID, documentID)\n}\n\nfunc (s *embeddingService) SearchSimilarDocuments(ctx context.Context, orgID int32, text string, limit int32) ([]*domain.SimilarDocument, error) {\n\t// Generate embedding for the search query\n\tembedding, err := s.textVectorizer.Vectorize(ctx, text)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %v\", domain.ErrEmbeddingGenerationFailed, err)\n\t}\n\n\t// Search for similar documents\n\treturn s.embeddingRepo.SearchSimilar(ctx, orgID, embedding, limit)\n}\n\nfunc (s *embeddingService) DeleteDocumentEmbeddings(ctx context.Context, orgID, documentID int32) error {\n\tif err := s.embeddingRepo.Delete(ctx, orgID, documentID); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete embeddings: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *embeddingService) GetStats(ctx context.Context, orgID int32) (*domain.EmbeddingStats, error) {\n\tcount, err := s.embeddingRepo.Count(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get embedding count: %w\", err)\n\t}\n\n\treturn &domain.EmbeddingStats{\n\t\tTotalEmbeddings: count,\n\t\tTotalDocuments:  count, // For now, 1:1 relationship\n\t}, nil\n}\n\n// hashContent creates a SHA-256 hash of the content for deduplication\nfunc (s *embeddingService) hashContent(content string) string {\n\thash := sha256.Sum256([]byte(content))\n\treturn hex.EncodeToString(hash[:])\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/app/services/interface.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n)\n\n// EmbeddingService defines the interface for embedding operations\ntype EmbeddingService interface {\n\t// EmbedDocument generates and stores embeddings for a document\n\tEmbedDocument(ctx context.Context, orgID, documentID int32, text string) (*domain.DocumentEmbedding, error)\n\n\t// GetDocumentEmbeddings retrieves embeddings for a document\n\tGetDocumentEmbeddings(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error)\n\n\t// SearchSimilarDocuments finds documents similar to the given text\n\tSearchSimilarDocuments(ctx context.Context, orgID int32, text string, limit int32) ([]*domain.SimilarDocument, error)\n\n\t// DeleteDocumentEmbeddings removes embeddings for a document\n\tDeleteDocumentEmbeddings(ctx context.Context, orgID, documentID int32) error\n\n\t// GetStats retrieves embedding statistics\n\tGetStats(ctx context.Context, orgID int32) (*domain.EmbeddingStats, error)\n}\n\n// RAGService defines the interface for RAG (Retrieval-Augmented Generation) operations\ntype RAGService interface {\n\t// Chat sends a message and gets a response, optionally using RAG\n\tChat(ctx context.Context, orgID, accountID int32, req *domain.ChatRequest) (*domain.ChatResponse, error)\n\n\t// GetSession retrieves a chat session\n\tGetSession(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error)\n\n\t// ListSessions lists chat sessions for an account\n\tListSessions(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error)\n\n\t// DeleteSession deletes a chat session\n\tDeleteSession(ctx context.Context, orgID, sessionID int32) error\n\n\t// GetSessionHistory retrieves messages for a session\n\tGetSessionHistory(ctx context.Context, orgID, sessionID int32) ([]*domain.ChatMessage, error)\n\n\t// UpdateSessionTitle updates the title of a chat session\n\tUpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error)\n}\n\n// DocumentListener handles document events from the documents module\ntype DocumentListener interface {\n\t// HandleDocumentUploaded processes the DocumentUploaded event\n\tHandleDocumentUploaded(ctx context.Context, documentID, orgID int32, text string) error\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/app/services/rag_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n)\n\nconst (\n\t// DefaultMaxDocuments is the default number of documents to retrieve for RAG\n\tDefaultMaxDocuments = 3\n\t// DefaultContextHistory is the default number of messages to include in context\n\tDefaultContextHistory = 10\n\t// SystemPrompt is the default system prompt for RAG\n\tSystemPrompt = `You are a helpful assistant that answers questions based on the provided context.\nIf the context doesn't contain relevant information, say so clearly.\nAlways cite which documents you used to answer the question.`\n)\n\ntype ragService struct {\n\tchatRepo          domain.ChatRepository\n\tembeddingRepo     domain.EmbeddingRepository\n\ttextVectorizer    domain.TextVectorizer\n\tassistantProvider domain.AssistantProvider\n}\n\nfunc NewRAGService(\n\tchatRepo domain.ChatRepository,\n\tembeddingRepo domain.EmbeddingRepository,\n\ttextVectorizer domain.TextVectorizer,\n\tassistantProvider domain.AssistantProvider,\n) RAGService {\n\treturn &ragService{\n\t\tchatRepo:          chatRepo,\n\t\tembeddingRepo:     embeddingRepo,\n\t\ttextVectorizer:    textVectorizer,\n\t\tassistantProvider: assistantProvider,\n\t}\n}\n\nfunc (s *ragService) Chat(ctx context.Context, orgID, accountID int32, req *domain.ChatRequest) (*domain.ChatResponse, error) {\n\tvar session *domain.ChatSession\n\tvar err error\n\n\t// Get or create session\n\tif req.SessionID > 0 {\n\t\tsession, err = s.chatRepo.GetSessionByID(ctx, orgID, req.SessionID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get session: %w\", err)\n\t\t}\n\t} else {\n\t\t// Create new session\n\t\tsession = &domain.ChatSession{\n\t\t\tOrganizationID: orgID,\n\t\t\tAccountID:      accountID,\n\t\t\tTitle:          generateSessionTitle(req.Message),\n\t\t}\n\t\tsession, err = s.chatRepo.CreateSession(ctx, session)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create session: %w\", err)\n\t\t}\n\t}\n\n\t// Save user message\n\tuserMessage := &domain.ChatMessage{\n\t\tSessionID: session.ID,\n\t\tRole:      domain.ChatRoleUser,\n\t\tContent:   req.Message,\n\t}\n\tuserMessage, err = s.chatRepo.CreateMessage(ctx, userMessage)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save user message: %w\", err)\n\t}\n\n\t// Build context and generate response\n\tvar referencedDocs []*domain.SimilarDocument\n\tvar prompt string\n\n\tif req.UseRAG {\n\t\t// Search for similar documents\n\t\tmaxDocs := req.MaxDocuments\n\t\tif maxDocs <= 0 {\n\t\t\tmaxDocs = DefaultMaxDocuments\n\t\t}\n\n\t\t// Generate embedding for the query and search\n\t\tembedding, err := s.textVectorizer.Vectorize(ctx, req.Message)\n\t\tif err == nil {\n\t\t\tdocs, err := s.embeddingRepo.SearchSimilar(ctx, orgID, embedding, int32(maxDocs))\n\t\t\tif err == nil {\n\t\t\t\treferencedDocs = docs\n\t\t\t}\n\t\t}\n\n\t\t// Build RAG prompt\n\t\tprompt = s.buildRAGPrompt(req.Message, referencedDocs)\n\t} else {\n\t\tprompt = req.Message\n\t}\n\n\t// Get conversation history for context\n\tcontextHistory := req.ContextHistory\n\tif contextHistory <= 0 {\n\t\tcontextHistory = DefaultContextHistory\n\t}\n\n\thistory, _ := s.chatRepo.GetRecentMessages(ctx, session.ID, int32(contextHistory))\n\n\t// Build full prompt with history\n\tfullPrompt := s.buildPromptWithHistory(prompt, history)\n\n\t// Generate response using AI assistant\n\tresponse, err := s.assistantProvider.GenerateResponse(ctx, fullPrompt)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %v\", domain.ErrRAGCompletionFailed, err)\n\t}\n\n\t// Extract document IDs from referenced docs\n\tvar docIDs []int32\n\tfor _, doc := range referencedDocs {\n\t\tdocIDs = append(docIDs, doc.DocumentID)\n\t}\n\n\t// Save assistant response\n\tassistantMessage := &domain.ChatMessage{\n\t\tSessionID:      session.ID,\n\t\tRole:           domain.ChatRoleAssistant,\n\t\tContent:        response.Content,\n\t\tReferencedDocs: docIDs,\n\t\tTokensUsed:     int32(response.TokensUsed),\n\t}\n\tassistantMessage, err = s.chatRepo.CreateMessage(ctx, assistantMessage)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to save assistant message: %w\", err)\n\t}\n\n\t// Convert []*SimilarDocument to []SimilarDocument\n\tvar docs []domain.SimilarDocument\n\tfor _, doc := range referencedDocs {\n\t\tif doc != nil {\n\t\t\tdocs = append(docs, *doc)\n\t\t}\n\t}\n\n\treturn &domain.ChatResponse{\n\t\tSessionID:      session.ID,\n\t\tMessage:        assistantMessage,\n\t\tReferencedDocs: docs,\n\t\tTokensUsed:     int32(response.TokensUsed),\n\t}, nil\n}\n\nfunc (s *ragService) GetSession(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) {\n\treturn s.chatRepo.GetSessionByID(ctx, orgID, sessionID)\n}\n\nfunc (s *ragService) ListSessions(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) {\n\treturn s.chatRepo.ListSessionsByAccount(ctx, orgID, accountID, limit, offset)\n}\n\nfunc (s *ragService) DeleteSession(ctx context.Context, orgID, sessionID int32) error {\n\treturn s.chatRepo.DeleteSession(ctx, orgID, sessionID)\n}\n\nfunc (s *ragService) GetSessionHistory(ctx context.Context, orgID, sessionID int32) ([]*domain.ChatMessage, error) {\n\t// Verify session belongs to organization\n\t_, err := s.chatRepo.GetSessionByID(ctx, orgID, sessionID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to verify session: %w\", err)\n\t}\n\n\treturn s.chatRepo.GetMessagesBySession(ctx, sessionID)\n}\n\nfunc (s *ragService) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) {\n\treturn s.chatRepo.UpdateSessionTitle(ctx, orgID, sessionID, title)\n}\n\n// buildRAGPrompt builds a prompt with RAG context\nfunc (s *ragService) buildRAGPrompt(query string, docs []*domain.SimilarDocument) string {\n\tif len(docs) == 0 {\n\t\treturn fmt.Sprintf(\"%s\\n\\nUser Question: %s\", SystemPrompt, query)\n\t}\n\n\tvar contextBuilder strings.Builder\n\tcontextBuilder.WriteString(SystemPrompt)\n\tcontextBuilder.WriteString(\"\\n\\n--- CONTEXT FROM DOCUMENTS ---\\n\")\n\n\tfor i, doc := range docs {\n\t\tcontextBuilder.WriteString(fmt.Sprintf(\"\\n[Document %d (similarity: %.2f)]:\\n%s\\n\",\n\t\t\ti+1, doc.SimilarityScore, doc.ContentPreview))\n\t}\n\n\tcontextBuilder.WriteString(\"\\n--- END OF CONTEXT ---\\n\\n\")\n\tcontextBuilder.WriteString(fmt.Sprintf(\"User Question: %s\", query))\n\n\treturn contextBuilder.String()\n}\n\n// buildPromptWithHistory builds a prompt including conversation history\nfunc (s *ragService) buildPromptWithHistory(prompt string, history []*domain.ChatMessage) string {\n\tif len(history) == 0 {\n\t\treturn prompt\n\t}\n\n\tvar builder strings.Builder\n\tbuilder.WriteString(\"Previous conversation:\\n\")\n\n\t// History is in descending order, so reverse it\n\tfor i := len(history) - 1; i >= 0; i-- {\n\t\tmsg := history[i]\n\t\trole := \"User\"\n\t\tif msg.Role == domain.ChatRoleAssistant {\n\t\t\trole = \"Assistant\"\n\t\t}\n\t\tbuilder.WriteString(fmt.Sprintf(\"%s: %s\\n\", role, msg.Content))\n\t}\n\n\tbuilder.WriteString(\"\\nCurrent prompt:\\n\")\n\tbuilder.WriteString(prompt)\n\n\treturn builder.String()\n}\n\n// generateSessionTitle generates a title from the first message\nfunc generateSessionTitle(message string) string {\n\t// Take first 50 characters of the message as title\n\tif len(message) <= 50 {\n\t\treturn message\n\t}\n\treturn message[:50] + \"...\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/app/services\"\n\tdocEvents \"github.com/moasq/go-b2b-starter/internal/modules/documents/domain/events\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/eventbus\"\n)\n\nfunc Init(container *dig.Container) error {\n\tmodule := cognitive.NewModule(container)\n\tif err := module.RegisterDependencies(); err != nil {\n\t\treturn fmt.Errorf(\"failed to register cognitive dependencies: %w\", err)\n\t}\n\n\t// Wire up event listener for document uploads\n\tif err := container.Invoke(func(\n\t\tbus eventbus.EventBus,\n\t\tlistener services.DocumentListener,\n\t) error {\n\t\t// Subscribe to DocumentUploaded events\n\t\treturn bus.Subscribe(docEvents.DocumentUploadedEventType, func(ctx context.Context, event eventbus.Event) error {\n\t\t\t// Type assert to get the specific event\n\t\t\tdocEvent, ok := event.(*docEvents.DocumentUploaded)\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"unexpected event type: %T\", event)\n\t\t\t}\n\n\t\t\t// Handle the event\n\t\t\treturn listener.HandleDocumentUploaded(ctx, docEvent.DocumentID, docEvent.OrganizationID, docEvent.ExtractedText)\n\t\t})\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to wire document event listener: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/domain/ai_provider.go",
    "content": "package domain\n\nimport \"context\"\n\n// TextVectorizer creates searchable vector representations of text content.\n// This enables semantic document search and similarity matching.\n// Implementation details (embedding models, providers) are in the infra layer.\ntype TextVectorizer interface {\n\t// Vectorize converts text content into a searchable vector representation\n\tVectorize(ctx context.Context, text string) ([]float64, error)\n}\n\n// AssistantProvider provides AI-powered conversational assistance.\n// This enables intelligent responses based on context and user queries.\n// Implementation details (LLM providers, models) are in the infra layer.\ntype AssistantProvider interface {\n\t// GenerateResponse creates an AI response for the given prompt with context\n\tGenerateResponse(ctx context.Context, prompt string) (*AssistantResponse, error)\n}\n\n// AssistantResponse contains the result of an AI assistance request\ntype AssistantResponse struct {\n\tContent    string // The generated response text\n\tTokensUsed int    // Tokens consumed (for usage tracking)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/domain/entity.go",
    "content": "package domain\n\nimport (\n\t\"time\"\n)\n\n// ChatRole represents the role of a message sender\ntype ChatRole string\n\nconst (\n\tChatRoleUser      ChatRole = \"user\"\n\tChatRoleAssistant ChatRole = \"assistant\"\n\tChatRoleSystem    ChatRole = \"system\"\n)\n\n// DocumentEmbedding represents a vector embedding for a document\ntype DocumentEmbedding struct {\n\tID             int32     `json:\"id\"`\n\tDocumentID     int32     `json:\"document_id\"`\n\tOrganizationID int32     `json:\"organization_id\"`\n\tEmbedding      []float64 `json:\"embedding,omitempty\"` // 1536 dimensions for OpenAI\n\tContentHash    string    `json:\"content_hash,omitempty\"`\n\tContentPreview string    `json:\"content_preview,omitempty\"`\n\tChunkIndex     int32     `json:\"chunk_index\"`\n\tCreatedAt      time.Time `json:\"created_at\"`\n\tUpdatedAt      time.Time `json:\"updated_at\"`\n}\n\n// SimilarDocument represents a document found through similarity search\ntype SimilarDocument struct {\n\tDocumentEmbedding\n\tSimilarityScore float64 `json:\"similarity_score\"`\n}\n\n// ChatSession represents a conversation session\ntype ChatSession struct {\n\tID             int32     `json:\"id\"`\n\tOrganizationID int32     `json:\"organization_id\"`\n\tAccountID      int32     `json:\"account_id\"`\n\tTitle          string    `json:\"title,omitempty\"`\n\tCreatedAt      time.Time `json:\"created_at\"`\n\tUpdatedAt      time.Time `json:\"updated_at\"`\n}\n\nfunc (s *ChatSession) GetID() int32 {\n\treturn s.ID\n}\n\n// Validate validates the chat session entity\nfunc (s *ChatSession) Validate() error {\n\tif s.OrganizationID == 0 {\n\t\treturn ErrSessionOrganizationRequired\n\t}\n\tif s.AccountID == 0 {\n\t\treturn ErrSessionAccountRequired\n\t}\n\treturn nil\n}\n\n// ChatMessage represents a message within a chat session\ntype ChatMessage struct {\n\tID             int32     `json:\"id\"`\n\tSessionID      int32     `json:\"session_id\"`\n\tRole           ChatRole  `json:\"role\"`\n\tContent        string    `json:\"content\"`\n\tReferencedDocs []int32   `json:\"referenced_docs,omitempty\"`\n\tTokensUsed     int32     `json:\"tokens_used,omitempty\"`\n\tCreatedAt      time.Time `json:\"created_at\"`\n}\n\nfunc (m *ChatMessage) GetID() int32 {\n\treturn m.ID\n}\n\n// Validate validates the chat message entity\nfunc (m *ChatMessage) Validate() error {\n\tif m.SessionID == 0 {\n\t\treturn ErrMessageSessionRequired\n\t}\n\tif m.Content == \"\" {\n\t\treturn ErrMessageContentRequired\n\t}\n\tif m.Role == \"\" {\n\t\treturn ErrMessageRoleRequired\n\t}\n\treturn nil\n}\n\nfunc (m *ChatMessage) IsUserMessage() bool {\n\treturn m.Role == ChatRoleUser\n}\n\nfunc (m *ChatMessage) IsAssistantMessage() bool {\n\treturn m.Role == ChatRoleAssistant\n}\n\n// RAGContext represents context retrieved for RAG\ntype RAGContext struct {\n\tDocuments []SimilarDocument `json:\"documents\"`\n\tQuery     string            `json:\"query\"`\n}\n\n// ChatRequest represents a request to send a chat message\ntype ChatRequest struct {\n\tSessionID      int32  `json:\"session_id,omitempty\"` // Optional - create new session if not provided\n\tMessage        string `json:\"message\"`\n\tUseRAG         bool   `json:\"use_rag,omitempty\"` // Whether to use RAG for context\n\tMaxDocuments   int    `json:\"max_documents,omitempty\"`\n\tContextHistory int    `json:\"context_history,omitempty\"` // Number of previous messages to include\n}\n\n// ChatResponse represents a response from the chat service\ntype ChatResponse struct {\n\tSessionID        int32             `json:\"session_id\"`\n\tMessage          *ChatMessage      `json:\"message\"`\n\tReferencedDocs   []SimilarDocument `json:\"referenced_docs,omitempty\"`\n\tTokensUsed       int32             `json:\"tokens_used,omitempty\"`\n}\n\n// EmbeddingStats represents embedding statistics\ntype EmbeddingStats struct {\n\tTotalEmbeddings int64 `json:\"total_embeddings\"`\n\tTotalDocuments  int64 `json:\"total_documents\"`\n}\n\n// ChatStats represents chat statistics\ntype ChatStats struct {\n\tTotalSessions int64 `json:\"total_sessions\"`\n\tTotalMessages int64 `json:\"total_messages\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/domain/errors.go",
    "content": "package domain\n\nimport \"errors\"\n\n// Domain errors for cognitive module\nvar (\n\t// Embedding errors\n\tErrEmbeddingNotFound         = errors.New(\"embedding not found\")\n\tErrEmbeddingGenerationFailed = errors.New(\"failed to generate embedding\")\n\tErrEmbeddingAlreadyExists    = errors.New(\"embedding already exists for this document\")\n\n\t// Session errors\n\tErrSessionNotFound             = errors.New(\"chat session not found\")\n\tErrSessionOrganizationRequired = errors.New(\"session organization ID is required\")\n\tErrSessionAccountRequired      = errors.New(\"session account ID is required\")\n\n\t// Message errors\n\tErrMessageNotFound        = errors.New(\"chat message not found\")\n\tErrMessageSessionRequired = errors.New(\"message session ID is required\")\n\tErrMessageContentRequired = errors.New(\"message content is required\")\n\tErrMessageRoleRequired    = errors.New(\"message role is required\")\n\n\t// RAG errors\n\tErrRAGContextEmpty      = errors.New(\"no relevant documents found for RAG context\")\n\tErrRAGSearchFailed      = errors.New(\"RAG similarity search failed\")\n\tErrRAGCompletionFailed  = errors.New(\"RAG completion generation failed\")\n\n\t// LLM errors\n\tErrLLMUnavailable      = errors.New(\"LLM service is unavailable\")\n\tErrLLMRequestFailed    = errors.New(\"LLM request failed\")\n\tErrLLMResponseInvalid  = errors.New(\"LLM response is invalid\")\n)\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/domain/repository.go",
    "content": "package domain\n\nimport \"context\"\n\n// EmbeddingRepository defines the interface for embedding data operations\ntype EmbeddingRepository interface {\n\t// Create creates a new document embedding\n\tCreate(ctx context.Context, embedding *DocumentEmbedding) (*DocumentEmbedding, error)\n\n\t// GetByID retrieves an embedding by ID\n\tGetByID(ctx context.Context, orgID, embeddingID int32) (*DocumentEmbedding, error)\n\n\t// GetByDocumentID retrieves all embeddings for a document\n\tGetByDocumentID(ctx context.Context, orgID, documentID int32) ([]*DocumentEmbedding, error)\n\n\t// SearchSimilar finds similar documents using vector similarity\n\tSearchSimilar(ctx context.Context, orgID int32, embedding []float64, limit int32) ([]*SimilarDocument, error)\n\n\t// Delete removes embeddings for a document\n\tDelete(ctx context.Context, orgID, documentID int32) error\n\n\t// Count returns the total count of embeddings for an organization\n\tCount(ctx context.Context, orgID int32) (int64, error)\n}\n\n// ChatRepository defines the interface for chat session and message operations\ntype ChatRepository interface {\n\t// Sessions\n\tCreateSession(ctx context.Context, session *ChatSession) (*ChatSession, error)\n\tGetSessionByID(ctx context.Context, orgID, sessionID int32) (*ChatSession, error)\n\tListSessionsByAccount(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*ChatSession, error)\n\tUpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*ChatSession, error)\n\tDeleteSession(ctx context.Context, orgID, sessionID int32) error\n\n\t// Messages\n\tCreateMessage(ctx context.Context, message *ChatMessage) (*ChatMessage, error)\n\tGetMessagesBySession(ctx context.Context, sessionID int32) ([]*ChatMessage, error)\n\tGetRecentMessages(ctx context.Context, sessionID int32, limit int32) ([]*ChatMessage, error)\n\tCountMessagesBySession(ctx context.Context, sessionID int32) (int64, error)\n\tDeleteMessage(ctx context.Context, messageID int32) error\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/handler.go",
    "content": "package cognitive\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/pkg/httperr\"\n)\n\ntype Handler struct {\n\tragService       services.RAGService\n\tembeddingService services.EmbeddingService\n}\n\nfunc NewHandler(ragService services.RAGService, embeddingService services.EmbeddingService) *Handler {\n\treturn &Handler{\n\t\tragService:       ragService,\n\t\tembeddingService: embeddingService,\n\t}\n}\n\n// ChatRequest represents the JSON request body for chat\ntype ChatRequest struct {\n\tSessionID      int32  `json:\"session_id,omitempty\"`\n\tMessage        string `json:\"message\" binding:\"required\"`\n\tUseRAG         bool   `json:\"use_rag,omitempty\"`\n\tMaxDocuments   int    `json:\"max_documents,omitempty\"`\n\tContextHistory int    `json:\"context_history,omitempty\"`\n}\n\n// Chat sends a message and gets a response\n// @Summary Chat with AI\n// @Description Sends a message to the AI and gets a response, optionally using RAG\n// @Tags Cognitive\n// @Accept json\n// @Produce json\n// @Param request body ChatRequest true \"Chat request\"\n// @Success 200 {object} domain.ChatResponse\n// @Failure 400 {object} httperr.HTTPError\n// @Failure 500 {object} httperr.HTTPError\n// @Router /example_cognitive/chat [post]\nfunc (h *Handler) Chat(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\tvar req ChatRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"invalid_request\",\n\t\t\t\"Invalid JSON format: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\t// Create domain request\n\tchatReq := &domain.ChatRequest{\n\t\tSessionID:      req.SessionID,\n\t\tMessage:        req.Message,\n\t\tUseRAG:         req.UseRAG,\n\t\tMaxDocuments:   req.MaxDocuments,\n\t\tContextHistory: req.ContextHistory,\n\t}\n\n\tresponse, err := h.ragService.Chat(c.Request.Context(), reqCtx.OrganizationID, reqCtx.AccountID, chatReq)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"chat_failed\",\n\t\t\t\"Failed to process chat: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, response)\n}\n\n// ListSessions lists chat sessions for the current user\n// @Summary List chat sessions\n// @Description Lists chat sessions for the current user with pagination\n// @Tags Cognitive\n// @Produce json\n// @Param limit query int false \"Limit\" default(10)\n// @Param offset query int false \"Offset\" default(0)\n// @Success 200 {object} map[string]interface{}\n// @Failure 500 {object} httperr.HTTPError\n// @Router /example_cognitive/sessions [get]\nfunc (h *Handler) ListSessions(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\t// Parse query parameters\n\tlimit, _ := strconv.Atoi(c.DefaultQuery(\"limit\", \"10\"))\n\toffset, _ := strconv.Atoi(c.DefaultQuery(\"offset\", \"0\"))\n\n\tsessions, err := h.ragService.ListSessions(c.Request.Context(), reqCtx.OrganizationID, reqCtx.AccountID, int32(limit), int32(offset))\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"list_failed\",\n\t\t\t\"Failed to list sessions: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"sessions\": sessions,\n\t\t\"limit\":    limit,\n\t\t\"offset\":   offset,\n\t})\n}\n\n// GetSessionHistory retrieves messages for a session\n// @Summary Get session history\n// @Description Retrieves all messages for a chat session\n// @Tags Cognitive\n// @Produce json\n// @Param id path int true \"Session ID\"\n// @Success 200 {array} domain.ChatMessage\n// @Failure 400 {object} httperr.HTTPError\n// @Failure 500 {object} httperr.HTTPError\n// @Router /example_cognitive/sessions/{id}/messages [get]\nfunc (h *Handler) GetSessionHistory(c *gin.Context) {\n\tidParam := c.Param(\"id\")\n\tvar sessionID int32\n\tif _, err := fmt.Sscanf(idParam, \"%d\", &sessionID); err != nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"invalid_id\",\n\t\t\t\"Session ID must be a valid number\",\n\t\t))\n\t\treturn\n\t}\n\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\tmessages, err := h.ragService.GetSessionHistory(c.Request.Context(), reqCtx.OrganizationID, sessionID)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"fetch_failed\",\n\t\t\t\"Failed to fetch session history: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, messages)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/infra/ai/assistant_provider.go",
    "content": "package ai\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n\tllmdomain \"github.com/moasq/go-b2b-starter/internal/platform/llm/domain\"\n)\n\ntype openAIAssistantProvider struct {\n\tllmClient llmdomain.LLMClient\n}\n\n// NewAssistantProvider creates an AssistantProvider backed by OpenAI\nfunc NewAssistantProvider(llmClient llmdomain.LLMClient) domain.AssistantProvider {\n\treturn &openAIAssistantProvider{llmClient: llmClient}\n}\n\nfunc (p *openAIAssistantProvider) GenerateResponse(ctx context.Context, prompt string) (*domain.AssistantResponse, error) {\n\treq := llmdomain.CompletionRequest{Prompt: prompt}\n\tresp, err := p.llmClient.Complete(ctx, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &domain.AssistantResponse{\n\t\tContent:    resp.Text,\n\t\tTokensUsed: resp.TokensUsed,\n\t}, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/infra/ai/text_vectorizer.go",
    "content": "package ai\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n\tllmdomain \"github.com/moasq/go-b2b-starter/internal/platform/llm/domain\"\n)\n\nconst embeddingModel = \"text-embedding-3-small\"\n\ntype openAITextVectorizer struct {\n\tllmClient llmdomain.LLMClient\n}\n\nfunc NewTextVectorizer(llmClient llmdomain.LLMClient) domain.TextVectorizer {\n\treturn &openAITextVectorizer{llmClient: llmClient}\n}\n\nfunc (v *openAITextVectorizer) Vectorize(ctx context.Context, text string) ([]float64, error) {\n\treturn v.llmClient.GenerateEmbedding(ctx, text, embeddingModel)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/infra/repositories/chat_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/helpers\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n)\n\n// chatRepository implements domain.ChatRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype chatRepository struct {\n\tstore sqlc.Store\n}\n\n// NewChatRepository creates a new ChatRepository implementation.\nfunc NewChatRepository(store sqlc.Store) domain.ChatRepository {\n\treturn &chatRepository{store: store}\n}\n\n// Sessions\n\nfunc (r *chatRepository) CreateSession(ctx context.Context, session *domain.ChatSession) (*domain.ChatSession, error) {\n\tparams := sqlc.CreateChatSessionParams{\n\t\tOrganizationID: session.OrganizationID,\n\t\tAccountID:      session.AccountID,\n\t\tTitle:          helpers.ToPgText(session.Title),\n\t}\n\n\tresult, err := r.store.CreateChatSession(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create chat session: %w\", err)\n\t}\n\n\treturn r.mapSessionToDomain(&result), nil\n}\n\nfunc (r *chatRepository) GetSessionByID(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) {\n\tparams := sqlc.GetChatSessionByIDParams{\n\t\tID:             sessionID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.GetChatSessionByID(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get chat session: %w\", err)\n\t}\n\n\treturn r.mapSessionToDomain(&result), nil\n}\n\nfunc (r *chatRepository) ListSessionsByAccount(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) {\n\tparams := sqlc.ListChatSessionsByAccountParams{\n\t\tOrganizationID: orgID,\n\t\tAccountID:      accountID,\n\t\tLimit:          limit,\n\t\tOffset:         offset,\n\t}\n\n\tresults, err := r.store.ListChatSessionsByAccount(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list chat sessions: %w\", err)\n\t}\n\n\tsessions := make([]*domain.ChatSession, len(results))\n\tfor i, result := range results {\n\t\tsessions[i] = r.mapSessionToDomain(&result)\n\t}\n\n\treturn sessions, nil\n}\n\nfunc (r *chatRepository) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) {\n\tparams := sqlc.UpdateChatSessionTitleParams{\n\t\tID:             sessionID,\n\t\tOrganizationID: orgID,\n\t\tTitle:          helpers.ToPgText(title),\n\t}\n\n\tresult, err := r.store.UpdateChatSessionTitle(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update chat session title: %w\", err)\n\t}\n\n\treturn r.mapSessionToDomain(&result), nil\n}\n\nfunc (r *chatRepository) DeleteSession(ctx context.Context, orgID, sessionID int32) error {\n\tparams := sqlc.DeleteChatSessionParams{\n\t\tID:             sessionID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tif err := r.store.DeleteChatSession(ctx, params); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete chat session: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// Messages\n\nfunc (r *chatRepository) CreateMessage(ctx context.Context, message *domain.ChatMessage) (*domain.ChatMessage, error) {\n\tparams := sqlc.CreateChatMessageParams{\n\t\tSessionID:      message.SessionID,\n\t\tRole:           string(message.Role),\n\t\tContent:        message.Content,\n\t\tReferencedDocs: message.ReferencedDocs,\n\t\tTokensUsed:     helpers.ToPgInt4(message.TokensUsed),\n\t}\n\n\tresult, err := r.store.CreateChatMessage(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create chat message: %w\", err)\n\t}\n\n\treturn r.mapMessageToDomain(&result), nil\n}\n\nfunc (r *chatRepository) GetMessagesBySession(ctx context.Context, sessionID int32) ([]*domain.ChatMessage, error) {\n\tresults, err := r.store.GetChatMessagesBySession(ctx, sessionID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get chat messages: %w\", err)\n\t}\n\n\tmessages := make([]*domain.ChatMessage, len(results))\n\tfor i, result := range results {\n\t\tmessages[i] = r.mapMessageToDomain(&result)\n\t}\n\n\treturn messages, nil\n}\n\nfunc (r *chatRepository) GetRecentMessages(ctx context.Context, sessionID int32, limit int32) ([]*domain.ChatMessage, error) {\n\tparams := sqlc.GetRecentChatMessagesParams{\n\t\tSessionID: sessionID,\n\t\tLimit:     limit,\n\t}\n\n\tresults, err := r.store.GetRecentChatMessages(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get recent chat messages: %w\", err)\n\t}\n\n\tmessages := make([]*domain.ChatMessage, len(results))\n\tfor i, result := range results {\n\t\tmessages[i] = r.mapMessageToDomain(&result)\n\t}\n\n\treturn messages, nil\n}\n\nfunc (r *chatRepository) CountMessagesBySession(ctx context.Context, sessionID int32) (int64, error) {\n\tcount, err := r.store.CountChatMessagesBySession(ctx, sessionID)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to count chat messages: %w\", err)\n\t}\n\n\treturn count, nil\n}\n\nfunc (r *chatRepository) DeleteMessage(ctx context.Context, messageID int32) error {\n\tif err := r.store.DeleteChatMessage(ctx, messageID); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete chat message: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// mapSessionToDomain maps SQLC session type to domain type.\n// This is the translation boundary - SQLC types never escape this function.\nfunc (r *chatRepository) mapSessionToDomain(s *sqlc.CognitiveChatSession) *domain.ChatSession {\n\treturn &domain.ChatSession{\n\t\tID:             s.ID,\n\t\tOrganizationID: s.OrganizationID,\n\t\tAccountID:      s.AccountID,\n\t\tTitle:          helpers.FromPgText(s.Title),\n\t\tCreatedAt:      s.CreatedAt.Time,\n\t\tUpdatedAt:      s.UpdatedAt.Time,\n\t}\n}\n\n// mapMessageToDomain maps SQLC message type to domain type.\n// This is the translation boundary - SQLC types never escape this function.\nfunc (r *chatRepository) mapMessageToDomain(m *sqlc.CognitiveChatMessage) *domain.ChatMessage {\n\treturn &domain.ChatMessage{\n\t\tID:             m.ID,\n\t\tSessionID:      m.SessionID,\n\t\tRole:           domain.ChatRole(m.Role),\n\t\tContent:        m.Content,\n\t\tReferencedDocs: m.ReferencedDocs,\n\t\tTokensUsed:     helpers.FromPgInt4(m.TokensUsed),\n\t\tCreatedAt:      m.CreatedAt.Time,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/infra/repositories/embedding_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/helpers\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n)\n\n// embeddingRepository implements domain.EmbeddingRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype embeddingRepository struct {\n\tstore sqlc.Store\n}\n\n// NewEmbeddingRepository creates a new EmbeddingRepository implementation.\nfunc NewEmbeddingRepository(store sqlc.Store) domain.EmbeddingRepository {\n\treturn &embeddingRepository{store: store}\n}\n\nfunc (r *embeddingRepository) Create(ctx context.Context, embedding *domain.DocumentEmbedding) (*domain.DocumentEmbedding, error) {\n\tparams := sqlc.CreateDocumentEmbeddingParams{\n\t\tDocumentID:     embedding.DocumentID,\n\t\tOrganizationID: embedding.OrganizationID,\n\t\tEmbedding:      helpers.ToVector(embedding.Embedding),\n\t\tContentHash:    helpers.ToPgText(embedding.ContentHash),\n\t\tContentPreview: helpers.ToPgText(embedding.ContentPreview),\n\t\tChunkIndex:     helpers.ToPgInt4(embedding.ChunkIndex),\n\t}\n\n\tresult, err := r.store.CreateDocumentEmbedding(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create document embedding: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *embeddingRepository) GetByID(ctx context.Context, orgID, embeddingID int32) (*domain.DocumentEmbedding, error) {\n\tparams := sqlc.GetDocumentEmbeddingByIDParams{\n\t\tID:             embeddingID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.GetDocumentEmbeddingByID(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get document embedding: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *embeddingRepository) GetByDocumentID(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) {\n\tparams := sqlc.GetDocumentEmbeddingsByDocumentIDParams{\n\t\tDocumentID:     documentID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresults, err := r.store.GetDocumentEmbeddingsByDocumentID(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get document embeddings: %w\", err)\n\t}\n\n\tembeddings := make([]*domain.DocumentEmbedding, len(results))\n\tfor i, result := range results {\n\t\tembeddings[i] = r.mapToDomain(&result)\n\t}\n\n\treturn embeddings, nil\n}\n\nfunc (r *embeddingRepository) SearchSimilar(ctx context.Context, orgID int32, embedding []float64, limit int32) ([]*domain.SimilarDocument, error) {\n\tparams := sqlc.SearchSimilarDocumentsParams{\n\t\tColumn1:        helpers.ToVector(embedding),\n\t\tOrganizationID: orgID,\n\t\tLimit:          limit,\n\t}\n\n\tresults, err := r.store.SearchSimilarDocuments(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to search similar documents: %w\", err)\n\t}\n\n\tdocs := make([]*domain.SimilarDocument, len(results))\n\tfor i, result := range results {\n\t\tdocs[i] = &domain.SimilarDocument{\n\t\t\tDocumentEmbedding: domain.DocumentEmbedding{\n\t\t\t\tID:             result.ID,\n\t\t\t\tDocumentID:     result.DocumentID,\n\t\t\t\tOrganizationID: result.OrganizationID,\n\t\t\t\tContentHash:    helpers.FromPgText(result.ContentHash),\n\t\t\t\tContentPreview: helpers.FromPgText(result.ContentPreview),\n\t\t\t\tChunkIndex:     helpers.FromPgInt4(result.ChunkIndex),\n\t\t\t\tCreatedAt:      result.CreatedAt.Time,\n\t\t\t\tUpdatedAt:      result.UpdatedAt.Time,\n\t\t\t},\n\t\t\tSimilarityScore: result.SimilarityScore,\n\t\t}\n\t}\n\n\treturn docs, nil\n}\n\nfunc (r *embeddingRepository) Delete(ctx context.Context, orgID, documentID int32) error {\n\tparams := sqlc.DeleteDocumentEmbeddingsParams{\n\t\tDocumentID:     documentID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tif err := r.store.DeleteDocumentEmbeddings(ctx, params); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete document embeddings: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *embeddingRepository) Count(ctx context.Context, orgID int32) (int64, error) {\n\tcount, err := r.store.CountDocumentEmbeddingsByOrganization(ctx, orgID)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to count document embeddings: %w\", err)\n\t}\n\n\treturn count, nil\n}\n\n// mapToDomain maps SQLC embedding type to domain type.\n// This is the translation boundary - SQLC types never escape this function.\nfunc (r *embeddingRepository) mapToDomain(e *sqlc.CognitiveDocumentEmbedding) *domain.DocumentEmbedding {\n\treturn &domain.DocumentEmbedding{\n\t\tID:             e.ID,\n\t\tDocumentID:     e.DocumentID,\n\t\tOrganizationID: e.OrganizationID,\n\t\tEmbedding:      helpers.FromVector(e.Embedding),\n\t\tContentHash:    helpers.FromPgText(e.ContentHash),\n\t\tContentPreview: helpers.FromPgText(e.ContentPreview),\n\t\tChunkIndex:     helpers.FromPgInt4(e.ChunkIndex),\n\t\tCreatedAt:      e.CreatedAt.Time,\n\t\tUpdatedAt:      e.UpdatedAt.Time,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/infra/repositories/helpers.go",
    "content": "package repositories\n\nimport \"github.com/jackc/pgx/v5/pgtype\"\n\n// Helper functions for type conversion\n\nfunc toPgText(s string) pgtype.Text {\n\tif s == \"\" {\n\t\treturn pgtype.Text{Valid: false}\n\t}\n\treturn pgtype.Text{String: s, Valid: true}\n}\n\nfunc fromPgText(t pgtype.Text) string {\n\tif !t.Valid {\n\t\treturn \"\"\n\t}\n\treturn t.String\n}\n\nfunc toPgInt4(i int32) pgtype.Int4 {\n\tif i == 0 {\n\t\treturn pgtype.Int4{Valid: false}\n\t}\n\treturn pgtype.Int4{Int32: i, Valid: true}\n}\n\nfunc fromPgInt4(i pgtype.Int4) int32 {\n\tif !i.Valid {\n\t\treturn 0\n\t}\n\treturn i.Int32\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/module.go",
    "content": "package cognitive\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/cognitive/infra/ai\"\n\tllmdomain \"github.com/moasq/go-b2b-starter/internal/platform/llm/domain\"\n)\n\n// Module provides cognitive module dependencies\ntype Module struct {\n\tcontainer *dig.Container\n}\n\nfunc NewModule(container *dig.Container) *Module {\n\treturn &Module{\n\t\tcontainer: container,\n\t}\n}\n\n// RegisterDependencies registers all cognitive module dependencies\n// Note: Repository implementations are registered in internal/db/inject.go\nfunc (m *Module) RegisterDependencies() error {\n\t// Register AI adapters (infra layer)\n\tif err := m.container.Provide(func(\n\t\tllmClient llmdomain.LLMClient,\n\t) domain.TextVectorizer {\n\t\treturn ai.NewTextVectorizer(llmClient)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif err := m.container.Provide(func(\n\t\tllmClient llmdomain.LLMClient,\n\t) domain.AssistantProvider {\n\t\treturn ai.NewAssistantProvider(llmClient)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register embedding service\n\tif err := m.container.Provide(func(\n\t\tembeddingRepo domain.EmbeddingRepository,\n\t\ttextVectorizer domain.TextVectorizer,\n\t) services.EmbeddingService {\n\t\treturn services.NewEmbeddingService(embeddingRepo, textVectorizer)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register RAG service\n\tif err := m.container.Provide(func(\n\t\tchatRepo domain.ChatRepository,\n\t\tembeddingRepo domain.EmbeddingRepository,\n\t\ttextVectorizer domain.TextVectorizer,\n\t\tassistantProvider domain.AssistantProvider,\n\t) services.RAGService {\n\t\treturn services.NewRAGService(chatRepo, embeddingRepo, textVectorizer, assistantProvider)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register document listener\n\tif err := m.container.Provide(func(\n\t\tembeddingService services.EmbeddingService,\n\t) services.DocumentListener {\n\t\treturn services.NewDocumentListener(embeddingService)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/provider.go",
    "content": "package cognitive\n\nimport (\n\t\"go.uber.org/dig\"\n)\n\ntype Provider struct {\n\tcontainer *dig.Container\n}\n\nfunc NewProvider(container *dig.Container) *Provider {\n\treturn &Provider{container: container}\n}\n\nfunc (p *Provider) RegisterDependencies() error {\n\t// Register handler\n\tif err := p.container.Provide(NewHandler); err != nil {\n\t\treturn err\n\t}\n\n\t// Register routes\n\tif err := p.container.Provide(NewRoutes); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/cognitive/routes.go",
    "content": "package cognitive\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\tserverDomain \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\ntype Routes struct {\n\thandler *Handler\n}\n\nfunc NewRoutes(handler *Handler) *Routes {\n\treturn &Routes{\n\t\thandler: handler,\n\t}\n}\n\nfunc (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\tcognitiveGroup := router.Group(\"/example_cognitive\")\n\tcognitiveGroup.Use(\n\t\tresolver.Get(\"auth\"),\n\t\tresolver.Get(\"org_context\"),\n\t\tresolver.Get(\"subscription\"),\n\t)\n\t{\n\t\t// Chat endpoint\n\t\tcognitiveGroup.POST(\"/chat\",\n\t\t\tauth.RequirePermissionFunc(\"resource\", \"create\"),\n\t\t\tr.handler.Chat)\n\n\t\t// Chat sessions\n\t\tsessionsGroup := cognitiveGroup.Group(\"/sessions\")\n\t\t{\n\t\t\tsessionsGroup.GET(\"\",\n\t\t\t\tauth.RequirePermissionFunc(\"resource\", \"view\"),\n\t\t\t\tr.handler.ListSessions)\n\n\t\t\tsessionsGroup.GET(\"/:id/messages\",\n\t\t\t\tauth.RequirePermissionFunc(\"resource\", \"view\"),\n\t\t\t\tr.handler.GetSessionHistory)\n\t\t}\n\t}\n}\n\n// Routes returns a RouteRegistrar function compatible with the server interface\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\tr.RegisterRoutes(router, resolver)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/app/services/document_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/domain/events\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/eventbus\"\n\tfilemanager \"github.com/moasq/go-b2b-starter/internal/modules/files\"\n\tfiledomain \"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\tloggerdomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tocrdomain \"github.com/moasq/go-b2b-starter/internal/platform/ocr/domain\"\n)\n\ntype documentService struct {\n\tdocRepo     domain.DocumentRepository\n\tfileService filedomain.FileService\n\tocrService  ocrdomain.OCRService\n\teventBus    eventbus.EventBus\n\tlogger      logger.Logger\n}\n\nfunc NewDocumentService(\n\tdocRepo domain.DocumentRepository,\n\tfileService filedomain.FileService,\n\tocrService ocrdomain.OCRService,\n\teventBus eventbus.EventBus,\n\tlogger logger.Logger,\n) DocumentService {\n\treturn &documentService{\n\t\tdocRepo:     docRepo,\n\t\tfileService: fileService,\n\t\tocrService:  ocrService,\n\t\teventBus:    eventBus,\n\t\tlogger:      logger,\n\t}\n}\n\nfunc (s *documentService) UploadDocument(ctx context.Context, orgID int32, req *UploadDocumentRequest, content io.Reader) (*domain.Document, error) {\n\t// Validate content type (only PDFs allowed)\n\tif !strings.Contains(strings.ToLower(req.ContentType), \"pdf\") {\n\t\treturn nil, domain.ErrInvalidFileType\n\t}\n\n\t// Upload file using file manager\n\tfileReq := &filedomain.FileUploadRequest{\n\t\tFilename:    req.FileName,\n\t\tSize:        req.FileSize,\n\t\tContentType: req.ContentType,\n\t\tContext:     filemanager.ContextGeneral,\n\t\tMetadata:    req.Metadata,\n\t}\n\n\tfileAsset, err := s.fileService.UploadFile(ctx, fileReq, content)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %v\", domain.ErrFileUploadFailed, err)\n\t}\n\n\t// Create document record\n\tdoc := &domain.Document{\n\t\tOrganizationID: orgID,\n\t\tFileAssetID:    fileAsset.ID,\n\t\tTitle:          req.Title,\n\t\tFileName:       req.FileName,\n\t\tContentType:    req.ContentType,\n\t\tFileSize:       req.FileSize,\n\t\tStatus:         domain.DocumentStatusPending,\n\t\tMetadata:       req.Metadata,\n\t}\n\n\tcreatedDoc, err := s.docRepo.Create(ctx, doc)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create document: %w\", err)\n\t}\n\n\t// Process document asynchronously (extract text)\n\tgo func() {\n\t\t// Create a new context with timeout for background processing\n\t\t// Don't use request context as it will be cancelled when request completes\n\t\tprocessCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)\n\t\tdefer cancel()\n\n\t\tif _, err := s.ProcessDocument(processCtx, orgID, createdDoc.ID); err != nil {\n\t\t\ts.logger.Error(\"background document processing failed\", loggerdomain.Fields{\n\t\t\t\t\"document_id\":     createdDoc.ID,\n\t\t\t\t\"organization_id\": orgID,\n\t\t\t\t\"error\":           err.Error(),\n\t\t\t})\n\t\t}\n\t}()\n\n\treturn createdDoc, nil\n}\n\nfunc (s *documentService) GetDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) {\n\tdoc, err := s.docRepo.GetByID(ctx, orgID, docID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get document: %w\", err)\n\t}\n\n\treturn doc, nil\n}\n\nfunc (s *documentService) ListDocuments(ctx context.Context, orgID int32, req *ListDocumentsRequest) (*ListDocumentsResponse, error) {\n\tvar docs []*domain.Document\n\tvar total int64\n\tvar err error\n\n\tif req.Status != nil {\n\t\tdocs, err = s.docRepo.ListByStatus(ctx, orgID, *req.Status, req.Limit, req.Offset)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to list documents by status: %w\", err)\n\t\t}\n\t\ttotal, err = s.docRepo.CountByStatus(ctx, orgID, *req.Status)\n\t} else {\n\t\tdocs, err = s.docRepo.List(ctx, orgID, req.Limit, req.Offset)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to list documents: %w\", err)\n\t\t}\n\t\ttotal, err = s.docRepo.Count(ctx, orgID)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to count documents: %w\", err)\n\t}\n\n\treturn &ListDocumentsResponse{\n\t\tDocuments: docs,\n\t\tTotal:     total,\n\t\tLimit:     req.Limit,\n\t\tOffset:    req.Offset,\n\t}, nil\n}\n\nfunc (s *documentService) UpdateDocument(ctx context.Context, orgID, docID int32, req *UpdateDocumentRequest) (*domain.Document, error) {\n\t// Get existing document\n\tdoc, err := s.docRepo.GetByID(ctx, orgID, docID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get document: %w\", err)\n\t}\n\n\t// Update fields\n\tif req.Title != \"\" {\n\t\tdoc.Title = req.Title\n\t}\n\tif req.Metadata != nil {\n\t\tdoc.Metadata = req.Metadata\n\t}\n\n\tupdatedDoc, err := s.docRepo.Update(ctx, doc)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update document: %w\", err)\n\t}\n\n\treturn updatedDoc, nil\n}\n\nfunc (s *documentService) DeleteDocument(ctx context.Context, orgID, docID int32) error {\n\t// Get document to verify it exists\n\tdoc, err := s.docRepo.GetByID(ctx, orgID, docID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get document: %w\", err)\n\t}\n\n\t// Delete the file asset\n\tif err := s.fileService.DeleteFile(ctx, doc.FileAssetID); err != nil {\n\t\t// Continue with document deletion even if file deletion fails\n\t}\n\n\t// Delete the document record\n\tif err := s.docRepo.Delete(ctx, orgID, docID); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete document: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *documentService) GetDocumentStats(ctx context.Context, orgID int32) (*domain.DocumentStats, error) {\n\ttotal, err := s.docRepo.Count(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to count documents: %w\", err)\n\t}\n\n\tpending, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusPending)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to count pending documents: %w\", err)\n\t}\n\n\tprocessed, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusProcessed)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to count processed documents: %w\", err)\n\t}\n\n\tfailed, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusFailed)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to count failed documents: %w\", err)\n\t}\n\n\treturn &domain.DocumentStats{\n\t\tTotalCount:     total,\n\t\tPendingCount:   pending,\n\t\tProcessedCount: processed,\n\t\tFailedCount:    failed,\n\t}, nil\n}\n\nfunc (s *documentService) ProcessDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) {\n\t// Update status to processing\n\tdoc, err := s.docRepo.UpdateStatus(ctx, orgID, docID, domain.DocumentStatusProcessing)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update document status: %w\", err)\n\t}\n\n\t// Download file content\n\tcontent, _, err := s.fileService.DownloadFile(ctx, doc.FileAssetID)\n\tif err != nil {\n\t\ts.markDocumentFailed(ctx, orgID, docID, err.Error())\n\t\treturn nil, fmt.Errorf(\"%w: %v\", domain.ErrFileDownloadFailed, err)\n\t}\n\tdefer content.Close()\n\n\t// Extract text from PDF\n\textractedText, err := s.extractTextFromPDF(content)\n\tif err != nil {\n\t\ts.markDocumentFailed(ctx, orgID, docID, err.Error())\n\t\treturn nil, fmt.Errorf(\"%w: %v\", domain.ErrTextExtractionFailed, err)\n\t}\n\n\t// Update document with extracted text\n\tdoc, err = s.docRepo.UpdateExtractedText(ctx, orgID, docID, extractedText)\n\tif err != nil {\n\t\ts.markDocumentFailed(ctx, orgID, docID, err.Error())\n\t\treturn nil, fmt.Errorf(\"failed to update extracted text: %w\", err)\n\t}\n\n\t// Publish event for cognitive module to pick up\n\tevent := events.NewDocumentUploaded(docID, orgID, doc.FileAssetID, doc.Title, extractedText)\n\tif err := s.eventBus.Publish(ctx, event); err != nil {\n\t\t// Don't fail the operation just because event publishing failed\n\t}\n\n\treturn doc, nil\n}\n\n// markDocumentFailed marks a document as failed and publishes failure event\nfunc (s *documentService) markDocumentFailed(ctx context.Context, orgID, docID int32, errMsg string) {\n\ts.docRepo.UpdateStatus(ctx, orgID, docID, domain.DocumentStatusFailed)\n\n\t// Publish failure event\n\tevent := events.NewDocumentFailed(docID, orgID, errMsg)\n\ts.eventBus.Publish(ctx, event)\n}\n\n// extractTextFromPDF extracts text from a PDF file using OCR service\nfunc (s *documentService) extractTextFromPDF(content io.Reader) (string, error) {\n\t// Read all content into memory\n\tdata, err := io.ReadAll(content)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read PDF content: %w\", err)\n\t}\n\n\t// Encode to base64 for OCR service\n\tbase64Data := base64.StdEncoding.EncodeToString(data)\n\n\t// Call OCR service\n\tctx := context.Background()\n\tocrResult, err := s.ocrService.ExtractText(ctx, base64Data, \"application/pdf\")\n\tif err != nil {\n\t\ts.logger.Error(\"OCR extraction failed\", loggerdomain.Fields{\"error\": err.Error()})\n\t\treturn \"\", fmt.Errorf(\"OCR extraction failed: %w\", err)\n\t}\n\n\t// Check confidence score\n\tconst MinOCRConfidence = 0.7\n\tif ocrResult.Confidence < MinOCRConfidence {\n\t\ts.logger.Warn(\"OCR confidence below threshold\", loggerdomain.Fields{\n\t\t\t\"confidence\":    ocrResult.Confidence,\n\t\t\t\"pages\":         ocrResult.Pages,\n\t\t\t\"min_threshold\": MinOCRConfidence,\n\t\t})\n\t\t// Still proceed but log the warning\n\t}\n\n\t// Log success\n\ts.logger.Info(\"Successfully extracted PDF text via OCR\", loggerdomain.Fields{\n\t\t\"pages\":      ocrResult.Pages,\n\t\t\"chars\":      len(ocrResult.Text),\n\t\t\"confidence\": ocrResult.Confidence,\n\t})\n\n\t// Return extracted text (already in markdown format from Mistral)\n\treturn ocrResult.Text, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/app/services/interface.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n)\n\n// DocumentService defines the interface for document operations\ntype DocumentService interface {\n\t// UploadDocument uploads a new document and extracts text from it\n\tUploadDocument(ctx context.Context, orgID int32, req *UploadDocumentRequest, content io.Reader) (*domain.Document, error)\n\n\t// GetDocument retrieves a document by ID\n\tGetDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error)\n\n\t// ListDocuments lists documents with pagination\n\tListDocuments(ctx context.Context, orgID int32, req *ListDocumentsRequest) (*ListDocumentsResponse, error)\n\n\t// UpdateDocument updates document metadata\n\tUpdateDocument(ctx context.Context, orgID, docID int32, req *UpdateDocumentRequest) (*domain.Document, error)\n\n\t// DeleteDocument deletes a document\n\tDeleteDocument(ctx context.Context, orgID, docID int32) error\n\n\t// GetDocumentStats retrieves document statistics\n\tGetDocumentStats(ctx context.Context, orgID int32) (*domain.DocumentStats, error)\n\n\t// ProcessDocument processes a document (extract text, etc.)\n\tProcessDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error)\n}\n\n// UploadDocumentRequest represents a request to upload a document\ntype UploadDocumentRequest struct {\n\tTitle       string                 `json:\"title\"`\n\tFileName    string                 `json:\"file_name\"`\n\tContentType string                 `json:\"content_type\"`\n\tFileSize    int64                  `json:\"file_size\"`\n\tMetadata    map[string]interface{} `json:\"metadata,omitempty\"`\n}\n\n// ListDocumentsRequest represents a request to list documents\ntype ListDocumentsRequest struct {\n\tStatus *domain.DocumentStatus `json:\"status,omitempty\"`\n\tLimit  int32                  `json:\"limit\"`\n\tOffset int32                  `json:\"offset\"`\n}\n\n// ListDocumentsResponse represents the response for listing documents\ntype ListDocumentsResponse struct {\n\tDocuments []*domain.Document `json:\"documents\"`\n\tTotal     int64              `json:\"total\"`\n\tLimit     int32              `json:\"limit\"`\n\tOffset    int32              `json:\"offset\"`\n}\n\n// UpdateDocumentRequest represents a request to update a document\ntype UpdateDocumentRequest struct {\n\tTitle    string                 `json:\"title,omitempty\"`\n\tMetadata map[string]interface{} `json:\"metadata,omitempty\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents\"\n)\n\nfunc Init(container *dig.Container) error {\n\tmodule := documents.NewModule(container)\n\treturn module.RegisterDependencies()\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/domain/entity.go",
    "content": "package domain\n\nimport (\n\t\"time\"\n)\n\n// DocumentStatus represents the processing status of a document\ntype DocumentStatus string\n\nconst (\n\tDocumentStatusPending    DocumentStatus = \"pending\"\n\tDocumentStatusProcessing DocumentStatus = \"processing\"\n\tDocumentStatusProcessed  DocumentStatus = \"processed\"\n\tDocumentStatusFailed     DocumentStatus = \"failed\"\n)\n\n// Document represents an uploaded document (PDF)\ntype Document struct {\n\tID             int32                  `json:\"id\"`\n\tOrganizationID int32                  `json:\"organization_id\"`\n\tFileAssetID    int32                  `json:\"file_asset_id\"`\n\tTitle          string                 `json:\"title\"`\n\tFileName       string                 `json:\"file_name\"`\n\tContentType    string                 `json:\"content_type\"`\n\tFileSize       int64                  `json:\"file_size\"`\n\tExtractedText  string                 `json:\"extracted_text,omitempty\"`\n\tStatus         DocumentStatus         `json:\"status\"`\n\tMetadata       map[string]interface{} `json:\"metadata,omitempty\"`\n\tCreatedAt      time.Time              `json:\"created_at\"`\n\tUpdatedAt      time.Time              `json:\"updated_at\"`\n}\n\nfunc (d *Document) GetID() int32 {\n\treturn d.ID\n}\n\n// Validate validates the document entity\nfunc (d *Document) Validate() error {\n\tif d.OrganizationID == 0 {\n\t\treturn ErrDocumentOrganizationRequired\n\t}\n\tif d.Title == \"\" {\n\t\treturn ErrDocumentTitleRequired\n\t}\n\tif d.FileName == \"\" {\n\t\treturn ErrDocumentFileNameRequired\n\t}\n\tif d.FileAssetID == 0 {\n\t\treturn ErrDocumentFileAssetRequired\n\t}\n\treturn nil\n}\n\nfunc (d *Document) IsProcessed() bool {\n\treturn d.Status == DocumentStatusProcessed\n}\n\nfunc (d *Document) IsPending() bool {\n\treturn d.Status == DocumentStatusPending\n}\n\nfunc (d *Document) HasText() bool {\n\treturn d.ExtractedText != \"\"\n}\n\n// DocumentUploadRequest represents a request to upload a new document\ntype DocumentUploadRequest struct {\n\tOrganizationID int32                  `json:\"organization_id\"`\n\tTitle          string                 `json:\"title\"`\n\tFileName       string                 `json:\"file_name\"`\n\tContentType    string                 `json:\"content_type\"`\n\tFileSize       int64                  `json:\"file_size\"`\n\tMetadata       map[string]interface{} `json:\"metadata,omitempty\"`\n}\n\n// DocumentFilter represents filter options for listing documents\ntype DocumentFilter struct {\n\tStatus *DocumentStatus `json:\"status,omitempty\"`\n}\n\n// DocumentStats represents document statistics\ntype DocumentStats struct {\n\tTotalCount     int64 `json:\"total_count\"`\n\tPendingCount   int64 `json:\"pending_count\"`\n\tProcessedCount int64 `json:\"processed_count\"`\n\tFailedCount    int64 `json:\"failed_count\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/domain/errors.go",
    "content": "package domain\n\nimport \"errors\"\n\n// Domain errors for documents\nvar (\n\t// Validation errors\n\tErrDocumentOrganizationRequired = errors.New(\"document organization ID is required\")\n\tErrDocumentTitleRequired        = errors.New(\"document title is required\")\n\tErrDocumentFileNameRequired     = errors.New(\"document file name is required\")\n\tErrDocumentFileAssetRequired    = errors.New(\"document file asset ID is required\")\n\n\t// Not found errors\n\tErrDocumentNotFound = errors.New(\"document not found\")\n\n\t// Processing errors\n\tErrDocumentAlreadyProcessed = errors.New(\"document has already been processed\")\n\tErrDocumentProcessingFailed = errors.New(\"document processing failed\")\n\tErrTextExtractionFailed     = errors.New(\"text extraction from document failed\")\n\n\t// File errors\n\tErrInvalidFileType     = errors.New(\"invalid file type: only PDF files are allowed\")\n\tErrFileTooLarge        = errors.New(\"file size exceeds maximum allowed limit\")\n\tErrFileUploadFailed    = errors.New(\"failed to upload file\")\n\tErrFileDownloadFailed  = errors.New(\"failed to download file\")\n)\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/domain/events/document_events.go",
    "content": "package events\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/eventbus\"\n)\n\nconst (\n\tDocumentUploadedEventType  = \"document.uploaded\"\n\tDocumentProcessedEventType = \"document.processed\"\n\tDocumentFailedEventType    = \"document.failed\"\n)\n\n// DocumentUploaded is published when a document has been uploaded and text extracted\ntype DocumentUploaded struct {\n\teventbus.BaseEvent\n\tDocumentID     int32  `json:\"document_id\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n\tFileAssetID    int32  `json:\"file_asset_id\"`\n\tTitle          string `json:\"title\"`\n\tExtractedText  string `json:\"extracted_text\"`\n}\n\nfunc NewDocumentUploaded(documentID, organizationID, fileAssetID int32, title, extractedText string) *DocumentUploaded {\n\treturn &DocumentUploaded{\n\t\tBaseEvent: eventbus.BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      DocumentUploadedEventType,\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tDocumentID:     documentID,\n\t\tOrganizationID: organizationID,\n\t\tFileAssetID:    fileAssetID,\n\t\tTitle:          title,\n\t\tExtractedText:  extractedText,\n\t}\n}\n\n// DocumentProcessed is published when a document embedding has been created\ntype DocumentProcessed struct {\n\teventbus.BaseEvent\n\tDocumentID     int32 `json:\"document_id\"`\n\tOrganizationID int32 `json:\"organization_id\"`\n\tEmbeddingID    int32 `json:\"embedding_id\"`\n}\n\nfunc NewDocumentProcessed(documentID, organizationID, embeddingID int32) *DocumentProcessed {\n\treturn &DocumentProcessed{\n\t\tBaseEvent: eventbus.BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      DocumentProcessedEventType,\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tDocumentID:     documentID,\n\t\tOrganizationID: organizationID,\n\t\tEmbeddingID:    embeddingID,\n\t}\n}\n\n// DocumentFailed is published when document processing fails\ntype DocumentFailed struct {\n\teventbus.BaseEvent\n\tDocumentID     int32  `json:\"document_id\"`\n\tOrganizationID int32  `json:\"organization_id\"`\n\tError          string `json:\"error\"`\n}\n\nfunc NewDocumentFailed(documentID, organizationID int32, err string) *DocumentFailed {\n\treturn &DocumentFailed{\n\t\tBaseEvent: eventbus.BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      DocumentFailedEventType,\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tDocumentID:     documentID,\n\t\tOrganizationID: organizationID,\n\t\tError:          err,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/domain/repository.go",
    "content": "package domain\n\nimport \"context\"\n\n// DocumentRepository defines the interface for document data operations\ntype DocumentRepository interface {\n\t// Create creates a new document\n\tCreate(ctx context.Context, doc *Document) (*Document, error)\n\n\t// GetByID retrieves a document by ID\n\tGetByID(ctx context.Context, orgID, docID int32) (*Document, error)\n\n\t// GetByFileAssetID retrieves a document by file asset ID\n\tGetByFileAssetID(ctx context.Context, orgID, fileAssetID int32) (*Document, error)\n\n\t// List retrieves documents with pagination\n\tList(ctx context.Context, orgID int32, limit, offset int32) ([]*Document, error)\n\n\t// ListByStatus retrieves documents by status with pagination\n\tListByStatus(ctx context.Context, orgID int32, status DocumentStatus, limit, offset int32) ([]*Document, error)\n\n\t// UpdateStatus updates the document status\n\tUpdateStatus(ctx context.Context, orgID, docID int32, status DocumentStatus) (*Document, error)\n\n\t// UpdateExtractedText updates the extracted text and sets status to processed\n\tUpdateExtractedText(ctx context.Context, orgID, docID int32, text string) (*Document, error)\n\n\t// Update updates document metadata\n\tUpdate(ctx context.Context, doc *Document) (*Document, error)\n\n\t// Delete removes a document\n\tDelete(ctx context.Context, orgID, docID int32) error\n\n\t// Count returns the total count of documents for an organization\n\tCount(ctx context.Context, orgID int32) (int64, error)\n\n\t// CountByStatus returns the count of documents with a specific status\n\tCountByStatus(ctx context.Context, orgID int32, status DocumentStatus) (int64, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/handler.go",
    "content": "package documents\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/app/services\"\n\t_ \"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\" // for swagger\n\t\"github.com/moasq/go-b2b-starter/pkg/httperr\"\n)\n\ntype Handler struct {\n\tservice services.DocumentService\n}\n\nfunc NewHandler(service services.DocumentService) *Handler {\n\treturn &Handler{service: service}\n}\n\n// UploadDocument uploads a new PDF document\n// @Summary Upload PDF document\n// @Description Uploads a PDF document, extracts text, and creates embeddings\n// @Tags Documents\n// @Accept multipart/form-data\n// @Produce json\n// @Param file formData file true \"PDF file to upload\"\n// @Param title formData string true \"Document title\"\n// @Success 201 {object} domain.Document\n// @Failure 400 {object} httperr.HTTPError\n// @Failure 500 {object} httperr.HTTPError\n// @Router /example_documents/upload [post]\nfunc (h *Handler) UploadDocument(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\t// Get uploaded file\n\tfile, header, err := c.Request.FormFile(\"file\")\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"invalid_file\",\n\t\t\t\"Failed to read file: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\t// Get title from form\n\ttitle := c.PostForm(\"title\")\n\tif title == \"\" {\n\t\ttitle = header.Filename\n\t}\n\n\t// Create upload request\n\treq := &services.UploadDocumentRequest{\n\t\tTitle:       title,\n\t\tFileName:    header.Filename,\n\t\tContentType: header.Header.Get(\"Content-Type\"),\n\t\tFileSize:    header.Size,\n\t}\n\n\t// Upload document\n\tdocument, err := h.service.UploadDocument(c.Request.Context(), reqCtx.OrganizationID, req, file)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"upload_failed\",\n\t\t\t\"Failed to upload document: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusCreated, document)\n}\n\n// ListDocuments lists documents with pagination\n// @Summary List documents\n// @Description Lists documents with optional filtering and pagination\n// @Tags Documents\n// @Produce json\n// @Param limit query int false \"Limit\" default(10)\n// @Param offset query int false \"Offset\" default(0)\n// @Param status query string false \"Filter by status (pending, processing, processed, failed)\"\n// @Success 200 {object} services.ListDocumentsResponse\n// @Failure 500 {object} httperr.HTTPError\n// @Router /example_documents [get]\nfunc (h *Handler) ListDocuments(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\t// Parse query parameters\n\tlimit, _ := strconv.Atoi(c.DefaultQuery(\"limit\", \"10\"))\n\toffset, _ := strconv.Atoi(c.DefaultQuery(\"offset\", \"0\"))\n\n\treq := &services.ListDocumentsRequest{\n\t\tLimit:  int32(limit),\n\t\tOffset: int32(offset),\n\t}\n\n\t// Optional status filter\n\t// Note: Status filtering would need to be added if needed\n\n\tresponse, err := h.service.ListDocuments(c.Request.Context(), reqCtx.OrganizationID, req)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"list_failed\",\n\t\t\t\"Failed to list documents: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, response)\n}\n\n// @Summary Delete document\n// @Description Deletes a document and its associated file\n// @Tags Documents\n// @Param id path int true \"Document ID\"\n// @Success 204\n// @Failure 400 {object} httperr.HTTPError\n// @Failure 500 {object} httperr.HTTPError\n// @Router /example_documents/{id} [delete]\nfunc (h *Handler) DeleteDocument(c *gin.Context) {\n\tidParam := c.Param(\"id\")\n\tvar docID int32\n\tif _, err := fmt.Sscanf(idParam, \"%d\", &docID); err != nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"invalid_id\",\n\t\t\t\"Document ID must be a valid number\",\n\t\t))\n\t\treturn\n\t}\n\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\tc.JSON(http.StatusBadRequest, httperr.NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"missing_context\",\n\t\t\t\"Organization context is required\",\n\t\t))\n\t\treturn\n\t}\n\n\tif err := h.service.DeleteDocument(c.Request.Context(), reqCtx.OrganizationID, docID); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, httperr.NewHTTPError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\t\"delete_failed\",\n\t\t\t\"Failed to delete document: \"+err.Error(),\n\t\t))\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/infra/repositories/document_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/helpers\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n)\n\n// documentRepository implements domain.DocumentRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype documentRepository struct {\n\tstore sqlc.Store\n}\n\n// NewDocumentRepository creates a new DocumentRepository implementation.\nfunc NewDocumentRepository(store sqlc.Store) domain.DocumentRepository {\n\treturn &documentRepository{store: store}\n}\n\nfunc (r *documentRepository) Create(ctx context.Context, doc *domain.Document) (*domain.Document, error) {\n\tparams := sqlc.CreateDocumentParams{\n\t\tOrganizationID: doc.OrganizationID,\n\t\tFileAssetID:    doc.FileAssetID,\n\t\tTitle:          doc.Title,\n\t\tFileName:       doc.FileName,\n\t\tContentType:    doc.ContentType,\n\t\tFileSize:       doc.FileSize,\n\t\tExtractedText:  helpers.ToPgText(doc.ExtractedText),\n\t\tStatus:         string(doc.Status),\n\t\tMetadata:       helpers.ToJSONB(doc.Metadata),\n\t}\n\n\tresult, err := r.store.CreateDocument(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create document: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *documentRepository) GetByID(ctx context.Context, orgID, docID int32) (*domain.Document, error) {\n\tparams := sqlc.GetDocumentByIDParams{\n\t\tID:             docID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.GetDocumentByID(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get document: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *documentRepository) GetByFileAssetID(ctx context.Context, orgID, fileAssetID int32) (*domain.Document, error) {\n\tparams := sqlc.GetDocumentByFileAssetIDParams{\n\t\tFileAssetID:    fileAssetID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.GetDocumentByFileAssetID(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get document by file asset: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *documentRepository) List(ctx context.Context, orgID int32, limit, offset int32) ([]*domain.Document, error) {\n\tparams := sqlc.ListDocumentsByOrganizationParams{\n\t\tOrganizationID: orgID,\n\t\tLimit:          limit,\n\t\tOffset:         offset,\n\t}\n\n\tresults, err := r.store.ListDocumentsByOrganization(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list documents: %w\", err)\n\t}\n\n\tdocs := make([]*domain.Document, len(results))\n\tfor i, result := range results {\n\t\tdocs[i] = r.mapToDomain(&result)\n\t}\n\n\treturn docs, nil\n}\n\nfunc (r *documentRepository) ListByStatus(ctx context.Context, orgID int32, status domain.DocumentStatus, limit, offset int32) ([]*domain.Document, error) {\n\tparams := sqlc.ListDocumentsByStatusParams{\n\t\tOrganizationID: orgID,\n\t\tStatus:         string(status),\n\t\tLimit:          limit,\n\t\tOffset:         offset,\n\t}\n\n\tresults, err := r.store.ListDocumentsByStatus(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list documents by status: %w\", err)\n\t}\n\n\tdocs := make([]*domain.Document, len(results))\n\tfor i, result := range results {\n\t\tdocs[i] = r.mapToDomain(&result)\n\t}\n\n\treturn docs, nil\n}\n\nfunc (r *documentRepository) UpdateStatus(ctx context.Context, orgID, docID int32, status domain.DocumentStatus) (*domain.Document, error) {\n\tparams := sqlc.UpdateDocumentStatusParams{\n\t\tID:             docID,\n\t\tOrganizationID: orgID,\n\t\tStatus:         string(status),\n\t}\n\n\tresult, err := r.store.UpdateDocumentStatus(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update document status: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *documentRepository) UpdateExtractedText(ctx context.Context, orgID, docID int32, text string) (*domain.Document, error) {\n\tparams := sqlc.UpdateDocumentExtractedTextParams{\n\t\tID:             docID,\n\t\tOrganizationID: orgID,\n\t\tExtractedText:  helpers.ToPgText(text),\n\t}\n\n\tresult, err := r.store.UpdateDocumentExtractedText(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update extracted text: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *documentRepository) Update(ctx context.Context, doc *domain.Document) (*domain.Document, error) {\n\tparams := sqlc.UpdateDocumentParams{\n\t\tID:             doc.ID,\n\t\tOrganizationID: doc.OrganizationID,\n\t\tTitle:          doc.Title,\n\t\tMetadata:       helpers.ToJSONB(doc.Metadata),\n\t}\n\n\tresult, err := r.store.UpdateDocument(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to update document: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *documentRepository) Delete(ctx context.Context, orgID, docID int32) error {\n\tparams := sqlc.DeleteDocumentParams{\n\t\tID:             docID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tif err := r.store.DeleteDocument(ctx, params); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete document: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *documentRepository) Count(ctx context.Context, orgID int32) (int64, error) {\n\tcount, err := r.store.CountDocumentsByOrganization(ctx, orgID)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to count documents: %w\", err)\n\t}\n\n\treturn count, nil\n}\n\nfunc (r *documentRepository) CountByStatus(ctx context.Context, orgID int32, status domain.DocumentStatus) (int64, error) {\n\tparams := sqlc.CountDocumentsByStatusParams{\n\t\tOrganizationID: orgID,\n\t\tStatus:         string(status),\n\t}\n\n\tcount, err := r.store.CountDocumentsByStatus(ctx, params)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to count documents by status: %w\", err)\n\t}\n\n\treturn count, nil\n}\n\n// mapToDomain converts SQLC document type to domain type.\n// This is the translation boundary - SQLC types never escape this function.\nfunc (r *documentRepository) mapToDomain(doc *sqlc.DocumentsDocument) *domain.Document {\n\treturn &domain.Document{\n\t\tID:             doc.ID,\n\t\tOrganizationID: doc.OrganizationID,\n\t\tFileAssetID:    doc.FileAssetID,\n\t\tTitle:          doc.Title,\n\t\tFileName:       doc.FileName,\n\t\tContentType:    doc.ContentType,\n\t\tFileSize:       doc.FileSize,\n\t\tExtractedText:  helpers.FromPgText(doc.ExtractedText),\n\t\tStatus:         domain.DocumentStatus(doc.Status),\n\t\tMetadata:       helpers.FromJSONB(doc.Metadata),\n\t\tCreatedAt:      doc.CreatedAt.Time,\n\t\tUpdatedAt:      doc.UpdatedAt.Time,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/module.go",
    "content": "package documents\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/documents/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/eventbus\"\n\tfiledomain \"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\tocrdomain \"github.com/moasq/go-b2b-starter/internal/platform/ocr/domain\"\n)\n\n// Module provides documents module dependencies\ntype Module struct {\n\tcontainer *dig.Container\n}\n\nfunc NewModule(container *dig.Container) *Module {\n\treturn &Module{\n\t\tcontainer: container,\n\t}\n}\n\n// RegisterDependencies registers all documents module dependencies\n// Note: Repository implementations are registered in internal/db/inject.go\nfunc (m *Module) RegisterDependencies() error {\n\t// Register document service\n\tif err := m.container.Provide(func(\n\t\tdocRepo domain.DocumentRepository,\n\t\tfileService filedomain.FileService,\n\t\tocrService ocrdomain.OCRService,\n\t\teventBus eventbus.EventBus,\n\t\tlogger logger.Logger,\n\t) services.DocumentService {\n\t\treturn services.NewDocumentService(docRepo, fileService, ocrService, eventBus, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/provider.go",
    "content": "package documents\n\nimport (\n\t\"go.uber.org/dig\"\n)\n\ntype Provider struct {\n\tcontainer *dig.Container\n}\n\nfunc NewProvider(container *dig.Container) *Provider {\n\treturn &Provider{container: container}\n}\n\nfunc (p *Provider) RegisterDependencies() error {\n\t// Register handler\n\tif err := p.container.Provide(NewHandler); err != nil {\n\t\treturn err\n\t}\n\n\t// Register routes\n\tif err := p.container.Provide(NewRoutes); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/documents/routes.go",
    "content": "package documents\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\tserverDomain \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\ntype Routes struct {\n\thandler *Handler\n}\n\nfunc NewRoutes(handler *Handler) *Routes {\n\treturn &Routes{\n\t\thandler: handler,\n\t}\n}\n\nfunc (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\tdocsGroup := router.Group(\"/example_documents\")\n\tdocsGroup.Use(\n\t\tresolver.Get(\"auth\"),\n\t\tresolver.Get(\"org_context\"),\n\t\tresolver.Get(\"subscription\"),\n\t)\n\t{\n\t\t// Upload document\n\t\tdocsGroup.POST(\"/upload\",\n\t\t\tauth.RequirePermissionFunc(\"resource\", \"create\"),\n\t\t\tr.handler.UploadDocument)\n\n\t\t// List documents\n\t\tdocsGroup.GET(\"\",\n\t\t\tauth.RequirePermissionFunc(\"resource\", \"view\"),\n\t\t\tr.handler.ListDocuments)\n\n\t\t// Delete document\n\t\tdocsGroup.DELETE(\"/:id\",\n\t\t\tauth.RequirePermissionFunc(\"resource\", \"delete\"),\n\t\t\tr.handler.DeleteDocument)\n\t}\n}\n\n// Routes returns a RouteRegistrar function compatible with the server interface\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\tr.RegisterRoutes(router, resolver)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/README.md",
    "content": "# File Manager Guide\n\nSimple guide for uploading and managing files with Cloudflare R2 (S3-compatible storage).\n\n## Setup\n\n### R2 Configuration\n\nAdd to your `.env`:\n\n```bash\nR2_ACCOUNT_ID=your-cloudflare-account-id\nR2_ACCESS_KEY_ID=your-r2-access-key\nR2_SECRET_ACCESS_KEY=your-r2-secret-key\nR2_BUCKET=your-bucket-name\nR2_REGION=auto                    # Default\n```\n\n### Get R2 Credentials\n\n1. Go to Cloudflare Dashboard → R2\n2. Create a bucket or use existing one\n3. Go to \"Manage R2 API Tokens\"\n4. Create API token with read/write permissions\n5. Copy Account ID, Access Key ID, and Secret Access Key\n\n## Usage in Your Module\n\n### 1. Inject File Service\n\n```go\nimport \"github.com/moasq/go-b2b-starter/pkg/file_manager/domain\"\n\ntype InvoiceService struct {\n    fileService domain.FileService\n}\n\nfunc NewInvoiceService(fileService domain.FileService) *InvoiceService {\n    return &InvoiceService{fileService: fileService}\n}\n```\n\n### 2. Upload a File\n\n```go\nfunc (s *InvoiceService) UploadInvoice(ctx context.Context, file io.Reader, filename string, size int64) (*domain.FileAsset, error) {\n    // Create upload request\n    req := &domain.FileUploadRequest{\n        Filename:    filename,\n        Size:        size,\n        ContentType: \"application/pdf\",\n        Context:     file_manager.ContextInvoice, // Business context\n        Metadata: map[string]any{\n            \"uploaded_by\": userID,\n            \"invoice_number\": \"INV-2024-001\",\n        },\n    }\n\n    // Upload to R2\n    fileAsset, err := s.fileService.UploadFile(ctx, req, file)\n    if err != nil {\n        return nil, fmt.Errorf(\"upload failed: %w\", err)\n    }\n\n    s.logger.Info(\"File uploaded\", map[string]any{\n        \"file_id\": fileAsset.ID,\n        \"size\":    fileAsset.Size,\n        \"path\":    fileAsset.StoragePath,\n    })\n\n    return fileAsset, nil\n}\n```\n\n### 3. Download a File\n\n```go\nfunc (s *InvoiceService) DownloadInvoice(ctx context.Context, fileID int32) (io.ReadCloser, error) {\n    content, fileAsset, err := s.fileService.DownloadFile(ctx, fileID)\n    if err != nil {\n        return nil, fmt.Errorf(\"download failed: %w\", err)\n    }\n    defer content.Close()\n\n    // Use the file content\n    data, err := io.ReadAll(content)\n    if err != nil {\n        return nil, err\n    }\n\n    return content, nil\n}\n```\n\n### 4. Get Presigned URL\n\nGet a temporary signed URL for direct browser access:\n\n```go\nfunc (s *InvoiceService) GetInvoiceURL(ctx context.Context, fileID int32) (string, error) {\n    // Generate URL valid for 24 hours\n    url, err := s.fileService.GetFileURL(ctx, fileID, 24)\n    if err != nil {\n        return \"\", err\n    }\n\n    return url, nil\n}\n```\n\n### 5. Delete a File\n\n```go\nfunc (s *InvoiceService) DeleteInvoice(ctx context.Context, fileID int32) error {\n    return s.fileService.DeleteFile(ctx, fileID)\n}\n```\n\n### 6. List Files with Filter\n\n```go\nfunc (s *InvoiceService) ListInvoices(ctx context.Context) ([]*domain.FileAsset, error) {\n    // Filter by context\n    invoiceContext := file_manager.ContextInvoice\n\n    filter := &domain.FileSearchFilter{\n        Context: &invoiceContext,\n    }\n\n    files, err := s.fileService.ListFiles(ctx, filter, 50, 0)\n    if err != nil {\n        return nil, err\n    }\n\n    return files, nil\n}\n```\n\n## File Contexts\n\nOrganize files by business purpose:\n\n```go\nfile_manager.ContextInvoice              // Invoices\nfile_manager.ContextReceipt              // Receipts\nfile_manager.ContextContract             // Contracts\nfile_manager.ContextReport               // Reports\nfile_manager.ContextProfile              // User profiles\nfile_manager.ContextPaymentInstruction   // Payment instructions\nfile_manager.ContextPaymentBatch         // Payment batches\nfile_manager.ContextGeneral              // General files\n```\n\n## File Categories & Limits\n\n**Documents (PDFs):**\n- Allowed: `.pdf`\n- Max size: 2 MB\n- Category: `file_manager.CategoryDocument`\n\n**Images:**\n- Allowed: `.jpg`, `.jpeg`, `.png`\n- Max size: 1 MB\n- Category: `file_manager.CategoryImage`\n\n## Security Features\n\nThe file manager automatically:\n- ✅ Sanitizes filenames (prevents path traversal)\n- ✅ Validates file types (magic byte detection)\n- ✅ Enforces size limits\n- ✅ Validates content matches extension\n- ✅ Stores metadata in PostgreSQL\n- ✅ Organizes files in R2 by context and date\n\n## Real-World Example: Complete Upload Flow\n\n```go\nfunc (s *InvoiceService) ProcessInvoiceUpload(ctx context.Context, r *http.Request) (*Invoice, error) {\n    // 1. Parse multipart form\n    file, header, err := r.FormFile(\"invoice\")\n    if err != nil {\n        return nil, err\n    }\n    defer file.Close()\n\n    // 2. Upload to R2 via file manager\n    req := &domain.FileUploadRequest{\n        Filename:    header.Filename,\n        Size:        header.Size,\n        ContentType: header.Header.Get(\"Content-Type\"),\n        Context:     file_manager.ContextInvoice,\n        Metadata: map[string]any{\n            \"uploaded_by\": ctx.Value(\"user_id\"),\n            \"organization_id\": ctx.Value(\"organization_id\"),\n        },\n    }\n\n    fileAsset, err := s.fileService.UploadFile(ctx, req, file)\n    if err != nil {\n        return nil, err\n    }\n\n    // 3. Create invoice record with file reference\n    invoice := &Invoice{\n        Number:         \"INV-2024-001\",\n        FileID:         fileAsset.ID,\n        FileName:       fileAsset.Filename,\n        OrganizationID: organizationID,\n    }\n\n    err = s.repo.CreateInvoice(ctx, invoice)\n    if err != nil {\n        // Rollback: delete the uploaded file\n        s.fileService.DeleteFile(ctx, fileAsset.ID)\n        return nil, err\n    }\n\n    return invoice, nil\n}\n```\n\n## Storage Structure\n\nFiles are organized in R2 with this pattern:\n```\n{category}/{context}/{date}/{filename}\n```\n\nExample:\n```\ndocument/invoice/2024/12/09/invoice-12345.pdf\nimage/receipt/2024/12/09/receipt-photo.jpg\n```\n\n## Configuration Reference\n\n| Variable | Required | Description |\n|----------|----------|-------------|\n| `R2_ACCOUNT_ID` | Yes | Your Cloudflare account ID |\n| `R2_ACCESS_KEY_ID` | Yes | R2 API access key ID |\n| `R2_SECRET_ACCESS_KEY` | Yes | R2 API secret key |\n| `R2_BUCKET` | Yes | R2 bucket name |\n| `R2_REGION` | No | Region (default: `auto`) |\n\n## Best Practices\n\n**1. Always use contexts:**\n```go\n// ✅ Good - organized by purpose\nContext: file_manager.ContextInvoice\n\n// ❌ Bad - generic context\nContext: file_manager.ContextGeneral\n```\n\n**2. Store file references:**\n```go\ntype Invoice struct {\n    FileID   int32  `json:\"file_id\"`\n    FileName string `json:\"file_name\"`\n}\n```\n\n**3. Handle deletions:**\n```go\n// Delete invoice and its file\ns.invoiceRepo.Delete(ctx, invoiceID)\ns.fileService.DeleteFile(ctx, invoice.FileID)\n```\n\n**4. Use presigned URLs for downloads:**\n```go\n// Generate temporary URL instead of downloading in backend\nurl, _ := s.fileService.GetFileURL(ctx, fileID, 1) // 1 hour\n// Return URL to frontend\n```\n\n## Why R2?\n\n- **S3-compatible**: Use standard AWS SDK\n- **No egress fees**: Free bandwidth\n- **Global CDN**: Fast downloads worldwide\n- **Cost-effective**: Lower storage costs than S3\n\nThat's it! Just inject `FileService` and manage files with R2.\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"log\"\n\n\t\"go.uber.org/dig\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/config\"\n)\n\nfunc Init(container *dig.Container) {\n\n\tif err := container.Provide(config.LoadConfig); err != nil {\n\t\tlog.Fatalf(\"Failed to provide file_manager config: %v\", err)\n\t}\n\n\tSetupDependencies(container)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/cmd/provider.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/config\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/internal/infra\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\nfunc SetupDependencies(container *dig.Container) error {\n\t// Provider for R2 repository with development mode support\n\tif err := container.Provide(func(cfg *config.Config, log logger.Logger) (domain.R2Repository, error) {\n\t\t// Check for placeholder credentials (development mode)\n\t\tif isPlaceholderR2Credentials(cfg) {\n\t\t\tlog.Warn(\"R2 credentials are placeholders - using mock file storage (development mode)\", map[string]any{\n\t\t\t\t\"account_id\": cfg.R2.AccountID,\n\t\t\t\t\"message\":    \"File upload/download will not work. Update R2_* variables in app.env with real credentials\",\n\t\t\t})\n\t\t\t// Return mock repository for development mode\n\t\t\treturn infra.NewMockR2Repository(log), nil\n\t\t}\n\n\t\treturn infra.NewR2Repository(cfg)\n\t}); err != nil {\n\t\tfmt.Printf(\"Error providing R2 repository: %v\", err)\n\t\treturn err\n\t}\n\n\t// Note: FileMetadataRepository is registered in internal/db/inject.go\n\n\t// Provider for composite file repository\n\tif err := container.Provide(infra.NewCompositeRepository); err != nil {\n\t\tfmt.Printf(\"Error providing composite file repository: %v\", err)\n\t\treturn err\n\t}\n\n\t// Provider for file service\n\tif err := container.Provide(domain.NewFileService); err != nil {\n\t\tfmt.Printf(\"Error providing file service: %v\", err)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// isPlaceholderR2Credentials checks if the R2 credentials are placeholder values.\nfunc isPlaceholderR2Credentials(cfg *config.Config) bool {\n\treturn strings.Contains(cfg.R2.AccountID, \"REPLACE\") ||\n\t\tstrings.Contains(cfg.R2.AccessKeyID, \"REPLACE\") ||\n\t\tstrings.Contains(cfg.R2.SecretAccessKey, \"REPLACE\") ||\n\t\tcfg.R2.AccountID == \"\" ||\n\t\tcfg.R2.AccessKeyID == \"\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/config/config.go",
    "content": "package config\n\nimport (\n\t\"github.com/spf13/viper\"\n)\n\ntype Config struct {\n\tR2 R2Config\n}\n\ntype R2Config struct {\n\tAccountID       string\n\tAccessKeyID     string\n\tSecretAccessKey string\n\tBucketName      string\n\tRegion          string\n}\n\nfunc LoadConfig() (*Config, error) {\n\tviper.SetConfigName(\"config\")\n\tviper.SetConfigType(\"json\")\n\tviper.AddConfigPath(\".\")\n\tviper.AddConfigPath(\"./config\")\n\n\t// Set environment variable overrides\n\tviper.SetEnvPrefix(\"APCASH\")\n\tviper.AutomaticEnv()\n\n\t// Set default values for R2\n\tviper.SetDefault(\"r2.region\", \"auto\")\n\tviper.SetDefault(\"r2.bucketName\", \"invoices\")\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); !ok {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Bind environment variables to viper keys for R2\n\tviper.BindEnv(\"r2.accountID\", \"R2_ACCOUNT_ID\")\n\tviper.BindEnv(\"r2.accessKeyID\", \"R2_ACCESS_KEY_ID\")\n\tviper.BindEnv(\"r2.secretAccessKey\", \"R2_SECRET_ACCESS_KEY\")\n\tviper.BindEnv(\"r2.bucketName\", \"R2_BUCKET\")\n\tviper.BindEnv(\"r2.region\", \"R2_REGION\")\n\n\tconfig := &Config{\n\t\tR2: R2Config{\n\t\t\tAccountID:       viper.GetString(\"r2.accountID\"),\n\t\t\tAccessKeyID:     viper.GetString(\"r2.accessKeyID\"),\n\t\t\tSecretAccessKey: viper.GetString(\"r2.secretAccessKey\"),\n\t\t\tBucketName:      viper.GetString(\"r2.bucketName\"),\n\t\t\tRegion:          viper.GetString(\"r2.region\"),\n\t\t},\n\t}\n\n\treturn config, nil\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/constants.go",
    "content": "package files\n\nimport (\n\t\"strings\"\n)\n\n// File Type Categories\ntype FileCategory string\n\nconst (\n\tCategoryDocument FileCategory = \"document\"\n\tCategoryImage    FileCategory = \"image\"\n\tCategoryArchive  FileCategory = \"archive\"\n)\n\n// Supported file types\n// SECURITY: Restricted to invoice-safe formats only (PDF and common image formats)\n// Removed: Office documents (.doc, .docx, .xls, .xlsx), text files (.txt, .csv),\n//          archives (.zip, .rar, etc.), and risky image formats (.svg, .gif)\nvar (\n\tDocumentTypes = []string{\".pdf\"}\n\tImageTypes    = []string{\".jpg\", \".jpeg\", \".png\"}\n\tArchiveTypes  = []string{} // Archives disabled for security\n)\n\n// Business file contexts\ntype FileContext string\n\nconst (\n\tContextInvoice            FileContext = \"invoice\"\n\tContextReceipt            FileContext = \"receipt\"\n\tContextContract           FileContext = \"contract\"\n\tContextReport             FileContext = \"report\"\n\tContextProfile            FileContext = \"profile\"\n\tContextGeneral            FileContext = \"general\"\n\tContextPaymentInstruction FileContext = \"payment_instruction\"\n\tContextPaymentBatch       FileContext = \"payment_batch\"\n)\n\n// File size limits (in bytes)\n// SECURITY: Strict limits for invoice processing to minimize attack surface\nconst (\n\tMaxDocumentSize = 2 * 1024 * 1024 // 2MB - sufficient for most invoice PDFs\n\tMaxImageSize    = 1 * 1024 * 1024 // 1MB - sufficient for scanned invoices\n\tMaxArchiveSize  = 0               // Archives disabled\n)\n\n// GetFileCategory determines the category based on file extension\nfunc GetFileCategory(filename string) FileCategory {\n\text := strings.ToLower(getFileExtension(filename))\n\t\n\tfor _, docType := range DocumentTypes {\n\t\tif ext == docType {\n\t\t\treturn CategoryDocument\n\t\t}\n\t}\n\t\n\tfor _, imgType := range ImageTypes {\n\t\tif ext == imgType {\n\t\t\treturn CategoryImage\n\t\t}\n\t}\n\t\n\tfor _, archType := range ArchiveTypes {\n\t\tif ext == archType {\n\t\t\treturn CategoryArchive\n\t\t}\n\t}\n\t\n\treturn CategoryDocument // Default to document\n}\n\n// GetMaxFileSize returns the maximum file size for a given category\nfunc GetMaxFileSize(category FileCategory) int64 {\n\tswitch category {\n\tcase CategoryImage:\n\t\treturn MaxImageSize\n\tcase CategoryArchive:\n\t\treturn MaxArchiveSize\n\tdefault:\n\t\treturn MaxDocumentSize\n\t}\n}\n\n// IsAllowedFileType checks if the file type is allowed\nfunc IsAllowedFileType(filename string) bool {\n\text := strings.ToLower(getFileExtension(filename))\n\t\n\tallTypes := append(DocumentTypes, ImageTypes...)\n\tallTypes = append(allTypes, ArchiveTypes...)\n\t\n\tfor _, allowedType := range allTypes {\n\t\tif ext == allowedType {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\nfunc getFileExtension(filename string) string {\n\tif idx := strings.LastIndex(filename, \".\"); idx != -1 {\n\t\treturn filename[idx:]\n\t}\n\treturn \"\"\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/domain/entity.go",
    "content": "package domain\n\nimport (\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files\"\n\t\"github.com/google/uuid\"\n)\n\ntype FileAsset struct {\n\tID               int32                     `json:\"id\"`   // Database ID\n\tUUID             uuid.UUID                 `json:\"uuid\"` // UUID for external reference\n\tFilename         string                    `json:\"filename\"`\n\tOriginalFilename string                    `json:\"original_filename\"`\n\tSize             int64                     `json:\"size\"`\n\tContentType      string                    `json:\"content_type\"`\n\tCategory         files.FileCategory `json:\"category\"`\n\tContext          files.FileContext  `json:\"context\"`\n\tStoragePath      string                    `json:\"storage_path\"` // R2 object path\n\tBucketName       string                    `json:\"bucket_name\"`\n\tIsPublic         bool                      `json:\"is_public\"`\n\tEntityType       string                    `json:\"entity_type,omitempty\"`\n\tEntityID         int32                     `json:\"entity_id,omitempty\"`\n\tPurpose          string                    `json:\"purpose,omitempty\"`\n\tMetadata         map[string]interface{}    `json:\"metadata,omitempty\"`\n\tURL              string                    `json:\"url,omitempty\"` // Presigned URL\n\tCreatedAt        time.Time                 `json:\"created_at\"`\n\tUpdatedAt        time.Time                 `json:\"updated_at\"`\n}\n\ntype FileUploadRequest struct {\n\tFilename    string                   `json:\"filename\"`\n\tSize        int64                    `json:\"size\"`\n\tContentType string                   `json:\"content_type\"`\n\tContext     files.FileContext `json:\"context\"`\n\tMetadata    map[string]any           `json:\"metadata,omitempty\"`\n}\n\ntype FileSearchFilter struct {\n\tCategory *files.FileCategory `json:\"category,omitempty\"`\n\tContext  *files.FileContext  `json:\"context,omitempty\"`\n\tMinSize  *int64                     `json:\"min_size,omitempty\"`\n\tMaxSize  *int64                     `json:\"max_size,omitempty\"`\n\tDateFrom *time.Time                 `json:\"date_from,omitempty\"`\n\tDateTo   *time.Time                 `json:\"date_to,omitempty\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/domain/helpers.go",
    "content": "package domain\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n)\n\n// ConvertFileToBase64 reads a file from storage and converts it to a base64 data URI\n// Returns a data URI in the format: data:{mimeType};base64,{encodedContent}\nfunc ConvertFileToBase64(ctx context.Context, repo FileRepository, fileID int32) (string, error) {\n\t// Download the file content and metadata\n\tcontent, fileAsset, err := repo.Download(ctx, fileID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to download file %d: %w\", fileID, err)\n\t}\n\tdefer content.Close()\n\n\t// Read all content into memory\n\tdata, err := io.ReadAll(content)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read file content: %w\", err)\n\t}\n\n\t// Convert to base64 and format as data URI\n\treturn formatAsDataURI(data, fileAsset.ContentType), nil\n}\n\n// ConvertReaderToBase64 converts an io.Reader to a base64 data URI\n// Useful for converting files that haven't been stored yet\nfunc ConvertReaderToBase64(content io.Reader, contentType string) (string, error) {\n\t// Read all content into memory\n\tdata, err := io.ReadAll(content)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read content: %w\", err)\n\t}\n\n\t// Convert to base64 and format as data URI\n\treturn formatAsDataURI(data, contentType), nil\n}\n\n// formatAsDataURI creates a properly formatted data URI from binary data\nfunc formatAsDataURI(data []byte, contentType string) string {\n\tencoded := base64.StdEncoding.EncodeToString(data)\n\treturn fmt.Sprintf(\"data:%s;base64,%s\", contentType, encoded)\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/domain/repository.go",
    "content": "package domain\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files\"\n)\n\ntype FileRepository interface {\n\t// Combined operations (R2 + Database)\n\tUpload(ctx context.Context, file *FileAsset, content io.Reader) error\n\tDownload(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error)\n\tGetByID(ctx context.Context, id int32) (*FileAsset, error)\n\tDelete(ctx context.Context, id int32) error\n\tList(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error)\n\tGetURL(ctx context.Context, id int32, expiryHours int) (string, error)\n\tExists(ctx context.Context, id int32) (bool, error)\n\n\t// Additional operations\n\tGetByCategory(ctx context.Context, category files.FileCategory, limit, offset int) ([]*FileAsset, error)\n\tGetByContext(ctx context.Context, context files.FileContext, limit, offset int) ([]*FileAsset, error)\n\tGetByEntity(ctx context.Context, entityType string, entityID int32) ([]*FileAsset, error)\n}\n\n// R2Repository handles only object storage operations (Cloudflare R2)\ntype R2Repository interface {\n\tUploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error\n\tDownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error)\n\tDeleteObject(ctx context.Context, objectKey string) error\n\tGetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error)\n\tObjectExists(ctx context.Context, objectKey string) (bool, error)\n}\n\n// FileMetadataRepository handles only database operations\ntype FileMetadataRepository interface {\n\tCreate(ctx context.Context, file *FileAsset) (*FileAsset, error)\n\tGetByID(ctx context.Context, id int32) (*FileAsset, error)\n\tUpdate(ctx context.Context, file *FileAsset) error\n\tDelete(ctx context.Context, id int32) error\n\tList(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error)\n\tGetByStoragePath(ctx context.Context, storagePath string) (*FileAsset, error)\n\tGetByCategory(ctx context.Context, category string, limit, offset int) ([]*FileAsset, error)\n\tGetByContext(ctx context.Context, context string, limit, offset int) ([]*FileAsset, error)\n\tGetByEntity(ctx context.Context, entityType string, entityID int32) ([]*FileAsset, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/domain/service.go",
    "content": "package domain\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files\"\n)\n\ntype FileService interface {\n\tUploadFile(ctx context.Context, req *FileUploadRequest, content io.Reader) (*FileAsset, error)\n\tDownloadFile(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error)\n\tGetFile(ctx context.Context, id int32) (*FileAsset, error)\n\tDeleteFile(ctx context.Context, id int32) error\n\tListFiles(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error)\n\tGetFileURL(ctx context.Context, id int32, expiryHours int) (string, error)\n}\n\ntype fileService struct {\n\trepo FileRepository\n}\n\nfunc NewFileService(repo FileRepository) FileService {\n\treturn &fileService{\n\t\trepo: repo,\n\t}\n}\n\nfunc (s *fileService) UploadFile(ctx context.Context, req *FileUploadRequest, content io.Reader) (*FileAsset, error) {\n\t// SECURITY: Sanitize filename to prevent path traversal and dangerous characters\n\tsanitizedFilename := SanitizeFilename(req.Filename)\n\n\t// SECURITY: Validate file extension is allowed\n\tif !files.IsAllowedFileType(sanitizedFilename) {\n\t\treturn nil, fmt.Errorf(\"file type not allowed: %s\", sanitizedFilename)\n\t}\n\n\t// Get file category\n\tcategory := files.GetFileCategory(sanitizedFilename)\n\n\t// SECURITY: Check file size limits\n\tmaxSize := files.GetMaxFileSize(category)\n\tif req.Size > maxSize {\n\t\treturn nil, fmt.Errorf(\"file size %d exceeds limit %d for category %s\", req.Size, maxSize, category)\n\t}\n\n\t// SOLUTION: Read entire file into buffer (makes it seekable for R2 retries)\n\t// AWS SDK v2 requires io.ReadSeeker for retryable uploads\n\t// bytes.Reader implements io.ReadSeeker, allowing the SDK to rewind the stream\n\tfileData, err := io.ReadAll(content)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read file content: %w\", err)\n\t}\n\n\t// Verify actual size matches declared size\n\tif int64(len(fileData)) != req.Size {\n\t\treturn nil, fmt.Errorf(\"file size mismatch: declared %d bytes, actual %d bytes\",\n\t\t\treq.Size, len(fileData))\n\t}\n\n\t// SECURITY: Validate file content matches declared extension using magic bytes\n\tvalidationReader := bytes.NewReader(fileData)\n\tif err := ValidateFileContent(validationReader, sanitizedFilename); err != nil {\n\t\treturn nil, fmt.Errorf(\"file validation failed: %w\", err)\n\t}\n\n\t// Create file asset\n\tfileAsset := &FileAsset{\n\t\tFilename:         sanitizedFilename,\n\t\tOriginalFilename: req.Filename, // Keep original for reference\n\t\tSize:             req.Size,\n\t\tContentType:      req.ContentType,\n\t\tCategory:         category,\n\t\tContext:          req.Context,\n\t\tMetadata:         req.Metadata,\n\t\tCreatedAt:        time.Now(),\n\t\tUpdatedAt:        time.Now(),\n\t}\n\n\t// SOLUTION: Create seekable reader for R2 upload (supports AWS SDK retries)\n\tseekableContent := bytes.NewReader(fileData)\n\n\t// Upload to storage\n\tif err := s.repo.Upload(ctx, fileAsset, seekableContent); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to upload file: %w\", err)\n\t}\n\n\treturn fileAsset, nil\n}\n\nfunc (s *fileService) DownloadFile(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) {\n\tcontent, fileAsset, err := s.repo.Download(ctx, id)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to download file: %w\", err)\n\t}\n\n\treturn content, fileAsset, nil\n}\n\nfunc (s *fileService) GetFile(ctx context.Context, id int32) (*FileAsset, error) {\n\treturn s.repo.GetByID(ctx, id)\n}\n\nfunc (s *fileService) DeleteFile(ctx context.Context, id int32) error {\n\texists, err := s.repo.Exists(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check file existence: %w\", err)\n\t}\n\n\tif !exists {\n\t\treturn fmt.Errorf(\"file not found\")\n\t}\n\n\treturn s.repo.Delete(ctx, id)\n}\n\nfunc (s *fileService) ListFiles(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) {\n\treturn s.repo.List(ctx, filter, limit, offset)\n}\n\nfunc (s *fileService) GetFileURL(ctx context.Context, id int32, expiryHours int) (string, error) {\n\tfmt.Printf(\"[FILE-SERVICE] ==============================================\\n\")\n\tfmt.Printf(\"[FILE-SERVICE] GetFileURL requested for file_id=%d, expiry=%dh\\n\", id, expiryHours)\n\n\tfmt.Printf(\"[FILE-SERVICE] Checking file existence...\\n\")\n\texists, err := s.repo.Exists(ctx, id)\n\tif err != nil {\n\t\tfmt.Printf(\"[FILE-SERVICE] Exists check failed: %v\\n\", err)\n\t\tfmt.Printf(\"[FILE-SERVICE] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[FILE-SERVICE] ===========================================\\n\")\n\t\treturn \"\", fmt.Errorf(\"failed to check file existence: %w\", err)\n\t}\n\n\tfmt.Printf(\"[FILE-SERVICE] File exists check result: %v\\n\", exists)\n\tif !exists {\n\t\tfmt.Printf(\"[FILE-SERVICE] File not found - returning error\\n\")\n\t\tfmt.Printf(\"[FILE-SERVICE] This could mean:\\n\")\n\t\tfmt.Printf(\"  - File ID %d doesn't exist in database\\n\", id)\n\t\tfmt.Printf(\"  - File exists in database but not in R2 storage\\n\")\n\t\tfmt.Printf(\"  - Storage path mismatch between database and R2\\n\")\n\t\tfmt.Printf(\"[FILE-SERVICE] ===========================================\\n\")\n\t\treturn \"\", fmt.Errorf(\"file not found\")\n\t}\n\n\tfmt.Printf(\"[FILE-SERVICE] File exists, generating \\n presigned URL...\\n\")\n\turl, err := s.repo.GetURL(ctx, id, expiryHours)\n\tif err != nil {\n\t\tfmt.Printf(\"[FILE-SERVICE] URL generation failed: %v\\n\", err)\n\t\tfmt.Printf(\"[FILE-SERVICE] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[FILE-SERVICE] ===========================================\\n\")\n\t\treturn \"\", err\n\t}\n\n\tfmt.Printf(\"[FILE-SERVICE] URL generation successful:\\n\")\n\tfmt.Printf(\"  - URL length: %d characters\\n\", len(url))\n\tfmt.Printf(\"  - URL: %s\\n\", url)\n\tfmt.Printf(\"[FILE-SERVICE] ===========================================\\n\")\n\treturn url, nil\n}\n\n// generateFilePath creates a logical path for organizing files\nfunc generateFilePath(category files.FileCategory, context files.FileContext, filename string) string {\n\ttimestamp := time.Now().Format(\"2006/01/02\")\n\treturn fmt.Sprintf(\"%s/%s/%s/%s\", category, context, timestamp, filename)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/domain/validator.go",
    "content": "package domain\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/gabriel-vasile/mimetype\"\n)\n\n// ValidateFileContent verifies that the actual file content matches the declared file type\n// based on magic bytes inspection. This prevents file extension spoofing attacks.\nfunc ValidateFileContent(reader io.Reader, filename string) error {\n\t// Detect actual MIME type from file content (magic bytes)\n\tmtype, err := mimetype.DetectReader(reader)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to detect file MIME type: %w\", err)\n\t}\n\n\t// Get file extension\n\text := strings.ToLower(filepath.Ext(filename))\n\n\t// Get allowed MIME types for this extension\n\tallowedMIMEs, ok := getAllowedMIMETypes(ext)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unsupported file extension: %s\", ext)\n\t}\n\n\t// Check if detected MIME type matches expected types\n\tdetectedMIME := mtype.String()\n\tfor _, allowed := range allowedMIMEs {\n\t\tif detectedMIME == allowed {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"file content type (%s) does not match extension (%s)\", detectedMIME, ext)\n}\n\n// getAllowedMIMETypes returns the list of allowed MIME types for a given file extension\nfunc getAllowedMIMETypes(ext string) ([]string, bool) {\n\t// Invoice-specific allowed MIME types\n\tmimeMap := map[string][]string{\n\t\t\".pdf\": {\n\t\t\t\"application/pdf\",\n\t\t},\n\t\t\".png\": {\n\t\t\t\"image/png\",\n\t\t},\n\t\t\".jpg\": {\n\t\t\t\"image/jpeg\",\n\t\t},\n\t\t\".jpeg\": {\n\t\t\t\"image/jpeg\",\n\t\t},\n\t}\n\n\tmimes, ok := mimeMap[ext]\n\treturn mimes, ok\n}\n\n// SanitizeFilename removes dangerous characters and path traversal attempts from filenames\n// to prevent security vulnerabilities.\nfunc SanitizeFilename(filename string) string {\n\t// Step 1: Remove any path components (prevents path traversal)\n\tfilename = filepath.Base(filename)\n\n\t// Step 2: Get extension before sanitization\n\text := filepath.Ext(filename)\n\tnameWithoutExt := strings.TrimSuffix(filename, ext)\n\n\t// Step 3: Remove dangerous characters, keep only safe ones\n\t// Allow: alphanumeric, dash, underscore, space\n\tsafePattern := regexp.MustCompile(`[^a-zA-Z0-9\\-_ ]`)\n\tnameWithoutExt = safePattern.ReplaceAllString(nameWithoutExt, \"_\")\n\n\t// Step 4: Remove multiple consecutive underscores or spaces\n\tmultipleUnderscores := regexp.MustCompile(`_+`)\n\tnameWithoutExt = multipleUnderscores.ReplaceAllString(nameWithoutExt, \"_\")\n\n\tmultipleSpaces := regexp.MustCompile(`\\s+`)\n\tnameWithoutExt = multipleSpaces.ReplaceAllString(nameWithoutExt, \" \")\n\n\t// Step 5: Trim leading/trailing whitespace and underscores\n\tnameWithoutExt = strings.Trim(nameWithoutExt, \" _\")\n\n\t// Step 6: If filename is empty after sanitization, use default\n\tif nameWithoutExt == \"\" {\n\t\tnameWithoutExt = \"file\"\n\t}\n\n\t// Step 7: Reconstruct filename with extension\n\tsanitized := nameWithoutExt + ext\n\n\t// Step 8: Limit total filename length to 255 characters (filesystem limit)\n\tmaxLength := 255\n\tif len(sanitized) > maxLength {\n\t\t// Keep the extension, truncate the name\n\t\textLen := len(ext)\n\t\tmaxNameLen := maxLength - extLen\n\t\tif maxNameLen > 0 {\n\t\t\tsanitized = sanitized[:maxNameLen] + ext\n\t\t} else {\n\t\t\t// If extension itself is too long (unlikely), just truncate\n\t\t\tsanitized = sanitized[:maxLength]\n\t\t}\n\t}\n\n\treturn sanitized\n}\n\n// IsInvoiceFileType checks if the file extension is allowed for invoice uploads\nfunc IsInvoiceFileType(filename string) bool {\n\text := strings.ToLower(filepath.Ext(filename))\n\tallowedExtensions := []string{\".pdf\", \".png\", \".jpg\", \".jpeg\"}\n\n\tfor _, allowed := range allowedExtensions {\n\t\tif ext == allowed {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/infra/file_metadata_repository.go",
    "content": "package infra\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\n\tfile_manager \"github.com/moasq/go-b2b-starter/internal/modules/files\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n)\n\n// fileMetadataRepository implements domain.FileMetadataRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype fileMetadataRepository struct {\n\tstore sqlc.Store\n}\n\n// NewFileMetadataRepository creates a new FileMetadataRepository implementation.\nfunc NewFileMetadataRepository(store sqlc.Store) domain.FileMetadataRepository {\n\treturn &fileMetadataRepository{store: store}\n}\n\nfunc (r *fileMetadataRepository) Create(ctx context.Context, file *domain.FileAsset) (*domain.FileAsset, error) {\n\t// Convert metadata map to JSON bytes\n\tmetadataBytes, err := json.Marshal(file.Metadata)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal metadata: %w\", err)\n\t}\n\n\t// Get category and context IDs\n\tcategoryID, err := r.getCategoryID(ctx, file.Category)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get category ID: %w\", err)\n\t}\n\n\tcontextID, err := r.getContextID(ctx, file.Context)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get context ID: %w\", err)\n\t}\n\n\tparams := sqlc.CreateFileAssetParams{\n\t\tFileName:         file.Filename,\n\t\tOriginalFileName: file.OriginalFilename,\n\t\tStoragePath:      file.StoragePath,\n\t\tBucketName:       file.BucketName,\n\t\tFileSize:         file.Size,\n\t\tMimeType:         file.ContentType,\n\t\tFileCategoryID:   categoryID,\n\t\tFileContextID:    contextID,\n\t\tIsPublic:         pgtype.Bool{Bool: file.IsPublic, Valid: true},\n\t\tEntityType:       pgtype.Text{String: file.EntityType, Valid: file.EntityType != \"\"},\n\t\tEntityID:         pgtype.Int4{Int32: file.EntityID, Valid: file.EntityID != 0},\n\t\tPurpose:          pgtype.Text{String: file.Purpose, Valid: file.Purpose != \"\"},\n\t\tMetadata:         metadataBytes,\n\t}\n\n\tdbFile, err := r.store.CreateFileAsset(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create file asset: %w\", err)\n\t}\n\n\treturn r.convertFromDBModel(&dbFile), nil\n}\n\nfunc (r *fileMetadataRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) {\n\tdbFile, err := r.store.GetFileAssetByID(ctx, id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file asset: %w\", err)\n\t}\n\n\treturn r.convertFromDBModel(&dbFile), nil\n}\n\nfunc (r *fileMetadataRepository) Update(ctx context.Context, file *domain.FileAsset) error {\n\tmetadataBytes, err := json.Marshal(file.Metadata)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal metadata: %w\", err)\n\t}\n\n\tparams := sqlc.UpdateFileAssetParams{\n\t\tID:          file.ID,\n\t\tFileName:    file.Filename,\n\t\tStoragePath: file.StoragePath,\n\t\tPurpose:     pgtype.Text{String: file.Purpose, Valid: file.Purpose != \"\"},\n\t\tMetadata:    metadataBytes,\n\t}\n\n\treturn r.store.UpdateFileAsset(ctx, params)\n}\n\nfunc (r *fileMetadataRepository) Delete(ctx context.Context, id int32) error {\n\treturn r.store.DeleteFileAsset(ctx, id)\n}\n\nfunc (r *fileMetadataRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) {\n\tparams := sqlc.ListFileAssetsParams{\n\t\tLimit:  int32(limit),\n\t\tOffset: int32(offset),\n\t}\n\n\trows, err := r.store.ListFileAssets(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list file assets: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(rows))\n\tfor i, row := range rows {\n\t\tfiles[i] = r.convertFromListRow(&row)\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *fileMetadataRepository) GetByStoragePath(ctx context.Context, storagePath string) (*domain.FileAsset, error) {\n\tdbFile, err := r.store.GetFileAssetByStoragePath(ctx, storagePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file asset by storage path: %w\", err)\n\t}\n\n\treturn r.convertFromDBModel(&dbFile), nil\n}\n\nfunc (r *fileMetadataRepository) GetByCategory(ctx context.Context, category string, limit, offset int) ([]*domain.FileAsset, error) {\n\trows, err := r.store.GetFileAssetsByCategory(ctx, category)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file assets by category: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(rows))\n\tfor i, row := range rows {\n\t\tfiles[i] = r.convertFromCategoryRow(&row)\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *fileMetadataRepository) GetByContext(ctx context.Context, fileContext string, limit, offset int) ([]*domain.FileAsset, error) {\n\trows, err := r.store.GetFileAssetsByContext(ctx, fileContext)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file assets by context: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(rows))\n\tfor i, row := range rows {\n\t\tfiles[i] = r.convertFromContextRow(&row)\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *fileMetadataRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) {\n\tparams := sqlc.GetFileAssetsByEntityParams{\n\t\tEntityType: pgtype.Text{String: entityType, Valid: true},\n\t\tEntityID:   pgtype.Int4{Int32: entityID, Valid: true},\n\t}\n\n\tdbFiles, err := r.store.GetFileAssetsByEntity(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file assets by entity: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(dbFiles))\n\tfor i, dbFile := range dbFiles {\n\t\tfiles[i] = r.convertFromDBModel(&dbFile)\n\t}\n\n\treturn files, nil\n}\n\n// Helper methods for conversion and lookup\n\nfunc (r *fileMetadataRepository) getCategoryID(ctx context.Context, category file_manager.FileCategory) (int16, error) {\n\tcategories, err := r.store.GetFileCategories(ctx)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfor _, cat := range categories {\n\t\tif cat.Name == string(category) {\n\t\t\treturn cat.ID, nil\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"category not found: %s\", category)\n}\n\nfunc (r *fileMetadataRepository) getContextID(ctx context.Context, fileContext file_manager.FileContext) (int16, error) {\n\tcontexts, err := r.store.GetFileContexts(ctx)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfor _, ctx := range contexts {\n\t\tif ctx.Name == string(fileContext) {\n\t\t\treturn ctx.ID, nil\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"context not found: %s\", fileContext)\n}\n\n// Conversion functions - translate SQLC types to domain types\n\nfunc (r *fileMetadataRepository) convertFromDBModel(dbFile *sqlc.FileManagerFileAsset) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(dbFile.Metadata) > 0 {\n\t\tjson.Unmarshal(dbFile.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif dbFile.EntityType.Valid {\n\t\tentityType = dbFile.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif dbFile.EntityID.Valid {\n\t\tentityID = dbFile.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif dbFile.Purpose.Valid {\n\t\tpurpose = dbFile.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif dbFile.IsPublic.Valid {\n\t\tisPublic = dbFile.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               dbFile.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         dbFile.FileName,\n\t\tOriginalFilename: dbFile.OriginalFileName,\n\t\tSize:             dbFile.FileSize,\n\t\tContentType:      dbFile.MimeType,\n\t\tStoragePath:      dbFile.StoragePath,\n\t\tBucketName:       dbFile.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        dbFile.CreatedAt.Time,\n\t\tUpdatedAt:        dbFile.UpdatedAt.Time,\n\t}\n}\n\nfunc (r *fileMetadataRepository) convertFromListRow(row *sqlc.ListFileAssetsRow) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(row.Metadata) > 0 {\n\t\tjson.Unmarshal(row.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif row.EntityType.Valid {\n\t\tentityType = row.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif row.EntityID.Valid {\n\t\tentityID = row.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif row.Purpose.Valid {\n\t\tpurpose = row.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif row.IsPublic.Valid {\n\t\tisPublic = row.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               row.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         row.FileName,\n\t\tOriginalFilename: row.OriginalFileName,\n\t\tSize:             row.FileSize,\n\t\tContentType:      row.MimeType,\n\t\tCategory:         file_manager.FileCategory(row.CategoryName),\n\t\tContext:          file_manager.FileContext(row.ContextName),\n\t\tStoragePath:      row.StoragePath,\n\t\tBucketName:       row.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        row.CreatedAt.Time,\n\t\tUpdatedAt:        row.UpdatedAt.Time,\n\t}\n}\n\nfunc (r *fileMetadataRepository) convertFromCategoryRow(row *sqlc.GetFileAssetsByCategoryRow) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(row.Metadata) > 0 {\n\t\tjson.Unmarshal(row.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif row.EntityType.Valid {\n\t\tentityType = row.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif row.EntityID.Valid {\n\t\tentityID = row.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif row.Purpose.Valid {\n\t\tpurpose = row.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif row.IsPublic.Valid {\n\t\tisPublic = row.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               row.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         row.FileName,\n\t\tOriginalFilename: row.OriginalFileName,\n\t\tSize:             row.FileSize,\n\t\tContentType:      row.MimeType,\n\t\tCategory:         file_manager.FileCategory(row.CategoryName),\n\t\tStoragePath:      row.StoragePath,\n\t\tBucketName:       row.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        row.CreatedAt.Time,\n\t\tUpdatedAt:        row.UpdatedAt.Time,\n\t}\n}\n\nfunc (r *fileMetadataRepository) convertFromContextRow(row *sqlc.GetFileAssetsByContextRow) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(row.Metadata) > 0 {\n\t\tjson.Unmarshal(row.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif row.EntityType.Valid {\n\t\tentityType = row.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif row.EntityID.Valid {\n\t\tentityID = row.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif row.Purpose.Valid {\n\t\tpurpose = row.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif row.IsPublic.Valid {\n\t\tisPublic = row.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               row.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         row.FileName,\n\t\tOriginalFilename: row.OriginalFileName,\n\t\tSize:             row.FileSize,\n\t\tContentType:      row.MimeType,\n\t\tContext:          file_manager.FileContext(row.ContextName),\n\t\tStoragePath:      row.StoragePath,\n\t\tBucketName:       row.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        row.CreatedAt.Time,\n\t\tUpdatedAt:        row.UpdatedAt.Time,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/internal/infra/composite_repository.go",
    "content": "package infra\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/config\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\tfile_manager \"github.com/moasq/go-b2b-starter/internal/modules/files\"\n)\n\ntype compositeRepository struct {\n\tr2Repo       domain.R2Repository\n\tmetadataRepo domain.FileMetadataRepository\n\tbucketName   string\n}\n\nfunc NewCompositeRepository(cfg *config.Config, r2Repo domain.R2Repository, metadataRepo domain.FileMetadataRepository) domain.FileRepository {\n\treturn &compositeRepository{\n\t\tr2Repo:       r2Repo,\n\t\tmetadataRepo: metadataRepo,\n\t\tbucketName:   cfg.R2.BucketName,\n\t}\n}\n\nfunc (r *compositeRepository) Upload(ctx context.Context, file *domain.FileAsset, content io.Reader) error {\n\tfmt.Printf(\"  - Filename: %s\\n\", file.Filename)\n\tfmt.Printf(\"  - Size: %d bytes\\n\", file.Size)\n\tfmt.Printf(\"  - Content Type: %s\\n\", file.ContentType)\n\tfmt.Printf(\"  - Category: %s\\n\", file.Category)\n\tfmt.Printf(\"  - Context: %s\\n\", file.Context)\n\t\n\t// Set default values\n\tfile.BucketName = r.bucketName\n\tfile.StoragePath = r.generateStoragePath(file.Category, file.Context, file.Filename)\n\t\n\tfmt.Printf(\"  - Bucket Name: %s\\n\", file.BucketName)\n\tfmt.Printf(\"  - Initial Storage Path: %s\\n\", file.StoragePath)\n\t\n\t// First, save metadata to get database ID\n\tsavedFile, err := r.metadataRepo.Create(ctx, file)\n\tif err != nil {\n\t\tfmt.Printf(\"[UPLOAD-ERROR] Failed to save file metadata: %v\\n\", err)\n\t\treturn fmt.Errorf(\"failed to save file metadata: %w\", err)\n\t}\n\t\n\tfmt.Printf(\"  - Assigned File ID: %d\\n\", savedFile.ID)\n\tfmt.Printf(\"  - Database Storage Path: %s\\n\", savedFile.StoragePath)\n\t\n\t// Use database ID as part of the R2 object key\n\tobjectKey := r.generateObjectKey(savedFile.ID, savedFile.Filename)\n\n\t// Upload to R2\n\tfmt.Printf(\"  - Bucket: %s\\n\", r.bucketName)\n\tfmt.Printf(\"  - Object Key: %s\\n\", objectKey)\n\tfmt.Printf(\"  - File Size: %d bytes\\n\", file.Size)\n\tfmt.Printf(\"  - Content Type: %s\\n\", file.ContentType)\n\t\n\terr = r.r2Repo.UploadObject(ctx, objectKey, content, file.Size, file.ContentType)\n\tif err != nil {\n\t\tfmt.Printf(\"[UPLOAD-ERROR] R2 upload failed: %v\\n\", err)\n\t\tfmt.Printf(\"[UPLOAD-ERROR] Rolling back database entry...\\n\")\n\t\t// Rollback: delete metadata if R2 upload fails\n\t\tr.metadataRepo.Delete(ctx, savedFile.ID)\n\t\treturn fmt.Errorf(\"failed to upload file to R2: %w\", err)\n\t}\n\n\t\n\t// Update storage path with the actual object key\n\tfmt.Printf(\"  - Old Path: %s\\n\", savedFile.StoragePath)\n\tfmt.Printf(\"  - New Path: %s\\n\", objectKey)\n\t\n\tsavedFile.StoragePath = objectKey\n\terr = r.metadataRepo.Update(ctx, savedFile)\n\tif err != nil {\n\t\tfmt.Printf(\"[UPLOAD-ERROR] Database storage path update failed: %v\\n\", err)\n\t\tfmt.Printf(\"[UPLOAD-ERROR] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[UPLOAD-ERROR] Rolling back R2 and database...\\n\")\n\t\t// Rollback: delete from R2 and metadata\n\t\tr.r2Repo.DeleteObject(ctx, objectKey)\n\t\tr.metadataRepo.Delete(ctx, savedFile.ID)\n\t\treturn fmt.Errorf(\"failed to update storage path: %w\", err)\n\t}\n\t\n\tfmt.Printf(\"[UPLOAD-SUCCESS] Database storage path updated successfully\\n\")\n\t\n\t// Update the original file with saved data\n\t*file = *savedFile\n\t\n\tfmt.Printf(\"[UPLOAD-SUCCESS] File upload completed successfully:\\n\")\n\tfmt.Printf(\"  - File ID: %d\\n\", savedFile.ID)\n\tfmt.Printf(\"  - Final Storage Path: %s\\n\", savedFile.StoragePath)\n\tfmt.Printf(\"  - Bucket: %s\\n\", savedFile.BucketName)\n\tfmt.Printf(\"[UPLOAD-SUCCESS] ============================================\\n\")\n\t\n\treturn nil\n}\n\nfunc (r *compositeRepository) Download(ctx context.Context, id int32) (io.ReadCloser, *domain.FileAsset, error) {\n\t// Get file metadata\n\tfile, err := r.metadataRepo.GetByID(ctx, id)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to get file metadata: %w\", err)\n\t}\n\n\t// Download from R2\n\tcontent, err := r.r2Repo.DownloadObject(ctx, file.StoragePath)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to download file from R2: %w\", err)\n\t}\n\n\treturn content, file, nil\n}\n\nfunc (r *compositeRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) {\n\treturn r.metadataRepo.GetByID(ctx, id)\n}\n\nfunc (r *compositeRepository) Delete(ctx context.Context, id int32) error {\n\t// Get file metadata first\n\tfile, err := r.metadataRepo.GetByID(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get file metadata: %w\", err)\n\t}\n\n\t// Delete from R2\n\terr = r.r2Repo.DeleteObject(ctx, file.StoragePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to delete file from R2: %w\", err)\n\t}\n\n\t// Delete metadata\n\terr = r.metadataRepo.Delete(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to delete file metadata: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *compositeRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) {\n\treturn r.metadataRepo.List(ctx, filter, limit, offset)\n}\n\nfunc (r *compositeRepository) GetURL(ctx context.Context, id int32, expiryHours int) (string, error) {\n\tfmt.Printf(\"[COMPOSITE-REPO] ==============================================\\n\")\n\tfmt.Printf(\"[COMPOSITE-REPO] GetURL requested for file_id=%d, expiry=%dh\\n\", id, expiryHours)\n\t\n\t// Get file metadata\n\tfmt.Printf(\"[COMPOSITE-REPO] Fetching file metadata from database...\\n\")\n\tfile, err := r.metadataRepo.GetByID(ctx, id)\n\tif err != nil {\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Failed to get file metadata: %v\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] ===========================================\\n\")\n\t\treturn \"\", fmt.Errorf(\"failed to get file metadata: %w\", err)\n\t}\n\t\n\tfmt.Printf(\"[COMPOSITE-REPO] File metadata retrieved:\\n\")\n\tfmt.Printf(\"  - Storage Path: %s\\n\", file.StoragePath)\n\tfmt.Printf(\"  - Bucket Name: %s\\n\", file.BucketName)\n\tfmt.Printf(\"  - File Name: %s\\n\", file.Filename)\n\n\t// Get presigned URL from R2\n\tfmt.Printf(\"[COMPOSITE-REPO] Generating R2 presigned URL...\\n\")\n\tfmt.Printf(\"[COMPOSITE-REPO] R2 parameters:\\n\")\n\tfmt.Printf(\"  - Bucket: %s\\n\", r.bucketName)\n\tfmt.Printf(\"  - Object Key: %s\\n\", file.StoragePath)\n\tfmt.Printf(\"  - Expiry: %d hours\\n\", expiryHours)\n\n\turl, err := r.r2Repo.GetPresignedURL(ctx, file.StoragePath, expiryHours)\n\tif err != nil {\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Failed to get presigned URL: %v\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] This could indicate:\\n\")\n\t\tfmt.Printf(\"  - R2 connection issues\\n\")\n\t\tfmt.Printf(\"  - Invalid bucket name: %s\\n\", r.bucketName)\n\t\tfmt.Printf(\"  - Invalid object key: %s\\n\", file.StoragePath)\n\t\tfmt.Printf(\"  - R2 authentication problems\\n\")\n\t\tfmt.Printf(\"  - R2 service issues\\n\")\n\t\tfmt.Printf(\"[COMPOSITE-REPO] ===========================================\\n\")\n\t\treturn \"\", fmt.Errorf(\"failed to get presigned URL: %w\", err)\n\t}\n\t\n\tfmt.Printf(\"[COMPOSITE-REPO] Presigned URL generated successfully:\\n\")\n\tfmt.Printf(\"  - URL length: %d characters\\n\", len(url))\n\tfmt.Printf(\"  - URL: %s\\n\", url)\n\tfmt.Printf(\"[COMPOSITE-REPO] ===========================================\\n\")\n\t\n\treturn url, nil\n}\n\nfunc (r *compositeRepository) Exists(ctx context.Context, id int32) (bool, error) {\n\tfmt.Printf(\"[COMPOSITE-REPO] ==============================================\\n\")\n\tfmt.Printf(\"[COMPOSITE-REPO] Checking existence for file_id=%d\\n\", id)\n\t\n\t// Check if metadata exists\n\tfmt.Printf(\"[COMPOSITE-REPO] Step 1: Checking file metadata in database...\\n\")\n\tfile, err := r.metadataRepo.GetByID(ctx, id)\n\tif err != nil {\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Metadata lookup failed: %v\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] This means file_id=%d does not exist in database\\n\", id)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] ===========================================\\n\")\n\t\treturn false, fmt.Errorf(\"failed to check file metadata: %w\", err) // FIX THE BUG\n\t}\n\t\n\tfmt.Printf(\"[COMPOSITE-REPO] Metadata found successfully:\\n\")\n\tfmt.Printf(\"  - File ID: %d\\n\", file.ID)\n\tfmt.Printf(\"  - Filename: %s\\n\", file.Filename)\n\tfmt.Printf(\"  - Storage Path: %s\\n\", file.StoragePath)\n\tfmt.Printf(\"  - Bucket Name: %s\\n\", file.BucketName)\n\tfmt.Printf(\"  - Content Type: %s\\n\", file.ContentType)\n\tfmt.Printf(\"  - File Size: %d bytes\\n\", file.Size)\n\n\t// Check if object exists in R2\n\tfmt.Printf(\"[COMPOSITE-REPO] Step 2: Checking object existence in R2...\\n\")\n\tfmt.Printf(\"[COMPOSITE-REPO] Looking for object: %s in bucket: %s\\n\", file.StoragePath, r.bucketName)\n\n\texists, err := r.r2Repo.ObjectExists(ctx, file.StoragePath)\n\tif err != nil {\n\t\tfmt.Printf(\"[COMPOSITE-REPO] R2 existence check failed: %v\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] This could indicate:\\n\")\n\t\tfmt.Printf(\"  - R2 connection problems\\n\")\n\t\tfmt.Printf(\"  - Incorrect bucket name: %s\\n\", r.bucketName)\n\t\tfmt.Printf(\"  - Incorrect object path: %s\\n\", file.StoragePath)\n\t\tfmt.Printf(\"  - R2 authentication issues\\n\")\n\t\tfmt.Printf(\"[COMPOSITE-REPO] ===========================================\\n\")\n\t\treturn false, fmt.Errorf(\"failed to check R2 object existence: %w\", err)\n\t}\n\n\tfmt.Printf(\"[COMPOSITE-REPO] R2 object existence check result: %v\\n\", exists)\n\tif !exists {\n\t\tfmt.Printf(\"[COMPOSITE-REPO] File metadata exists in database but object missing in R2\\n\")\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Expected object path: %s\\n\", file.StoragePath)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] Expected bucket: %s\\n\", r.bucketName)\n\t\tfmt.Printf(\"[COMPOSITE-REPO] This indicates a storage consistency issue\\n\")\n\t} else {\n\t\tfmt.Printf(\"[COMPOSITE-REPO] File exists in both database and R2 storage\\n\")\n\t}\n\t\n\tfmt.Printf(\"[COMPOSITE-REPO] ===========================================\\n\")\n\treturn exists, nil\n}\n\nfunc (r *compositeRepository) GetByCategory(ctx context.Context, category file_manager.FileCategory, limit, offset int) ([]*domain.FileAsset, error) {\n\treturn r.metadataRepo.GetByCategory(ctx, string(category), limit, offset)\n}\n\nfunc (r *compositeRepository) GetByContext(ctx context.Context, context file_manager.FileContext, limit, offset int) ([]*domain.FileAsset, error) {\n\treturn r.metadataRepo.GetByContext(ctx, string(context), limit, offset)\n}\n\nfunc (r *compositeRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) {\n\treturn r.metadataRepo.GetByEntity(ctx, entityType, entityID)\n}\n\n// Helper methods\nfunc (r *compositeRepository) generateStoragePath(category file_manager.FileCategory, context file_manager.FileContext, filename string) string {\n\ttimestamp := time.Now().Format(\"2006/01/02\")\n\treturn fmt.Sprintf(\"%s/%s/%s/%s\", category, context, timestamp, filename)\n}\n\nfunc (r *compositeRepository) generateObjectKey(id int32, filename string) string {\n\t// Use database ID as part of the object key for easy lookup\n\treturn fmt.Sprintf(\"files/%d/%s\", id, filename)\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/internal/infra/db_repository.go",
    "content": "package infra\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/jackc/pgx/v5/pgtype\"\n\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/db/adapters\"\n\tfile_manager \"github.com/moasq/go-b2b-starter/internal/modules/files\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n)\n\ntype dbRepository struct {\n\tstore adapters.FileAssetStore\n}\n\nfunc NewDBRepository(store adapters.FileAssetStore) domain.FileMetadataRepository {\n\treturn &dbRepository{\n\t\tstore: store,\n\t}\n}\n\nfunc (r *dbRepository) Create(ctx context.Context, file *domain.FileAsset) (*domain.FileAsset, error) {\n\t// Convert metadata map to JSON bytes\n\tmetadataBytes, err := json.Marshal(file.Metadata)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal metadata: %w\", err)\n\t}\n\n\t// Get category and context IDs\n\tcategoryID, err := r.getCategoryID(ctx, file.Category)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get category ID: %w\", err)\n\t}\n\n\tcontextID, err := r.getContextID(ctx, file.Context)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get context ID: %w\", err)\n\t}\n\n\tparams := sqlc.CreateFileAssetParams{\n\t\tFileName:         file.Filename,\n\t\tOriginalFileName: file.OriginalFilename,\n\t\tStoragePath:      file.StoragePath,\n\t\tBucketName:       file.BucketName,\n\t\tFileSize:         file.Size,\n\t\tMimeType:         file.ContentType,\n\t\tFileCategoryID:   categoryID,\n\t\tFileContextID:    contextID,\n\t\tIsPublic:         pgtype.Bool{Bool: file.IsPublic, Valid: true},\n\t\tEntityType:       pgtype.Text{String: file.EntityType, Valid: file.EntityType != \"\"},\n\t\tEntityID:         pgtype.Int4{Int32: file.EntityID, Valid: file.EntityID != 0},\n\t\tPurpose:          pgtype.Text{String: file.Purpose, Valid: file.Purpose != \"\"},\n\t\tMetadata:         metadataBytes,\n\t}\n\n\tdbFile, err := r.store.CreateFileAsset(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create file asset: %w\", err)\n\t}\n\n\treturn r.convertFromDBModel(&dbFile), nil\n}\n\nfunc (r *dbRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) {\n\tfmt.Printf(\"[DB-REPO] ==============================================\\n\")\n\tfmt.Printf(\"[DB-REPO] Querying file_asset table for id=%d\\n\", id)\n\n\tdbFile, err := r.store.GetFileAssetByID(ctx, id)\n\tif err != nil {\n\t\tfmt.Printf(\"[DB-REPO] Database query failed: %v\\n\", err)\n\t\tfmt.Printf(\"[DB-REPO] Error type: %T\\n\", err)\n\t\tfmt.Printf(\"[DB-REPO] This could mean:\\n\")\n\t\tfmt.Printf(\"  - File ID %d does not exist in database\\n\", id)\n\t\tfmt.Printf(\"  - Database connection problems\\n\")\n\t\tfmt.Printf(\"  - SQL query execution issues\\n\")\n\t\tfmt.Printf(\"  - Table or column structure problems\\n\")\n\t\tfmt.Printf(\"[DB-REPO] ===========================================\\n\")\n\t\treturn nil, fmt.Errorf(\"failed to get file asset: %w\", err)\n\t}\n\n\tfmt.Printf(\"[DB-REPO] File found in database:\\n\")\n\tfmt.Printf(\"  - ID: %d\\n\", dbFile.ID)\n\tfmt.Printf(\"  - Filename: %s\\n\", dbFile.FileName)\n\tfmt.Printf(\"  - Original Filename: %s\\n\", dbFile.OriginalFileName)\n\tfmt.Printf(\"  - Storage Path: %s\\n\", dbFile.StoragePath)\n\tfmt.Printf(\"  - Bucket Name: %s\\n\", dbFile.BucketName)\n\tfmt.Printf(\"  - File Size: %d bytes\\n\", dbFile.FileSize)\n\tfmt.Printf(\"  - MIME Type: %s\\n\", dbFile.MimeType)\n\tfmt.Printf(\"  - Created At: %v\\n\", dbFile.CreatedAt.Time)\n\tfmt.Printf(\"  - Updated At: %v\\n\", dbFile.UpdatedAt.Time)\n\n\tfile := r.convertFromDBModel(&dbFile)\n\tfmt.Printf(\"[DB-REPO] File conversion successful\\n\")\n\tfmt.Printf(\"[DB-REPO] ===========================================\\n\")\n\n\treturn file, nil\n}\n\nfunc (r *dbRepository) Update(ctx context.Context, file *domain.FileAsset) error {\n\tfmt.Printf(\"[DB-UPDATE] ==============================================\\n\")\n\tfmt.Printf(\"[DB-UPDATE] Updating file asset in database\\n\")\n\tfmt.Printf(\"[DB-UPDATE] File details:\\n\")\n\tfmt.Printf(\"  - File ID: %d\\n\", file.ID)\n\tfmt.Printf(\"  - Filename: %s\\n\", file.Filename)\n\tfmt.Printf(\"  - Storage Path: %s\\n\", file.StoragePath)\n\tfmt.Printf(\"  - Purpose: %s\\n\", file.Purpose)\n\n\tmetadataBytes, err := json.Marshal(file.Metadata)\n\tif err != nil {\n\t\tfmt.Printf(\"[DB-UPDATE-ERROR] Failed to marshal metadata: %v\\n\", err)\n\t\treturn fmt.Errorf(\"failed to marshal metadata: %w\", err)\n\t}\n\n\tparams := sqlc.UpdateFileAssetParams{\n\t\tID:          file.ID,\n\t\tFileName:    file.Filename,\n\t\tStoragePath: file.StoragePath, // FIX: Add missing StoragePath field\n\t\tPurpose:     pgtype.Text{String: file.Purpose, Valid: file.Purpose != \"\"},\n\t\tMetadata:    metadataBytes,\n\t}\n\n\tfmt.Printf(\"[DB-UPDATE] Executing database update...\\n\")\n\tfmt.Printf(\"  - Updating Storage Path to: %s\\n\", file.StoragePath)\n\n\terr = r.store.UpdateFileAsset(ctx, params)\n\tif err != nil {\n\t\tfmt.Printf(\"[DB-UPDATE-ERROR] Database update failed: %v\\n\", err)\n\t\tfmt.Printf(\"[DB-UPDATE-ERROR] Error type: %T\\n\", err)\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"[DB-UPDATE-SUCCESS] Database update completed successfully\\n\")\n\tfmt.Printf(\"[DB-UPDATE-SUCCESS] ==========================================\\n\")\n\n\treturn nil\n}\n\nfunc (r *dbRepository) Delete(ctx context.Context, id int32) error {\n\treturn r.store.DeleteFileAsset(ctx, id)\n}\n\nfunc (r *dbRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) {\n\tparams := sqlc.ListFileAssetsParams{\n\t\tLimit:  int32(limit),\n\t\tOffset: int32(offset),\n\t}\n\n\trows, err := r.store.ListFileAssets(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list file assets: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(rows))\n\tfor i, row := range rows {\n\t\tfiles[i] = r.convertFromListRow(&row)\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *dbRepository) GetByStoragePath(ctx context.Context, storagePath string) (*domain.FileAsset, error) {\n\tdbFile, err := r.store.GetFileAssetByStoragePath(ctx, storagePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file asset by storage path: %w\", err)\n\t}\n\n\treturn r.convertFromDBModel(&dbFile), nil\n}\n\nfunc (r *dbRepository) GetByCategory(ctx context.Context, category string, limit, offset int) ([]*domain.FileAsset, error) {\n\trows, err := r.store.GetFileAssetsByCategory(ctx, category)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file assets by category: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(rows))\n\tfor i, row := range rows {\n\t\tfiles[i] = r.convertFromCategoryRow(&row)\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *dbRepository) GetByContext(ctx context.Context, context string, limit, offset int) ([]*domain.FileAsset, error) {\n\trows, err := r.store.GetFileAssetsByContext(ctx, context)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file assets by context: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(rows))\n\tfor i, row := range rows {\n\t\tfiles[i] = r.convertFromContextRow(&row)\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *dbRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) {\n\tparams := sqlc.GetFileAssetsByEntityParams{\n\t\tEntityType: pgtype.Text{String: entityType, Valid: true},\n\t\tEntityID:   pgtype.Int4{Int32: entityID, Valid: true},\n\t}\n\n\tdbFiles, err := r.store.GetFileAssetsByEntity(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file assets by entity: %w\", err)\n\t}\n\n\tfiles := make([]*domain.FileAsset, len(dbFiles))\n\tfor i, dbFile := range dbFiles {\n\t\tfiles[i] = r.convertFromDBModel(&dbFile)\n\t}\n\n\treturn files, nil\n}\n\n// Helper methods for conversion and lookup\nfunc (r *dbRepository) getCategoryID(ctx context.Context, category file_manager.FileCategory) (int16, error) {\n\tcategories, err := r.store.GetFileCategories(ctx)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfor _, cat := range categories {\n\t\tif cat.Name == string(category) {\n\t\t\treturn cat.ID, nil\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"category not found: %s\", category)\n}\n\nfunc (r *dbRepository) getContextID(ctx context.Context, context file_manager.FileContext) (int16, error) {\n\tcontexts, err := r.store.GetFileContexts(ctx)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfor _, ctx := range contexts {\n\t\tif ctx.Name == string(context) {\n\t\t\treturn ctx.ID, nil\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"context not found: %s\", context)\n}\n\nfunc (r *dbRepository) convertFromDBModel(dbFile *sqlc.FileManagerFileAsset) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(dbFile.Metadata) > 0 {\n\t\tjson.Unmarshal(dbFile.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif dbFile.EntityType.Valid {\n\t\tentityType = dbFile.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif dbFile.EntityID.Valid {\n\t\tentityID = dbFile.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif dbFile.Purpose.Valid {\n\t\tpurpose = dbFile.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif dbFile.IsPublic.Valid {\n\t\tisPublic = dbFile.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               dbFile.ID,\n\t\tUUID:             uuid.New(), // Generate UUID for external reference\n\t\tFilename:         dbFile.FileName,\n\t\tOriginalFilename: dbFile.OriginalFileName,\n\t\tSize:             dbFile.FileSize,\n\t\tContentType:      dbFile.MimeType,\n\t\tStoragePath:      dbFile.StoragePath,\n\t\tBucketName:       dbFile.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        dbFile.CreatedAt.Time,\n\t\tUpdatedAt:        dbFile.UpdatedAt.Time,\n\t}\n}\n\nfunc (r *dbRepository) convertFromListRow(row *sqlc.ListFileAssetsRow) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(row.Metadata) > 0 {\n\t\tjson.Unmarshal(row.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif row.EntityType.Valid {\n\t\tentityType = row.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif row.EntityID.Valid {\n\t\tentityID = row.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif row.Purpose.Valid {\n\t\tpurpose = row.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif row.IsPublic.Valid {\n\t\tisPublic = row.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               row.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         row.FileName,\n\t\tOriginalFilename: row.OriginalFileName,\n\t\tSize:             row.FileSize,\n\t\tContentType:      row.MimeType,\n\t\tCategory:         file_manager.FileCategory(row.CategoryName),\n\t\tContext:          file_manager.FileContext(row.ContextName),\n\t\tStoragePath:      row.StoragePath,\n\t\tBucketName:       row.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        row.CreatedAt.Time,\n\t\tUpdatedAt:        row.UpdatedAt.Time,\n\t}\n}\n\nfunc (r *dbRepository) convertFromCategoryRow(row *sqlc.GetFileAssetsByCategoryRow) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(row.Metadata) > 0 {\n\t\tjson.Unmarshal(row.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif row.EntityType.Valid {\n\t\tentityType = row.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif row.EntityID.Valid {\n\t\tentityID = row.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif row.Purpose.Valid {\n\t\tpurpose = row.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif row.IsPublic.Valid {\n\t\tisPublic = row.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               row.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         row.FileName,\n\t\tOriginalFilename: row.OriginalFileName,\n\t\tSize:             row.FileSize,\n\t\tContentType:      row.MimeType,\n\t\tCategory:         file_manager.FileCategory(row.CategoryName),\n\t\tStoragePath:      row.StoragePath,\n\t\tBucketName:       row.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        row.CreatedAt.Time,\n\t\tUpdatedAt:        row.UpdatedAt.Time,\n\t}\n}\n\nfunc (r *dbRepository) convertFromContextRow(row *sqlc.GetFileAssetsByContextRow) *domain.FileAsset {\n\tvar metadata map[string]interface{}\n\tif len(row.Metadata) > 0 {\n\t\tjson.Unmarshal(row.Metadata, &metadata)\n\t}\n\n\tvar entityType string\n\tif row.EntityType.Valid {\n\t\tentityType = row.EntityType.String\n\t}\n\n\tvar entityID int32\n\tif row.EntityID.Valid {\n\t\tentityID = row.EntityID.Int32\n\t}\n\n\tvar purpose string\n\tif row.Purpose.Valid {\n\t\tpurpose = row.Purpose.String\n\t}\n\n\tvar isPublic bool\n\tif row.IsPublic.Valid {\n\t\tisPublic = row.IsPublic.Bool\n\t}\n\n\treturn &domain.FileAsset{\n\t\tID:               row.ID,\n\t\tUUID:             uuid.New(),\n\t\tFilename:         row.FileName,\n\t\tOriginalFilename: row.OriginalFileName,\n\t\tSize:             row.FileSize,\n\t\tContentType:      row.MimeType,\n\t\tContext:          file_manager.FileContext(row.ContextName),\n\t\tStoragePath:      row.StoragePath,\n\t\tBucketName:       row.BucketName,\n\t\tIsPublic:         isPublic,\n\t\tEntityType:       entityType,\n\t\tEntityID:         entityID,\n\t\tPurpose:          purpose,\n\t\tMetadata:         metadata,\n\t\tCreatedAt:        row.CreatedAt.Time,\n\t\tUpdatedAt:        row.UpdatedAt.Time,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/internal/infra/mock_r2_repository.go",
    "content": "package infra\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\ntype mockR2Repository struct {\n\tlogger logger.Logger\n}\n\n// NewMockR2Repository creates a mock R2 repository for development mode\n// This repository simulates R2 operations without actual cloud storage\nfunc NewMockR2Repository(log logger.Logger) domain.R2Repository {\n\treturn &mockR2Repository{\n\t\tlogger: log,\n\t}\n}\n\nfunc (m *mockR2Repository) UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error {\n\tm.logger.Warn(\"Mock R2: Simulating file upload (no actual storage)\", map[string]any{\n\t\t\"object_key\":   objectKey,\n\t\t\"size\":         size,\n\t\t\"content_type\": contentType,\n\t})\n\n\t// Drain the reader to simulate upload\n\tio.Copy(io.Discard, content)\n\n\treturn nil\n}\n\nfunc (m *mockR2Repository) DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) {\n\tm.logger.Warn(\"Mock R2: Simulating file download (returning empty content)\", map[string]any{\n\t\t\"object_key\": objectKey,\n\t})\n\n\t// Return empty reader\n\treturn io.NopCloser(strings.NewReader(\"\")), nil\n}\n\nfunc (m *mockR2Repository) DeleteObject(ctx context.Context, objectKey string) error {\n\tm.logger.Warn(\"Mock R2: Simulating file deletion (no actual storage)\", map[string]any{\n\t\t\"object_key\": objectKey,\n\t})\n\n\treturn nil\n}\n\nfunc (m *mockR2Repository) GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) {\n\tm.logger.Warn(\"Mock R2: Generating mock presigned URL\", map[string]any{\n\t\t\"object_key\":   objectKey,\n\t\t\"expiry_hours\": expiryHours,\n\t})\n\n\t// Return a mock URL\n\treturn fmt.Sprintf(\"https://mock-r2-storage.example.com/%s?expires=%dh\", objectKey, expiryHours), nil\n}\n\nfunc (m *mockR2Repository) ObjectExists(ctx context.Context, objectKey string) (bool, error) {\n\tm.logger.Warn(\"Mock R2: Checking object existence (always returns true)\", map[string]any{\n\t\t\"object_key\": objectKey,\n\t})\n\n\t// Always return true for mock\n\treturn true, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/files/internal/infra/r2_repository.go",
    "content": "package infra\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/config\"\n\t\"github.com/aws/aws-sdk-go-v2/credentials\"\n\t\"github.com/aws/aws-sdk-go-v2/service/s3\"\n\t\"github.com/aws/smithy-go\"\n\n\tfileconfig \"github.com/moasq/go-b2b-starter/internal/modules/files/config\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/files/domain\"\n)\n\ntype r2Repository struct {\n\tclient     *s3.Client\n\tbucketName string\n}\n\nfunc NewR2Repository(cfg *fileconfig.Config) (domain.R2Repository, error) {\n\t// Create custom AWS config for R2\n\tr2Cfg, err := config.LoadDefaultConfig(context.Background(),\n\t\tconfig.WithRegion(cfg.R2.Region), // Always \"auto\" for R2\n\t\tconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(\n\t\t\tcfg.R2.AccessKeyID,\n\t\t\tcfg.R2.SecretAccessKey,\n\t\t\t\"\", // No session token needed\n\t\t)),\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load R2 config: %w\", err)\n\t}\n\n\t// Create S3 client with R2 endpoint\n\tclient := s3.NewFromConfig(r2Cfg, func(o *s3.Options) {\n\t\t// R2 endpoint format: https://<account_id>.r2.cloudflarestorage.com\n\t\to.BaseEndpoint = aws.String(fmt.Sprintf(\"https://%s.r2.cloudflarestorage.com\",\n\t\t\tcfg.R2.AccountID))\n\n\t\t// R2 only supports path-style URLs (not virtual-host style)\n\t\to.UsePathStyle = true\n\t})\n\n\trepo := &r2Repository{\n\t\tclient:     client,\n\t\tbucketName: cfg.R2.BucketName,\n\t}\n\n\t// Ensure bucket exists (R2 doesn't auto-create buckets)\n\tif err := repo.ensureBucket(context.Background()); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to ensure R2 bucket exists: %w\", err)\n\t}\n\n\treturn repo, nil\n}\n\n// ensureBucket checks if bucket exists (R2 requires manual bucket creation)\nfunc (r *r2Repository) ensureBucket(ctx context.Context) error {\n\t_, err := r.client.HeadBucket(ctx, &s3.HeadBucketInput{\n\t\tBucket: aws.String(r.bucketName),\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"bucket '%s' does not exist in R2. Please create it manually in Cloudflare dashboard: %w\",\n\t\t\tr.bucketName, err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *r2Repository) UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error {\n\t_, err := r.client.PutObject(ctx, &s3.PutObjectInput{\n\t\tBucket:        aws.String(r.bucketName),\n\t\tKey:           aws.String(objectKey),\n\t\tBody:          content,\n\t\tContentLength: aws.Int64(size),\n\t\tContentType:   aws.String(contentType),\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to upload object to R2: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// DownloadObject downloads a file from R2\nfunc (r *r2Repository) DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) {\n\tresult, err := r.client.GetObject(ctx, &s3.GetObjectInput{\n\t\tBucket: aws.String(r.bucketName),\n\t\tKey:    aws.String(objectKey),\n\t})\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get object from R2: %w\", err)\n\t}\n\n\treturn result.Body, nil\n}\n\nfunc (r *r2Repository) DeleteObject(ctx context.Context, objectKey string) error {\n\t_, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{\n\t\tBucket: aws.String(r.bucketName),\n\t\tKey:    aws.String(objectKey),\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to delete object from R2: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// GetPresignedURL generates a presigned URL for temporary access\nfunc (r *r2Repository) GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) {\n\t// Create presign client\n\tpresignClient := s3.NewPresignClient(r.client)\n\n\t// Generate presigned URL\n\trequest, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{\n\t\tBucket: aws.String(r.bucketName),\n\t\tKey:    aws.String(objectKey),\n\t}, func(opts *s3.PresignOptions) {\n\t\topts.Expires = time.Duration(expiryHours) * time.Hour\n\t})\n\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to generate R2 presigned URL: %w\", err)\n\t}\n\n\treturn request.URL, nil\n}\n\n// ObjectExists checks if an object exists in R2\nfunc (r *r2Repository) ObjectExists(ctx context.Context, objectKey string) (bool, error) {\n\t_, err := r.client.HeadObject(ctx, &s3.HeadObjectInput{\n\t\tBucket: aws.String(r.bucketName),\n\t\tKey:    aws.String(objectKey),\n\t})\n\n\tif err != nil {\n\t\t// Check if error is \"NotFound\" using AWS SDK error handling\n\t\tvar apiErr smithy.APIError\n\t\tif errors.As(err, &apiErr) {\n\t\t\tif apiErr.ErrorCode() == \"NotFound\" || apiErr.ErrorCode() == \"NoSuchKey\" {\n\t\t\t\treturn false, nil\n\t\t\t}\n\t\t}\n\t\treturn false, fmt.Errorf(\"failed to check R2 object existence: %w\", err)\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/account_handler.go",
    "content": "package organizations\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\t\"github.com/moasq/go-b2b-starter/pkg/response\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\ntype AccountHandler struct {\n\torgService services.OrganizationService\n\tlogger     logger.Logger\n}\n\nfunc NewAccountHandler(orgService services.OrganizationService, logger logger.Logger) *AccountHandler {\n\treturn &AccountHandler{\n\t\torgService: orgService,\n\t\tlogger:     logger,\n\t}\n}\n\n// CreateAccount creates a new account in an organization\nfunc (h *AccountHandler) CreateAccount(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tvar req services.CreateAccountRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"invalid request payload\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid request payload\", err)\n\t\treturn\n\t}\n\n\tdomainReq := &req\n\taccount, err := h.orgService.CreateAccount(c.Request.Context(), reqCtx.OrganizationID, domainReq)\n\tif err != nil {\n\t\tif err == domain.ErrOrganizationNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"organization not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to create account\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to create account\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusCreated, account)\n}\n\n// GetAccount gets an account by ID\nfunc (h *AccountHandler) GetAccount(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\t// Extract account_id from path parameter\n\taccountIDParam := c.Param(\"id\")\n\tvar accountID int32\n\tif _, err := fmt.Sscanf(accountIDParam, \"%d\", &accountID); err != nil {\n\t\th.logger.Error(\"invalid account ID\", map[string]interface{}{\"id\": accountIDParam, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid account ID format\", err)\n\t\treturn\n\t}\n\n\taccount, err := h.orgService.GetAccount(c.Request.Context(), reqCtx.OrganizationID, accountID)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to get account\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"account_id\": accountID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to get account\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, account)\n}\n\n// GetAccountByEmail gets an account by email\nfunc (h *AccountHandler) GetAccountByEmail(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\temail := c.Query(\"email\")\n\tif email == \"\" {\n\t\tresponse.Error(c, http.StatusBadRequest, \"email query parameter is required\", nil)\n\t\treturn\n\t}\n\n\taccount, err := h.orgService.GetAccountByEmail(c.Request.Context(), reqCtx.OrganizationID, email)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to get account by email\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"email\": email, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to get account\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, account)\n}\n\n// ListAccounts lists all accounts in an organization\nfunc (h *AccountHandler) ListAccounts(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\taccounts, err := h.orgService.ListAccounts(c.Request.Context(), reqCtx.OrganizationID)\n\tif err != nil {\n\t\tif err == domain.ErrOrganizationNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"organization not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to list accounts\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to list accounts\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, accounts)\n}\n\n// UpdateAccount updates an account\nfunc (h *AccountHandler) UpdateAccount(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\t// Extract account_id from path parameter\n\taccountIDParam := c.Param(\"id\")\n\tvar accountID int32\n\tif _, err := fmt.Sscanf(accountIDParam, \"%d\", &accountID); err != nil {\n\t\th.logger.Error(\"invalid account ID\", map[string]interface{}{\"id\": accountIDParam, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid account ID format\", err)\n\t\treturn\n\t}\n\n\tvar req services.UpdateAccountRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"invalid request payload\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid request payload\", err)\n\t\treturn\n\t}\n\n\tdomainReq := &req\n\taccount, err := h.orgService.UpdateAccount(c.Request.Context(), reqCtx.OrganizationID, accountID, domainReq)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to update account\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"account_id\": accountID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to update account\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, account)\n}\n\nfunc (h *AccountHandler) DeleteAccount(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\t// Extract account_id from path parameter\n\taccountIDParam := c.Param(\"id\")\n\tvar accountID int32\n\tif _, err := fmt.Sscanf(accountIDParam, \"%d\", &accountID); err != nil {\n\t\th.logger.Error(\"invalid account ID\", map[string]interface{}{\"id\": accountIDParam, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid account ID format\", err)\n\t\treturn\n\t}\n\n\terr := h.orgService.DeleteAccount(c.Request.Context(), reqCtx.OrganizationID, accountID)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to delete account\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"account_id\": accountID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to delete account\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusNoContent, nil)\n}\n\n// UpdateAccountLastLogin updates account last login timestamp\nfunc (h *AccountHandler) UpdateAccountLastLogin(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\t// Extract account_id from path parameter\n\taccountIDParam := c.Param(\"id\")\n\tvar accountID int32\n\tif _, err := fmt.Sscanf(accountIDParam, \"%d\", &accountID); err != nil {\n\t\th.logger.Error(\"invalid account ID\", map[string]interface{}{\"id\": accountIDParam, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid account ID format\", err)\n\t\treturn\n\t}\n\n\taccount, err := h.orgService.UpdateAccountLastLogin(c.Request.Context(), reqCtx.OrganizationID, accountID)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to update account last login\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"account_id\": accountID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to update account last login\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, account)\n}\n\nfunc (h *AccountHandler) CheckAccountPermission(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\t// Extract account_id from path parameter\n\taccountIDParam := c.Param(\"id\")\n\tvar accountID int32\n\tif _, err := fmt.Sscanf(accountIDParam, \"%d\", &accountID); err != nil {\n\t\th.logger.Error(\"invalid account ID\", map[string]interface{}{\"id\": accountIDParam, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid account ID format\", err)\n\t\treturn\n\t}\n\n\tpermission, err := h.orgService.CheckAccountPermission(c.Request.Context(), reqCtx.OrganizationID, accountID)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to check account permission\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"account_id\": accountID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to check account permission\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, permission)\n}\n\n// GetAccountStats gets account statistics\nfunc (h *AccountHandler) GetAccountStats(c *gin.Context) {\n\t// Extract account_id from path parameter\n\taccountIDParam := c.Param(\"id\")\n\tvar accountID int32\n\tif _, err := fmt.Sscanf(accountIDParam, \"%d\", &accountID); err != nil {\n\t\th.logger.Error(\"invalid account ID\", map[string]interface{}{\"id\": accountIDParam, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid account ID format\", err)\n\t\treturn\n\t}\n\n\tstats, err := h.orgService.GetAccountStats(c.Request.Context(), accountID)\n\tif err != nil {\n\t\tif err == domain.ErrAccountNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"account not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to get account stats\", map[string]interface{}{\"account_id\": accountID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to get account stats\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, stats)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/app/services/member_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// MemberService defines the core authentication and member management operations\n// This interface focuses on organization bootstrap and member operations\ntype MemberService interface {\n\t// BootstrapOrganizationWithOwner creates a new organization with an initial owner user\n\t// This is the primary signup flow for new organizations\n\tBootstrapOrganizationWithOwner(ctx context.Context, req *BootstrapOrganizationRequest) (*BootstrapOrganizationResponse, error)\n\n\t// AddMemberDirect adds a new member to an existing organization without invitation\n\t// Creates the user if they don't exist, then adds them to the organization with specified roles\n\tAddMemberDirect(ctx context.Context, req *AddMemberRequest) (*AddMemberResponse, error)\n\n\t// ListOrganizationMembers retrieves all members of an organization\n\t// Returns a list of members with their details including roles and status\n\tListOrganizationMembers(ctx context.Context, orgID string) (*ListMembersResponse, error)\n\n\t// GetCurrentUserProfile retrieves the current authenticated user's profile\n\t// Returns comprehensive profile information including member, organization, and account details\n\tGetCurrentUserProfile(ctx context.Context, orgID, memberID, email string) (*ProfileResponse, error)\n\n\t// DeleteOrganizationMember removes a member from the organization (admin only)\n\t// Deletes from both auth provider and internal database\n\tDeleteOrganizationMember(ctx context.Context, orgID, memberID string) error\n\n\t// CheckEmailExists checks if an email exists in the system\n\t// Returns true if email is found, false otherwise\n\t// Used for login flow to verify if user has an account\n\tCheckEmailExists(ctx context.Context, email string) (bool, error)\n}\n\n// BootstrapOrganizationRequest represents the request to create a new organization with an owner\ntype BootstrapOrganizationRequest struct {\n\t// Organization details\n\tOrgDisplayName string `json:\"org_display_name\" binding:\"required\"`\n\n\t// Owner member details\n\tOwnerEmail string `json:\"owner_email\" binding:\"required,email\"`\n\tOwnerName  string `json:\"owner_name\" binding:\"required\"`\n}\n\n// Validate performs business validation on the bootstrap request\nfunc (r *BootstrapOrganizationRequest) Validate() error {\n\tif strings.TrimSpace(r.OrgDisplayName) == \"\" {\n\t\treturn fmt.Errorf(\"organization display name cannot be empty\")\n\t}\n\tif strings.TrimSpace(r.OwnerEmail) == \"\" {\n\t\treturn fmt.Errorf(\"owner email cannot be empty\")\n\t}\n\tif strings.TrimSpace(r.OwnerName) == \"\" {\n\t\treturn fmt.Errorf(\"owner name cannot be empty\")\n\t}\n\treturn nil\n}\n\n// BootstrapOrganizationResponse represents the response after organization bootstrap\ntype BootstrapOrganizationResponse struct {\n\tOrganizationID string `json:\"organization_id\"`\n\tOrgSlug        string `json:\"org_slug\"`\n\tDisplayName    string `json:\"display_name\"`\n\tOwnerMemberID  string `json:\"owner_member_id\"`\n\tOwnerEmail     string `json:\"owner_email\"`\n\tOwnerName      string `json:\"owner_name\"`\n\tInviteSent     bool   `json:\"invite_sent\"`\n\tMagicLinkSent  bool   `json:\"magic_link_sent\"`\n}\n\n// AddMemberRequest represents the request to add a member to an organization\ntype AddMemberRequest struct {\n\t// Organization context (populated by handler from JWT middleware, not from request body)\n\tOrgID string `json:\"-\"`\n\n\t// Member user details\n\tEmail string `json:\"email\" binding:\"required,email\"`\n\tName  string `json:\"name\" binding:\"required\"`\n\n\t// Role assignment (single role per member)\n\tRoleSlug string `json:\"role_slug\"`\n}\n\n// Validate performs business validation on the add member request\nfunc (r *AddMemberRequest) Validate() error {\n\tif strings.TrimSpace(r.Email) == \"\" {\n\t\treturn fmt.Errorf(\"email cannot be empty\")\n\t}\n\tif strings.TrimSpace(r.Name) == \"\" {\n\t\treturn fmt.Errorf(\"name cannot be empty\")\n\t}\n\t// Note: OrgID is validated by handler (extracted from JWT middleware)\n\treturn nil\n}\n\n// AddMemberResponse represents the response after adding a member\ntype AddMemberResponse struct {\n\tMemberID   string `json:\"member_id\"`\n\tEmail      string `json:\"email\"`\n\tName       string `json:\"name\"`\n\tOrgID      string `json:\"org_id\"`\n\tRoleSlug   string `json:\"role_slug\"`\n\tInviteSent bool   `json:\"invite_sent\"`\n}\n\n// MemberInfo represents a member in the list response\ntype MemberInfo struct {\n\tMemberID      string   `json:\"member_id\"`\n\tEmail         string   `json:\"email\"`\n\tName          string   `json:\"name\"`\n\tRoles         []string `json:\"roles\"`\n\tStatus        string   `json:\"status\"`\n\tEmailVerified bool     `json:\"email_verified\"`\n\tCreatedAt     string   `json:\"created_at\"`\n\tUpdatedAt     string   `json:\"updated_at\"`\n}\n\n// ListMembersResponse represents the response for listing organization members\ntype ListMembersResponse struct {\n\tMembers []*MemberInfo `json:\"members\"`\n\tTotal   int           `json:\"total\"`\n}\n\n// ProfileResponse represents the current user's profile information\n// This is a composite response combining auth provider member data + internal account + organization\ntype ProfileResponse struct {\n\t// Auth provider member details\n\tMemberID      string   `json:\"member_id\"`\n\tEmail         string   `json:\"email\"`\n\tName          string   `json:\"name\"`\n\tRoles         []string `json:\"roles\"`\n\tPermissions   []string `json:\"permissions\"`\n\tEmailVerified bool     `json:\"email_verified\"`\n\tStatus        string   `json:\"status\"`\n\n\t// Organization details\n\tOrganization ProfileOrganization `json:\"organization\"`\n\n\t// Internal account details\n\tAccountID int32  `json:\"account_id\"`\n\tCreatedAt string `json:\"created_at\"`\n\tUpdatedAt string `json:\"updated_at\"`\n}\n\n// ProfileOrganization represents organization info in profile response\ntype ProfileOrganization struct {\n\tOrganizationID string `json:\"organization_id\"`\n\tSlug           string `json:\"slug\"`\n\tName           string `json:\"name\"`\n\tStatus         string `json:\"status\"`\n}\n\n// CheckEmailRequest represents the request to check if an email exists\n// Used for login flow to verify if user has an account\ntype CheckEmailRequest struct {\n\tEmail string `form:\"email\" binding:\"required,email\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/app/services/member_service_impl.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\n// rollbackFunc represents a function that can rollback a created resource\ntype rollbackFunc func(context.Context) error\n\n// rollbackStack manages rollback functions in LIFO order\ntype rollbackStack []rollbackFunc\n\n// add appends a rollback function to the stack\nfunc (rs *rollbackStack) add(fn rollbackFunc) {\n\t*rs = append(*rs, fn)\n}\n\n// execute runs all rollback functions in reverse order (LIFO)\nfunc (rs rollbackStack) execute(ctx context.Context, logger loggerDomain.Logger) {\n\t// Execute in reverse order (LIFO - Last In First Out)\n\tfor i := len(rs) - 1; i >= 0; i-- {\n\t\tif err := rs[i](ctx); err != nil {\n\t\t\tlogger.Error(\"rollback operation failed\", loggerDomain.Fields{\n\t\t\t\t\"step\":  i,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\t// Continue with remaining rollbacks even if one fails\n\t\t}\n\t}\n}\n\ntype memberService struct {\n\tauthOrgRepo      domain.AuthOrganizationRepository\n\tauthMemberRepo   domain.AuthMemberRepository\n\tauthRoleRepo     domain.AuthRoleRepository\n\tlocalOrgRepo     domain.OrganizationRepository\n\tlocalAccountRepo domain.AccountRepository\n\tlogger           loggerDomain.Logger\n}\n\nfunc NewMemberService(\n\tauthOrgRepo domain.AuthOrganizationRepository,\n\tauthMemberRepo domain.AuthMemberRepository,\n\tauthRoleRepo domain.AuthRoleRepository,\n\tlocalOrgRepo domain.OrganizationRepository,\n\tlocalAccountRepo domain.AccountRepository,\n\tlogger loggerDomain.Logger,\n) MemberService {\n\treturn &memberService{\n\t\tauthOrgRepo:      authOrgRepo,\n\t\tauthMemberRepo:   authMemberRepo,\n\t\tauthRoleRepo:     authRoleRepo,\n\t\tlocalOrgRepo:     localOrgRepo,\n\t\tlocalAccountRepo: localAccountRepo,\n\t\tlogger:           logger,\n\t}\n}\n\n// BootstrapOrganizationWithOwner creates a new organization with an initial owner member.\n// If any step fails, all previously created resources are automatically rolled back.\nfunc (s *memberService) BootstrapOrganizationWithOwner(\n\tctx context.Context,\n\treq *BootstrapOrganizationRequest,\n) (*BootstrapOrganizationResponse, error) {\n\tif err := req.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid bootstrap request: %w\", err)\n\t}\n\n\t// Always use \"admin\" role for bootstrap (primary admin user)\n\townerRoleSlug := \"admin\"\n\n\t// Initialize rollback stack for transaction-like behavior\n\tvar rollbacks rollbackStack\n\tshouldRollback := true\n\n\t// Defer rollback execution - runs on function exit if shouldRollback is true\n\tdefer func() {\n\t\tif shouldRollback {\n\t\t\ts.logger.Warn(\"bootstrap failed, executing rollback\", loggerDomain.Fields{\n\t\t\t\t\"org_name\":       req.OrgDisplayName,\n\t\t\t\t\"rollback_steps\": len(rollbacks),\n\t\t\t})\n\t\t\trollbacks.execute(context.Background(), s.logger)\n\t\t}\n\t}()\n\n\ts.logger.Info(\"starting organization bootstrap\", loggerDomain.Fields{\n\t\t\"org_name\":    req.OrgDisplayName,\n\t\t\"owner_email\": req.OwnerEmail,\n\t})\n\n\t// Step 1: Create organization in auth provider\n\t// Infrastructure layer handles slug generation and duplicate retry logic\n\tauthOrg, err := s.authOrgRepo.CreateOrganization(ctx, &domain.CreateAuthOrganizationRequest{\n\t\tDisplayName:         req.OrgDisplayName,\n\t\tEmailInvitesAllowed: true,\n\t})\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\ts.logger.Error(\"failed to create auth organization\", loggerDomain.Fields{\n\t\t\t\"org_name\": req.OrgDisplayName,\n\t\t\t\"error\":    err.Error(),\n\t\t})\n\t\treturn nil, err\n\t}\n\n\t// Track auth org creation for rollback\n\trollbacks.add(func(ctx context.Context) error {\n\t\ts.logger.Info(\"rolling back auth organization\", loggerDomain.Fields{\n\t\t\t\"auth_org_id\": authOrg.OrganizationID,\n\t\t})\n\t\treturn s.authOrgRepo.DeleteOrganization(ctx, authOrg.OrganizationID)\n\t})\n\n\ts.logger.Info(\"organization created in auth provider\", loggerDomain.Fields{\n\t\t\"auth_org_id\": authOrg.OrganizationID,\n\t\t\"org_slug\":    authOrg.Slug,\n\t})\n\n\t// Step 2: Create local organization record.\n\ts.logger.Info(\"creating local organization\", loggerDomain.Fields{\n\t\t\"slug\":         authOrg.Slug,\n\t\t\"display_name\": authOrg.DisplayName,\n\t})\n\n\tlocalOrg, err := s.localOrgRepo.Create(ctx, &domain.Organization{\n\t\tSlug:   authOrg.Slug,\n\t\tName:   authOrg.DisplayName,\n\t\tStatus: \"active\",\n\t})\n\tif err != nil {\n\t\ts.logger.Error(\"failed to create local organization\", loggerDomain.Fields{\n\t\t\t\"org_slug\":     authOrg.Slug,\n\t\t\t\"display_name\": authOrg.DisplayName,\n\t\t\t\"status\":       \"active\",\n\t\t\t\"error\":        err.Error(),\n\t\t\t\"error_type\":   fmt.Sprintf(\"%T\", err),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to create local organization: %w\", err)\n\t}\n\n\t// Track local org creation for rollback\n\trollbacks.add(func(ctx context.Context) error {\n\t\ts.logger.Info(\"rolling back local organization\", loggerDomain.Fields{\n\t\t\t\"local_org_id\": localOrg.ID,\n\t\t})\n\t\treturn s.localOrgRepo.Delete(ctx, localOrg.ID)\n\t})\n\n\ts.logger.Info(\"local organization created successfully\", loggerDomain.Fields{\n\t\t\"local_org_id\": localOrg.ID,\n\t\t\"slug\":         localOrg.Slug,\n\t})\n\n\tif _, err := s.localOrgRepo.UpdateStytchInfo(ctx, localOrg.ID, authOrg.OrganizationID, \"\", \"\"); err != nil {\n\t\ts.logger.Error(\"failed to map auth organization locally\", loggerDomain.Fields{\n\t\t\t\"local_org_id\": localOrg.ID,\n\t\t\t\"auth_org_id\":  authOrg.OrganizationID,\n\t\t\t\"error\":        err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to map auth organization: %w\", err)\n\t}\n\n\t// Step 3: Create owner member (no invite).\n\tcreateMemberReq := &domain.CreateAuthMemberRequest{\n\t\tOrganizationID: authOrg.OrganizationID,\n\t\tEmail:          req.OwnerEmail,\n\t\tName:           req.OwnerName,\n\t\tSendInvite:     false, // No magic link invite\n\t}\n\n\tmember, err := s.authMemberRepo.CreateMember(ctx, createMemberReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create owner member: %w\", err)\n\t}\n\n\t// Track auth member creation for rollback\n\trollbacks.add(func(ctx context.Context) error {\n\t\ts.logger.Info(\"rolling back auth member\", loggerDomain.Fields{\n\t\t\t\"member_id\":   member.MemberID,\n\t\t\t\"auth_org_id\": authOrg.OrganizationID,\n\t\t})\n\t\treturn s.authMemberRepo.RemoveMembers(ctx, &domain.RemoveAuthMembersRequest{\n\t\t\tOrganizationID: authOrg.OrganizationID,\n\t\t\tMemberIDs:      []string{member.MemberID},\n\t\t})\n\t})\n\n\t// Step 4: Assign admin role in auth provider.\n\tif err := s.authMemberRepo.AssignRoles(ctx, &domain.AssignAuthRolesRequest{\n\t\tOrganizationID: authOrg.OrganizationID,\n\t\tMemberID:       member.MemberID,\n\t\tRoles:          []string{ownerRoleSlug},\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to assign admin role: %w\", err)\n\t}\n\n\trole, err := s.authRoleRepo.GetRoleBySlug(ctx, ownerRoleSlug)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch admin role metadata: %w\", err)\n\t}\n\n\t// Step 5: Create local account record.\n\tlocalAccount, err := s.localAccountRepo.Create(ctx, &domain.Account{\n\t\tOrganizationID: localOrg.ID,\n\t\tEmail:          member.Email,\n\t\tFullName:       member.Name,\n\t\tRole:           mapRoleSlugToAccountRole(ownerRoleSlug),\n\t\tStatus:         \"active\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create local account: %w\", err)\n\t}\n\n\t// Track local account creation for rollback\n\trollbacks.add(func(ctx context.Context) error {\n\t\ts.logger.Info(\"rolling back local account\", loggerDomain.Fields{\n\t\t\t\"account_id\":   localAccount.ID,\n\t\t\t\"local_org_id\": localOrg.ID,\n\t\t})\n\t\treturn s.localAccountRepo.Delete(ctx, localOrg.ID, localAccount.ID)\n\t})\n\n\tif _, err := s.localAccountRepo.UpdateStytchInfo(\n\t\tctx,\n\t\tlocalOrg.ID,\n\t\tlocalAccount.ID,\n\t\tmember.MemberID,\n\t\trole.RoleID,\n\t\townerRoleSlug,\n\t\tmember.EmailVerified,\n\t); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to map auth member locally: %w\", err)\n\t}\n\n\t// Success! Disable rollback\n\tshouldRollback = false\n\n\ts.logger.Info(\"organization bootstrap completed\", loggerDomain.Fields{\n\t\t\"stytch_org_id\": authOrg.OrganizationID,\n\t\t\"owner_member\":  member.MemberID,\n\t})\n\n\treturn &BootstrapOrganizationResponse{\n\t\tOrganizationID: authOrg.OrganizationID,\n\t\tOrgSlug:        authOrg.Slug,\n\t\tDisplayName:    authOrg.DisplayName,\n\t\tOwnerMemberID:  member.MemberID,\n\t\tOwnerEmail:     member.Email,\n\t\tOwnerName:      member.Name,\n\t\tInviteSent:     false, // No invite sent\n\t\tMagicLinkSent:  false, // No magic link sent\n\t}, nil\n}\n\n// AddMemberDirect adds a new member to an existing organization without invitation workflows.\nfunc (s *memberService) AddMemberDirect(\n\tctx context.Context,\n\treq *AddMemberRequest,\n) (*AddMemberResponse, error) {\n\tif err := req.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid add member request: %w\", err)\n\t}\n\n\troleSlug := strings.ToLower(strings.TrimSpace(req.RoleSlug))\n\tif roleSlug == \"\" {\n\t\troleSlug = \"member\"\n\t}\n\n\torgID := req.OrgID\n\tif orgID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\n\tlocalOrgID, err := s.resolveLocalOrganizationID(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif existingAccount, err := s.localAccountRepo.GetByEmail(ctx, localOrgID, req.Email); err == nil {\n\t\ts.logger.Warn(\"member email already exists locally\", loggerDomain.Fields{\n\t\t\t\"org_id\": localOrgID,\n\t\t\t\"email\":  req.Email,\n\t\t\t\"status\": existingAccount.Status,\n\t\t})\n\t\treturn nil, domain.ErrAuthMemberAlreadyExists\n\t} else if !errors.Is(err, domain.ErrAccountNotFound) {\n\t\treturn nil, fmt.Errorf(\"failed to check existing account: %w\", err)\n\t}\n\n\tcreateReq := &domain.CreateAuthMemberRequest{\n\t\tOrganizationID: orgID,\n\t\tEmail:          req.Email,\n\t\tName:           req.Name,\n\t\tSendInvite:     false, // No magic link invite\n\t}\n\n\tmember, err := s.authMemberRepo.CreateMember(ctx, createReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create member: %w\", err)\n\t}\n\n\tif err := s.authMemberRepo.AssignRoles(ctx, &domain.AssignAuthRolesRequest{\n\t\tOrganizationID: orgID,\n\t\tMemberID:       member.MemberID,\n\t\tRoles:          []string{roleSlug},\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to assign member role: %w\", err)\n\t}\n\n\trole, err := s.authRoleRepo.GetRoleBySlug(ctx, roleSlug)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch role metadata: %w\", err)\n\t}\n\n\tlocalAccount, err := s.localAccountRepo.Create(ctx, &domain.Account{\n\t\tOrganizationID: localOrgID,\n\t\tEmail:          member.Email,\n\t\tFullName:       member.Name,\n\t\tRole:           mapRoleSlugToAccountRole(roleSlug),\n\t\tStatus:         \"active\",\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create local account: %w\", err)\n\t}\n\n\tif _, err := s.localAccountRepo.UpdateStytchInfo(\n\t\tctx,\n\t\tlocalOrgID,\n\t\tlocalAccount.ID,\n\t\tmember.MemberID,\n\t\trole.RoleID,\n\t\troleSlug,\n\t\tmember.EmailVerified,\n\t); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to map auth member locally: %w\", err)\n\t}\n\n\ts.logger.Info(\"member added successfully\", loggerDomain.Fields{\n\t\t\"org_id\":      orgID,\n\t\t\"member_id\":   member.MemberID,\n\t\t\"invite_sent\": true,\n\t})\n\n\treturn &AddMemberResponse{\n\t\tMemberID:   member.MemberID,\n\t\tEmail:      member.Email,\n\t\tName:       member.Name,\n\t\tOrgID:      orgID,\n\t\tRoleSlug:   roleSlug,\n\t\tInviteSent: true, // Always true (passwordless)\n\t}, nil\n}\n\n// ListOrganizationMembers retrieves all members of an organization.\nfunc (s *memberService) ListOrganizationMembers(\n\tctx context.Context,\n\torgID string,\n) (*ListMembersResponse, error) {\n\tif orgID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\n\t// Retrieve members from repository (no pagination limit)\n\tmembers, err := s.authMemberRepo.ListMembers(ctx, orgID, 0, 0)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to list organization members\", loggerDomain.Fields{\n\t\t\t\"org_id\": orgID,\n\t\t\t\"error\":  err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to list members: %w\", err)\n\t}\n\n\t// Convert domain members to response info\n\tmemberInfos := make([]*MemberInfo, 0, len(members))\n\tfor _, member := range members {\n\t\tmemberInfos = append(memberInfos, &MemberInfo{\n\t\t\tMemberID:      member.MemberID,\n\t\t\tEmail:         member.Email,\n\t\t\tName:          member.Name,\n\t\t\tRoles:         member.Roles,\n\t\t\tStatus:        member.Status,\n\t\t\tEmailVerified: member.EmailVerified,\n\t\t\tCreatedAt:     member.CreatedAt.Format(\"2006-01-02T15:04:05Z07:00\"),\n\t\t\tUpdatedAt:     member.UpdatedAt.Format(\"2006-01-02T15:04:05Z07:00\"),\n\t\t})\n\t}\n\n\ts.logger.Info(\"members listed successfully\", loggerDomain.Fields{\n\t\t\"org_id\": orgID,\n\t\t\"count\":  len(memberInfos),\n\t})\n\n\treturn &ListMembersResponse{\n\t\tMembers: memberInfos,\n\t\tTotal:   len(memberInfos),\n\t}, nil\n}\n\n// GetCurrentUserProfile retrieves the current authenticated user's profile.\nfunc (s *memberService) GetCurrentUserProfile(\n\tctx context.Context,\n\torgID, memberID, email string,\n) (*ProfileResponse, error) {\n\t// Validate required parameters\n\tif orgID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\tif memberID == \"\" {\n\t\treturn nil, domain.ErrAuthMemberIDRequired\n\t}\n\tif email == \"\" {\n\t\treturn nil, domain.ErrAuthEmailRequired\n\t}\n\n\t// Get member details from auth provider\n\tmember, err := s.authMemberRepo.GetMember(ctx, orgID, memberID)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to get member details\", loggerDomain.Fields{\n\t\t\t\"org_id\":    orgID,\n\t\t\t\"member_id\": memberID,\n\t\t\t\"error\":     err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to get member details: %w\", err)\n\t}\n\n\t// Get organization details from auth provider\n\torganization, err := s.authOrgRepo.GetOrganization(ctx, orgID)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to get organization details\", loggerDomain.Fields{\n\t\t\t\"org_id\": orgID,\n\t\t\t\"error\":  err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to get organization details: %w\", err)\n\t}\n\n\t// Get local organization details (for database ID)\n\tlocalOrg, err := s.localOrgRepo.GetByStytchID(ctx, orgID)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to get local organization\", loggerDomain.Fields{\n\t\t\t\"auth_org_id\": orgID,\n\t\t\t\"error\":       err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to get local organization: %w\", err)\n\t}\n\n\t// Get local account details (for database ID)\n\tlocalAccount, err := s.localAccountRepo.GetByEmail(ctx, localOrg.ID, email)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to get local account\", loggerDomain.Fields{\n\t\t\t\"org_id\": localOrg.ID,\n\t\t\t\"email\":  email,\n\t\t\t\"error\":  err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"failed to get local account: %w\", err)\n\t}\n\n\t// Build profile response\n\tprofile := &ProfileResponse{\n\t\tMemberID:      member.MemberID,\n\t\tEmail:         member.Email,\n\t\tName:          member.Name,\n\t\tRoles:         member.Roles,\n\t\tEmailVerified: member.EmailVerified,\n\t\tStatus:        member.Status,\n\t\tOrganization: ProfileOrganization{\n\t\t\tOrganizationID: organization.OrganizationID,\n\t\t\tSlug:           organization.Slug,\n\t\t\tName:           organization.DisplayName,\n\t\t\tStatus:         organization.Status,\n\t\t},\n\t\tAccountID: localAccount.ID,\n\t\tCreatedAt: member.CreatedAt.Format(\"2006-01-02T15:04:05Z07:00\"),\n\t\tUpdatedAt: member.UpdatedAt.Format(\"2006-01-02T15:04:05Z07:00\"),\n\t}\n\n\ts.logger.Info(\"profile retrieved successfully\", loggerDomain.Fields{\n\t\t\"member_id\": memberID,\n\t\t\"org_id\":    orgID,\n\t\t\"email\":     email,\n\t})\n\n\treturn profile, nil\n}\n\n// DeleteOrganizationMember removes a member from the organization\n// This deletes from both auth provider and the internal database\n// Admin-only operation (permission check done at handler level)\nfunc (s *memberService) DeleteOrganizationMember(\n\tctx context.Context,\n\torgID, memberID string,\n) error {\n\tif orgID == \"\" || memberID == \"\" {\n\t\treturn fmt.Errorf(\"organization ID and member ID are required\")\n\t}\n\n\ts.logger.Info(\"deleting organization member\", map[string]interface{}{\n\t\t\"org_id\":    orgID,\n\t\t\"member_id\": memberID,\n\t})\n\n\t// Create remove members request\n\treq := &domain.RemoveAuthMembersRequest{\n\t\tOrganizationID: orgID,\n\t\tMemberIDs:      []string{memberID},\n\t}\n\n\t// Remove from auth organization\n\terr := s.authMemberRepo.RemoveMembers(ctx, req)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to remove member from auth organization\", map[string]interface{}{\n\t\t\t\"org_id\":    orgID,\n\t\t\t\"member_id\": memberID,\n\t\t\t\"error\":     err.Error(),\n\t\t})\n\t\treturn fmt.Errorf(\"failed to remove member: %w\", err)\n\t}\n\n\ts.logger.Info(\"member successfully deleted from organization\", map[string]interface{}{\n\t\t\"org_id\":    orgID,\n\t\t\"member_id\": memberID,\n\t})\n\n\treturn nil\n}\n\n// Returns true if email is found in any organization, false otherwise\nfunc (s *memberService) CheckEmailExists(ctx context.Context, email string) (bool, error) {\n\t// Validate email format\n\temail = strings.TrimSpace(email)\n\tif email == \"\" {\n\t\treturn false, fmt.Errorf(\"email cannot be empty\")\n\t}\n\n\ts.logger.Info(\"checking email existence\", loggerDomain.Fields{\n\t\t\"email\": email,\n\t})\n\n\t// Check using organization repository\n\texists, err := s.authOrgRepo.CheckEmailExists(ctx, email)\n\tif err != nil {\n\t\ts.logger.Error(\"failed to check email existence\", loggerDomain.Fields{\n\t\t\t\"email\": email,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn false, fmt.Errorf(\"failed to check email existence: %w\", err)\n\t}\n\n\ts.logger.Info(\"email existence check completed\", loggerDomain.Fields{\n\t\t\"email\":  email,\n\t\t\"exists\": exists,\n\t})\n\n\treturn exists, nil\n}\n\nfunc (s *memberService) resolveLocalOrganizationID(ctx context.Context, authOrgID string) (int32, error) {\n\torg, err := s.localOrgRepo.GetByStytchID(ctx, authOrgID)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to resolve local organization: %w\", err)\n\t}\n\treturn org.ID, nil\n}\n\nfunc mapRoleSlugToAccountRole(slug string) string {\n\tswitch strings.ToLower(strings.TrimSpace(slug)) {\n\tcase \"owner\":\n\t\t// Legacy: map owner to admin\n\t\treturn \"admin\"\n\tcase \"admin\":\n\t\treturn \"admin\"\n\tcase \"approver\":\n\t\treturn \"approver\"\n\tcase \"reviewer\":\n\t\t// Legacy: map reviewer to approver\n\t\treturn \"approver\"\n\tcase \"employee\", \"member\":\n\t\treturn \"member\"\n\tdefault:\n\t\treturn slug\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/app/services/organization_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n)\n\ntype organizationService struct {\n\torgRepo     domain.OrganizationRepository\n\taccountRepo domain.AccountRepository\n}\n\nfunc NewOrganizationService(orgRepo domain.OrganizationRepository, accountRepo domain.AccountRepository) OrganizationService {\n\treturn &organizationService{\n\t\torgRepo:     orgRepo,\n\t\taccountRepo: accountRepo,\n\t}\n}\n\nfunc (s *organizationService) CreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*domain.Organization, error) {\n\t// Create organization\n\torg := &domain.Organization{\n\t\tSlug:                 req.Slug,\n\t\tName:                 req.Name,\n\t\tStatus:               \"active\",\n\t\tStytchOrgID:          req.StytchOrgID,\n\t\tStytchConnectionID:   req.StytchConnectionID,\n\t\tStytchConnectionName: req.StytchConnectionName,\n\t}\n\n\tcreatedOrg, err := s.orgRepo.Create(ctx, org)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create organization: %w\", err)\n\t}\n\n\tif req.StytchOrgID != \"\" || req.StytchConnectionID != \"\" || req.StytchConnectionName != \"\" {\n\t\tcreatedOrg.StytchOrgID = req.StytchOrgID\n\t\tcreatedOrg.StytchConnectionID = req.StytchConnectionID\n\t\tcreatedOrg.StytchConnectionName = req.StytchConnectionName\n\t\tcreatedOrg, err = s.orgRepo.Update(ctx, createdOrg)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to persist organization Stytch metadata: %w\", err)\n\t\t}\n\t}\n\n\t// Create admin account (primary admin user)\n\tadminAccount := &domain.Account{\n\t\tOrganizationID: createdOrg.ID,\n\t\tEmail:          req.OwnerEmail,\n\t\tFullName:       req.OwnerName,\n\t\tRole:           \"admin\",\n\t\tStatus:         \"active\",\n\t}\n\n\t_, err = s.accountRepo.Create(ctx, adminAccount)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create admin account: %w\", err)\n\t}\n\n\treturn createdOrg, nil\n}\n\nfunc (s *organizationService) GetOrganization(ctx context.Context, orgID int32) (*domain.Organization, error) {\n\treturn s.orgRepo.GetByID(ctx, orgID)\n}\n\nfunc (s *organizationService) GetOrganizationBySlug(ctx context.Context, slug string) (*domain.Organization, error) {\n\treturn s.orgRepo.GetBySlug(ctx, slug)\n}\n\nfunc (s *organizationService) GetOrganizationByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) {\n\treturn s.orgRepo.GetByStytchID(ctx, stytchOrgID)\n}\n\nfunc (s *organizationService) GetOrganizationByUserEmail(ctx context.Context, email string) (*domain.Organization, error) {\n\treturn s.orgRepo.GetByUserEmail(ctx, email)\n}\n\nfunc (s *organizationService) UpdateOrganization(ctx context.Context, orgID int32, req *UpdateOrganizationRequest) (*domain.Organization, error) {\n\t// Get existing organization\n\torg, err := s.orgRepo.GetByID(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Update fields\n\torg.Name = req.Name\n\torg.Status = req.Status\n\tif req.StytchOrgID != \"\" {\n\t\torg.StytchOrgID = req.StytchOrgID\n\t}\n\tif req.StytchConnectionID != \"\" {\n\t\torg.StytchConnectionID = req.StytchConnectionID\n\t}\n\tif req.StytchConnectionName != \"\" {\n\t\torg.StytchConnectionName = req.StytchConnectionName\n\t}\n\n\treturn s.orgRepo.Update(ctx, org)\n}\n\nfunc (s *organizationService) ListOrganizations(ctx context.Context, req *ListOrganizationsRequest) (*ListOrganizationsResponse, error) {\n\torganizations, err := s.orgRepo.List(ctx, req.Limit, req.Offset)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// For simplicity, we're not implementing total count yet\n\t// In production, you'd want a separate query for total count\n\ttotal := int32(len(organizations))\n\n\treturn &ListOrganizationsResponse{\n\t\tOrganizations: organizations,\n\t\tTotal:         total,\n\t\tLimit:         req.Limit,\n\t\tOffset:        req.Offset,\n\t}, nil\n}\n\nfunc (s *organizationService) GetOrganizationStats(ctx context.Context, orgID int32) (*domain.OrganizationStats, error) {\n\treturn s.orgRepo.GetStats(ctx, orgID)\n}\n\nfunc (s *organizationService) CreateAccount(ctx context.Context, orgID int32, req *CreateAccountRequest) (*domain.Account, error) {\n\t// Verify organization exists\n\t_, err := s.orgRepo.GetByID(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\taccount := &domain.Account{\n\t\tOrganizationID:      orgID,\n\t\tEmail:               req.Email,\n\t\tFullName:            req.FullName,\n\t\tStytchMemberID:      req.StytchMemberID,\n\t\tStytchRoleID:        req.StytchRoleID,\n\t\tStytchRoleSlug:      req.StytchRoleSlug,\n\t\tStytchEmailVerified: req.StytchEmailVerified,\n\t\tRole:                req.Role,\n\t\tStatus:              \"active\",\n\t}\n\n\treturn s.accountRepo.Create(ctx, account)\n}\n\nfunc (s *organizationService) GetAccount(ctx context.Context, orgID, accountID int32) (*domain.Account, error) {\n\treturn s.accountRepo.GetByID(ctx, orgID, accountID)\n}\n\nfunc (s *organizationService) GetAccountByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) {\n\treturn s.accountRepo.GetByEmail(ctx, orgID, email)\n}\n\nfunc (s *organizationService) ListAccounts(ctx context.Context, orgID int32) ([]*domain.Account, error) {\n\t// Verify organization exists\n\t_, err := s.orgRepo.GetByID(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn s.accountRepo.ListByOrganization(ctx, orgID)\n}\n\nfunc (s *organizationService) UpdateAccount(ctx context.Context, orgID, accountID int32, req *UpdateAccountRequest) (*domain.Account, error) {\n\t// Get existing account\n\taccount, err := s.accountRepo.GetByID(ctx, orgID, accountID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Update fields\n\taccount.FullName = req.FullName\n\taccount.Role = req.Role\n\taccount.Status = req.Status\n\tif req.StytchRoleID != \"\" {\n\t\taccount.StytchRoleID = req.StytchRoleID\n\t}\n\tif req.StytchRoleSlug != \"\" {\n\t\taccount.StytchRoleSlug = req.StytchRoleSlug\n\t}\n\tif req.StytchEmailVerified != nil {\n\t\taccount.StytchEmailVerified = *req.StytchEmailVerified\n\t}\n\n\treturn s.accountRepo.Update(ctx, account)\n}\n\nfunc (s *organizationService) DeleteAccount(ctx context.Context, orgID, accountID int32) error {\n\treturn s.accountRepo.Delete(ctx, orgID, accountID)\n}\n\nfunc (s *organizationService) UpdateAccountLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) {\n\treturn s.accountRepo.UpdateLastLogin(ctx, orgID, accountID)\n}\n\nfunc (s *organizationService) CheckAccountPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) {\n\treturn s.accountRepo.CheckPermission(ctx, orgID, accountID)\n}\n\nfunc (s *organizationService) GetAccountStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) {\n\treturn s.accountRepo.GetStats(ctx, accountID)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/app/services/organization_service_interface.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n)\n\n// OrganizationService defines the interface for organization business operations\ntype OrganizationService interface {\n\t// Organization operations\n\tCreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*domain.Organization, error)\n\tGetOrganization(ctx context.Context, orgID int32) (*domain.Organization, error)\n\tGetOrganizationBySlug(ctx context.Context, slug string) (*domain.Organization, error)\n\tGetOrganizationByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error)\n\tGetOrganizationByUserEmail(ctx context.Context, email string) (*domain.Organization, error)\n\tUpdateOrganization(ctx context.Context, orgID int32, req *UpdateOrganizationRequest) (*domain.Organization, error)\n\tListOrganizations(ctx context.Context, req *ListOrganizationsRequest) (*ListOrganizationsResponse, error)\n\tGetOrganizationStats(ctx context.Context, orgID int32) (*domain.OrganizationStats, error)\n\n\t// Account operations\n\tCreateAccount(ctx context.Context, orgID int32, req *CreateAccountRequest) (*domain.Account, error)\n\tGetAccount(ctx context.Context, orgID, accountID int32) (*domain.Account, error)\n\tGetAccountByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error)\n\tListAccounts(ctx context.Context, orgID int32) ([]*domain.Account, error)\n\tUpdateAccount(ctx context.Context, orgID, accountID int32, req *UpdateAccountRequest) (*domain.Account, error)\n\tDeleteAccount(ctx context.Context, orgID, accountID int32) error\n\tUpdateAccountLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error)\n\n\t// Utility operations\n\tCheckAccountPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error)\n\tGetAccountStats(ctx context.Context, accountID int32) (*domain.AccountStats, error)\n}\n\n// CreateOrganizationRequest represents data needed to create an organization\ntype CreateOrganizationRequest struct {\n\tSlug                 string `json:\"slug\" binding:\"required,min=3\"`\n\tName                 string `json:\"name\" binding:\"required\"`\n\tOwnerEmail           string `json:\"owner_email\" binding:\"required,email\"`\n\tOwnerName            string `json:\"owner_name\" binding:\"required\"`\n\tStytchOrgID          string `json:\"stytch_org_id\"`\n\tStytchConnectionID   string `json:\"stytch_connection_id\"`\n\tStytchConnectionName string `json:\"stytch_connection_name\"`\n}\n\n// UpdateOrganizationRequest represents data needed to update an organization\ntype UpdateOrganizationRequest struct {\n\tName                 string `json:\"name\" binding:\"required\"`\n\tStatus               string `json:\"status\" binding:\"required,oneof=active suspended\"`\n\tStytchOrgID          string `json:\"stytch_org_id\"`\n\tStytchConnectionID   string `json:\"stytch_connection_id\"`\n\tStytchConnectionName string `json:\"stytch_connection_name\"`\n}\n\n// CreateAccountRequest represents data needed to create an account\ntype CreateAccountRequest struct {\n\tEmail               string `json:\"email\" binding:\"required,email\"`\n\tFullName            string `json:\"full_name\" binding:\"required\"`\n\tRole                string `json:\"role\" binding:\"required,oneof=admin approver member\"`\n\tStytchMemberID      string `json:\"stytch_member_id\"`\n\tStytchRoleID        string `json:\"stytch_role_id\"`\n\tStytchRoleSlug      string `json:\"stytch_role_slug\"`\n\tStytchEmailVerified bool   `json:\"stytch_email_verified\"`\n}\n\n// UpdateAccountRequest represents data needed to update an account\ntype UpdateAccountRequest struct {\n\tFullName            string `json:\"full_name\" binding:\"required\"`\n\tRole                string `json:\"role\" binding:\"required,oneof=admin approver member\"`\n\tStatus              string `json:\"status\" binding:\"required,oneof=active inactive suspended\"`\n\tStytchRoleID        string `json:\"stytch_role_id\"`\n\tStytchRoleSlug      string `json:\"stytch_role_slug\"`\n\tStytchEmailVerified *bool  `json:\"stytch_email_verified\"`\n}\n\n// ListOrganizationsRequest represents parameters for listing organizations\ntype ListOrganizationsRequest struct {\n\tLimit  int32 `json:\"limit\" binding:\"min=1,max=100\"`\n\tOffset int32 `json:\"offset\" binding:\"min=0\"`\n}\n\n// ListOrganizationsResponse represents the response for listing organizations\ntype ListOrganizationsResponse struct {\n\tOrganizations []*domain.Organization `json:\"organizations\"`\n\tTotal         int32                  `json:\"total\"`\n\tLimit         int32                  `json:\"limit\"`\n\tOffset        int32                  `json:\"offset\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations\"\n)\n\nfunc Init(container *dig.Container) error {\n\tmodule := organizations.NewModule(container)\n\treturn module.RegisterDependencies()\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/domain/auth_provider.go",
    "content": "package domain\n\nimport (\n\t\"context\"\n\t\"net/mail\"\n\t\"time\"\n)\n\n// AuthMember represents an authenticated member from the auth provider.\ntype AuthMember struct {\n\tMemberID       string    `json:\"member_id\"`\n\tOrganizationID string    `json:\"organization_id\"`\n\tEmail          string    `json:\"email\"`\n\tName           string    `json:\"name\"`\n\tRoles          []string  `json:\"roles\"`\n\tStatus         string    `json:\"status\"`\n\tEmailVerified  bool      `json:\"email_verified\"`\n\tCreatedAt      time.Time `json:\"created_at\"`\n\tUpdatedAt      time.Time `json:\"updated_at\"`\n}\n\n// AuthOrganization represents an organization (tenant) from the auth provider.\ntype AuthOrganization struct {\n\tOrganizationID string    `json:\"organization_id\"`\n\tSlug           string    `json:\"slug\"`\n\tDisplayName    string    `json:\"display_name\"`\n\tStatus         string    `json:\"status\"`\n\tCreatedAt      time.Time `json:\"created_at\"`\n\tUpdatedAt      time.Time `json:\"updated_at\"`\n}\n\n// AuthRole represents an RBAC role from the auth provider.\ntype AuthRole struct {\n\tRoleID      string   `json:\"role_id\"`\n\tName        string   `json:\"name\"`\n\tDescription string   `json:\"description\"`\n\tPermissions []string `json:\"permissions\"`\n}\n\n// CreateAuthMemberRequest represents the data needed to create a member in the auth provider.\ntype CreateAuthMemberRequest struct {\n\tOrganizationID string   `json:\"organization_id\"`\n\tEmail          string   `json:\"email\"`\n\tName           string   `json:\"name\"`\n\tRoles          []string `json:\"roles\"`\n\tSendInvite     bool     `json:\"send_invite\"`\n\tPassword       string   `json:\"password\"`\n}\n\n// UpdateAuthMemberRequest represents member profile updates in the auth provider.\ntype UpdateAuthMemberRequest struct {\n\tOrganizationID string         `json:\"organization_id\"`\n\tMemberID       string         `json:\"member_id\"`\n\tName           *string        `json:\"name,omitempty\"`\n\tRoles          []string       `json:\"roles,omitempty\"`\n\tTrustedMeta    map[string]any `json:\"trusted_metadata,omitempty\"`\n\tUntrustedMeta  map[string]any `json:\"untrusted_metadata,omitempty\"`\n}\n\n// CreateAuthOrganizationRequest represents the data needed to create an organization in the auth provider.\ntype CreateAuthOrganizationRequest struct {\n\tDisplayName         string `json:\"display_name\"`\n\tEmailInvitesAllowed bool   `json:\"email_invites_allowed\"`\n}\n\n// AssignAuthRolesRequest represents assigning roles to a member in the auth provider.\ntype AssignAuthRolesRequest struct {\n\tOrganizationID string   `json:\"organization_id\"`\n\tMemberID       string   `json:\"member_id\"`\n\tRoles          []string `json:\"roles\"`\n}\n\n// RemoveAuthMembersRequest represents removing members from an organization in the auth provider.\ntype RemoveAuthMembersRequest struct {\n\tOrganizationID string   `json:\"organization_id\"`\n\tMemberIDs      []string `json:\"member_ids\"`\n}\n\n// SendMagicLinkRequest represents the payload required to email a login magic link.\ntype SendMagicLinkRequest struct {\n\tOrganizationID    string `json:\"organization_id\"`\n\tEmail             string `json:\"email\"`\n\tLoginRedirectURL  string `json:\"login_redirect_url\"`\n\tSignupRedirectURL string `json:\"signup_redirect_url\"`\n}\n\n// Validate validates the CreateAuthMemberRequest.\nfunc (r *CreateAuthMemberRequest) Validate() error {\n\tif r.OrganizationID == \"\" {\n\t\treturn ErrAuthOrganizationIDRequired\n\t}\n\tif r.Email == \"\" {\n\t\treturn ErrAuthEmailRequired\n\t}\n\tif _, err := mail.ParseAddress(r.Email); err != nil {\n\t\treturn ErrAuthInvalidEmail\n\t}\n\tif r.Name == \"\" {\n\t\treturn ErrAuthNameRequired\n\t}\n\treturn nil\n}\n\n// Validate ensures the SendMagicLinkRequest contains core identifiers.\nfunc (r *SendMagicLinkRequest) Validate() error {\n\tif r.OrganizationID == \"\" {\n\t\treturn ErrAuthOrganizationIDRequired\n\t}\n\tif r.Email == \"\" {\n\t\treturn ErrAuthEmailRequired\n\t}\n\tif _, err := mail.ParseAddress(r.Email); err != nil {\n\t\treturn ErrAuthInvalidEmail\n\t}\n\treturn nil\n}\n\n// Validate validates the UpdateAuthMemberRequest.\nfunc (r *UpdateAuthMemberRequest) Validate() error {\n\tif r.OrganizationID == \"\" {\n\t\treturn ErrAuthOrganizationIDRequired\n\t}\n\tif r.MemberID == \"\" {\n\t\treturn ErrAuthMemberIDRequired\n\t}\n\treturn nil\n}\n\n// Validate validates the CreateAuthOrganizationRequest.\nfunc (r *CreateAuthOrganizationRequest) Validate() error {\n\tif r.DisplayName == \"\" {\n\t\treturn ErrAuthOrganizationDisplayNameRequired\n\t}\n\tif len(r.DisplayName) < 2 {\n\t\treturn ErrAuthOrganizationNameTooShort\n\t}\n\treturn nil\n}\n\n// Validate validates the AssignAuthRolesRequest.\nfunc (r *AssignAuthRolesRequest) Validate() error {\n\tif r.OrganizationID == \"\" {\n\t\treturn ErrAuthOrganizationIDRequired\n\t}\n\tif r.MemberID == \"\" {\n\t\treturn ErrAuthMemberIDRequired\n\t}\n\tif len(r.Roles) == 0 {\n\t\treturn ErrAuthRoleIDsRequired\n\t}\n\treturn nil\n}\n\n// Validate validates the RemoveAuthMembersRequest.\nfunc (r *RemoveAuthMembersRequest) Validate() error {\n\tif r.OrganizationID == \"\" {\n\t\treturn ErrAuthOrganizationIDRequired\n\t}\n\tif len(r.MemberIDs) == 0 {\n\t\treturn ErrAuthMemberIDsRequired\n\t}\n\treturn nil\n}\n\n// AuthOrganizationRepository defines auth provider organization operations.\ntype AuthOrganizationRepository interface {\n\tCreateOrganization(ctx context.Context, req *CreateAuthOrganizationRequest) (*AuthOrganization, error)\n\tGetOrganization(ctx context.Context, organizationID string) (*AuthOrganization, error)\n\tDeleteOrganization(ctx context.Context, organizationID string) error\n\tCheckEmailExists(ctx context.Context, email string) (bool, error)\n}\n\n// AuthMemberRepository defines auth provider member operations.\ntype AuthMemberRepository interface {\n\tCreateMember(ctx context.Context, req *CreateAuthMemberRequest) (*AuthMember, error)\n\tUpdateMember(ctx context.Context, req *UpdateAuthMemberRequest) (*AuthMember, error)\n\tGetMember(ctx context.Context, organizationID, memberID string) (*AuthMember, error)\n\tGetMemberByEmail(ctx context.Context, organizationID, email string) (*AuthMember, error)\n\tListMembers(ctx context.Context, organizationID string, limit, offset int) ([]*AuthMember, error)\n\tRemoveMembers(ctx context.Context, req *RemoveAuthMembersRequest) error\n\tAssignRoles(ctx context.Context, req *AssignAuthRolesRequest) error\n\tSendMagicLink(ctx context.Context, req *SendMagicLinkRequest) error\n}\n\n// AuthRoleRepository defines auth provider RBAC operations.\ntype AuthRoleRepository interface {\n\tGetRoleByID(ctx context.Context, roleID string) (*AuthRole, error)\n\tGetRoleBySlug(ctx context.Context, slug string) (*AuthRole, error)\n\tListRoles(ctx context.Context, limit, offset int) ([]*AuthRole, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/domain/entity.go",
    "content": "package domain\n\nimport \"time\"\n\n// Organization represents an organization (tenant) in the system\ntype Organization struct {\n\tID                   int32     `json:\"id\"`\n\tSlug                 string    `json:\"slug\"`\n\tName                 string    `json:\"name\"`\n\tStatus               string    `json:\"status\"`\n\tStytchOrgID          string    `json:\"stytch_org_id\"`\n\tStytchConnectionID   string    `json:\"stytch_connection_id\"`\n\tStytchConnectionName string    `json:\"stytch_connection_name\"`\n\tCreatedAt            time.Time `json:\"created_at\"`\n\tUpdatedAt            time.Time `json:\"updated_at\"`\n}\n\n// Account represents a user account within an organization\ntype Account struct {\n\tID                  int32      `json:\"id\"`\n\tOrganizationID      int32      `json:\"organization_id\"`\n\tEmail               string     `json:\"email\"`\n\tFullName            string     `json:\"full_name\"`\n\tStytchMemberID      string     `json:\"stytch_member_id\"`\n\tStytchRoleID        string     `json:\"stytch_role_id\"`\n\tStytchRoleSlug      string     `json:\"stytch_role_slug\"`\n\tStytchEmailVerified bool       `json:\"stytch_email_verified\"`\n\tRole                string     `json:\"role\"`\n\tStatus              string     `json:\"status\"`\n\tLastLoginAt         *time.Time `json:\"last_login_at,omitempty\"`\n\tCreatedAt           time.Time  `json:\"created_at\"`\n\tUpdatedAt           time.Time  `json:\"updated_at\"`\n}\n\n// OrganizationContext provides context for operations within an organization\ntype OrganizationContext struct {\n\tOrganizationID int32  `json:\"organization_id\"`\n\tAccountID      int32  `json:\"account_id\"`\n\tAccountRole    string `json:\"account_role\"`\n}\n\n// Implements auth.OrganizationEntity interface.\nfunc (o *Organization) GetID() int32 {\n\treturn o.ID\n}\n\n// Validate validates the organization entity\nfunc (o *Organization) Validate() error {\n\tif o.Name == \"\" {\n\t\treturn ErrOrganizationNameRequired\n\t}\n\tif o.Slug == \"\" {\n\t\treturn ErrOrganizationSlugRequired\n\t}\n\tif len(o.Slug) < 3 {\n\t\treturn ErrOrganizationSlugTooShort\n\t}\n\treturn nil\n}\n\n// Implements auth.AccountEntity interface.\nfunc (a *Account) GetID() int32 {\n\treturn a.ID\n}\n\n// Validate validates the account entity\nfunc (a *Account) Validate() error {\n\tif a.Email == \"\" {\n\t\treturn ErrAccountEmailRequired\n\t}\n\tif a.FullName == \"\" {\n\t\treturn ErrAccountFullNameRequired\n\t}\n\tif a.OrganizationID == 0 {\n\t\treturn ErrAccountOrganizationRequired\n\t}\n\treturn nil\n}\n\n// IsOwner checks if the account has admin role (legacy function name, kept for compatibility)\nfunc (a *Account) IsOwner() bool {\n\treturn a.Role == \"admin\"\n}\n\n// IsAdmin checks if the account has admin role\nfunc (a *Account) IsAdmin() bool {\n\treturn a.Role == \"admin\"\n}\n\n// CanManageAccounts checks if the account can manage other accounts\nfunc (a *Account) CanManageAccounts() bool {\n\treturn a.IsAdmin()\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/domain/errors.go",
    "content": "package domain\n\nimport \"errors\"\n\n// Organization errors\nvar (\n\tErrOrganizationNotFound      = errors.New(\"organization not found\")\n\tErrOrganizationNameRequired  = errors.New(\"organization name is required\")\n\tErrOrganizationSlugRequired  = errors.New(\"organization slug is required\")\n\tErrOrganizationSlugTooShort  = errors.New(\"organization slug must be at least 3 characters\")\n\tErrOrganizationSlugTaken     = errors.New(\"organization slug is already taken\")\n\tErrOrganizationInactive      = errors.New(\"organization is inactive\")\n)\n\n// Account errors\nvar (\n\tErrAccountNotFound             = errors.New(\"account not found\")\n\tErrAccountEmailRequired        = errors.New(\"account email is required\")\n\tErrAccountFullNameRequired     = errors.New(\"account full name is required\")\n\tErrAccountOrganizationRequired = errors.New(\"account organization is required\")\n\tErrAccountEmailTaken           = errors.New(\"account email is already taken\")\n\tErrAccountInactive             = errors.New(\"account is inactive\")\n\tErrAccountInsufficientRole     = errors.New(\"account does not have sufficient permissions\")\n)\n\n// Permission errors\nvar (\n\tErrPermissionDenied = errors.New(\"permission denied\")\n\tErrInvalidRole      = errors.New(\"invalid role\")\n)\n\n// Auth provider member-related errors\nvar (\n\tErrAuthMemberNotFound      = errors.New(\"auth member not found\")\n\tErrAuthMemberAlreadyExists = errors.New(\"auth member already exists\")\n\tErrAuthEmailRequired       = errors.New(\"email is required\")\n\tErrAuthInvalidEmail        = errors.New(\"invalid email format\")\n\tErrAuthPasswordRequired    = errors.New(\"password is required\")\n\tErrAuthNameRequired        = errors.New(\"name is required\")\n\tErrAuthMemberIDRequired    = errors.New(\"member ID is required\")\n\tErrAuthMemberIDsRequired   = errors.New(\"member IDs are required\")\n)\n\n// Auth provider organization-related errors\nvar (\n\tErrAuthOrganizationNotFound            = errors.New(\"auth organization not found\")\n\tErrAuthOrganizationAlreadyExists       = errors.New(\"auth organization already exists\")\n\tErrAuthOrganizationNameRequired        = errors.New(\"auth organization name is required\")\n\tErrAuthOrganizationDisplayNameRequired = errors.New(\"auth organization display name is required\")\n\tErrAuthOrganizationNameTooShort        = errors.New(\"auth organization name must be at least 2 characters\")\n\tErrAuthOrganizationIDRequired          = errors.New(\"auth organization ID is required\")\n)\n\n// Auth provider role-related errors\nvar (\n\tErrAuthRoleNotFound    = errors.New(\"auth role not found\")\n\tErrAuthRoleIDsRequired = errors.New(\"auth role IDs are required\")\n)\n\n// Auth provider integration errors\nvar (\n\tErrAuthConnection   = errors.New(\"failed to connect to auth provider\")\n\tErrAuthOperation    = errors.New(\"auth provider operation failed\")\n\tErrAuthUnauthorized = errors.New(\"unauthorized auth operation\")\n\tErrAuthRateLimit    = errors.New(\"auth provider rate limit exceeded\")\n)\n\n// OrganizationError represents a domain-specific organization error\ntype OrganizationError struct {\n\tType           string `json:\"type\"`\n\tMessage        string `json:\"message\"`\n\tOrganizationID *int32 `json:\"organization_id,omitempty\"`\n\tCause          error  `json:\"-\"`\n}\n\nfunc (e *OrganizationError) Error() string {\n\treturn e.Message\n}\n\nfunc (e *OrganizationError) Unwrap() error {\n\treturn e.Cause\n}\n\nfunc NewOrganizationError(errorType, message string, orgID *int32, cause error) *OrganizationError {\n\treturn &OrganizationError{\n\t\tType:           errorType,\n\t\tMessage:        message,\n\t\tOrganizationID: orgID,\n\t\tCause:          cause,\n\t}\n}\n\n// AccountError represents a domain-specific account error\ntype AccountError struct {\n\tType           string `json:\"type\"`\n\tMessage        string `json:\"message\"`\n\tAccountID      *int32 `json:\"account_id,omitempty\"`\n\tOrganizationID *int32 `json:\"organization_id,omitempty\"`\n\tCause          error  `json:\"-\"`\n}\n\nfunc (e *AccountError) Error() string {\n\treturn e.Message\n}\n\nfunc (e *AccountError) Unwrap() error {\n\treturn e.Cause\n}\n\nfunc NewAccountError(errorType, message string, accountID, orgID *int32, cause error) *AccountError {\n\treturn &AccountError{\n\t\tType:           errorType,\n\t\tMessage:        message,\n\t\tAccountID:      accountID,\n\t\tOrganizationID: orgID,\n\t\tCause:          cause,\n\t}\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/domain/events/organization_events.go",
    "content": "package events\n\nimport (\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n)\n\nconst (\n\tOrganizationCreatedEventType = \"organization.created\"\n\tOrganizationUpdatedEventType = \"organization.updated\"\n\tAccountCreatedEventType      = \"account.created\"\n\tAccountUpdatedEventType      = \"account.updated\"\n\tAccountDeletedEventType      = \"account.deleted\"\n\tAccountLoginEventType        = \"account.login\"\n)\n\ntype OrganizationCreatedEvent struct {\n\tEventID       string                `json:\"event_id\"`\n\tEventType     string                `json:\"event_type\"`\n\tTimestamp     time.Time             `json:\"timestamp\"`\n\tOrganization  *domain.Organization  `json:\"organization\"`\n\tOwnerAccount  *domain.Account       `json:\"owner_account\"`\n}\n\ntype OrganizationUpdatedEvent struct {\n\tEventID      string               `json:\"event_id\"`\n\tEventType    string               `json:\"event_type\"`\n\tTimestamp    time.Time            `json:\"timestamp\"`\n\tOrganization *domain.Organization `json:\"organization\"`\n\tPreviousName string               `json:\"previous_name\"`\n}\n\ntype AccountCreatedEvent struct {\n\tEventID        string              `json:\"event_id\"`\n\tEventType      string              `json:\"event_type\"`\n\tTimestamp      time.Time           `json:\"timestamp\"`\n\tAccount        *domain.Account     `json:\"account\"`\n\tOrganizationID int32               `json:\"organization_id\"`\n}\n\ntype AccountUpdatedEvent struct {\n\tEventID        string              `json:\"event_id\"`\n\tEventType      string              `json:\"event_type\"`\n\tTimestamp      time.Time           `json:\"timestamp\"`\n\tAccount        *domain.Account     `json:\"account\"`\n\tOrganizationID int32               `json:\"organization_id\"`\n\tPreviousRole   string              `json:\"previous_role\"`\n\tPreviousStatus string              `json:\"previous_status\"`\n}\n\ntype AccountDeletedEvent struct {\n\tEventID        string              `json:\"event_id\"`\n\tEventType      string              `json:\"event_type\"`\n\tTimestamp      time.Time           `json:\"timestamp\"`\n\tAccountID      int32               `json:\"account_id\"`\n\tOrganizationID int32               `json:\"organization_id\"`\n\tEmail          string              `json:\"email\"`\n}\n\ntype AccountLoginEvent struct {\n\tEventID        string              `json:\"event_id\"`\n\tEventType      string              `json:\"event_type\"`\n\tTimestamp      time.Time           `json:\"timestamp\"`\n\tAccountID      int32               `json:\"account_id\"`\n\tOrganizationID int32               `json:\"organization_id\"`\n\tEmail          string              `json:\"email\"`\n}"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/domain/repository.go",
    "content": "package domain\n\nimport \"context\"\n\n// OrganizationRepository defines the interface for organization data operations\ntype OrganizationRepository interface {\n\tCreate(ctx context.Context, org *Organization) (*Organization, error)\n\tGetByID(ctx context.Context, id int32) (*Organization, error)\n\tGetBySlug(ctx context.Context, slug string) (*Organization, error)\n\tGetByStytchID(ctx context.Context, stytchOrgID string) (*Organization, error)\n\tGetByUserEmail(ctx context.Context, email string) (*Organization, error)\n\tUpdate(ctx context.Context, org *Organization) (*Organization, error)\n\tUpdateStytchInfo(ctx context.Context, id int32, stytchOrgID, stytchConnectionID, stytchConnectionName string) (*Organization, error)\n\tDelete(ctx context.Context, id int32) error\n\tList(ctx context.Context, limit, offset int32) ([]*Organization, error)\n\tGetStats(ctx context.Context, id int32) (*OrganizationStats, error)\n}\n\n// AccountRepository defines the interface for account data operations\ntype AccountRepository interface {\n\tCreate(ctx context.Context, account *Account) (*Account, error)\n\tGetByID(ctx context.Context, orgID, accountID int32) (*Account, error)\n\tGetByEmail(ctx context.Context, orgID int32, email string) (*Account, error)\n\tListByOrganization(ctx context.Context, orgID int32) ([]*Account, error)\n\tUpdate(ctx context.Context, account *Account) (*Account, error)\n\tUpdateStytchInfo(ctx context.Context, orgID, accountID int32, stytchMemberID, stytchRoleID, stytchRoleSlug string, stytchEmailVerified bool) (*Account, error)\n\tUpdateLastLogin(ctx context.Context, orgID, accountID int32) (*Account, error)\n\tDelete(ctx context.Context, orgID, accountID int32) error\n\tGetOrganization(ctx context.Context, accountID int32) (*Organization, error)\n\tCheckPermission(ctx context.Context, orgID, accountID int32) (*AccountPermission, error)\n\tGetStats(ctx context.Context, accountID int32) (*AccountStats, error)\n}\n\n// OrganizationStats represents organization statistics\ntype OrganizationStats struct {\n\tOrganization       *Organization `json:\"organization\"`\n\tAccountCount       int64         `json:\"account_count\"`\n\tActiveAccountCount int64         `json:\"active_account_count\"`\n}\n\n// AccountStats represents account statistics with organization info\ntype AccountStats struct {\n\tAccount          *Account `json:\"account\"`\n\tOrganizationName string   `json:\"organization_name\"`\n\tOrganizationSlug string   `json:\"organization_slug\"`\n}\n\n// AccountPermission represents account permission check result\ntype AccountPermission struct {\n\tAccountID int32  `json:\"account_id\"`\n\tRole      string `json:\"role\"`\n\tStatus    string `json:\"status\"`\n\tOrgStatus string `json:\"org_status\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/infra/repositories/account_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/helpers\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n)\n\n// accountRepository implements domain.AccountRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype accountRepository struct {\n\tstore sqlc.Store\n}\n\n// NewAccountRepository creates a new AccountRepository implementation.\nfunc NewAccountRepository(store sqlc.Store) domain.AccountRepository {\n\treturn &accountRepository{store: store}\n}\n\nfunc (r *accountRepository) Create(ctx context.Context, account *domain.Account) (*domain.Account, error) {\n\tparams := sqlc.CreateAccountParams{\n\t\tOrganizationID:      account.OrganizationID,\n\t\tEmail:               account.Email,\n\t\tFullName:            account.FullName,\n\t\tStytchMemberID:      helpers.ToPgText(account.StytchMemberID),\n\t\tStytchRoleID:        helpers.ToPgText(account.StytchRoleID),\n\t\tStytchRoleSlug:      helpers.ToPgText(account.StytchRoleSlug),\n\t\tStytchEmailVerified: account.StytchEmailVerified,\n\t\tRole:                account.Role,\n\t\tStatus:              account.Status,\n\t}\n\n\tresult, err := r.store.CreateAccount(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create account: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *accountRepository) GetByID(ctx context.Context, orgID, accountID int32) (*domain.Account, error) {\n\tparams := sqlc.GetAccountByIDParams{\n\t\tID:             accountID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.GetAccountByID(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get account by ID: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *accountRepository) GetByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) {\n\tparams := sqlc.GetAccountByEmailParams{\n\t\tEmail:          email,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.GetAccountByEmail(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get account by email: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *accountRepository) ListByOrganization(ctx context.Context, orgID int32) ([]*domain.Account, error) {\n\tresults, err := r.store.ListAccountsByOrganization(ctx, orgID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list accounts by organization: %w\", err)\n\t}\n\n\taccounts := make([]*domain.Account, len(results))\n\tfor i, result := range results {\n\t\taccounts[i] = r.mapToDomain(&result)\n\t}\n\n\treturn accounts, nil\n}\n\nfunc (r *accountRepository) Update(ctx context.Context, account *domain.Account) (*domain.Account, error) {\n\tparams := sqlc.UpdateAccountParams{\n\t\tID:                  account.ID,\n\t\tOrganizationID:      account.OrganizationID,\n\t\tFullName:            account.FullName,\n\t\tStytchRoleID:        helpers.ToPgText(account.StytchRoleID),\n\t\tStytchRoleSlug:      helpers.ToPgText(account.StytchRoleSlug),\n\t\tStytchEmailVerified: account.StytchEmailVerified,\n\t\tRole:                account.Role,\n\t\tStatus:              account.Status,\n\t}\n\n\tresult, err := r.store.UpdateAccount(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to update account: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *accountRepository) UpdateStytchInfo(ctx context.Context, orgID, accountID int32, stytchMemberID, stytchRoleID, stytchRoleSlug string, stytchEmailVerified bool) (*domain.Account, error) {\n\tparams := sqlc.UpdateAccountStytchInfoParams{\n\t\tID:                  accountID,\n\t\tOrganizationID:      orgID,\n\t\tStytchMemberID:      helpers.ToPgText(stytchMemberID),\n\t\tStytchRoleID:        helpers.ToPgText(stytchRoleID),\n\t\tStytchRoleSlug:      helpers.ToPgText(stytchRoleSlug),\n\t\tStytchEmailVerified: stytchEmailVerified,\n\t}\n\n\tresult, err := r.store.UpdateAccountStytchInfo(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to update account Stytch info: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *accountRepository) UpdateLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) {\n\tparams := sqlc.UpdateAccountLastLoginParams{\n\t\tID:             accountID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.UpdateAccountLastLogin(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to update account last login: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *accountRepository) Delete(ctx context.Context, orgID, accountID int32) error {\n\tparams := sqlc.DeleteAccountParams{\n\t\tID:             accountID,\n\t\tOrganizationID: orgID,\n\t}\n\n\terr := r.store.DeleteAccount(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn domain.ErrAccountNotFound\n\t\t}\n\t\treturn fmt.Errorf(\"failed to delete account: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *accountRepository) GetOrganization(ctx context.Context, accountID int32) (*domain.Organization, error) {\n\tresult, err := r.store.GetAccountOrganization(ctx, accountID)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get account organization: %w\", err)\n\t}\n\n\treturn &domain.Organization{\n\t\tID:        result.ID,\n\t\tSlug:      result.Slug,\n\t\tName:      result.Name,\n\t\tStatus:    result.Status,\n\t\tCreatedAt: result.CreatedAt.Time,\n\t\tUpdatedAt: result.UpdatedAt.Time,\n\t}, nil\n}\n\nfunc (r *accountRepository) CheckPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) {\n\tparams := sqlc.CheckAccountPermissionParams{\n\t\tID:             accountID,\n\t\tOrganizationID: orgID,\n\t}\n\n\tresult, err := r.store.CheckAccountPermission(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to check account permission: %w\", err)\n\t}\n\n\treturn &domain.AccountPermission{\n\t\tAccountID: result.ID,\n\t\tRole:      result.Role,\n\t\tStatus:    result.Status,\n\t\tOrgStatus: result.OrgStatus,\n\t}, nil\n}\n\nfunc (r *accountRepository) GetStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) {\n\tresult, err := r.store.GetAccountStats(ctx, accountID)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrAccountNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get account stats: %w\", err)\n\t}\n\n\taccount := &domain.Account{\n\t\tID:                  result.ID,\n\t\tOrganizationID:      result.OrganizationID,\n\t\tEmail:               result.Email,\n\t\tFullName:            result.FullName,\n\t\tStytchMemberID:      helpers.FromPgText(result.StytchMemberID),\n\t\tStytchRoleID:        helpers.FromPgText(result.StytchRoleID),\n\t\tStytchRoleSlug:      helpers.FromPgText(result.StytchRoleSlug),\n\t\tStytchEmailVerified: result.StytchEmailVerified,\n\t\tRole:                result.Role,\n\t\tStatus:              result.Status,\n\t\tCreatedAt:           result.CreatedAt.Time,\n\t\tUpdatedAt:           result.UpdatedAt.Time,\n\t}\n\n\tif result.LastLoginAt.Valid {\n\t\taccount.LastLoginAt = &result.LastLoginAt.Time\n\t}\n\n\tstats := &domain.AccountStats{\n\t\tAccount:          account,\n\t\tOrganizationName: result.OrganizationName,\n\t\tOrganizationSlug: result.OrganizationSlug,\n\t}\n\n\treturn stats, nil\n}\n\n// mapToDomain converts SQLC account type to domain type.\n// This is the translation boundary - SQLC types never escape this function.\nfunc (r *accountRepository) mapToDomain(sqlcAccount *sqlc.OrganizationsAccount) *domain.Account {\n\taccount := &domain.Account{\n\t\tID:                  sqlcAccount.ID,\n\t\tOrganizationID:      sqlcAccount.OrganizationID,\n\t\tEmail:               sqlcAccount.Email,\n\t\tFullName:            sqlcAccount.FullName,\n\t\tStytchMemberID:      helpers.FromPgText(sqlcAccount.StytchMemberID),\n\t\tStytchRoleID:        helpers.FromPgText(sqlcAccount.StytchRoleID),\n\t\tStytchRoleSlug:      helpers.FromPgText(sqlcAccount.StytchRoleSlug),\n\t\tStytchEmailVerified: sqlcAccount.StytchEmailVerified,\n\t\tRole:                sqlcAccount.Role,\n\t\tStatus:              sqlcAccount.Status,\n\t\tCreatedAt:           sqlcAccount.CreatedAt.Time,\n\t\tUpdatedAt:           sqlcAccount.UpdatedAt.Time,\n\t}\n\n\t// Handle nullable LastLoginAt\n\tif sqlcAccount.LastLoginAt.Valid {\n\t\taccount.LastLoginAt = &sqlcAccount.LastLoginAt.Time\n\t}\n\n\treturn account\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/infra/repositories/organization_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/db/helpers\"\n\tsqlc \"github.com/moasq/go-b2b-starter/internal/db/postgres/sqlc/gen\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n)\n\n// organizationRepository implements domain.OrganizationRepository using SQLC internally.\n// SQLC types are never exposed outside this package.\ntype organizationRepository struct {\n\tstore sqlc.Store\n}\n\n// NewOrganizationRepository creates a new OrganizationRepository implementation.\nfunc NewOrganizationRepository(store sqlc.Store) domain.OrganizationRepository {\n\treturn &organizationRepository{store: store}\n}\n\nfunc (r *organizationRepository) Create(ctx context.Context, org *domain.Organization) (*domain.Organization, error) {\n\tparams := sqlc.CreateOrganizationParams{\n\t\tSlug:   org.Slug,\n\t\tName:   org.Name,\n\t\tStatus: org.Status,\n\t}\n\n\tresult, err := r.store.CreateOrganization(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create organization: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) GetByID(ctx context.Context, id int32) (*domain.Organization, error) {\n\tresult, err := r.store.GetOrganizationByID(ctx, id)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get organization by ID: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*domain.Organization, error) {\n\tresult, err := r.store.GetOrganizationBySlug(ctx, slug)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get organization by slug: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) GetByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) {\n\tresult, err := r.store.GetOrganizationByStytchID(ctx, helpers.ToPgText(stytchOrgID))\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get organization by Stytch ID: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) GetByUserEmail(ctx context.Context, email string) (*domain.Organization, error) {\n\tresult, err := r.store.GetOrganizationByUserEmail(ctx, email)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get organization by user email: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) Update(ctx context.Context, org *domain.Organization) (*domain.Organization, error) {\n\tparams := sqlc.UpdateOrganizationParams{\n\t\tID:                   org.ID,\n\t\tName:                 org.Name,\n\t\tStatus:               org.Status,\n\t\tStytchOrgID:          helpers.ToPgText(org.StytchOrgID),\n\t\tStytchConnectionID:   helpers.ToPgText(org.StytchConnectionID),\n\t\tStytchConnectionName: helpers.ToPgText(org.StytchConnectionName),\n\t}\n\n\tresult, err := r.store.UpdateOrganization(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to update organization: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) UpdateStytchInfo(ctx context.Context, id int32, stytchOrgID, stytchConnectionID, stytchConnectionName string) (*domain.Organization, error) {\n\tparams := sqlc.UpdateOrganizationStytchInfoParams{\n\t\tID:                   id,\n\t\tStytchOrgID:          helpers.ToPgText(stytchOrgID),\n\t\tStytchConnectionID:   helpers.ToPgText(stytchConnectionID),\n\t\tStytchConnectionName: helpers.ToPgText(stytchConnectionName),\n\t}\n\n\tresult, err := r.store.UpdateOrganizationStytchInfo(ctx, params)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to update organization Stytch info: %w\", err)\n\t}\n\n\treturn r.mapToDomain(&result), nil\n}\n\nfunc (r *organizationRepository) List(ctx context.Context, limit, offset int32) ([]*domain.Organization, error) {\n\tparams := sqlc.ListOrganizationsParams{\n\t\tLimit:  limit,\n\t\tOffset: offset,\n\t}\n\n\tresults, err := r.store.ListOrganizations(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list organizations: %w\", err)\n\t}\n\n\torganizations := make([]*domain.Organization, len(results))\n\tfor i, result := range results {\n\t\torganizations[i] = r.mapToDomain(&result)\n\t}\n\n\treturn organizations, nil\n}\n\nfunc (r *organizationRepository) Delete(ctx context.Context, id int32) error {\n\tif err := r.store.DeleteOrganization(ctx, id); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete organization: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (r *organizationRepository) GetStats(ctx context.Context, id int32) (*domain.OrganizationStats, error) {\n\tresult, err := r.store.GetOrganizationStats(ctx, id)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, domain.ErrOrganizationNotFound\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to get organization stats: %w\", err)\n\t}\n\n\torg := &domain.Organization{\n\t\tID:                   result.ID,\n\t\tSlug:                 result.Slug,\n\t\tName:                 result.Name,\n\t\tStatus:               result.Status,\n\t\tStytchOrgID:          helpers.FromPgText(result.StytchOrgID),\n\t\tStytchConnectionID:   helpers.FromPgText(result.StytchConnectionID),\n\t\tStytchConnectionName: helpers.FromPgText(result.StytchConnectionName),\n\t\tCreatedAt:            result.CreatedAt.Time,\n\t\tUpdatedAt:            result.UpdatedAt.Time,\n\t}\n\n\tstats := &domain.OrganizationStats{\n\t\tOrganization:       org,\n\t\tAccountCount:       result.AccountCount,\n\t\tActiveAccountCount: result.ActiveAccountCount,\n\t}\n\n\treturn stats, nil\n}\n\n// mapToDomain converts SQLC organization type to domain type.\n// This is the translation boundary - SQLC types never escape this function.\nfunc (r *organizationRepository) mapToDomain(sqlcOrg *sqlc.OrganizationsOrganization) *domain.Organization {\n\torg := &domain.Organization{\n\t\tID:        sqlcOrg.ID,\n\t\tSlug:      sqlcOrg.Slug,\n\t\tName:      sqlcOrg.Name,\n\t\tStatus:    sqlcOrg.Status,\n\t\tCreatedAt: sqlcOrg.CreatedAt.Time,\n\t\tUpdatedAt: sqlcOrg.UpdatedAt.Time,\n\t}\n\n\t// Map Stytch fields\n\tif sqlcOrg.StytchOrgID.Valid {\n\t\torg.StytchOrgID = sqlcOrg.StytchOrgID.String\n\t}\n\tif sqlcOrg.StytchConnectionID.Valid {\n\t\torg.StytchConnectionID = sqlcOrg.StytchConnectionID.String\n\t}\n\tif sqlcOrg.StytchConnectionName.Valid {\n\t\torg.StytchConnectionName = sqlcOrg.StytchConnectionName.String\n\t}\n\n\treturn org\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/infra/repositories/slug_generator.go",
    "content": "package repositories\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// generateSlug creates a URL-safe slug from an organization name\n// following Stytch requirements: 2-128 chars, alphanumeric + -._~\nfunc generateSlug(name string) string {\n\t// Convert to lowercase\n\tslug := strings.ToLower(name)\n\n\t// Replace spaces with hyphens\n\tslug = strings.ReplaceAll(slug, \" \", \"-\")\n\n\t// Remove characters not allowed by Stytch (keep alphanumeric and -._~)\n\treg := regexp.MustCompile(`[^a-z0-9\\-._~]+`)\n\tslug = reg.ReplaceAllString(slug, \"\")\n\n\t// Remove leading and trailing hyphens, dots, underscores, tildes\n\tslug = strings.Trim(slug, \"-._~\")\n\n\t// Ensure minimum length of 2 characters\n\tif len(slug) < 2 {\n\t\tslug = \"org-\" + slug\n\t}\n\n\t// Ensure maximum length of 128 characters\n\tif len(slug) > 128 {\n\t\tslug = slug[:128]\n\t\t// Re-trim in case we cut off in middle of separator\n\t\tslug = strings.TrimRight(slug, \"-._~\")\n\t}\n\n\treturn slug\n}\n\n// generateSlugWithSuffix generates a slug with numeric suffix for retry attempts\n// attempt 1 returns base slug, attempt 2+ adds suffix\nfunc generateSlugWithSuffix(baseSlug string, attempt int) string {\n\tif attempt <= 1 {\n\t\treturn baseSlug\n\t}\n\n\tsuffix := fmt.Sprintf(\"-%d\", attempt)\n\tslug := baseSlug + suffix\n\n\t// Ensure we don't exceed 128 character limit\n\tif len(slug) > 128 {\n\t\t// Truncate base slug to make room for suffix\n\t\tmaxBaseLength := 128 - len(suffix)\n\t\tslug = baseSlug[:maxBaseLength] + suffix\n\t\t// Re-trim in case we cut off in middle of separator\n\t\tslug = strings.TrimRight(slug[:len(slug)-len(suffix)], \"-._~\") + suffix\n\t}\n\n\treturn slug\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/infra/repositories/stytch_member_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\tstytchcfg \"github.com/moasq/go-b2b-starter/internal/platform/stytch\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/magiclinks/email\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations/members\"\n)\n\ntype stytchMemberRepository struct {\n\tclient *stytchcfg.Client\n\tconfig stytchcfg.Config\n\tlogger loggerDomain.Logger\n}\n\n// NewStytchMemberRepository creates a Stytch-backed member repository.\nfunc NewStytchMemberRepository(client *stytchcfg.Client, cfg stytchcfg.Config, logger loggerDomain.Logger) domain.AuthMemberRepository {\n\treturn &stytchMemberRepository{\n\t\tclient: client,\n\t\tconfig: cfg,\n\t\tlogger: logger,\n\t}\n}\n\nfunc (r *stytchMemberRepository) CreateMember(ctx context.Context, req *domain.CreateAuthMemberRequest) (*domain.AuthMember, error) {\n\tif err := req.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid create member request: %w\", err)\n\t}\n\n\tstytchReq := &members.CreateParams{\n\t\tOrganizationID: req.OrganizationID,\n\t\tEmailAddress:   req.Email,\n\t\tName:           req.Name,\n\t}\n\n\tresp, err := r.client.API().Organizations.Members.Create(ctx, stytchReq)\n\tif err != nil {\n\t\tr.logger.Error(\"failed to create member in Stytch\", loggerDomain.Fields{\n\t\t\t\"org_id\": req.OrganizationID,\n\t\t\t\"email\":  req.Email,\n\t\t\t\"error\":  err.Error(),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"stytch create member: %w\", stytchcfg.MapError(err))\n\t}\n\n\tmember := mapToAuthMember(resp.Member)\n\n\treturn member, nil\n}\n\nfunc (r *stytchMemberRepository) UpdateMember(ctx context.Context, req *domain.UpdateAuthMemberRequest) (*domain.AuthMember, error) {\n\tif err := req.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid update member request: %w\", err)\n\t}\n\n\tparams := &members.UpdateParams{\n\t\tOrganizationID: req.OrganizationID,\n\t\tMemberID:       req.MemberID,\n\t}\n\n\tif req.Name != nil {\n\t\tparams.Name = *req.Name\n\t}\n\tif len(req.Roles) > 0 {\n\t\trolesCopy := append([]string(nil), req.Roles...)\n\t\tparams.Roles = &rolesCopy\n\t}\n\tif req.TrustedMeta != nil {\n\t\tparams.TrustedMetadata = req.TrustedMeta\n\t}\n\tif req.UntrustedMeta != nil {\n\t\tparams.UntrustedMetadata = req.UntrustedMeta\n\t}\n\n\tresp, err := r.client.API().Organizations.Members.Update(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch update member: %w\", stytchcfg.MapError(err))\n\t}\n\n\treturn mapToAuthMember(resp.Member), nil\n}\n\nfunc (r *stytchMemberRepository) GetMember(ctx context.Context, organizationID, memberID string) (*domain.AuthMember, error) {\n\tif organizationID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\tif memberID == \"\" {\n\t\treturn nil, domain.ErrAuthMemberIDRequired\n\t}\n\n\tresp, err := r.client.API().Organizations.Members.Get(ctx, &members.GetParams{\n\t\tOrganizationID: organizationID,\n\t\tMemberID:       memberID,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch get member: %w\", stytchcfg.MapError(err))\n\t}\n\n\treturn mapToAuthMember(resp.Member), nil\n}\n\nfunc (r *stytchMemberRepository) GetMemberByEmail(ctx context.Context, organizationID, emailAddr string) (*domain.AuthMember, error) {\n\tif organizationID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\tif emailAddr == \"\" {\n\t\treturn nil, domain.ErrAuthEmailRequired\n\t}\n\n\tresp, err := r.client.API().Organizations.Members.Get(ctx, &members.GetParams{\n\t\tOrganizationID: organizationID,\n\t\tEmailAddress:   emailAddr,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch get member by email: %w\", stytchcfg.MapError(err))\n\t}\n\n\treturn mapToAuthMember(resp.Member), nil\n}\n\nfunc (r *stytchMemberRepository) ListMembers(ctx context.Context, organizationID string, limit, offset int) ([]*domain.AuthMember, error) {\n\tif organizationID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\n\tparams := &members.SearchParams{\n\t\tOrganizationIds: []string{organizationID},\n\t}\n\n\trequested := 0\n\tif limit > 0 {\n\t\trequested = limit\n\t}\n\tif offset > 0 {\n\t\trequested += offset\n\t}\n\tif requested > 0 {\n\t\tparams.Limit = uint32(requested)\n\t}\n\n\tresp, err := r.client.API().Organizations.Members.Search(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch search members: %w\", stytchcfg.MapError(err))\n\t}\n\n\tmembersList := resp.Members\n\tif offset > 0 {\n\t\tif offset >= len(membersList) {\n\t\t\tmembersList = nil\n\t\t} else {\n\t\t\tmembersList = membersList[offset:]\n\t\t}\n\t}\n\tif limit > 0 && limit < len(membersList) {\n\t\tmembersList = membersList[:limit]\n\t}\n\n\tresults := make([]*domain.AuthMember, 0, len(membersList))\n\tfor _, m := range membersList {\n\t\tresults = append(results, mapToAuthMember(m))\n\t}\n\n\treturn results, nil\n}\n\nfunc (r *stytchMemberRepository) RemoveMembers(ctx context.Context, req *domain.RemoveAuthMembersRequest) error {\n\tif err := req.Validate(); err != nil {\n\t\treturn fmt.Errorf(\"invalid remove members request: %w\", err)\n\t}\n\n\tfor _, memberID := range req.MemberIDs {\n\t\t_, err := r.client.API().Organizations.Members.Delete(ctx, &members.DeleteParams{\n\t\t\tOrganizationID: req.OrganizationID,\n\t\t\tMemberID:       memberID,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"stytch delete member %s: %w\", memberID, stytchcfg.MapError(err))\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *stytchMemberRepository) AssignRoles(ctx context.Context, req *domain.AssignAuthRolesRequest) error {\n\tif err := req.Validate(); err != nil {\n\t\treturn fmt.Errorf(\"invalid assign roles request: %w\", err)\n\t}\n\n\tupdateParams := &members.UpdateParams{\n\t\tOrganizationID: req.OrganizationID,\n\t\tMemberID:       req.MemberID,\n\t}\n\tif len(req.Roles) > 0 {\n\t\trolesCopy := append([]string(nil), req.Roles...)\n\t\tupdateParams.Roles = &rolesCopy\n\t}\n\n\t_, err := r.client.API().Organizations.Members.Update(ctx, updateParams)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"stytch update member roles: %w\", stytchcfg.MapError(err))\n\t}\n\treturn nil\n}\n\nfunc (r *stytchMemberRepository) SendMagicLink(ctx context.Context, req *domain.SendMagicLinkRequest) error {\n\tif err := req.Validate(); err != nil {\n\t\treturn fmt.Errorf(\"invalid magic link request: %w\", err)\n\t}\n\n\tparams := &email.LoginOrSignupParams{\n\t\tOrganizationID: req.OrganizationID,\n\t\tEmailAddress:   req.Email,\n\t}\n\n\tloginRedirect := strings.TrimSpace(req.LoginRedirectURL)\n\tif loginRedirect == \"\" {\n\t\tloginRedirect = strings.TrimSpace(r.config.LoginRedirectURL)\n\t}\n\tif loginRedirect != \"\" {\n\t\tparams.LoginRedirectURL = loginRedirect\n\t}\n\n\tsignupRedirect := strings.TrimSpace(req.SignupRedirectURL)\n\tif signupRedirect == \"\" {\n\t\tsignupRedirect = strings.TrimSpace(r.config.InviteRedirectURL)\n\t\tif signupRedirect == \"\" {\n\t\t\tsignupRedirect = loginRedirect\n\t\t}\n\t}\n\tif signupRedirect != \"\" {\n\t\tparams.SignupRedirectURL = signupRedirect\n\t}\n\n\tif _, err := r.client.API().MagicLinks.Email.LoginOrSignup(ctx, params); err != nil {\n\t\treturn fmt.Errorf(\"stytch send magic link: %w\", stytchcfg.MapError(err))\n\t}\n\n\treturn nil\n}\n\nfunc mapToAuthMember(src organizations.Member) *domain.AuthMember {\n\tvar createdAt, updatedAt time.Time\n\tif src.CreatedAt != nil {\n\t\tcreatedAt = src.CreatedAt.UTC()\n\t}\n\tif src.UpdatedAt != nil {\n\t\tupdatedAt = src.UpdatedAt.UTC()\n\t}\n\n\troleIDs := make([]string, 0, len(src.Roles))\n\tfor _, role := range src.Roles {\n\t\tif role.RoleID != \"\" {\n\t\t\troleIDs = append(roleIDs, role.RoleID)\n\t\t}\n\t}\n\n\treturn &domain.AuthMember{\n\t\tMemberID:       src.MemberID,\n\t\tOrganizationID: src.OrganizationID,\n\t\tEmail:          src.EmailAddress,\n\t\tName:           src.Name,\n\t\tRoles:          roleIDs,\n\t\tStatus:         src.Status,\n\t\tEmailVerified:  src.EmailAddressVerified,\n\t\tCreatedAt:      createdAt,\n\t\tUpdatedAt:      updatedAt,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/infra/repositories/stytch_organization_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tstytchcfg \"github.com/moasq/go-b2b-starter/internal/platform/stytch\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations\"\n)\n\ntype stytchOrganizationRepository struct {\n\tclient       *stytchcfg.Client\n\tlogger       loggerDomain.Logger\n\tlocalOrgRepo domain.OrganizationRepository\n}\n\n// NewStytchOrganizationRepository creates a Stytch-backed organization repository.\nfunc NewStytchOrganizationRepository(\n\tclient *stytchcfg.Client,\n\tlogger loggerDomain.Logger,\n\tlocalOrgRepo domain.OrganizationRepository,\n) domain.AuthOrganizationRepository {\n\treturn &stytchOrganizationRepository{\n\t\tclient:       client,\n\t\tlogger:       logger,\n\t\tlocalOrgRepo: localOrgRepo,\n\t}\n}\n\nfunc (r *stytchOrganizationRepository) CreateOrganization(ctx context.Context, req *domain.CreateAuthOrganizationRequest) (*domain.AuthOrganization, error) {\n\tif err := req.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid create organization request: %w\", err)\n\t}\n\n\t// Generate base slug from display name (infrastructure concern)\n\tbaseSlug := generateSlug(req.DisplayName)\n\n\t// Prepare email invites parameter\n\temailInvites := \"NOT_ALLOWED\"\n\tif req.EmailInvitesAllowed {\n\t\temailInvites = \"ALL_ALLOWED\"\n\t}\n\n\t// Retry loop for duplicate slug handling (infrastructure concern)\n\tconst maxAttempts = 5\n\tvar lastErr error\n\n\tfor attempt := 1; attempt <= maxAttempts; attempt++ {\n\t\t// Generate slug with suffix if needed\n\t\tslug := generateSlugWithSuffix(baseSlug, attempt)\n\n\t\tr.logger.Debug(\"attempting to create organization\", loggerDomain.Fields{\n\t\t\t\"display_name\": req.DisplayName,\n\t\t\t\"slug\":         slug,\n\t\t\t\"attempt\":      attempt,\n\t\t})\n\n\t\t// Try to create in Stytch\n\t\tparams := &organizations.CreateParams{\n\t\t\tOrganizationSlug: slug,\n\t\t\tOrganizationName: req.DisplayName,\n\t\t\tEmailInvites:     emailInvites,\n\t\t}\n\n\t\tresp, err := r.client.API().Organizations.Create(ctx, params)\n\n\t\t// Success - return immediately\n\t\tif err == nil {\n\t\t\tif attempt > 1 {\n\t\t\t\tr.logger.Info(\"created organization with retry\", loggerDomain.Fields{\n\t\t\t\t\t\"display_name\": req.DisplayName,\n\t\t\t\t\t\"final_slug\":   slug,\n\t\t\t\t\t\"attempts\":     attempt,\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn mapToAuthOrganization(resp.Organization), nil\n\t\t}\n\n\t\t// Check if duplicate slug error - retry\n\t\tif stytchcfg.IsDuplicateSlugError(err) {\n\t\t\tr.logger.Debug(\"slug already exists, retrying\", loggerDomain.Fields{\n\t\t\t\t\"attempted_slug\": slug,\n\t\t\t\t\"attempt\":        attempt,\n\t\t\t\t\"max_attempts\":   maxAttempts,\n\t\t\t})\n\t\t\tlastErr = err\n\t\t\tcontinue // Try next suffix\n\t\t}\n\n\t\t// Other error - fail immediately\n\t\treturn nil, fmt.Errorf(\"stytch create organization: %w\", stytchcfg.MapError(err))\n\t}\n\n\t// All attempts exhausted\n\tr.logger.Error(\"failed to create organization after retries\", loggerDomain.Fields{\n\t\t\"display_name\": req.DisplayName,\n\t\t\"base_slug\":    baseSlug,\n\t\t\"attempts\":     maxAttempts,\n\t})\n\treturn nil, fmt.Errorf(\"failed to create organization after %d attempts, slug conflicts: %w\",\n\t\tmaxAttempts, stytchcfg.MapError(lastErr))\n}\n\nfunc (r *stytchOrganizationRepository) GetOrganization(ctx context.Context, organizationID string) (*domain.AuthOrganization, error) {\n\tif organizationID == \"\" {\n\t\treturn nil, domain.ErrAuthOrganizationIDRequired\n\t}\n\n\tresp, err := r.client.API().Organizations.Get(ctx, &organizations.GetParams{OrganizationID: organizationID})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch get organization: %w\", stytchcfg.MapError(err))\n\t}\n\n\treturn mapToAuthOrganization(resp.Organization), nil\n}\n\nfunc (r *stytchOrganizationRepository) DeleteOrganization(ctx context.Context, organizationID string) error {\n\tif organizationID == \"\" {\n\t\treturn domain.ErrAuthOrganizationIDRequired\n\t}\n\n\t_, err := r.client.API().Organizations.Delete(ctx, &organizations.DeleteParams{OrganizationID: organizationID})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"stytch delete organization: %w\", stytchcfg.MapError(err))\n\t}\n\n\treturn nil\n}\n\nfunc (r *stytchOrganizationRepository) CheckEmailExists(ctx context.Context, email string) (bool, error) {\n\tif email == \"\" {\n\t\treturn false, fmt.Errorf(\"email cannot be empty\")\n\t}\n\n\tr.logger.Debug(\"checking if email exists\", loggerDomain.Fields{\n\t\t\"email\": email,\n\t})\n\n\t// Use the local organization repository to check if email exists\n\t// GetByUserEmail returns organization if email is found (and account is active)\n\t_, err := r.localOrgRepo.GetByUserEmail(ctx, email)\n\tif err != nil {\n\t\t// Check if it's a \"not found\" error using proper error comparison\n\t\tif errors.Is(err, domain.ErrOrganizationNotFound) || errors.Is(err, sql.ErrNoRows) {\n\t\t\tr.logger.Debug(\"email not found\", loggerDomain.Fields{\n\t\t\t\t\"email\": email,\n\t\t\t})\n\t\t\treturn false, nil\n\t\t}\n\t\t// Other errors are real failures\n\t\tr.logger.Error(\"failed to check email existence\", loggerDomain.Fields{\n\t\t\t\"email\": email,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn false, fmt.Errorf(\"failed to check email existence: %w\", err)\n\t}\n\n\tr.logger.Debug(\"email exists\", loggerDomain.Fields{\n\t\t\"email\": email,\n\t})\n\treturn true, nil\n}\n\nfunc mapToAuthOrganization(src organizations.Organization) *domain.AuthOrganization {\n\tvar createdAt, updatedAt time.Time\n\tif src.CreatedAt != nil {\n\t\tcreatedAt = src.CreatedAt.UTC()\n\t}\n\tif src.UpdatedAt != nil {\n\t\tupdatedAt = src.UpdatedAt.UTC()\n\t}\n\n\treturn &domain.AuthOrganization{\n\t\tOrganizationID: src.OrganizationID,\n\t\tSlug:           src.OrganizationSlug,\n\t\tDisplayName:    src.OrganizationName,\n\t\tCreatedAt:      createdAt,\n\t\tUpdatedAt:      updatedAt,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/infra/repositories/stytch_role_repository.go",
    "content": "package repositories\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tstytchcfg \"github.com/moasq/go-b2b-starter/internal/platform/stytch\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac\"\n)\n\ntype stytchRoleRepository struct {\n\tclient *stytchcfg.Client\n\tlogger loggerDomain.Logger\n}\n\n// NewStytchRoleRepository creates a Stytch-backed role repository.\nfunc NewStytchRoleRepository(client *stytchcfg.Client, logger loggerDomain.Logger) domain.AuthRoleRepository {\n\treturn &stytchRoleRepository{\n\t\tclient: client,\n\t\tlogger: logger,\n\t}\n}\n\nfunc (r *stytchRoleRepository) GetRoleByID(ctx context.Context, roleID string) (*domain.AuthRole, error) {\n\tif roleID == \"\" {\n\t\treturn nil, domain.ErrAuthRoleNotFound\n\t}\n\n\trole, err := r.findRole(ctx, func(role *rbac.PolicyRole) bool {\n\t\treturn role.RoleID == roleID\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif role == nil {\n\t\treturn nil, domain.ErrAuthRoleNotFound\n\t}\n\treturn role, nil\n}\n\nfunc (r *stytchRoleRepository) GetRoleBySlug(ctx context.Context, slug string) (*domain.AuthRole, error) {\n\tif slug == \"\" {\n\t\treturn nil, domain.ErrAuthRoleNotFound\n\t}\n\n\trole, err := r.findRole(ctx, func(role *rbac.PolicyRole) bool {\n\t\treturn role.RoleID == slug\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif role == nil {\n\t\treturn nil, domain.ErrAuthRoleNotFound\n\t}\n\treturn role, nil\n}\n\nfunc (r *stytchRoleRepository) ListRoles(ctx context.Context, limit, offset int) ([]*domain.AuthRole, error) {\n\tpolicy, err := r.fetchPolicy(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif policy == nil || len(policy.Roles) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tstart := offset\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif start > len(policy.Roles) {\n\t\tstart = len(policy.Roles)\n\t}\n\n\tend := len(policy.Roles)\n\tif limit > 0 && start+limit < end {\n\t\tend = start + limit\n\t}\n\n\tresult := make([]*domain.AuthRole, 0, end-start)\n\troles := policy.Roles[start:end]\n\tfor i := range roles {\n\t\trole := roles[i]\n\t\tresult = append(result, mapToAuthRole(&role))\n\t}\n\treturn result, nil\n}\n\nfunc (r *stytchRoleRepository) findRole(ctx context.Context, predicate func(*rbac.PolicyRole) bool) (*domain.AuthRole, error) {\n\tpolicy, err := r.fetchPolicy(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif policy == nil {\n\t\treturn nil, nil\n\t}\n\n\tfor i := range policy.Roles {\n\t\trole := policy.Roles[i]\n\t\tif predicate(&role) {\n\t\t\treturn mapToAuthRole(&role), nil\n\t\t}\n\t}\n\treturn nil, nil\n}\n\nfunc (r *stytchRoleRepository) fetchPolicy(ctx context.Context) (*rbac.Policy, error) {\n\tresp, err := r.client.API().RBAC.Policy(ctx, &rbac.PolicyParams{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch fetch rbac policy: %w\", stytchcfg.MapError(err))\n\t}\n\treturn resp.Policy, nil\n}\n\nfunc mapToAuthRole(src *rbac.PolicyRole) *domain.AuthRole {\n\tif src == nil {\n\t\treturn nil\n\t}\n\n\trole := &domain.AuthRole{\n\t\tRoleID:      src.RoleID,\n\t\tName:        src.RoleID,\n\t\tDescription: src.Description,\n\t}\n\n\tif len(src.Permissions) > 0 {\n\t\tperms := make([]string, 0, len(src.Permissions))\n\t\tfor _, perm := range src.Permissions {\n\t\t\t// Actions may be empty for resource-only permissions.\n\t\t\tif len(perm.Actions) == 0 {\n\t\t\t\tperms = append(perms, perm.ResourceID)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, action := range perm.Actions {\n\t\t\t\tperms = append(perms, fmt.Sprintf(\"%s:%s\", perm.ResourceID, action))\n\t\t\t}\n\t\t}\n\t\trole.Permissions = perms\n\t}\n\n\treturn role\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/member_handler.go",
    "content": "package organizations\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services\"\n\t\"github.com/moasq/go-b2b-starter/pkg/response\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\ntype MemberHandler struct {\n\tmemberService services.MemberService\n\tlogger        logger.Logger\n}\n\nfunc NewMemberHandler(\n\tmemberService services.MemberService,\n\tlogger logger.Logger,\n) *MemberHandler {\n\treturn &MemberHandler{\n\t\tmemberService: memberService,\n\t\tlogger:        logger,\n\t}\n}\n\n// BootstrapOrganization creates a new organization with an admin member.\n// @Summary Bootstrap organization\n// @Description Creates a new organization in Stytch with an initial admin member. The admin receives a magic link invite email to complete passwordless onboarding. Organization slug is auto-generated from the organization name.\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Param request body services.BootstrapOrganizationRequest true \"Organization bootstrap request (passwordless - no password required)\"\n// @Success 201 {object} services.BootstrapOrganizationResponse\n// @Failure 400 {object} map[string]any \"Invalid request payload\"\n// @Failure 500 {object} map[string]any \"Failed to bootstrap organization\"\n// @Router /auth/signup [post]\nfunc (h *MemberHandler) BootstrapOrganization(c *gin.Context) {\n\tvar req services.BootstrapOrganizationRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"invalid bootstrap request payload\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid request payload\", err)\n\t\treturn\n\t}\n\n\t// Infrastructure layer handles slug generation and duplicate handling\n\tresult, err := h.memberService.BootstrapOrganizationWithOwner(c.Request.Context(), &req)\n\tif err != nil {\n\t\th.logger.Error(\"failed to bootstrap organization\", map[string]any{\n\t\t\t\"org_name\": req.OrgDisplayName,\n\t\t\t\"error\":    err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to bootstrap organization\", err)\n\t\treturn\n\t}\n\n\th.logger.Info(\"organization bootstrapped successfully\", map[string]any{\n\t\t\"stytch_org_id\": result.OrganizationID,\n\t\t\"admin_member\":  result.OwnerMemberID,\n\t\t\"magic_link\":    result.MagicLinkSent,\n\t})\n\n\tresponse.Success(c, http.StatusCreated, result)\n}\n\n// AddMember adds a new member to an existing organization.\n// @Summary Add member to organization\n// @Description Adds a new member to an existing organization with a specified role. Organization ID is automatically extracted from JWT token. Member receives a magic link invite email for passwordless authentication. Request body: {\"email\": \"user@example.com\", \"name\": \"Full Name\", \"role_slug\": \"member\"}\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Param Authorization header string true \"Bearer JWT token\"\n// @Param email body string true \"Member email address\"\n// @Param name body string true \"Member full name\"\n// @Param role_slug body string false \"Role slug (defaults to 'member')\"\n// @Success 201 {object} services.AddMemberResponse\n// @Failure 400 {object} map[string]any \"Invalid request payload or missing organization context\"\n// @Failure 500 {object} map[string]any \"Failed to add member\"\n// @Router /auth/members [post]\nfunc (h *MemberHandler) AddMember(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"request context not found\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tvar req services.AddMemberRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"invalid add member request payload\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid request payload\", err)\n\t\treturn\n\t}\n\n\treq.OrgID = reqCtx.ProviderOrgID\n\tif strings.TrimSpace(req.RoleSlug) == \"\" {\n\t\treq.RoleSlug = \"member\"\n\t}\n\n\tresult, err := h.memberService.AddMemberDirect(c.Request.Context(), &req)\n\tif err != nil {\n\t\th.logger.Error(\"failed to add member\", map[string]any{\n\t\t\t\"org_id\": reqCtx.ProviderOrgID,\n\t\t\t\"email\":  req.Email,\n\t\t\t\"error\":  err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to add member\", err)\n\t\treturn\n\t}\n\n\th.logger.Info(\"member added to organization\", map[string]any{\n\t\t\"org_id\":      result.OrgID,\n\t\t\"member_id\":   result.MemberID,\n\t\t\"invite_sent\": result.InviteSent,\n\t})\n\n\tresponse.Success(c, http.StatusCreated, result)\n}\n\n// ListMembers retrieves all members of the current organization.\n// @Summary List organization members\n// @Description Retrieves all members of the current organization. Restricted to admin role only.\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Success 200 {object} services.ListMembersResponse\n// @Failure 400 {object} map[string]any \"Missing organization context\"\n// @Failure 403 {object} map[string]any \"Insufficient permissions - admin role required\"\n// @Failure 500 {object} map[string]any \"Failed to list members\"\n// @Router /auth/members [get]\nfunc (h *MemberHandler) ListMembers(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"request context not found\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tresult, err := h.memberService.ListOrganizationMembers(c.Request.Context(), reqCtx.ProviderOrgID)\n\tif err != nil {\n\t\th.logger.Error(\"failed to list members\", map[string]any{\n\t\t\t\"org_id\": reqCtx.ProviderOrgID,\n\t\t\t\"error\":  err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to list members\", err)\n\t\treturn\n\t}\n\n\th.logger.Info(\"members listed successfully\", map[string]any{\n\t\t\"org_id\": reqCtx.ProviderOrgID,\n\t\t\"count\":  result.Total,\n\t})\n\n\tresponse.Success(c, http.StatusOK, result)\n}\n\n// GetProfile retrieves the current authenticated user's profile.\n// @Summary Get current user profile\n// @Description Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Success 200 {object} services.ProfileResponse\n// @Failure 400 {object} map[string]any \"Missing required context (organization or claims)\"\n// @Failure 401 {object} map[string]any \"Authentication required\"\n// @Failure 500 {object} map[string]any \"Failed to retrieve profile\"\n// @Router /auth/profile/me [get]\nfunc (h *MemberHandler) GetProfile(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"request context not found\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tidentity := reqCtx.Identity\n\tif identity == nil {\n\t\th.logger.Error(\"identity not found in context\", nil)\n\t\tresponse.Error(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\treturn\n\t}\n\n\t// Get profile using service\n\tprofile, err := h.memberService.GetCurrentUserProfile(\n\t\tc.Request.Context(),\n\t\treqCtx.ProviderOrgID,\n\t\tidentity.UserID, // member_id\n\t\tidentity.Email,\n\t)\n\tif err != nil {\n\t\th.logger.Error(\"failed to get user profile\", map[string]any{\n\t\t\t\"org_id\":    reqCtx.ProviderOrgID,\n\t\t\t\"member_id\": identity.UserID,\n\t\t\t\"email\":     identity.Email,\n\t\t\t\"error\":     err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to retrieve profile\", err)\n\t\treturn\n\t}\n\n\t// Add computed permissions from identity (derived from Stytch RBAC policy)\n\tprofile.Permissions = auth.PermissionsToStrings(identity.Permissions)\n\n\th.logger.Info(\"profile retrieved successfully\", map[string]any{\n\t\t\"member_id\":         identity.UserID,\n\t\t\"org_id\":            reqCtx.ProviderOrgID,\n\t\t\"email\":             identity.Email,\n\t\t\"permissions_count\": len(profile.Permissions),\n\t})\n\n\tresponse.Success(c, http.StatusOK, profile)\n}\n\n// @Summary Delete organization member\n// @Description Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Param Authorization header string true \"Bearer JWT token\"\n// @Param member_id path string true \"Member ID to delete\"\n// @Success 204 {object} map[string]any \"Member deleted successfully\"\n// @Failure 400 {object} map[string]any \"Invalid member ID or missing organization context\"\n// @Failure 403 {object} map[string]any \"Insufficient permissions - admin role required\"\n// @Failure 404 {object} map[string]any \"Member not found\"\n// @Failure 500 {object} map[string]any \"Failed to delete member\"\n// @Router /auth/members/{member_id} [delete]\nfunc (h *MemberHandler) DeleteMember(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"request context not found\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tidentity := reqCtx.Identity\n\tif identity == nil {\n\t\th.logger.Error(\"identity not found in context\", nil)\n\t\tresponse.Error(c, http.StatusUnauthorized, \"authentication required\", nil)\n\t\treturn\n\t}\n\n\t// Extract member_id from path parameter\n\tmemberID := c.Param(\"member_id\")\n\tif memberID == \"\" {\n\t\th.logger.Error(\"member_id path parameter is missing\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"member_id is required\", nil)\n\t\treturn\n\t}\n\n\t// Business rule: Cannot delete yourself\n\tif memberID == identity.UserID {\n\t\th.logger.Warn(\"user attempted to delete themselves\", map[string]any{\n\t\t\t\"member_id\":    memberID,\n\t\t\t\"current_user\": identity.UserID,\n\t\t\t\"org_id\":       reqCtx.ProviderOrgID,\n\t\t})\n\t\tresponse.Error(c, http.StatusForbidden, \"cannot delete yourself\", nil)\n\t\treturn\n\t}\n\n\t// Delete member using service\n\terr := h.memberService.DeleteOrganizationMember(c.Request.Context(), reqCtx.ProviderOrgID, memberID)\n\tif err != nil {\n\t\th.logger.Error(\"failed to delete member\", map[string]any{\n\t\t\t\"org_id\":    reqCtx.ProviderOrgID,\n\t\t\t\"member_id\": memberID,\n\t\t\t\"error\":     err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to delete member\", err)\n\t\treturn\n\t}\n\n\th.logger.Info(\"member deleted successfully\", map[string]any{\n\t\t\"member_id\":  memberID,\n\t\t\"org_id\":     reqCtx.ProviderOrgID,\n\t\t\"deleted_by\": identity.UserID,\n\t})\n\n\tresponse.Success(c, http.StatusNoContent, nil)\n}\n\n// @Summary Check if email exists\n// @Description Checks if an email exists in any organization. Returns 200 OK (empty response) if exists, 404 Not Found if doesn't exist. This is a public endpoint used during login flow.\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Param email query string true \"Email address to check\"\n// @Success 200 \"Email exists\"\n// @Failure 400 {object} map[string]any \"Invalid email format\"\n// @Failure 404 {object} map[string]any \"Email not found\"\n// @Failure 500 {object} map[string]any \"Internal server error\"\n// @Router /auth/check-email [get]\nfunc (h *MemberHandler) CheckEmail(c *gin.Context) {\n\t// Extract and validate email from query parameter\n\temail := strings.TrimSpace(c.Query(\"email\"))\n\tif email == \"\" {\n\t\th.logger.Warn(\"email parameter is missing\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"email parameter is required\", nil)\n\t\treturn\n\t}\n\n\th.logger.Debug(\"checking email existence\", map[string]any{\n\t\t\"email\": email,\n\t})\n\n\t// Check if email exists using service\n\texists, err := h.memberService.CheckEmailExists(c.Request.Context(), email)\n\tif err != nil {\n\t\th.logger.Error(\"failed to check email existence\", map[string]any{\n\t\t\t\"email\": email,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to check email existence\", err)\n\t\treturn\n\t}\n\n\t// Return 404 if email doesn't exist\n\tif !exists {\n\t\th.logger.Debug(\"email not found\", map[string]any{\n\t\t\t\"email\": email,\n\t\t})\n\t\tresponse.Error(c, http.StatusNotFound, \"email not found\", nil)\n\t\treturn\n\t}\n\n\t// Return 200 OK with empty response if email exists\n\th.logger.Debug(\"email exists\", map[string]any{\n\t\t\"email\": email,\n\t})\n\tresponse.Success(c, http.StatusOK, gin.H{})\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/module.go",
    "content": "package organizations\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/infra/repositories\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tstytchcfg \"github.com/moasq/go-b2b-starter/internal/platform/stytch\"\n)\n\n// Module provides organization module dependencies\ntype Module struct {\n\tcontainer *dig.Container\n}\n\nfunc NewModule(container *dig.Container) *Module {\n\treturn &Module{\n\t\tcontainer: container,\n\t}\n}\n\n// RegisterDependencies registers all organization module dependencies\n// Note: Repository implementations are registered in internal/db/inject.go\nfunc (m *Module) RegisterDependencies() error {\n\t// Register auth provider repositories (Stytch implementation)\n\tif err := m.container.Provide(func(\n\t\tclient *stytchcfg.Client,\n\t\tlogger loggerDomain.Logger,\n\t\tlocalOrgRepo domain.OrganizationRepository,\n\t) domain.AuthOrganizationRepository {\n\t\treturn repositories.NewStytchOrganizationRepository(client, logger, localOrgRepo)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif err := m.container.Provide(func(\n\t\tclient *stytchcfg.Client,\n\t\tcfg *stytchcfg.Config,\n\t\tlogger loggerDomain.Logger,\n\t) domain.AuthMemberRepository {\n\t\treturn repositories.NewStytchMemberRepository(client, *cfg, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif err := m.container.Provide(func(\n\t\tclient *stytchcfg.Client,\n\t\tlogger loggerDomain.Logger,\n\t) domain.AuthRoleRepository {\n\t\treturn repositories.NewStytchRoleRepository(client, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register organization service\n\tif err := m.container.Provide(func(\n\t\torgRepo domain.OrganizationRepository,\n\t\taccountRepo domain.AccountRepository,\n\t) services.OrganizationService {\n\t\treturn services.NewOrganizationService(orgRepo, accountRepo)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register member service (for auth member operations)\n\tif err := m.container.Provide(func(\n\t\tauthOrgRepo domain.AuthOrganizationRepository,\n\t\tauthMemberRepo domain.AuthMemberRepository,\n\t\tauthRoleRepo domain.AuthRoleRepository,\n\t\tlocalOrgRepo domain.OrganizationRepository,\n\t\tlocalAccountRepo domain.AccountRepository,\n\t\tlogger loggerDomain.Logger,\n\t) services.MemberService {\n\t\treturn services.NewMemberService(\n\t\t\tauthOrgRepo,\n\t\t\tauthMemberRepo,\n\t\t\tauthRoleRepo,\n\t\t\tlocalOrgRepo,\n\t\t\tlocalAccountRepo,\n\t\t\tlogger,\n\t\t)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/organization_handler.go",
    "content": "package organizations\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/domain\"\n\t\"github.com/moasq/go-b2b-starter/pkg/response\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\ntype OrganizationHandler struct {\n\torgService services.OrganizationService\n\tlogger     logger.Logger\n}\n\nfunc NewOrganizationHandler(orgService services.OrganizationService, logger logger.Logger) *OrganizationHandler {\n\treturn &OrganizationHandler{\n\t\torgService: orgService,\n\t\tlogger:     logger,\n\t}\n}\n\n// CreateOrganization creates a new organization\nfunc (h *OrganizationHandler) CreateOrganization(c *gin.Context) {\n\tvar req services.CreateOrganizationRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"invalid request payload\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid request payload\", err)\n\t\treturn\n\t}\n\n\torg, err := h.orgService.CreateOrganization(c.Request.Context(), &req)\n\tif err != nil {\n\t\th.logger.Error(\"failed to create organization\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to create organization\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusCreated, org)\n}\n\n// GetOrganization gets the current organization (from context)\nfunc (h *OrganizationHandler) GetOrganization(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\torg, err := h.orgService.GetOrganization(c.Request.Context(), reqCtx.OrganizationID)\n\tif err != nil {\n\t\tif err == domain.ErrOrganizationNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"organization not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to get organization\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to get organization\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, org)\n}\n\n// GetOrganizationBySlug gets an organization by slug\nfunc (h *OrganizationHandler) GetOrganizationBySlug(c *gin.Context) {\n\tslug := c.Param(\"slug\")\n\tif slug == \"\" {\n\t\tresponse.Error(c, http.StatusBadRequest, \"slug is required\", nil)\n\t\treturn\n\t}\n\n\torg, err := h.orgService.GetOrganizationBySlug(c.Request.Context(), slug)\n\tif err != nil {\n\t\tif err == domain.ErrOrganizationNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"organization not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to get organization by slug\", map[string]interface{}{\"slug\": slug, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to get organization\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, org)\n}\n\n// UpdateOrganization updates the current organization (from context)\nfunc (h *OrganizationHandler) UpdateOrganization(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tvar req services.UpdateOrganizationRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\th.logger.Error(\"invalid request payload\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid request payload\", err)\n\t\treturn\n\t}\n\n\torg, err := h.orgService.UpdateOrganization(c.Request.Context(), reqCtx.OrganizationID, &req)\n\tif err != nil {\n\t\tif err == domain.ErrOrganizationNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"organization not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to update organization\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to update organization\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, org)\n}\n\n// ListOrganizations lists organizations with pagination\nfunc (h *OrganizationHandler) ListOrganizations(c *gin.Context) {\n\tvar req services.ListOrganizationsRequest\n\tif err := c.ShouldBindQuery(&req); err != nil {\n\t\th.logger.Error(\"invalid query parameters\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusBadRequest, \"invalid query parameters\", err)\n\t\treturn\n\t}\n\n\t// Set defaults\n\tif req.Limit == 0 {\n\t\treq.Limit = 10\n\t}\n\n\torgResponse, err := h.orgService.ListOrganizations(c.Request.Context(), &req)\n\tif err != nil {\n\t\th.logger.Error(\"failed to list organizations\", map[string]interface{}{\"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to list organizations\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, orgResponse)\n}\n\n// GetOrganizationStats gets statistics for the current organization (from context)\nfunc (h *OrganizationHandler) GetOrganizationStats(c *gin.Context) {\n\treqCtx := auth.GetRequestContext(c)\n\tif reqCtx == nil {\n\t\th.logger.Error(\"missing request context\", nil)\n\t\tresponse.Error(c, http.StatusBadRequest, \"organization context is required\", nil)\n\t\treturn\n\t}\n\n\tstats, err := h.orgService.GetOrganizationStats(c.Request.Context(), reqCtx.OrganizationID)\n\tif err != nil {\n\t\tif err == domain.ErrOrganizationNotFound {\n\t\t\tresponse.Error(c, http.StatusNotFound, \"organization not found\", err)\n\t\t\treturn\n\t\t}\n\t\th.logger.Error(\"failed to get organization stats\", map[string]interface{}{\"org_id\": reqCtx.OrganizationID, \"error\": err.Error()})\n\t\tresponse.Error(c, http.StatusInternalServerError, \"failed to get organization stats\", err)\n\t\treturn\n\t}\n\n\tresponse.Success(c, http.StatusOK, stats)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/provider.go",
    "content": "package organizations\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/organizations/app/services\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n)\n\n// Provider provides organization API dependencies\ntype Provider struct {\n\tcontainer *dig.Container\n}\n\nfunc NewProvider(container *dig.Container) *Provider {\n\treturn &Provider{\n\t\tcontainer: container,\n\t}\n}\n\n// RegisterDependencies registers organization API dependencies\nfunc (p *Provider) RegisterDependencies() error {\n\t// Register handlers\n\tif err := p.container.Provide(func(\n\t\torgService services.OrganizationService,\n\t\tlogger logger.Logger,\n\t) *OrganizationHandler {\n\t\treturn NewOrganizationHandler(orgService, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif err := p.container.Provide(func(\n\t\torgService services.OrganizationService,\n\t\tlogger logger.Logger,\n\t) *AccountHandler {\n\t\treturn NewAccountHandler(orgService, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register member handler (for auth/member routes)\n\tif err := p.container.Provide(func(\n\t\tmemberService services.MemberService,\n\t\tlogger logger.Logger,\n\t) *MemberHandler {\n\t\treturn NewMemberHandler(memberService, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Register routes\n\tif err := p.container.Provide(func(\n\t\torganizationHandler *OrganizationHandler,\n\t\taccountHandler *AccountHandler,\n\t\tmemberHandler *MemberHandler,\n\t) *Routes {\n\t\treturn NewRoutes(organizationHandler, accountHandler, memberHandler)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/organizations/routes.go",
    "content": "package organizations\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\tserverDomain \"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n)\n\ntype Routes struct {\n\torganizationHandler *OrganizationHandler\n\taccountHandler      *AccountHandler\n\tmemberHandler       *MemberHandler\n}\n\nfunc NewRoutes(\n\torganizationHandler *OrganizationHandler,\n\taccountHandler *AccountHandler,\n\tmemberHandler *MemberHandler,\n) *Routes {\n\treturn &Routes{\n\t\torganizationHandler: organizationHandler,\n\t\taccountHandler:      accountHandler,\n\t\tmemberHandler:       memberHandler,\n\t}\n}\n\n// RegisterRoutes registers organization, account, and auth member management routes\nfunc (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\t// Auth routes - member management and authentication\n\tauthGroup := router.Group(\"/auth\")\n\t{\n\t\t// Public endpoint - Organization signup (no authentication required)\n\t\tauthGroup.POST(\"/signup\", r.memberHandler.BootstrapOrganization)\n\n\t\t// Public endpoint - Check if email exists (no authentication required)\n\t\tauthGroup.GET(\"/check-email\", r.memberHandler.CheckEmail)\n\n\t\t// Protected endpoint - Add member (requires JWT authentication)\n\t\tauthGroup.POST(\"/members\",\n\t\t\tresolver.Get(\"auth\"),\n\t\t\tresolver.Get(\"org_context\"),\n\t\t\tr.memberHandler.AddMember)\n\n\t\t// Protected endpoint - List members (requires JWT authentication and org:manage permission)\n\t\tauthGroup.GET(\"/members\",\n\t\t\tresolver.Get(\"auth\"),\n\t\t\tresolver.Get(\"org_context\"),\n\t\t\tauth.RequirePermissionFunc(\"org\", \"manage\"),\n\t\t\tr.memberHandler.ListMembers)\n\n\t\t// Protected endpoint - Get current user profile (requires JWT authentication only)\n\t\tauthGroup.GET(\"/profile/me\",\n\t\t\tresolver.Get(\"auth\"),\n\t\t\tresolver.Get(\"org_context\"),\n\t\t\tr.memberHandler.GetProfile)\n\n\t\t// Protected endpoint - Delete organization member (requires JWT authentication and org:manage permission)\n\t\tauthGroup.DELETE(\"/members/:member_id\",\n\t\t\tresolver.Get(\"auth\"),\n\t\t\tresolver.Get(\"org_context\"),\n\t\t\tauth.RequirePermissionFunc(\"org\", \"manage\"),\n\t\t\tr.memberHandler.DeleteMember)\n\t}\n\n\t// Organization routes - require JWT authentication\n\torgGroup := router.Group(\"/organizations\")\n\torgGroup.Use(\n\t\tresolver.Get(\"auth\"),\n\t\tresolver.Get(\"org_context\"),\n\t)\n\t{\n\t\t// Current organization endpoints\n\t\torgGroup.GET(\"\", auth.RequirePermissionFunc(\"org\", \"view\"), r.organizationHandler.GetOrganization)\n\t\torgGroup.PUT(\"\", auth.RequirePermissionFunc(\"org\", \"manage\"), r.organizationHandler.UpdateOrganization)\n\t\torgGroup.GET(\"/stats\", auth.RequirePermissionFunc(\"org\", \"view\"), r.organizationHandler.GetOrganizationStats)\n\t}\n\n\t// Account routes - require JWT authentication\n\taccountGroup := router.Group(\"/accounts\")\n\taccountGroup.Use(\n\t\tresolver.Get(\"auth\"),\n\t\tresolver.Get(\"org_context\"),\n\t)\n\t{\n\t\t// Account management\n\t\taccountGroup.POST(\"\", auth.RequirePermissionFunc(\"org\", \"manage\"), r.accountHandler.CreateAccount)\n\t\taccountGroup.GET(\"\", auth.RequirePermissionFunc(\"org\", \"view\"), r.accountHandler.ListAccounts)\n\t\taccountGroup.GET(\"/by-email\", auth.RequirePermissionFunc(\"org\", \"view\"), r.accountHandler.GetAccountByEmail)\n\t\taccountGroup.GET(\"/:id\", auth.RequirePermissionFunc(\"org\", \"view\"), r.accountHandler.GetAccount)\n\t\taccountGroup.PUT(\"/:id\", auth.RequirePermissionFunc(\"org\", \"manage\"), r.accountHandler.UpdateAccount)\n\t\taccountGroup.DELETE(\"/:id\", auth.RequirePermissionFunc(\"org\", \"manage\"), r.accountHandler.DeleteAccount)\n\t\taccountGroup.POST(\"/:id/last-login\", auth.RequirePermissionFunc(\"org\", \"view\"), r.accountHandler.UpdateAccountLastLogin)\n\t\taccountGroup.GET(\"/:id/permissions\", auth.RequirePermissionFunc(\"org\", \"view\"), r.accountHandler.CheckAccountPermission)\n\t\taccountGroup.GET(\"/:id/stats\", auth.RequirePermissionFunc(\"org\", \"view\"), r.accountHandler.GetAccountStats)\n\t}\n}\n\n// Routes returns a RouteRegistrar function compatible with the server interface\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n\tr.RegisterRoutes(router, resolver)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/README.md",
    "content": "# Paywall Middleware Package\n\nProvider-agnostic access gating middleware for B2B SaaS applications. This package provides the **\"Payment Bouncer\"** - checking if an organization has an active subscription before allowing access to protected features.\n\n## Key Concept: Separation of Concerns\n\nThis package ONLY handles **access gating** (the \"can they use this feature?\" question). It does NOT manage subscriptions directly.\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                          PAYWALL (This Package)                             │\n│                                                                             │\n│  \"Can this organization access premium features right now?\"                 │\n│                                                                             │\n│  - Reads subscription status from LOCAL DATABASE                            │\n│  - Makes NO external API calls (fast, reliable)                             │\n│  - Returns 402 if payment required                                          │\n└─────────────────────────────────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                          BILLING MODULE (app/billing)                       │\n│                                                                             │\n│  \"Manage subscription lifecycle via webhooks\"                               │\n│                                                                             │\n│  - Processes Polar.sh webhooks (subscription created, updated, canceled)    │\n│  - Updates LOCAL DATABASE with subscription state                           │\n│  - Tracks quotas and usage                                                  │\n│  - No direct API calls during request handling                              │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n## Architecture: Event-Driven Integration\n\nThe paywall middleware and billing module are decoupled via **event-driven architecture**:\n\n```\n┌─────────────┐   webhook   ┌─────────────────┐   writes   ┌─────────────┐\n│  Polar.sh   │ ─────────►  │ Billing Module  │ ─────────► │  Local DB   │\n└─────────────┘             └─────────────────┘            └─────────────┘\n                                                                  │\n                                                           reads  │\n                                                                  ▼\n┌─────────────┐             ┌─────────────────┐            ┌─────────────┐\n│   Request   │ ─────────►  │    Paywall      │ ◄───────── │  Local DB   │\n└─────────────┘             └─────────────────┘            └─────────────┘\n                                    │\n                                    ▼\n                            Pass (200) or Block (402)\n```\n\n**Why Event-Driven?**\n- No external API calls during request handling (fast responses)\n- Billing provider outage doesn't block your users\n- Clean separation between access control and subscription management\n- Easy to swap billing providers without touching access logic\n\n## Request Flow\n\n```\nHTTP Request\n     │\n     ▼\n┌────────────────┐\n│ Auth Middleware│ ── Verify JWT, extract Identity\n└────────────────┘\n     │\n     ▼\n┌────────────────┐\n│ Org Middleware │ ── Resolve OrganizationID from Identity\n└────────────────┘\n     │\n     ▼\n┌────────────────┐\n│Paywall Middleware│ ── Check subscription status (LOCAL DB)\n└────────────────┘\n     │\n     ├── Active? ──► Handler ──► 200 OK\n     │\n     └── Inactive? ──► 402 Payment Required\n```\n\n## Usage\n\n### 1. Setup (Already configured in init_mods.go)\n\n```go\nimport (\n    \"github.com/moasq/go-b2b-starter/pkg/paywall\"\n)\n\n// Setup middleware (after billing module is initialized)\nif err := paywall.SetupMiddleware(container); err != nil {\n    panic(err)\n}\n\n// Register named middlewares for route configuration\nif err := paywall.RegisterNamedMiddlewares(container); err != nil {\n    panic(err)\n}\n```\n\n### 2. Protecting Routes\n\n**Using Named Middleware (Recommended):**\n\n```go\n// In routes.go\nfunc (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) {\n    // Premium features - require active subscription\n    premiumGroup := router.Group(\"/premium\")\n    premiumGroup.Use(\n        resolver.Get(\"auth\"),           // Verify JWT\n        resolver.Get(\"org_context\"),    // Resolve org/account IDs\n        resolver.Get(\"paywall\"),        // Block if no active subscription\n    )\n    {\n        premiumGroup.POST(\"/ai/generate\", r.handler.Generate)\n        premiumGroup.POST(\"/reports/export\", r.handler.Export)\n    }\n\n    // Basic features - auth only, no paywall\n    basicGroup := router.Group(\"/basic\")\n    basicGroup.Use(\n        resolver.Get(\"auth\"),\n        resolver.Get(\"org_context\"),\n        // No paywall - allow access to fix billing issues\n    )\n    {\n        basicGroup.GET(\"/billing/status\", r.handler.GetBillingStatus)\n        basicGroup.POST(\"/billing/portal\", r.handler.CreatePortalSession)\n    }\n}\n```\n\n**Direct Middleware Usage:**\n\n```go\n// Get middleware from DI container\nvar paywallMiddleware *paywall.Middleware\ncontainer.Invoke(func(m *paywall.Middleware) {\n    paywallMiddleware = m\n})\n\n// Apply to routes\nrouter.Use(paywallMiddleware.RequireActiveSubscription())\n```\n\n### 3. Accessing Subscription Status in Handlers\n\n```go\nfunc (h *Handler) MyHandler(c *gin.Context) {\n    // Safe get - returns nil if not set\n    status := paywall.GetSubscriptionStatus(c)\n    if status != nil {\n        log.Printf(\"Org %d status: %s\", status.OrganizationID, status.Status)\n    }\n\n    // Quick boolean check\n    if paywall.IsSubscriptionActive(c) {\n        // Show premium features\n    }\n\n    // Must get - panics if not set (use after RequireActiveSubscription)\n    status := paywall.MustGetSubscriptionStatus(c)\n}\n```\n\n### 4. Optional Status Check (No Blocking)\n\nWhen you want to know status without blocking access:\n\n```go\n// Show \"upgrade\" prompts to free users\ndashboardGroup.Use(\n    resolver.Get(\"auth\"),\n    resolver.Get(\"org_context\"),\n    resolver.Get(\"paywall_optional\"),  // Sets status, doesn't block\n)\n```\n\n## Configuration\n\n```go\nconfig := &paywall.MiddlewareConfig{\n    // URL included in 402 responses for upgrading\n    UpgradeURL: \"/billing\",\n\n    // Allow trialing subscriptions (default: true)\n    AllowTrialing: true,\n\n    // Custom error handler (optional)\n    ErrorHandler: func(c *gin.Context, statusCode int, response *paywall.ErrorResponse) {\n        c.JSON(statusCode, response)\n    },\n}\n\nmiddleware := paywall.NewMiddleware(provider, config)\n```\n\n## Subscription Status Mapping\n\n| DB Status     | IsActive | HTTP Response        |\n|---------------|----------|----------------------|\n| `active`      | true     | Pass through         |\n| `trialing`    | true     | Pass through         |\n| `past_due`    | false    | 402 Payment Required |\n| `canceled`    | false    | 402 Payment Required |\n| `unpaid`      | false    | 402 Payment Required |\n| No subscription | false  | 402 Payment Required |\n\n## Error Response Format\n\n```json\n{\n    \"error\": \"subscription_required\",\n    \"message\": \"An active subscription is required to access this feature\",\n    \"upgrade_url\": \"/billing\",\n    \"status\": \"past_due\"\n}\n```\n\n## The \"Swiss Cheese\" Strategy\n\nNot all routes should require a subscription. Allow users to fix billing issues:\n\n**Protected (require paywall):**\n- AI/ML features\n- OCR processing\n- Report generation\n- Premium API endpoints\n- Advanced analytics\n\n**Unprotected (auth only):**\n- Billing status and portal\n- Account settings\n- Profile management\n- Subscription upgrade flow\n- Webhooks\n- Public pages\n\n## Payment Verification Flow (\"Verification on Redirect\")\n\nWhen users complete payment on Polar, they're redirected back to your app with a checkout session ID. To provide instant access (instead of waiting for webhooks), use the **verification endpoint**.\n\n### Frontend Integration\n\n**1. Configure Polar Success URL:**\n\nSet your Polar checkout success URL to:\n```\nhttps://yoursaas.com/payment/success?session_id={CHECKOUT_SESSION_ID}\n```\n\nPolar will replace `{CHECKOUT_SESSION_ID}` with the actual session ID.\n\n**2. Handle Redirect (Frontend):**\n\n```javascript\n// On /payment/success page\nconst params = new URLSearchParams(window.location.search);\nconst sessionId = params.get('session_id');\n\nif (sessionId) {\n    // Show loading spinner\n    const response = await fetch('/api/subscriptions/verify-payment', {\n        method: 'POST',\n        headers: {\n            'Authorization': `Bearer ${token}`,\n            'Content-Type': 'application/json'\n        },\n        body: JSON.stringify({ session_id: sessionId })\n    });\n\n    if (response.ok) {\n        // Payment verified! Redirect to dashboard\n        window.location.href = '/dashboard';\n    } else {\n        // Show error message\n        const error = await response.json();\n        showError(error.message);\n    }\n}\n```\n\n**3. Expected Response:**\n\n```json\n{\n    \"organization_id\": 123,\n    \"has_active_subscription\": true,\n    \"can_process_invoices\": true,\n    \"invoice_count\": 100,\n    \"reason\": \"Payment verified successfully\",\n    \"checked_at\": \"2025-12-12T10:30:00Z\"\n}\n```\n\n### Backend Verification Endpoint\n\nEndpoint: `POST /api/subscriptions/verify-payment`\n\n**Request:**\n```json\n{\n    \"session_id\": \"cs_test_xxx\"\n}\n```\n\n**Flow:**\n1. Fetch checkout session from Polar API\n2. Verify status is \"succeeded\"\n3. Extract customer_id and map to organization\n4. Fetch full subscription details from Polar\n5. Update local database (subscription + quota)\n6. Return updated billing status\n\n**Error Codes:**\n- `400` - Checkout session failed or expired\n- `404` - Checkout session not found\n- `500` - Internal error (failed to sync)\n\n### Lazy Guarding (Renewal Webhooks)\n\nFor monthly renewals, the middleware includes **lazy guarding** to handle missed webhooks:\n\n**How It Works:**\n\n1. User makes request → Middleware checks DB status\n2. If DB says \"expired\" BUT subscription exists:\n   - Middleware calls Polar API to refresh status\n   - If Polar says \"active\" → Grant access and update DB\n   - If Polar says \"inactive\" → Block access (truly expired)\n\n```go\n// Automatic in paywall middleware - no configuration needed\nif !status.IsActive && status.Status != StatusNone {\n    freshStatus, err := provider.RefreshSubscriptionStatus(ctx, orgID)\n    if err == nil && freshStatus.IsActive {\n        // Webhook was missed! Allow access\n        status = freshStatus\n    }\n}\n```\n\n**Benefits:**\n- Self-healing: Missed webhooks don't lock out paying users\n- Fast: Only checks API when DB says inactive (edge case)\n- Reliable: Users always get access if they've paid\n\n### Architecture Overview\n\n| Scenario | Primary Mechanism | Fallback Mechanism |\n|----------|-------------------|-------------------|\n| **User Just Paid** | **Verification on Redirect** (Frontend → Backend → Polar API) | Webhook (processed if arrives later) |\n| **Monthly Renewal** | **Webhooks** (Standard processing) | **Lazy Guarding** (Middleware checks API if DB says expired) |\n\n**Success Metrics:**\n- Payment to access time: ~5 seconds (verification) vs. ~minutes (webhook only)\n- Webhook miss recovery: Automatic via lazy guarding\n- User complaints: Eliminated \"I paid but still locked out\" issues\n\n## Implementing SubscriptionStatusProvider\n\nThe middleware requires a `SubscriptionStatusProvider` implementation. This is provided by the billing module:\n\n```go\n// Interface defined in this package\ntype SubscriptionStatusProvider interface {\n    GetSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error)\n}\n\n// Implemented in app/billing/infra/adapters/status_provider.go\ntype StatusProviderAdapter struct {\n    service services.BillingService\n}\n\nfunc (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) {\n    billingStatus, err := a.service.GetBillingStatus(ctx, orgID)\n    if err != nil {\n        return nil, err\n    }\n    return &paywall.SubscriptionStatus{\n        OrganizationID: orgID,\n        Status:         billingStatus.SubscriptionStatus,\n        IsActive:       billingStatus.HasActiveSubscription,\n        // ...\n    }, nil\n}\n```\n\n## Named Middleware Reference\n\n| Name                  | Function                    | Description                    |\n|-----------------------|-----------------------------|--------------------------------|\n| `paywall`             | RequireActiveSubscription   | Block if no active subscription|\n| `paywall_optional`    | OptionalSubscriptionStatus  | Set status, don't block        |\n| `subscription` (legacy)| RequireActiveSubscription  | Deprecated, use `paywall`      |\n\n## Files\n\n```\nsrc/pkg/paywall/\n├── subscription.go    # Core types and SubscriptionStatusProvider interface\n├── middleware.go      # Gin middleware (RequireActiveSubscription)\n├── context.go         # Context helpers (Get/Set SubscriptionStatus)\n├── errors.go          # Error types (ErrNoSubscription, etc.)\n├── provider.go        # DI registration and named middleware\n└── README.md          # This file\n```\n\n## Related: Billing Module\n\nSee `app/billing/README.md` for:\n- Polar.sh webhook integration\n- Subscription lifecycle management\n- Quota tracking\n- Event-driven architecture details\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/cmd/init.go",
    "content": "// Package cmd provides initialization for the paywall module.\npackage cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/modules/paywall\"\n\t\"go.uber.org/dig\"\n)\n\n// InitMiddleware initializes the paywall middleware.\n//\n// This must be called after the billing module is initialized,\n// as it depends on the SubscriptionStatusProvider from that module.\n//\n// # Prerequisites\n//\n// The following must be available in the container:\n//   - paywall.SubscriptionStatusProvider (from app/billing module)\n//\n// # Usage\n//\n//\t// After billing module init:\n//\tif err := paywallCmd.InitMiddleware(container); err != nil {\n//\t    panic(err)\n//\t}\nfunc InitMiddleware(container *dig.Container) error {\n\tif err := paywall.SetupMiddleware(container); err != nil {\n\t\treturn fmt.Errorf(\"failed to setup paywall middleware: %w\", err)\n\t}\n\treturn nil\n}\n\n// InitMiddlewareWithConfig initializes the paywall middleware with custom configuration.\n//\n// # Usage\n//\n//\tconfig := &paywall.MiddlewareConfig{\n//\t    UpgradeURL: \"/settings/billing\",\n//\t}\n//\tif err := paywallCmd.InitMiddlewareWithConfig(container, config); err != nil {\n//\t    panic(err)\n//\t}\nfunc InitMiddlewareWithConfig(container *dig.Container, config *paywall.MiddlewareConfig) error {\n\tif err := paywall.SetupMiddlewareWithConfig(container, config); err != nil {\n\t\treturn fmt.Errorf(\"failed to setup paywall middleware: %w\", err)\n\t}\n\treturn nil\n}\n\n// SetupMiddleware is a direct alias to paywall.SetupMiddleware for convenience.\nfunc SetupMiddleware(container *dig.Container) error {\n\treturn paywall.SetupMiddleware(container)\n}\n\n// RegisterNamedMiddlewares is a direct alias to paywall.RegisterNamedMiddlewares for convenience.\nfunc RegisterNamedMiddlewares(container *dig.Container) error {\n\treturn paywall.RegisterNamedMiddlewares(container)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/context.go",
    "content": "package paywall\n\nimport (\n\t\"context\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Context keys for storing subscription data.\n// Using unexported type to prevent collisions with other packages.\ntype contextKey string\n\nconst (\n\t// subscriptionStatusKey is the context key for storing the SubscriptionStatus.\n\tsubscriptionStatusKey contextKey = \"subscription_status\"\n)\n\n// SetSubscriptionStatus stores the SubscriptionStatus in the Gin context.\n//\n// This is called by the RequireActiveSubscription middleware after checking\n// subscription status. Application code should not call this directly.\nfunc SetSubscriptionStatus(c *gin.Context, status *SubscriptionStatus) {\n\tc.Set(string(subscriptionStatusKey), status)\n}\n\n// GetSubscriptionStatus retrieves the SubscriptionStatus from the Gin context.\n//\n// Returns nil if no subscription status is set (middleware not applied).\n// Use MustGetSubscriptionStatus if you expect subscription middleware to have run.\n//\n// Example:\n//\n//\tstatus := subscription.GetSubscriptionStatus(c)\n//\tif status == nil || !status.IsActive {\n//\t    // Handle inactive subscription\n//\t}\nfunc GetSubscriptionStatus(c *gin.Context) *SubscriptionStatus {\n\tif val, exists := c.Get(string(subscriptionStatusKey)); exists {\n\t\tif status, ok := val.(*SubscriptionStatus); ok {\n\t\t\treturn status\n\t\t}\n\t}\n\treturn nil\n}\n\n// MustGetSubscriptionStatus retrieves the SubscriptionStatus from the Gin context.\n//\n// Panics if no subscription status is set. Only use this after subscription middleware.\n// For handlers where subscription status is optional, use GetSubscriptionStatus instead.\nfunc MustGetSubscriptionStatus(c *gin.Context) *SubscriptionStatus {\n\tstatus := GetSubscriptionStatus(c)\n\tif status == nil {\n\t\tpanic(\"subscription: MustGetSubscriptionStatus called without SubscriptionStatus in context - ensure RequireActiveSubscription middleware is applied\")\n\t}\n\treturn status\n}\n\n// IsSubscriptionActive is a convenience function to check if the subscription is active.\n//\n// Returns false if no subscription status is set or if the subscription is inactive.\n// Use this for quick checks in handlers.\n//\n// Example:\n//\n//\tif !subscription.IsSubscriptionActive(c) {\n//\t    // Handle inactive subscription\n//\t}\nfunc IsSubscriptionActive(c *gin.Context) bool {\n\tif status := GetSubscriptionStatus(c); status != nil {\n\t\treturn status.IsActive\n\t}\n\treturn false\n}\n\n// WithSubscriptionStatus adds the SubscriptionStatus to a context.Context.\n//\n// This is useful for passing subscription context through service layers\n// that don't use Gin context directly.\nfunc WithSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) context.Context {\n\treturn context.WithValue(ctx, subscriptionStatusKey, status)\n}\n\n// SubscriptionStatusFromContext retrieves the SubscriptionStatus from a context.Context.\n//\n// Returns nil if no subscription status is set.\nfunc SubscriptionStatusFromContext(ctx context.Context) *SubscriptionStatus {\n\tif val := ctx.Value(subscriptionStatusKey); val != nil {\n\t\tif status, ok := val.(*SubscriptionStatus); ok {\n\t\t\treturn status\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/errors.go",
    "content": "package paywall\n\nimport \"errors\"\n\n// Subscription errors.\n//\n// These errors are returned by the subscription package and can be checked\n// by application code to handle specific error cases.\nvar (\n\t// ErrNoSubscription is returned when no subscription exists for the organization.\n\t// HTTP status: 402 Payment Required\n\tErrNoSubscription = errors.New(\"no subscription found\")\n\n\t// ErrSubscriptionInactive is returned when the subscription exists but is not active.\n\t// This includes statuses like \"past_due\", \"canceled\", \"unpaid\".\n\t// HTTP status: 402 Payment Required\n\tErrSubscriptionInactive = errors.New(\"subscription is not active\")\n\n\t// ErrSubscriptionExpired is returned when the subscription's billing period has ended.\n\t// HTTP status: 402 Payment Required\n\tErrSubscriptionExpired = errors.New(\"subscription has expired\")\n\n\t// ErrSubscriptionCanceled is returned when the subscription has been explicitly canceled.\n\t// HTTP status: 402 Payment Required\n\tErrSubscriptionCanceled = errors.New(\"subscription has been canceled\")\n\n\t// ErrPaymentFailed is returned when the subscription payment has failed.\n\t// HTTP status: 402 Payment Required\n\tErrPaymentFailed = errors.New(\"subscription payment failed\")\n\n\t// ErrMissingOrganization is returned when organization ID is not in context.\n\t// This means RequireOrganization middleware hasn't run.\n\t// HTTP status: 500 Internal Server Error (misconfigured middleware)\n\tErrMissingOrganization = errors.New(\"organization context required\")\n)\n\n// IsPaymentRequiredError returns true if the error requires payment (402).\nfunc IsPaymentRequiredError(err error) bool {\n\treturn errors.Is(err, ErrNoSubscription) ||\n\t\terrors.Is(err, ErrSubscriptionInactive) ||\n\t\terrors.Is(err, ErrSubscriptionExpired) ||\n\t\terrors.Is(err, ErrSubscriptionCanceled) ||\n\t\terrors.Is(err, ErrPaymentFailed)\n}\n\n// HTTPStatusCode returns the appropriate HTTP status code for a subscription error.\n//\n// Returns:\n//   - 402 for payment-related errors\n//   - 500 for configuration errors\nfunc HTTPStatusCode(err error) int {\n\tif IsPaymentRequiredError(err) {\n\t\treturn 402\n\t}\n\tif errors.Is(err, ErrMissingOrganization) {\n\t\treturn 500\n\t}\n\treturn 500\n}\n\n// ErrorResponse represents the JSON error response for subscription errors.\n//\n// This is used by the middleware to return a consistent error format\n// that includes helpful information for the client.\ntype ErrorResponse struct {\n\t// Error is the error code (e.g., \"subscription_required\", \"payment_failed\")\n\tError string `json:\"error\"`\n\n\t// Message is a human-readable description of the error.\n\tMessage string `json:\"message\"`\n\n\t// UpgradeURL is the URL where the user can update their subscription.\n\t// Optional - only included when configured.\n\tUpgradeURL string `json:\"upgrade_url,omitempty\"`\n\n\t// Status is the subscription status that caused the error.\n\t// Optional - helps the client understand the specific issue.\n\tStatus string `json:\"status,omitempty\"`\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/middleware.go",
    "content": "package paywall\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n)\n\n// MiddlewareConfig configures the subscription middleware behavior.\ntype MiddlewareConfig struct {\n\t// ErrorHandler is called when subscription check fails.\n\t// If nil, default JSON responses are used.\n\tErrorHandler func(c *gin.Context, statusCode int, response *ErrorResponse)\n\n\t// UpgradeURL is the URL to include in error responses for upgrading subscription.\n\t// Example: \"/billing\" or \"https://app.example.com/billing\"\n\tUpgradeURL string\n\n\t// AllowTrialing determines if trialing subscriptions are allowed.\n\t// Default: true (trialing is allowed)\n\tAllowTrialing bool\n}\n\n// DefaultMiddlewareConfig returns the default middleware configuration.\nfunc DefaultMiddlewareConfig() *MiddlewareConfig {\n\treturn &MiddlewareConfig{\n\t\tErrorHandler:  defaultErrorHandler,\n\t\tUpgradeURL:    \"/billing\",\n\t\tAllowTrialing: true,\n\t}\n}\n\n// defaultErrorHandler sends JSON error responses.\nfunc defaultErrorHandler(c *gin.Context, statusCode int, response *ErrorResponse) {\n\tc.JSON(statusCode, response)\n}\n\n// Middleware provides subscription middleware functions.\n//\n// Use NewMiddleware to create an instance with proper dependencies.\ntype Middleware struct {\n\tprovider SubscriptionStatusProvider\n\tconfig   *MiddlewareConfig\n}\n\n// Parameters:\n//   - provider: The subscription status provider (implements SubscriptionStatusProvider)\n//   - config: Middleware configuration (optional, uses defaults if nil)\nfunc NewMiddleware(provider SubscriptionStatusProvider, config *MiddlewareConfig) *Middleware {\n\tif config == nil {\n\t\tconfig = DefaultMiddlewareConfig()\n\t}\n\tif config.ErrorHandler == nil {\n\t\tconfig.ErrorHandler = defaultErrorHandler\n\t}\n\treturn &Middleware{\n\t\tprovider: provider,\n\t\tconfig:   config,\n\t}\n}\n\n// RequireActiveSubscription returns middleware that checks subscription status.\n//\n// This middleware:\n//  1. Gets OrganizationID from auth context (requires RequireOrganization to run first)\n//  2. Checks subscription status from the SubscriptionStatusProvider\n//  3. Sets SubscriptionStatus in Gin context if active\n//  4. Returns 402 Payment Required if subscription is not active\n//\n// Must be called AFTER auth.RequireOrganization middleware.\n//\n// Usage:\n//\n//\trouter.Use(authMiddleware.RequireAuth())\n//\trouter.Use(authMiddleware.RequireOrganization())\n//\trouter.Use(subscriptionMiddleware.RequireActiveSubscription())\nfunc (m *Middleware) RequireActiveSubscription() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Skip OPTIONS requests (CORS preflight)\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Get organization ID from auth context\n\t\torgID := auth.GetOrganizationID(c)\n\t\tif orgID == 0 {\n\t\t\tm.config.ErrorHandler(c, http.StatusInternalServerError, &ErrorResponse{\n\t\t\t\tError:   \"configuration_error\",\n\t\t\t\tMessage: \"Organization context required - ensure RequireOrganization middleware is applied\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Check subscription status from database (fast read)\n\t\tstatus, err := m.provider.GetSubscriptionStatus(c.Request.Context(), orgID)\n\t\tif err != nil {\n\t\t\t// No subscription found\n\t\t\tm.config.ErrorHandler(c, http.StatusPaymentRequired, &ErrorResponse{\n\t\t\t\tError:      \"subscription_required\",\n\t\t\t\tMessage:    \"An active subscription is required to access this feature\",\n\t\t\t\tUpgradeURL: m.config.UpgradeURL,\n\t\t\t\tStatus:     StatusNone,\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Lazy Guarding: If DB says inactive BUT subscription exists (not \"none\"),\n\t\t// double-check with payment provider in case we missed a webhook\n\t\tif !status.IsActive && status.Status != StatusNone {\n\t\t\t// Attempt to refresh subscription status from provider\n\t\t\tfreshStatus, refreshErr := m.provider.RefreshSubscriptionStatus(c.Request.Context(), orgID)\n\n\t\t\tif refreshErr == nil && freshStatus != nil && freshStatus.IsActive {\n\t\t\t\t// Webhook was missed! Provider says active, update our status\n\t\t\t\tstatus = freshStatus\n\t\t\t\t// Log this occurrence for monitoring\n\t\t\t\t// Console log for lazy guard activation\n\t\t\t\tfmt.Printf(\"🔄 LAZY GUARD ACTIVATED - Org: %d | DB said: %s | Provider says: %s | Access granted\\n\",\n\t\t\t\t\torgID, status.Status, freshStatus.Status)\n\t\t\t}\n\t\t\t// If refresh fails or still inactive, continue with original status\n\t\t}\n\n\t\t// Check if subscription is active (after potential refresh)\n\t\tif !status.IsActive {\n\t\t\tresponse := m.buildErrorResponse(status)\n\t\t\tm.config.ErrorHandler(c, http.StatusPaymentRequired, response)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Set subscription status in context for downstream handlers\n\t\tSetSubscriptionStatus(c, status)\n\n\t\tc.Next()\n\t}\n}\n\n// buildErrorResponse creates an appropriate error response based on subscription status.\nfunc (m *Middleware) buildErrorResponse(status *SubscriptionStatus) *ErrorResponse {\n\tresponse := &ErrorResponse{\n\t\tUpgradeURL: m.config.UpgradeURL,\n\t\tStatus:     status.Status,\n\t}\n\n\tswitch status.Status {\n\tcase StatusPastDue:\n\t\tresponse.Error = \"payment_failed\"\n\t\tresponse.Message = \"Your subscription payment has failed. Please update your payment method.\"\n\tcase StatusCanceled:\n\t\tresponse.Error = \"subscription_canceled\"\n\t\tresponse.Message = \"Your subscription has been canceled. Please resubscribe to continue.\"\n\tcase StatusUnpaid:\n\t\tresponse.Error = \"payment_required\"\n\t\tresponse.Message = \"Your subscription is unpaid. Please update your payment method.\"\n\tdefault:\n\t\tresponse.Error = \"subscription_inactive\"\n\t\tresponse.Message = \"An active subscription is required to access this feature\"\n\t\tif status.Reason != \"\" {\n\t\t\tresponse.Message = status.Reason\n\t\t}\n\t}\n\n\treturn response\n}\n\n// RequireActiveSubscriptionFunc is a standalone middleware function.\n//\n// This is a convenience function that doesn't require a Middleware instance.\n// It uses the default configuration for error handling.\n//\n// Usage:\n//\n//\trouter.GET(\"/ai/generate\",\n//\t    subscription.RequireActiveSubscriptionFunc(provider),\n//\t    handler)\nfunc RequireActiveSubscriptionFunc(provider SubscriptionStatusProvider) gin.HandlerFunc {\n\tm := NewMiddleware(provider, nil)\n\treturn m.RequireActiveSubscription()\n}\n\n// OptionalSubscriptionStatus returns middleware that checks subscription status\n// but doesn't block the request if the subscription is inactive.\n//\n// This middleware:\n//  1. Gets OrganizationID from auth context\n//  2. Checks subscription status from the SubscriptionStatusProvider\n//  3. Sets SubscriptionStatus in Gin context (active or not)\n//  4. Always continues to the next handler\n//\n// Use this when you want to know subscription status but allow access regardless.\n// Handlers can then check subscription.GetSubscriptionStatus(c) to adjust behavior.\n//\n// Example use case: Show \"upgrade\" prompts to free users while still allowing access.\nfunc (m *Middleware) OptionalSubscriptionStatus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Skip OPTIONS requests (CORS preflight)\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Get organization ID from auth context\n\t\torgID := auth.GetOrganizationID(c)\n\t\tif orgID == 0 {\n\t\t\t// No org context, continue without subscription info\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Check subscription status (ignore errors, just set status if available)\n\t\tstatus, err := m.provider.GetSubscriptionStatus(c.Request.Context(), orgID)\n\t\tif err == nil && status != nil {\n\t\t\tSetSubscriptionStatus(c, status)\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/provider.go",
    "content": "package paywall\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/dig\"\n)\n\n// ServerMiddlewareRegistrar is the interface for registering named middleware.\n// This matches the server.Server interface's RegisterNamedMiddleware method.\ntype ServerMiddlewareRegistrar interface {\n\tRegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc)\n}\n\n// SetupMiddleware wires the subscription middleware into the DI container.\n//\n// This must be called after the SubscriptionStatusProvider is available.\n//\n// # Prerequisites\n//\n// The following must be available in the container:\n//   - subscription.SubscriptionStatusProvider\n//\n// # Usage\n//\n//\tif err := subscription.SetupMiddleware(container); err != nil {\n//\t    return err\n//\t}\nfunc SetupMiddleware(container *dig.Container) error {\n\tif err := container.Provide(func(\n\t\tprovider SubscriptionStatusProvider,\n\t) *Middleware {\n\t\treturn NewMiddleware(provider, nil)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide subscription middleware: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// SetupMiddlewareWithConfig wires the subscription middleware with custom configuration.\n//\n// # Usage\n//\n//\tconfig := &subscription.MiddlewareConfig{\n//\t    UpgradeURL: \"/settings/billing\",\n//\t}\n//\tif err := subscription.SetupMiddlewareWithConfig(container, config); err != nil {\n//\t    return err\n//\t}\nfunc SetupMiddlewareWithConfig(container *dig.Container, config *MiddlewareConfig) error {\n\tif err := container.Provide(func(\n\t\tprovider SubscriptionStatusProvider,\n\t) *Middleware {\n\t\treturn NewMiddleware(provider, config)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide subscription middleware: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// RegisterNamedMiddlewares registers the paywall middleware functions with the server.\n//\n// This should be called after SetupMiddleware and the server is available.\n// It registers the following named middlewares:\n//   - \"paywall\": RequireActiveSubscription middleware (blocks if inactive)\n//   - \"paywall_optional\": OptionalSubscriptionStatus middleware (sets status, no blocking)\n//\n// For backward compatibility, these legacy names are also registered:\n//   - \"subscription\" (deprecated, use \"paywall\")\n//   - \"subscription_optional\" (deprecated, use \"paywall_optional\")\n//\n// # Usage\n//\n//\tif err := paywall.RegisterNamedMiddlewares(container); err != nil {\n//\t    return err\n//\t}\nfunc RegisterNamedMiddlewares(container *dig.Container) error {\n\treturn container.Invoke(func(\n\t\tmiddleware *Middleware,\n\t\tserver ServerMiddlewareRegistrar,\n\t) {\n\t\t// Register paywall middleware (requires active subscription)\n\t\tserver.RegisterNamedMiddleware(\"paywall\", func() gin.HandlerFunc {\n\t\t\treturn middleware.RequireActiveSubscription()\n\t\t})\n\n\t\t// Register optional paywall middleware (sets status but doesn't block)\n\t\tserver.RegisterNamedMiddleware(\"paywall_optional\", func() gin.HandlerFunc {\n\t\t\treturn middleware.OptionalSubscriptionStatus()\n\t\t})\n\n\t\t// Backward compatibility: legacy names (deprecated)\n\t\tserver.RegisterNamedMiddleware(\"subscription\", func() gin.HandlerFunc {\n\t\t\treturn middleware.RequireActiveSubscription()\n\t\t})\n\t\tserver.RegisterNamedMiddleware(\"subscription_optional\", func() gin.HandlerFunc {\n\t\t\treturn middleware.OptionalSubscriptionStatus()\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/modules/paywall/subscription.go",
    "content": "// Package paywall provides access gating middleware for B2B SaaS applications.\n//\n// This package abstracts away the billing provider (Polar, Stripe, Paddle, etc.)\n// and provides a clean interface for checking subscription status before allowing\n// access to protected resources. It acts as a \"payment bouncer\" - checking if an\n// organization has an active subscription before granting access to premium features.\n//\n// # Architecture\n//\n// The paywall package follows the adapter pattern, similar to the auth package:\n//\n//\t┌─────────────────────────────────────────────────────────────────┐\n//\t│                        Application Layer                        │\n//\t│  (handlers, services - use paywall.GetSubscriptionStatus)       │\n//\t└─────────────────────────────────────────────────────────────────┘\n//\t                              │\n//\t                              ▼\n//\t┌─────────────────────────────────────────────────────────────────┐\n//\t│                       paywall package                           │\n//\t│  • SubscriptionStatusProvider interface                         │\n//\t│  • SubscriptionStatus (provider-agnostic status)               │\n//\t│  • Middleware (RequireActiveSubscription)                       │\n//\t│  • Type-safe context helpers                                    │\n//\t└─────────────────────────────────────────────────────────────────┘\n//\t                              │\n//\t                              ▼\n//\t┌─────────────────────────────────────────────────────────────────┐\n//\t│                  app/billing module adapter                     │\n//\t│  (Polar/Stripe-specific - hidden from app layer)               │\n//\t└─────────────────────────────────────────────────────────────────┘\n//\n// # Event-Driven Integration\n//\n// The paywall package does NOT manage subscriptions directly. It only reads\n// subscription status from local database. The billing module (app/billing)\n// handles subscription lifecycle via webhooks and events:\n//\n//   - Polar/Stripe sends webhook → billing module processes it\n//   - billing module updates local DB → paywall reads from DB\n//   - No direct coupling between paywall and billing providers\n//\n// # Usage\n//\n// In routes:\n//\n//\tpaywallMiddleware := paywall.NewMiddleware(provider, nil)\n//\trouter.Use(\n//\t    auth.RequireAuth(authProvider),\n//\t    auth.RequireOrganization(orgRepo, accountRepo),\n//\t    paywallMiddleware.RequireActiveSubscription(),\n//\t)\n//\n// In handlers:\n//\n//\tfunc Handler(c *gin.Context) {\n//\t    status := paywall.GetSubscriptionStatus(c)\n//\t    if status != nil && status.IsActive {\n//\t        // Subscription is active\n//\t    }\n//\t}\n//\n// # The \"Swiss Cheese\" Strategy\n//\n// NOT all routes should require active subscription. Users with failed payments\n// need access to billing/settings to fix their payment method:\n//\n//   - Protected routes: AI features, OCR, reports, expensive operations\n//   - Unprotected routes: Billing portal, settings, profile, webhooks\npackage paywall\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\n// SubscriptionStatusProvider abstracts how subscription status is retrieved.\n//\n// The subscriptions module implements this interface. The middleware package\n// doesn't know about Polar, Stripe, or any specific provider.\n//\n// Implementations should:\n//   - Read from local database only (for speed)\n//   - Never call external APIs during request handling\n//   - Return appropriate errors when subscription is missing\ntype SubscriptionStatusProvider interface {\n\t// GetSubscriptionStatus checks if organization has an active subscription.\n\t// Returns status from local database only (fast, no external API calls).\n\t// The organizationID is the database primary key (int32).\n\tGetSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error)\n\n\t// RefreshSubscriptionStatus forces a sync from the payment provider API.\n\t// This is the lazy guarding mechanism - used when DB says expired but we want\n\t// to double-check with the provider in case we missed a webhook.\n\t// Returns updated status after syncing with provider.\n\tRefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error)\n}\n\n// SubscriptionStatus represents the organization's billing state.\n//\n// This is a provider-agnostic representation of subscription status.\n// The status is typically synced from the payment provider via webhooks.\ntype SubscriptionStatus struct {\n\t// OrganizationID is the database primary key for the organization.\n\tOrganizationID int32 `json:\"organization_id\"`\n\n\t// IsActive indicates whether the subscription allows access to protected features.\n\t// True for \"active\" and \"trialing\" statuses.\n\tIsActive bool `json:\"is_active\"`\n\n\t// Status is the raw subscription status from the provider.\n\t// Common values: \"active\", \"trialing\", \"past_due\", \"canceled\", \"unpaid\"\n\tStatus string `json:\"status\"`\n\n\t// ExpiresAt is when the current billing period ends.\n\t// After this time, the subscription may need renewal.\n\tExpiresAt time.Time `json:\"expires_at,omitempty\"`\n\n\t// Reason provides a human-readable explanation when IsActive is false.\n\t// Examples: \"subscription expired\", \"payment failed\", \"no subscription found\"\n\tReason string `json:\"reason,omitempty\"`\n}\n\n// IsTrialing returns true if the subscription is in a trial period.\nfunc (s *SubscriptionStatus) IsTrialing() bool {\n\treturn s.Status == StatusTrialing\n}\n\n// IsPastDue returns true if the subscription has a failed payment.\nfunc (s *SubscriptionStatus) IsPastDue() bool {\n\treturn s.Status == StatusPastDue\n}\n\n// IsCanceled returns true if the subscription has been canceled.\nfunc (s *SubscriptionStatus) IsCanceled() bool {\n\treturn s.Status == StatusCanceled\n}\n\n// Subscription status constants.\n// These map to common status values from payment providers.\nconst (\n\tStatusActive   = \"active\"\n\tStatusTrialing = \"trialing\"\n\tStatusPastDue  = \"past_due\"\n\tStatusCanceled = \"canceled\"\n\tStatusUnpaid   = \"unpaid\"\n\tStatusNone     = \"none\" // No subscription exists\n)\n\n// IsActiveStatus returns true if the given status represents an active subscription.\n// Active statuses allow access to protected features.\nfunc IsActiveStatus(status string) bool {\n\tswitch status {\n\tcase StatusActive, StatusTrialing:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/eventbus/bus.go",
    "content": "package eventbus\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"sync\"\n)\n\n// EventBus handles publishing and subscribing to events\ntype EventBus interface {\n\t// Publish publishes an event to all subscribers\n\tPublish(ctx context.Context, event Event) error\n\t// Subscribe registers a handler for a specific event type\n\tSubscribe(eventName string, handler EventHandler[Event]) error\n\t// Unsubscribe removes a handler for a specific event type\n\tUnsubscribe(eventName string, handler EventHandler[Event]) error\n\t// Close gracefully shuts down the event bus\n\tClose() error\n}\n\n// InMemoryEventBus is an in-memory implementation of EventBus\ntype InMemoryEventBus struct {\n\tmu          sync.RWMutex\n\tsubscribers map[string][]EventHandler[Event]\n\tmiddleware  []EventMiddleware\n\tclosed      bool\n}\n\nfunc NewInMemoryEventBus(middleware ...EventMiddleware) EventBus {\n\treturn &InMemoryEventBus{\n\t\tsubscribers: make(map[string][]EventHandler[Event]),\n\t\tmiddleware:  middleware,\n\t\tclosed:      false,\n\t}\n}\n\n// Publish publishes an event to all registered handlers\nfunc (bus *InMemoryEventBus) Publish(ctx context.Context, event Event) error {\n\tbus.mu.RLock()\n\tif bus.closed {\n\t\tbus.mu.RUnlock()\n\t\treturn fmt.Errorf(\"event bus is closed\")\n\t}\n\n\thandlers := make([]EventHandler[Event], len(bus.subscribers[event.EventName()]))\n\tcopy(handlers, bus.subscribers[event.EventName()])\n\tbus.mu.RUnlock()\n\n\tif len(handlers) == 0 {\n\t\treturn nil\n\t}\n\n\t// Execute handlers concurrently\n\tvar wg sync.WaitGroup\n\terrCh := make(chan error, len(handlers))\n\n\tfor i, handler := range handlers {\n\t\twg.Add(1)\n\t\tgo func(handlerIndex int, h EventHandler[Event]) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// Apply middleware chain\n\t\t\tfinalHandler := h\n\t\t\tfor i := len(bus.middleware) - 1; i >= 0; i-- {\n\t\t\t\tfinalHandler = bus.middleware[i](finalHandler)\n\t\t\t}\n\n\t\t\tif err := finalHandler(ctx, event); err != nil {\n\t\t\t\terrCh <- fmt.Errorf(\"handler error for event %s: %w\", event.EventName(), err)\n\t\t\t}\n\t\t}(i, handler)\n\t}\n\n\t// Wait for all handlers to complete\n\twg.Wait()\n\tclose(errCh)\n\n\t// Collect any errors\n\tvar errors []error\n\tfor err := range errCh {\n\t\terrors = append(errors, err)\n\t}\n\n\tif len(errors) > 0 {\n\t\treturn fmt.Errorf(\"event handling errors: %v\", errors)\n\t}\n\n\treturn nil\n}\n\n// Subscribe registers a handler for a specific event type\nfunc (bus *InMemoryEventBus) Subscribe(eventName string, handler EventHandler[Event]) error {\n\tbus.mu.Lock()\n\tdefer bus.mu.Unlock()\n\n\tif bus.closed {\n\t\treturn fmt.Errorf(\"event bus is closed\")\n\t}\n\n\tbus.subscribers[eventName] = append(bus.subscribers[eventName], handler)\n\n\treturn nil\n}\n\n// Unsubscribe removes a handler for a specific event type\nfunc (bus *InMemoryEventBus) Unsubscribe(eventName string, handler EventHandler[Event]) error {\n\tbus.mu.Lock()\n\tdefer bus.mu.Unlock()\n\n\thandlers := bus.subscribers[eventName]\n\tfor i, h := range handlers {\n\t\t// Compare function pointers\n\t\tif reflect.ValueOf(h).Pointer() == reflect.ValueOf(handler).Pointer() {\n\t\t\tbus.subscribers[eventName] = append(handlers[:i], handlers[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Close gracefully shuts down the event bus\nfunc (bus *InMemoryEventBus) Close() error {\n\tbus.mu.Lock()\n\tdefer bus.mu.Unlock()\n\n\tbus.closed = true\n\tbus.subscribers = make(map[string][]EventHandler[Event])\n\treturn nil\n}\n\n// GetSubscriberCount returns the number of subscribers for an event (for testing/debugging)\nfunc (bus *InMemoryEventBus) GetSubscriberCount(eventName string) int {\n\tbus.mu.RLock()\n\tdefer bus.mu.RUnlock()\n\treturn len(bus.subscribers[eventName])\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/eventbus/cmd/init.go",
    "content": "package cmd\n\nimport \"go.uber.org/dig\"\n\nfunc Init(container *dig.Container) error {\n\tif err := ProvideEventBus(container); err != nil {\n\t\treturn err\n\t}\n\t\n\treturn nil\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/eventbus/cmd/provider.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n\t\n\t\"github.com/moasq/go-b2b-starter/internal/platform/eventbus\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\n// ProvideEventBus creates and configures the event bus with middleware\nfunc ProvideEventBus(container *dig.Container) error {\n\treturn container.Provide(func(logger domain.Logger) eventbus.EventBus {\n\t\tmiddleware := []eventbus.EventMiddleware{\n\t\t\teventbus.RecoveryMiddleware(logger),\n\t\t\teventbus.LoggingMiddleware(logger),\n\t\t\teventbus.MetricsMiddleware(),\n\t\t}\n\t\t\n\t\treturn eventbus.NewInMemoryEventBus(middleware...)\n\t})\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/eventbus/event.go",
    "content": "package eventbus\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\n// Event represents a domain event that can be published and subscribed to\ntype Event interface {\n\t// EventName returns the unique name/type of the event\n\tEventName() string\n\t// EventID returns a unique identifier for this specific event instance\n\tEventID() string\n\t// Timestamp returns when the event was created\n\tTimestamp() time.Time\n\t// Metadata returns additional event metadata\n\tMetadata() map[string]interface{}\n}\n\n// BaseEvent provides common event functionality\ntype BaseEvent struct {\n\tID        string                 `json:\"id\"`\n\tName      string                 `json:\"name\"`\n\tCreatedAt time.Time              `json:\"created_at\"`\n\tMeta      map[string]interface{} `json:\"metadata,omitempty\"`\n}\n\nfunc (e BaseEvent) EventName() string {\n\treturn e.Name\n}\n\nfunc (e BaseEvent) EventID() string {\n\treturn e.ID\n}\n\nfunc (e BaseEvent) Timestamp() time.Time {\n\treturn e.CreatedAt\n}\n\nfunc (e BaseEvent) Metadata() map[string]interface{} {\n\treturn e.Meta\n}\n\n// EventHandler represents a function that handles an event\ntype EventHandler[T Event] func(ctx context.Context, event T) error\n\n// EventMiddleware can be used to add cross-cutting concerns like logging, metrics, etc.\ntype EventMiddleware func(next EventHandler[Event]) EventHandler[Event]"
  },
  {
    "path": "go-b2b-starter/internal/platform/eventbus/events.go",
    "content": "package eventbus\n\nimport (\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/shopspring/decimal\"\n)\n\n// Common event types used across modules\n\n// Invoice Events\ntype InvoiceUploaded struct {\n\tBaseEvent\n\tInvoiceID int32  `json:\"invoice_id\"`\n\tFileID    int32  `json:\"file_id\"`\n\tVendorName string `json:\"vendor_name,omitempty\"`\n\tAmount     decimal.Decimal `json:\"amount,omitempty\"`\n\tUserID     int32  `json:\"user_id\"`\n}\n\nfunc NewInvoiceUploaded(invoiceID, fileID, userID int32, vendorName string, amount decimal.Decimal) *InvoiceUploaded {\n\treturn &InvoiceUploaded{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"invoice.uploaded\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:  invoiceID,\n\t\tFileID:     fileID,\n\t\tVendorName: vendorName,\n\t\tAmount:     amount,\n\t\tUserID:     userID,\n\t}\n}\n\ntype InvoiceValidated struct {\n\tBaseEvent\n\tInvoiceID     int32                  `json:\"invoice_id\"`\n\tFileID        int32                  `json:\"file_id\"`\n\tValidationData map[string]interface{} `json:\"validation_data\"`\n}\n\nfunc NewInvoiceValidated(invoiceID, fileID int32, validationData map[string]interface{}) *InvoiceValidated {\n\treturn &InvoiceValidated{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"invoice.validated\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:      invoiceID,\n\t\tFileID:         fileID,\n\t\tValidationData: validationData,\n\t}\n}\n\n// OCR Events\ntype OCRRequested struct {\n\tBaseEvent\n\tInvoiceID int32 `json:\"invoice_id\"`\n\tFileID    int32 `json:\"file_id\"`\n}\n\nfunc NewOCRRequested(invoiceID, fileID int32) *OCRRequested {\n\treturn &OCRRequested{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"ocr.requested\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID: invoiceID,\n\t\tFileID:    fileID,\n\t}\n}\n\ntype TextExtracted struct {\n\tBaseEvent\n\tInvoiceID     int32                  `json:\"invoice_id\"`\n\tFileID        int32                  `json:\"file_id\"`\n\tExtractedData map[string]interface{} `json:\"extracted_data\"`\n\tConfidence    float64                `json:\"confidence\"`\n}\n\nfunc NewTextExtracted(invoiceID, fileID int32, extractedData map[string]interface{}, confidence float64) *TextExtracted {\n\treturn &TextExtracted{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"text.extracted\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:     invoiceID,\n\t\tFileID:        fileID,\n\t\tExtractedData: extractedData,\n\t\tConfidence:    confidence,\n\t}\n}\n\n// Duplicate Detection Events\ntype DuplicateCheckRequested struct {\n\tBaseEvent\n\tInvoiceID int32                  `json:\"invoice_id\"`\n\tData      map[string]interface{} `json:\"data\"`\n}\n\nfunc NewDuplicateCheckRequested(invoiceID int32, data map[string]interface{}) *DuplicateCheckRequested {\n\treturn &DuplicateCheckRequested{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"duplicate.check_requested\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID: invoiceID,\n\t\tData:      data,\n\t}\n}\n\ntype DuplicateDetected struct {\n\tBaseEvent\n\tInvoiceID         int32   `json:\"invoice_id\"`\n\tDuplicateOf       int32   `json:\"duplicate_of\"`\n\tSimilarityScore   float64 `json:\"similarity_score\"`\n\tRequiresReview    bool    `json:\"requires_review\"`\n}\n\nfunc NewDuplicateDetected(invoiceID, duplicateOf int32, similarityScore float64, requiresReview bool) *DuplicateDetected {\n\treturn &DuplicateDetected{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"duplicate.detected\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:       invoiceID,\n\t\tDuplicateOf:     duplicateOf,\n\t\tSimilarityScore: similarityScore,\n\t\tRequiresReview:  requiresReview,\n\t}\n}\n\ntype UniqueConfirmed struct {\n\tBaseEvent\n\tInvoiceID int32 `json:\"invoice_id\"`\n}\n\nfunc NewUniqueConfirmed(invoiceID int32) *UniqueConfirmed {\n\treturn &UniqueConfirmed{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"duplicate.unique_confirmed\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID: invoiceID,\n\t}\n}\n\n// Approval Events\ntype ApprovalRequested struct {\n\tBaseEvent\n\tInvoiceID      int32           `json:\"invoice_id\"`\n\tAmount         decimal.Decimal `json:\"amount\"`\n\tVendorID       int32           `json:\"vendor_id\"`\n\tRequesterID    int32           `json:\"requester_id\"`\n\tApprovalLevel  int             `json:\"approval_level\"`\n}\n\nfunc NewApprovalRequested(invoiceID, vendorID, requesterID int32, amount decimal.Decimal, approvalLevel int) *ApprovalRequested {\n\treturn &ApprovalRequested{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"approval.requested\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:     invoiceID,\n\t\tAmount:        amount,\n\t\tVendorID:      vendorID,\n\t\tRequesterID:   requesterID,\n\t\tApprovalLevel: approvalLevel,\n\t}\n}\n\ntype ApprovalGranted struct {\n\tBaseEvent\n\tInvoiceID   int32  `json:\"invoice_id\"`\n\tApproverID  int32  `json:\"approver_id\"`\n\tApprovalID  int32  `json:\"approval_id\"`\n\tComments    string `json:\"comments,omitempty\"`\n}\n\nfunc NewApprovalGranted(invoiceID, approverID, approvalID int32, comments string) *ApprovalGranted {\n\treturn &ApprovalGranted{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"approval.granted\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:  invoiceID,\n\t\tApproverID: approverID,\n\t\tApprovalID: approvalID,\n\t\tComments:   comments,\n\t}\n}\n\ntype ApprovalRejected struct {\n\tBaseEvent\n\tInvoiceID   int32  `json:\"invoice_id\"`\n\tApproverID  int32  `json:\"approver_id\"`\n\tApprovalID  int32  `json:\"approval_id\"`\n\tReason      string `json:\"reason\"`\n}\n\nfunc NewApprovalRejected(invoiceID, approverID, approvalID int32, reason string) *ApprovalRejected {\n\treturn &ApprovalRejected{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"approval.rejected\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:  invoiceID,\n\t\tApproverID: approverID,\n\t\tApprovalID: approvalID,\n\t\tReason:     reason,\n\t}\n}\n\n// Payment Events\ntype PaymentScheduled struct {\n\tBaseEvent\n\tInvoiceID         int32           `json:\"invoice_id\"`\n\tPaymentID         int32           `json:\"payment_id\"`\n\tScheduledDate     time.Time       `json:\"scheduled_date\"`\n\tAmount            decimal.Decimal `json:\"amount\"`\n\tDiscountCaptured  decimal.Decimal `json:\"discount_captured\"`\n\tOptimalPayment    bool            `json:\"optimal_payment\"`\n}\n\nfunc NewPaymentScheduled(invoiceID, paymentID int32, scheduledDate time.Time, amount, discountCaptured decimal.Decimal, optimalPayment bool) *PaymentScheduled {\n\treturn &PaymentScheduled{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"payment.scheduled\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:        invoiceID,\n\t\tPaymentID:        paymentID,\n\t\tScheduledDate:    scheduledDate,\n\t\tAmount:           amount,\n\t\tDiscountCaptured: discountCaptured,\n\t\tOptimalPayment:   optimalPayment,\n\t}\n}\n\ntype PaymentExecuted struct {\n\tBaseEvent\n\tInvoiceID         int32           `json:\"invoice_id\"`\n\tPaymentID         int32           `json:\"payment_id\"`\n\tOrganizationID    int32           `json:\"organization_id\"`\n\tTransactionID     string          `json:\"transaction_id\"`\n\tAmount            decimal.Decimal `json:\"amount\"`\n\tDiscountCaptured  decimal.Decimal `json:\"discount_captured\"`\n\tExecutedDate      time.Time       `json:\"executed_date\"`\n}\n\nfunc NewPaymentExecuted(invoiceID, paymentID, organizationID int32, transactionID string, amount, discountCaptured decimal.Decimal, executedDate time.Time) *PaymentExecuted {\n\treturn &PaymentExecuted{\n\t\tBaseEvent: BaseEvent{\n\t\t\tID:        uuid.New().String(),\n\t\t\tName:      \"payment.executed\",\n\t\t\tCreatedAt: time.Now(),\n\t\t\tMeta:      make(map[string]interface{}),\n\t\t},\n\t\tInvoiceID:        invoiceID,\n\t\tPaymentID:        paymentID,\n\t\tOrganizationID:   organizationID,\n\t\tTransactionID:    transactionID,\n\t\tAmount:           amount,\n\t\tDiscountCaptured: discountCaptured,\n\t\tExecutedDate:     executedDate,\n\t}\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/eventbus/middleware.go",
    "content": "package eventbus\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"runtime/debug\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\n// LoggingMiddleware adds logging to event handling\nfunc LoggingMiddleware(logger domain.Logger) EventMiddleware {\n\treturn func(next EventHandler[Event]) EventHandler[Event] {\n\t\treturn func(ctx context.Context, event Event) error {\n\t\t\tstart := time.Now()\n\t\t\tlogger.Info(\"Processing event\", map[string]interface{}{\n\t\t\t\t\"event_name\": event.EventName(),\n\t\t\t\t\"event_id\":   event.EventID(),\n\t\t\t\t\"timestamp\":  event.Timestamp(),\n\t\t\t})\n\n\t\t\terr := next(ctx, event)\n\t\t\tduration := time.Since(start)\n\n\t\t\tif err != nil {\n\t\t\t\tlogger.Error(\"Event processing failed\", map[string]interface{}{\n\t\t\t\t\t\"event_name\": event.EventName(),\n\t\t\t\t\t\"event_id\":   event.EventID(),\n\t\t\t\t\t\"error\":      err.Error(),\n\t\t\t\t\t\"duration\":   duration,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tlogger.Info(\"Event processed successfully\", map[string]interface{}{\n\t\t\t\t\t\"event_name\": event.EventName(),\n\t\t\t\t\t\"event_id\":   event.EventID(),\n\t\t\t\t\t\"duration\":   duration,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\treturn err\n\t\t}\n\t}\n}\n\n// RecoveryMiddleware recovers from panics in event handlers\nfunc RecoveryMiddleware(logger domain.Logger) EventMiddleware {\n\treturn func(next EventHandler[Event]) EventHandler[Event] {\n\t\treturn func(ctx context.Context, event Event) (err error) {\n\t\t\tdefer func() {\n\t\t\t\tif r := recover(); r != nil {\n\t\t\t\t\tstack := debug.Stack()\n\n\t\t\t\t\t// Get event metadata size for debugging\n\t\t\t\t\tmetadata := event.Metadata()\n\t\t\t\t\tmetadataSize := len(fmt.Sprintf(\"%+v\", metadata))\n\n\t\t\t\t\tlogger.Error(\"Event handler panicked\", map[string]interface{}{\n\t\t\t\t\t\t\"event_name\":       event.EventName(),\n\t\t\t\t\t\t\"event_id\":         event.EventID(),\n\t\t\t\t\t\t\"event_timestamp\":  event.Timestamp(),\n\t\t\t\t\t\t\"panic\":            r,\n\t\t\t\t\t\t\"stack_trace\":      string(stack),\n\t\t\t\t\t\t\"metadata_size\":    metadataSize,\n\t\t\t\t\t\t\"metadata_keys\":    getMapKeys(metadata),\n\t\t\t\t\t\t\"recovery_context\": \"eventbus_middleware\",\n\t\t\t\t\t})\n\t\t\t\t\terr = fmt.Errorf(\"event handler panicked: %v\", r)\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\treturn next(ctx, event)\n\t\t}\n\t}\n}\n\n// Helper function to safely extract map keys\nfunc getMapKeys(m map[string]interface{}) []string {\n\tkeys := make([]string, 0, len(m))\n\tfor k := range m {\n\t\tkeys = append(keys, k)\n\t}\n\treturn keys\n}\n\n// MetricsMiddleware adds metrics collection to event handling\nfunc MetricsMiddleware() EventMiddleware {\n\treturn func(next EventHandler[Event]) EventHandler[Event] {\n\t\treturn func(ctx context.Context, event Event) error {\n\t\t\tstart := time.Now()\n\n\t\t\terr := next(ctx, event)\n\t\t\tduration := time.Since(start)\n\n\t\t\t// Here you could send metrics to Prometheus, StatsD, etc.\n\t\t\t// For now, we'll just log the metrics\n\t\t\t_ = duration // Placeholder for actual metrics implementation\n\n\t\t\treturn err\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/llm/README.md",
    "content": "# LLM Module Guide\n\nSimple guide for using AI completions and embeddings in your modules.\n\n## Setup\n\nAdd to your `.env`:\n\n```bash\nOPENAI_API_KEY=your-api-key-here\nOPENAI_MODEL=gpt-4\n```\n\n## Usage in Your Module\n\n### 1. Inject the LLM Client\n\n```go\nimport \"github.com/moasq/go-b2b-starter/pkg/llm/domain\"\n\ntype YourService struct {\n    llmClient domain.LLMClient\n}\n\nfunc NewYourService(llmClient domain.LLMClient) *YourService {\n    return &YourService{llmClient: llmClient}\n}\n```\n\n### 2. Use Completions (Prompts)\n\nSend a prompt, get AI-generated text:\n\n```go\nfunc (s *YourService) GenerateText(ctx context.Context, prompt string) (string, error) {\n    req := domain.CompletionRequest{\n        Prompt: prompt,\n    }\n\n    response, err := s.llmClient.Complete(ctx, req)\n    if err != nil {\n        return \"\", err\n    }\n\n    return response.Text, nil\n}\n```\n\nWith options:\n\n```go\nmaxTokens := 200\ntemperature := float32(0.7) // 0.0 = focused, 1.0 = creative\n\nreq := domain.CompletionRequest{\n    Prompt:      prompt,\n    MaxTokens:   &maxTokens,\n    Temperature: &temperature,\n}\n```\n\n### 3. Use Embeddings (Vectors)\n\nConvert text to vectors for semantic search:\n\n```go\nfunc (s *DocumentService) GenerateEmbedding(ctx context.Context, text string) ([]float32, error) {\n    embedding, err := s.llmClient.GenerateEmbedding(\n        ctx,\n        text,\n        \"text-embedding-3-small\",\n    )\n    if err != nil {\n        return nil, err\n    }\n\n    // Convert []float64 to []float32 for database\n    result := make([]float32, len(embedding))\n    for i, v := range embedding {\n        result[i] = float32(v)\n    }\n\n    return result, nil\n}\n```\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `OPENAI_API_KEY` | *required* | Your OpenAI API key |\n| `OPENAI_MODEL` | `gpt-4` | AI model |\n| `OPENAI_MAX_TOKENS` | `500` | Max response length |\n| `OPENAI_TEMPERATURE` | `0.7` | Creativity level (0.0-1.0) |\n| `LLM_TIMEOUT_SEC` | `60` | Request timeout |\n\n## Common Models\n\n**Completions:** `gpt-4`, `gpt-4-turbo`, `gpt-3.5-turbo`\n**Embeddings:** `text-embedding-3-small` (recommended), `text-embedding-3-large`\n\nThat's it! Just inject `LLMClient` and you're ready to use AI in your module.\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/llm/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/llm/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/llm/infra\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\nfunc Init(container *dig.Container) error {\n\t// Register LLMClient (which includes LLMService)\n\tif err := container.Provide(func(logger loggerDomain.Logger) (domain.LLMClient, error) {\n\t\tconfig := infra.NewLLMConfig()\n\t\treturn infra.NewOpenAIClient(config, logger)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Also register LLMService for backward compatibility\n\treturn container.Provide(func(client domain.LLMClient) domain.LLMService {\n\t\treturn client\n\t})\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/llm/domain/errors.go",
    "content": "package domain\n\nimport \"errors\"\n\nvar (\n\tErrInvalidPrompt    = errors.New(\"prompt cannot be empty\")\n\tErrProviderNotFound = errors.New(\"LLM provider not found\")\n\tErrAPIError         = errors.New(\"LLM API error\")\n\tErrTimeout          = errors.New(\"LLM request timeout\")\n)"
  },
  {
    "path": "go-b2b-starter/internal/platform/llm/domain/service.go",
    "content": "package domain\n\nimport \"context\"\n\ntype CompletionRequest struct {\n\tPrompt      string\n\tMaxTokens   *int\n\tTemperature *float32\n}\n\ntype CompletionResponse struct {\n\tText       string\n\tTokensUsed int\n\tModel      string\n}\n\ntype EmbeddingRequest struct {\n\tText  string\n\tModel string\n}\n\ntype EmbeddingResponse struct {\n\tEmbedding  []float64\n\tTokensUsed int\n\tModel      string\n}\n\ntype StreamChunk struct {\n\tContent string\n\tDone    bool\n}\n\ntype LLMService interface {\n\tComplete(ctx context.Context, request CompletionRequest) (*CompletionResponse, error)\n\tCompleteStream(ctx context.Context, request CompletionRequest, callback func(StreamChunk) error) (*CompletionResponse, error)\n}\n\ntype LLMClient interface {\n\tLLMService\n\tGenerateEmbedding(ctx context.Context, text string, model string) ([]float64, error)\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/llm/infra/openai_client.go",
    "content": "package infra\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/big\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/llm/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\ntype Config struct {\n\tAPIKey      string\n\tModel       string\n\tMaxTokens   int\n\tTemperature float32\n\tTimeoutSec  int\n\tMaxRetries  int\n\tDebugMode   bool\n}\n\nfunc (c Config) Validate() error {\n\tif c.APIKey == \"\" {\n\t\treturn fmt.Errorf(\"API key is required\")\n\t}\n\tif c.Model == \"\" {\n\t\treturn fmt.Errorf(\"model is required\")\n\t}\n\treturn nil\n}\n\n// CircuitBreaker implements a simple circuit breaker pattern\ntype CircuitBreaker struct {\n\tmu              sync.RWMutex\n\tfailureCount    int64\n\tsuccessCount    int64\n\tlastFailureTime time.Time\n\tstate           string // \"closed\", \"open\", \"half-open\"\n\tmaxFailures     int\n\tresetTimeout    time.Duration\n}\n\nfunc NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker {\n\treturn &CircuitBreaker{\n\t\tmaxFailures:  maxFailures,\n\t\tresetTimeout: resetTimeout,\n\t\tstate:        \"closed\",\n\t}\n}\n\n// CanExecute checks if a request can be executed based on circuit breaker state\nfunc (cb *CircuitBreaker) CanExecute() bool {\n\tcb.mu.Lock()\n\tdefer cb.mu.Unlock()\n\n\tif cb.state == \"closed\" {\n\t\treturn true\n\t}\n\n\tif cb.state == \"open\" {\n\t\tif time.Since(cb.lastFailureTime) > cb.resetTimeout {\n\t\t\tcb.state = \"half-open\"\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\t// half-open state - allow one request to test\n\treturn true\n}\n\n// RecordSuccess records a successful execution\nfunc (cb *CircuitBreaker) RecordSuccess() {\n\tcb.mu.Lock()\n\tdefer cb.mu.Unlock()\n\n\tcb.successCount++\n\tif cb.state == \"half-open\" {\n\t\tcb.state = \"closed\"\n\t\tcb.failureCount = 0\n\t}\n}\n\n// RecordFailure records a failed execution\nfunc (cb *CircuitBreaker) RecordFailure() {\n\tcb.mu.Lock()\n\tdefer cb.mu.Unlock()\n\n\tcb.failureCount++\n\tcb.lastFailureTime = time.Now()\n\n\tif cb.failureCount >= int64(cb.maxFailures) {\n\t\tcb.state = \"open\"\n\t}\n}\n\n// GetStats returns circuit breaker statistics\nfunc (cb *CircuitBreaker) GetStats() map[string]interface{} {\n\tcb.mu.RLock()\n\tdefer cb.mu.RUnlock()\n\n\treturn map[string]interface{}{\n\t\t\"state\":        cb.state,\n\t\t\"failures\":     cb.failureCount,\n\t\t\"successes\":    cb.successCount,\n\t\t\"last_failure\": cb.lastFailureTime,\n\t}\n}\n\ntype OpenAIClient struct {\n\tconfig         Config\n\tclient         *http.Client\n\tlogger         loggerDomain.Logger\n\tcircuitBreaker *CircuitBreaker\n}\n\ntype openAIRequest struct {\n\tModel       string          `json:\"model\"`\n\tMessages    []openAIMessage `json:\"messages\"`\n\tMaxTokens   int             `json:\"max_tokens\"`\n\tTemperature *float32        `json:\"temperature,omitempty\"`\n\tStop        []string        `json:\"stop,omitempty\"`\n\tStream      bool            `json:\"stream,omitempty\"`\n}\n\ntype ToolCall struct {\n\tID       string `json:\"id\"`\n\tType     string `json:\"type\"` // \"function\"\n\tFunction struct {\n\t\tName      string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIMessage struct {\n\tRole      string     `json:\"role\"`\n\tContent   string     `json:\"content\"`\n\tRefusal   string     `json:\"refusal,omitempty\"`\n\tToolCalls []ToolCall `json:\"tool_calls,omitempty\"`\n}\n\ntype openAIResponse struct {\n\tID      string         `json:\"id\"`\n\tObject  string         `json:\"object\"`\n\tCreated int64          `json:\"created\"`\n\tModel   string         `json:\"model\"`\n\tChoices []openAIChoice `json:\"choices\"`\n\tUsage   *openAIUsage   `json:\"usage,omitempty\"`\n\tError   *openAIError   `json:\"error,omitempty\"`\n}\n\ntype openAIChoice struct {\n\tIndex        int           `json:\"index\"`\n\tMessage      openAIMessage `json:\"message\"`\n\tFinishReason string        `json:\"finish_reason\"`\n}\n\ntype CompletionTokensDetails struct {\n\tReasoningTokens int `json:\"reasoning_tokens\"`\n}\n\ntype openAIUsage struct {\n\tPromptTokens            int                      `json:\"prompt_tokens\"`\n\tCompletionTokens        int                      `json:\"completion_tokens\"`\n\tTotalTokens             int                      `json:\"total_tokens\"`\n\tCachedTokens            int                      `json:\"cached_tokens,omitempty\"`\n\tCompletionTokensDetails *CompletionTokensDetails `json:\"completion_tokens_details,omitempty\"`\n}\n\ntype openAIError struct {\n\tMessage string      `json:\"message\"`\n\tType    string      `json:\"type\"`\n\tParam   any `json:\"param\"` // can be string or null\n\tCode    any `json:\"code\"`  // can be string, number, or null\n}\n\nfunc NewLLMConfig() Config {\n\tmaxTokens, _ := strconv.Atoi(getEnvOrDefault(\"OPENAI_MAX_TOKENS\", \"150\"))\n\ttemperature, _ := strconv.ParseFloat(getEnvOrDefault(\"OPENAI_TEMPERATURE\", \"0.1\"), 32)\n\ttimeoutSec, _ := strconv.Atoi(getEnvOrDefault(\"LLM_TIMEOUT_SEC\", \"60\")) // Increased default for GPT-5\n\tmaxRetries, _ := strconv.Atoi(getEnvOrDefault(\"LLM_MAX_RETRIES\", \"2\"))\n\tdebugMode, _ := strconv.ParseBool(getEnvOrDefault(\"LLM_DEBUG_MODE\", \"false\"))\n\n\treturn Config{\n\t\tAPIKey:      os.Getenv(\"OPENAI_API_KEY\"),\n\t\tModel:       getEnvOrDefault(\"OPENAI_MODEL\", \"gpt-5-mini\"),\n\t\tMaxTokens:   maxTokens,\n\t\tTemperature: float32(temperature),\n\t\tTimeoutSec:  timeoutSec,\n\t\tMaxRetries:  maxRetries,\n\t\tDebugMode:   debugMode,\n\t}\n}\n\nfunc NewOpenAIClient(config Config, logger loggerDomain.Logger) (domain.LLMClient, error) {\n\tif err := config.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\n\t}\n\n\t// Configure transport with keep-alive and proper timeouts\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        100,\n\t\tMaxIdleConnsPerHost: 10,\n\t\tIdleConnTimeout:     90 * time.Second,\n\t\tDisableCompression:  false,\n\t\tForceAttemptHTTP2:   true,\n\t\tTLSHandshakeTimeout: 10 * time.Second,\n\t}\n\n\t// No global timeout - let per-request context control deadlines\n\tclient := &http.Client{\n\t\tTimeout:   0,\n\t\tTransport: transport,\n\t}\n\n\t// Initialize circuit breaker if enabled\n\tvar circuitBreaker *CircuitBreaker\n\tif os.Getenv(\"LLM_CIRCUIT_BREAKER_ENABLED\") == \"true\" {\n\t\tmaxFailures := 3 // Default failure threshold\n\t\tresetTimeout := 30 * time.Second // Default reset timeout\n\t\t\n\t\tif val := os.Getenv(\"LLM_CIRCUIT_BREAKER_MAX_FAILURES\"); val != \"\" {\n\t\t\tif parsed, err := strconv.Atoi(val); err == nil {\n\t\t\t\tmaxFailures = parsed\n\t\t\t}\n\t\t}\n\t\t\n\t\tif val := os.Getenv(\"LLM_CIRCUIT_BREAKER_RESET_TIMEOUT\"); val != \"\" {\n\t\t\tif parsed, err := time.ParseDuration(val); err == nil {\n\t\t\t\tresetTimeout = parsed\n\t\t\t}\n\t\t}\n\t\t\n\t\tcircuitBreaker = NewCircuitBreaker(maxFailures, resetTimeout)\n\t\tlogger.Info(\"Circuit breaker enabled for OpenAI client\", map[string]interface{}{\n\t\t\t\"max_failures\":   maxFailures,\n\t\t\t\"reset_timeout\":  resetTimeout,\n\t\t})\n\t}\n\n\treturn &OpenAIClient{\n\t\tconfig:         config,\n\t\tclient:         client,\n\t\tlogger:         logger,\n\t\tcircuitBreaker: circuitBreaker,\n\t}, nil\n}\n\nfunc (c *OpenAIClient) Complete(ctx context.Context, request domain.CompletionRequest) (*domain.CompletionResponse, error) {\n\tif request.Prompt == \"\" {\n\t\treturn nil, domain.ErrInvalidPrompt\n\t}\n\n\tmaxTokens := c.config.MaxTokens\n\tif request.MaxTokens != nil {\n\t\tmaxTokens = *request.MaxTokens\n\t}\n\n\t// Right-size tokens for field extraction - avoid excessive budgets\n\tif strings.HasPrefix(c.config.Model, \"gpt-5\") {\n\t\t// For GPT-5, use smaller budgets unless explicitly requested\n\t\tif maxTokens == c.config.MaxTokens && maxTokens > 200 {\n\t\t\tmaxTokens = 128 // Reasonable default for most extraction tasks\n\t\t\tif c.config.DebugMode {\n\t\t\t\tfmt.Println(\"[DEBUG] Using optimized token budget for GPT-5:\", maxTokens)\n\t\t\t}\n\t\t}\n\t}\n\n\ttemperature := c.config.Temperature\n\tif request.Temperature != nil {\n\t\ttemperature = *request.Temperature\n\t}\n\n\topenAIReq := openAIRequest{\n\t\tModel: c.config.Model,\n\t\tMessages: []openAIMessage{\n\t\t\t{\n\t\t\t\tRole:    \"user\",\n\t\t\t\tContent: request.Prompt,\n\t\t\t},\n\t\t},\n\t\tMaxTokens: maxTokens,\n\t\tStream:    false, // Default to non-streaming for backward compatibility\n\t}\n\n\t// Only set temperature for models that support it (GPT-5 models don't accept custom temperature)\n\tif supportsTemperature(c.config.Model) {\n\t\topenAIReq.Temperature = &temperature\n\t}\n\n\t// Only set stop sequences for models that support them (GPT-5 models don't accept stop parameter)\n\tif supportsStop(c.config.Model) {\n\t\topenAIReq.Stop = []string{\"\\n\\n\", \"\\n---\"}\n\t}\n\n\t// Enhanced request logging\n\tif c.config.DebugMode {\n\t\tlogData := map[string]any{\n\t\t\t\"endpoint\":              \"https://api.openai.com/v1/chat/completions\",\n\t\t\t\"model\":                 c.config.Model,\n\t\t\t\"input_length\":          len(request.Prompt),\n\t\t\t\"max_tokens\":           maxTokens,\n\t\t\t\"supports_temperature\":  supportsTemperature(c.config.Model),\n\t\t\t\"supports_stop\":         supportsStop(c.config.Model),\n\t\t}\n\t\tif supportsTemperature(c.config.Model) {\n\t\t\tlogData[\"temperature\"] = temperature\n\t\t}\n\t\tif supportsStop(c.config.Model) {\n\t\t\tlogData[\"stop_sequences\"] = []string{\"\\n\\n\", \"\\n---\"}\n\t\t}\n\t\tc.logger.Info(\"Starting OpenAI request\", logData)\n\n\t\tdebugMsg := fmt.Sprintf(\"[DEBUG] Starting OpenAI request - Model: %s | MaxTokens: %d\", c.config.Model, maxTokens)\n\t\tif supportsTemperature(c.config.Model) {\n\t\t\tdebugMsg += fmt.Sprintf(\" | Temperature: %.1f\", temperature)\n\t\t} else {\n\t\t\tdebugMsg += \" | Temperature: OMITTED\"\n\t\t}\n\t\tif supportsStop(c.config.Model) {\n\t\t\tdebugMsg += \" | Stop: [\\\\n\\\\n, \\\\n---]\"\n\t\t} else {\n\t\t\tdebugMsg += \" | Stop: OMITTED\"\n\t\t}\n\t\tfmt.Println(debugMsg)\n\t}\n\n\tvar response *domain.CompletionResponse\n\tvar err error\n\n\t// Check circuit breaker before attempting requests\n\tif c.circuitBreaker != nil && !c.circuitBreaker.CanExecute() {\n\t\tstats := c.circuitBreaker.GetStats()\n\t\tc.logger.Warn(\"Circuit breaker is open, request blocked\", map[string]any{\n\t\t\t\"model\":         c.config.Model,\n\t\t\t\"breaker_state\": stats[\"state\"],\n\t\t\t\"failures\":      stats[\"failures\"],\n\t\t\t\"successes\":     stats[\"successes\"],\n\t\t})\n\t\treturn nil, fmt.Errorf(\"circuit breaker is open due to repeated failures\")\n\t}\n\n\t// Retry with fresh context per attempt and exponential backoff with jitter\n\tfor i := 0; i <= c.config.MaxRetries; i++ {\n\t\t// Create fresh context per attempt - THIS FIXES THE MAIN BUG\n\t\tcallTimeout := time.Duration(c.config.TimeoutSec) * time.Second\n\t\tif strings.HasPrefix(c.config.Model, \"gpt-5\") {\n\t\t\tcallTimeout += 30 * time.Second // Extra time for reasoning models\n\t\t}\n\t\tcallCtx, cancel := context.WithTimeout(ctx, callTimeout)\n\t\t\n\t\tresponse, err = c.makeRequest(callCtx, openAIReq)\n\t\tcancel() // Always cancel to free resources\n\t\t\n\t\tif err == nil {\n\t\t\t// Record success in circuit breaker\n\t\t\tif c.circuitBreaker != nil {\n\t\t\t\tc.circuitBreaker.RecordSuccess()\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\t// Categorize error and decide on retry strategy\n\t\tisTemp := isTemporaryError(err)\n\t\tisPerm := isPermanentError(err)\n\t\t\n\t\t// Only record failure in circuit breaker for temporary errors\n\t\t// Permanent errors (like invalid API key) shouldn't trip the breaker\n\t\tif c.circuitBreaker != nil && isTemp {\n\t\t\tc.circuitBreaker.RecordFailure()\n\t\t}\n\n\t\t// Don't retry permanent errors\n\t\tif isPerm {\n\t\t\tc.logger.Error(\"Permanent error detected, not retrying\", map[string]any{\n\t\t\t\t\"model\":       c.config.Model,\n\t\t\t\t\"error\":       err.Error(),\n\t\t\t\t\"error_type\":  \"permanent\",\n\t\t\t\t\"attempt\":     i + 1,\n\t\t\t})\n\t\t\tbreak\n\t\t}\n\n\t\tif i < c.config.MaxRetries {\n\t\t\tc.logger.Warn(\"OpenAI request failed, retrying\", map[string]any{\n\t\t\t\t\"attempt\":     i + 1,\n\t\t\t\t\"max_retries\": c.config.MaxRetries,\n\t\t\t\t\"model\":       c.config.Model,\n\t\t\t\t\"error\":       err.Error(),\n\t\t\t\t\"error_type\":  map[bool]string{true: \"temporary\", false: \"unknown\"}[isTemp],\n\t\t\t\t\"will_retry\":  true,\n\t\t\t})\n\t\t\t\n\t\t\t// Exponential backoff with jitter\n\t\t\tbackoff := time.Duration(1<<i) * time.Second\n\t\t\tjitter := time.Duration(generateJitter(int64(backoff))) * time.Millisecond\n\t\t\ttime.Sleep(backoff + jitter)\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tc.logger.Error(\"OpenAI request failed after all retries\", map[string]any{\n\t\t\t\"error\":       err.Error(),\n\t\t\t\"model\":       c.config.Model,\n\t\t\t\"endpoint\":    \"https://api.openai.com/v1/chat/completions\",\n\t\t\t\"max_retries\": c.config.MaxRetries,\n\t\t})\n\t\tfmt.Println(\"[ERROR] OpenAI request failed after all retries:\", err.Error(), \"Model:\", c.config.Model)\n\t\treturn nil, err\n\t}\n\n\treturn response, nil\n}\n\nfunc (c *OpenAIClient) CompleteStream(ctx context.Context, request domain.CompletionRequest, callback func(domain.StreamChunk) error) (*domain.CompletionResponse, error) {\n\tif request.Prompt == \"\" {\n\t\treturn nil, domain.ErrInvalidPrompt\n\t}\n\n\tmaxTokens := c.config.MaxTokens\n\tif request.MaxTokens != nil {\n\t\tmaxTokens = *request.MaxTokens\n\t}\n\n\t// Right-size tokens for field extraction - avoid excessive budgets\n\tif strings.HasPrefix(c.config.Model, \"gpt-5\") {\n\t\t// For GPT-5, use smaller budgets unless explicitly requested\n\t\tif maxTokens == c.config.MaxTokens && maxTokens > 200 {\n\t\t\tmaxTokens = 128 // Reasonable default for most extraction tasks\n\t\t\tif c.config.DebugMode {\n\t\t\t\tfmt.Println(\"[DEBUG] Using optimized token budget for GPT-5 streaming:\", maxTokens)\n\t\t\t}\n\t\t}\n\t}\n\n\ttemperature := c.config.Temperature\n\tif request.Temperature != nil {\n\t\ttemperature = *request.Temperature\n\t}\n\n\topenAIReq := openAIRequest{\n\t\tModel: c.config.Model,\n\t\tMessages: []openAIMessage{\n\t\t\t{\n\t\t\t\tRole:    \"user\",\n\t\t\t\tContent: request.Prompt,\n\t\t\t},\n\t\t},\n\t\tMaxTokens: maxTokens,\n\t\tStream:    true, // Enable streaming\n\t}\n\n\t// Only set temperature for models that support it\n\tif supportsTemperature(c.config.Model) {\n\t\topenAIReq.Temperature = &temperature\n\t}\n\n\t// Only set stop sequences for models that support them\n\tif supportsStop(c.config.Model) {\n\t\topenAIReq.Stop = []string{\"\\n\\n\", \"\\n---\"}\n\t}\n\n\tif c.config.DebugMode {\n\t\tc.logger.Info(\"Starting OpenAI streaming request\", map[string]any{\n\t\t\t\"model\":      c.config.Model,\n\t\t\t\"max_tokens\": maxTokens,\n\t\t\t\"stream\":     true,\n\t\t})\n\t}\n\n\tvar response *domain.CompletionResponse\n\tvar err error\n\n\t// Retry with fresh context per attempt\n\tfor i := 0; i <= c.config.MaxRetries; i++ {\n\t\tcallTimeout := time.Duration(c.config.TimeoutSec) * time.Second\n\t\tif strings.HasPrefix(c.config.Model, \"gpt-5\") {\n\t\t\tcallTimeout += 30 * time.Second\n\t\t}\n\t\tcallCtx, cancel := context.WithTimeout(ctx, callTimeout)\n\t\t\n\t\tresponse, err = c.makeStreamRequest(callCtx, openAIReq, callback)\n\t\tcancel()\n\t\t\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\n\t\tif i < c.config.MaxRetries {\n\t\t\tc.logger.Warn(\"OpenAI streaming request failed, retrying\", map[string]any{\n\t\t\t\t\"attempt\":     i + 1,\n\t\t\t\t\"max_retries\": c.config.MaxRetries,\n\t\t\t\t\"model\":       c.config.Model,\n\t\t\t\t\"error\":       err.Error(),\n\t\t\t})\n\t\t\t\n\t\t\tbackoff := time.Duration(1<<i) * time.Second\n\t\t\tjitter := time.Duration(generateJitter(int64(backoff))) * time.Millisecond\n\t\t\ttime.Sleep(backoff + jitter)\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tc.logger.Error(\"OpenAI streaming request failed after all retries\", map[string]any{\n\t\t\t\"error\":       err.Error(),\n\t\t\t\"model\":       c.config.Model,\n\t\t\t\"max_retries\": c.config.MaxRetries,\n\t\t})\n\t\treturn nil, err\n\t}\n\n\treturn response, nil\n}\n\nfunc (c *OpenAIClient) makeRequest(ctx context.Context, request openAIRequest) (*domain.CompletionResponse, error) {\n\tjsonData, err := json.Marshal(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", \"https://api.openai.com/v1/chat/completions\", bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.config.APIKey)\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to make request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check HTTP status code first\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\n\t\t// Enhanced error logging\n\t\tc.logger.Error(\"OpenAI API returned non-200 status\", map[string]any{\n\t\t\t\"status_code\":   resp.StatusCode,\n\t\t\t\"status\":        resp.Status,\n\t\t\t\"response_body\": string(body),\n\t\t\t\"model\":         c.config.Model,\n\t\t\t\"endpoint\":      req.URL.String(),\n\t\t\t\"content_type\":  resp.Header.Get(\"Content-Type\"),\n\t\t})\n\t\tfmt.Println(\"[ERROR] OpenAI API non-200 status:\", resp.StatusCode, \"Body:\", string(body), \"Model:\", c.config.Model)\n\n\t\treturn nil, fmt.Errorf(\"OpenAI API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\t// Debug successful response info\n\tif c.config.DebugMode {\n\t\tc.logger.Info(\"OpenAI API response received\", map[string]any{\n\t\t\t\"status_code\":  resp.StatusCode,\n\t\t\t\"content_type\": resp.Header.Get(\"Content-Type\"),\n\t\t})\n\t}\n\n\tvar openAIResp openAIResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\tfmt.Println(\"[ERROR] OpenAI API error response:\", openAIResp.Error.Message, \"Type:\", openAIResp.Error.Type, \"Code:\", openAIResp.Error.Code, \"Param:\", openAIResp.Error.Param)\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\tfmt.Println(\"[ERROR] No choices returned from OpenAI API\")\n\t\treturn nil, fmt.Errorf(\"no choices returned from OpenAI\")\n\t}\n\n\tchoice := openAIResp.Choices[0]\n\tmsg := choice.Message\n\n\t// Handle tool calls (not an error, but we don't support tools for this use case)\n\tif len(msg.ToolCalls) > 0 {\n\t\tfmt.Println(\"[WARN] Model returned tool calls, but we don't support tools. Treating as error.\")\n\t\treturn nil, fmt.Errorf(\"model returned tool calls but tools are not supported for this operation\")\n\t}\n\n\t// Handle refusal (model refused to respond)\n\tif strings.TrimSpace(msg.Refusal) != \"\" {\n\t\tfmt.Println(\"[ERROR] Model refusal:\", msg.Refusal)\n\t\treturn nil, fmt.Errorf(\"model refusal: %s\", msg.Refusal)\n\t}\n\n\t// Only then check for empty content\n\tif strings.TrimSpace(msg.Content) == \"\" {\n\t\tfmt.Println(\"[ERROR] Empty content returned from OpenAI API, finish_reason:\", choice.FinishReason)\n\t\treturn nil, fmt.Errorf(\"empty assistant content (finish_reason=%s)\", choice.FinishReason)\n\t}\n\n\tvar totalTokens int\n\tvar reasoningTokens int\n\tvar outputTokens int\n\tif openAIResp.Usage != nil {\n\t\ttotalTokens = openAIResp.Usage.TotalTokens\n\t\toutputTokens = openAIResp.Usage.CompletionTokens\n\t\tif openAIResp.Usage.CompletionTokensDetails != nil {\n\t\t\treasoningTokens = openAIResp.Usage.CompletionTokensDetails.ReasoningTokens\n\t\t}\n\t}\n\n\tresponseText := msg.Content\n\tif c.config.DebugMode {\n\t\ttextPreview := responseText\n\t\tif len(textPreview) > 50 {\n\t\t\ttextPreview = textPreview[:50] + \"...\"\n\t\t}\n\t\tif reasoningTokens > 0 {\n\t\t\tfmt.Printf(\"[DEBUG] OpenAI response - Text: %s | Total: %d tokens (Reasoning: %d, Output: %d)\\n\",\n\t\t\t\ttextPreview, totalTokens, reasoningTokens, outputTokens)\n\t\t} else {\n\t\t\tfmt.Printf(\"[DEBUG] OpenAI response - Text: %s | Tokens: %d\\n\", textPreview, totalTokens)\n\t\t}\n\t}\n\n\treturn &domain.CompletionResponse{\n\t\tText:       responseText,\n\t\tTokensUsed: totalTokens,\n\t\tModel:      openAIResp.Model,\n\t}, nil\n}\n\nfunc getEnvOrDefault(key, defaultValue string) string {\n\tif value := os.Getenv(key); value != \"\" {\n\t\treturn value\n\t}\n\treturn defaultValue\n}\n\nfunc supportsTemperature(model string) bool {\n\t// GPT-5 series (gpt-5, gpt-5-mini, gpt-5-nano) don't support custom temperature\n\treturn !strings.HasPrefix(model, \"gpt-5\")\n}\n\nfunc supportsStop(model string) bool {\n\t// GPT-5 series don't support `stop` parameter on Chat Completions\n\treturn !strings.HasPrefix(model, \"gpt-5\")\n}\n\n// GenerateEmbedding generates a vector embedding for the given text using OpenAI embeddings API\nfunc (c *OpenAIClient) GenerateEmbedding(ctx context.Context, text string, model string) ([]float64, error) {\n\tif text == \"\" {\n\t\treturn nil, fmt.Errorf(\"text cannot be empty\")\n\t}\n\n\tif model == \"\" {\n\t\tmodel = \"text-embedding-3-small\" // Default embedding model\n\t}\n\n\tembeddingReq := map[string]any{\n\t\t\"model\": model,\n\t\t\"input\": text,\n\t}\n\n\tjsonData, err := json.Marshal(embeddingReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal embedding request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", \"https://api.openai.com/v1/embeddings\", bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create embedding request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.config.APIKey)\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to make embedding request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tc.logger.Error(\"OpenAI embeddings API returned non-200 status\", map[string]any{\n\t\t\t\"status_code\":   resp.StatusCode,\n\t\t\t\"response_body\": string(body),\n\t\t\t\"model\":         model,\n\t\t})\n\t\treturn nil, fmt.Errorf(\"OpenAI embeddings API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar embeddingResp struct {\n\t\tData []struct {\n\t\t\tEmbedding []float64 `json:\"embedding\"`\n\t\t} `json:\"data\"`\n\t\tUsage struct {\n\t\t\tTotalTokens int `json:\"total_tokens\"`\n\t\t} `json:\"usage\"`\n\t\tError *openAIError `json:\"error,omitempty\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&embeddingResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode embedding response: %w\", err)\n\t}\n\n\tif embeddingResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI embeddings API error: %s\", embeddingResp.Error.Message)\n\t}\n\n\tif len(embeddingResp.Data) == 0 {\n\t\treturn nil, fmt.Errorf(\"no embedding data returned from OpenAI\")\n\t}\n\n\tembedding := embeddingResp.Data[0].Embedding\n\tif len(embedding) == 0 {\n\t\treturn nil, fmt.Errorf(\"empty embedding returned from OpenAI\")\n\t}\n\n\tif c.config.DebugMode {\n\t\tc.logger.Info(\"Generated embedding\", map[string]any{\n\t\t\t\"model\":           model,\n\t\t\t\"text_length\":     len(text),\n\t\t\t\"embedding_dims\":  len(embedding),\n\t\t\t\"tokens_used\":     embeddingResp.Usage.TotalTokens,\n\t\t})\n\t}\n\n\treturn embedding, nil\n}\n\ntype streamResponse struct {\n\tID      string `json:\"id\"`\n\tObject  string `json:\"object\"`\n\tCreated int64  `json:\"created\"`\n\tModel   string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tDelta struct {\n\t\t\tRole    string `json:\"role,omitempty\"`\n\t\t\tContent string `json:\"content,omitempty\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n}\n\nfunc (c *OpenAIClient) makeStreamRequest(ctx context.Context, request openAIRequest, callback func(domain.StreamChunk) error) (*domain.CompletionResponse, error) {\n\tjsonData, err := json.Marshal(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal stream request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", \"https://api.openai.com/v1/chat/completions\", bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create stream request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.config.APIKey)\n\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to make stream request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tc.logger.Error(\"OpenAI streaming API returned non-200 status\", map[string]any{\n\t\t\t\"status_code\":   resp.StatusCode,\n\t\t\t\"response_body\": string(body),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"OpenAI streaming API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar fullContent strings.Builder\n\tvar totalTokens int\n\tvar model string\n\t\n\tscanner := bufio.NewScanner(resp.Body)\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\t\n\t\tif line == \"\" || line == \"data: [DONE]\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\t\tvar streamResp streamResponse\n\t\tif err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {\n\t\t\t// Skip malformed JSON chunks\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tmodel = streamResp.Model\n\t\t\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tchoice := streamResp.Choices[0]\n\t\t\tcontent := choice.Delta.Content\n\t\t\t\n\t\t\tif content != \"\" {\n\t\t\t\tfullContent.WriteString(content)\n\t\t\t\t\n\t\t\t\t// Call callback with chunk\n\t\t\t\tif callback != nil {\n\t\t\t\t\tif err := callback(domain.StreamChunk{\n\t\t\t\t\t\tContent: content,\n\t\t\t\t\t\tDone:    false,\n\t\t\t\t\t}); err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"streaming callback error: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif choice.FinishReason != nil && *choice.FinishReason != \"\" {\n\t\t\t\t// Final chunk\n\t\t\t\tif callback != nil {\n\t\t\t\t\tcallback(domain.StreamChunk{\n\t\t\t\t\t\tContent: \"\",\n\t\t\t\t\t\tDone:    true,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading stream: %w\", err)\n\t}\n\n\tfinalContent := fullContent.String()\n\tif strings.TrimSpace(finalContent) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty content from streaming response\")\n\t}\n\n\t// Estimate token usage (rough approximation)\n\ttotalTokens = len(strings.Fields(finalContent)) + 10 // Add some overhead\n\n\treturn &domain.CompletionResponse{\n\t\tText:       finalContent,\n\t\tTokensUsed: totalTokens,\n\t\tModel:      model,\n\t}, nil\n}\n\n// generateJitter creates random jitter for exponential backoff to avoid thundering herd\nfunc generateJitter(maxJitterMs int64) int64 {\n\tif maxJitterMs <= 0 {\n\t\treturn 0\n\t}\n\t// Generate random number between 0 and maxJitterMs\n\tn, err := rand.Int(rand.Reader, big.NewInt(maxJitterMs))\n\tif err != nil {\n\t\treturn maxJitterMs / 2 // fallback to half the max\n\t}\n\treturn n.Int64()\n}\n\n// isTemporaryError determines if an error is temporary and should be retried\nfunc isTemporaryError(err error) bool {\n\terrStr := strings.ToLower(err.Error())\n\t\n\t// Network-level errors that are typically temporary\n\ttemporaryErrors := []string{\n\t\t\"connection reset by peer\",\n\t\t\"connection refused\",\n\t\t\"timeout\",\n\t\t\"context deadline exceeded\",\n\t\t\"temporary failure\",\n\t\t\"service unavailable\",\n\t\t\"bad gateway\",\n\t\t\"gateway timeout\",\n\t\t\"too many requests\",\n\t\t\"rate limit\",\n\t\t\"internal server error\",\n\t}\n\t\n\tfor _, tempErr := range temporaryErrors {\n\t\tif strings.Contains(errStr, tempErr) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\n// isPermanentError determines if an error is permanent and should not be retried\nfunc isPermanentError(err error) bool {\n\terrStr := strings.ToLower(err.Error())\n\t\n\t// Errors that indicate permanent issues\n\tpermanentErrors := []string{\n\t\t\"invalid api key\",\n\t\t\"unauthorized\",\n\t\t\"forbidden\",\n\t\t\"not found\",\n\t\t\"bad request\",\n\t\t\"invalid request\",\n\t\t\"model not found\",\n\t\t\"quota exceeded\",\n\t\t\"billing\",\n\t}\n\t\n\tfor _, permErr := range permanentErrors {\n\t\tif strings.Contains(errStr, permErr) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n)\n\nfunc Init(container *dig.Container) {\n\tProvideDependencies(container)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/cmd/provider.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"go.uber.org/dig\"\n)\n\nfunc ProvideDependencies(container *dig.Container) {\n\tcontainer.Provide(logger.New)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/domain/logger.go",
    "content": "package domain\n\ntype Level int\n\nconst (\n\tDebugLevel Level = iota\n\tInfoLevel\n\tWarnLevel\n\tErrorLevel\n\tFatalLevel\n)\n\ntype OutputType int\n\nconst (\n\tConsoleOutput OutputType = iota\n\tFileOutput\n\tBothOutput\n)\n\ntype Fields = map[string]interface{}\n\ntype Logger interface {\n\tDebug(msg string, fields ...Fields)\n\tInfo(msg string, fields ...Fields)\n\tWarn(msg string, fields ...Fields)\n\tError(msg string, fields ...Fields)\n\tFatal(msg string, fields ...Fields)\n\tWithFields(fields Fields) Logger\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/domain/options.go",
    "content": "package domain\n\ntype Option func(*Options)\n\ntype Options struct {\n\tLevel       Level\n\tOutput      OutputType\n\tFileOptions FileOptions\n}\n\ntype FileOptions struct {\n\tFilename   string\n\tMaxSize    int\n\tMaxBackups int\n\tMaxAge     int\n\tCompress   bool\n}\n\nfunc WithLevel(level Level) Option {\n\treturn func(o *Options) {\n\t\to.Level = level\n\t}\n}\n\nfunc WithOutput(output OutputType) Option {\n\treturn func(o *Options) {\n\t\to.Output = output\n\t}\n}\n\nfunc WithFileOptions(fileOpts FileOptions) Option {\n\treturn func(o *Options) {\n\t\to.FileOptions = fileOpts\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/factory.go",
    "content": "package logger\n\nimport (\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\tzerolog \"github.com/moasq/go-b2b-starter/internal/platform/logger/internal/zerologger\"\n)\n\nfunc New(opts ...domain.Option) domain.Logger {\n\toptions := &domain.Options{\n\t\tLevel:  domain.InfoLevel,\n\t\tOutput: domain.ConsoleOutput,\n\t\tFileOptions: domain.FileOptions{\n\t\t\tFilename:   \"app.log\",\n\t\t\tMaxSize:    100,\n\t\t\tMaxBackups: 3,\n\t\t\tMaxAge:     28,\n\t\t\tCompress:   true,\n\t\t},\n\t}\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\treturn zerolog.NewLogger(options)\n}\n\n// Re-export types and constants for ease of use\ntype (\n\tLogger = domain.Logger\n\tFields = domain.Fields\n\tLevel  = domain.Level\n\tOption = domain.Option\n)\n\nvar (\n\tDebugLevel = domain.DebugLevel\n\tInfoLevel  = domain.InfoLevel\n\tWarnLevel  = domain.WarnLevel\n\tErrorLevel = domain.ErrorLevel\n\tFatalLevel = domain.FatalLevel\n\n\tConsoleOutput = domain.ConsoleOutput\n\tFileOutput    = domain.FileOutput\n\tBothOutput    = domain.BothOutput\n\n\tWithLevel       = domain.WithLevel\n\tWithOutput      = domain.WithOutput\n\tWithFileOptions = domain.WithFileOptions\n)\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/internal/zerologger/factory.go",
    "content": "package zerolog\n\nimport (\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\nfunc NewLogger(opts *domain.Options) domain.Logger {\n\treturn newZerologLogger(opts)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/logger/internal/zerologger/logger.go",
    "content": "package zerolog\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"time\"\n\n\tlogger \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n\t\"github.com/rs/zerolog\"\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n)\n\ntype zerologLogger struct {\n\tzl zerolog.Logger\n}\n\nfunc newZerologLogger(opts *logger.Options) logger.Logger {\n\tvar output io.Writer\n\n\tswitch opts.Output {\n\tcase logger.ConsoleOutput:\n\t\toutput = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}\n\tcase logger.FileOutput:\n\t\toutput = &lumberjack.Logger{\n\t\t\tFilename:   opts.FileOptions.Filename,\n\t\t\tMaxSize:    opts.FileOptions.MaxSize,\n\t\t\tMaxBackups: opts.FileOptions.MaxBackups,\n\t\t\tMaxAge:     opts.FileOptions.MaxAge,\n\t\t\tCompress:   opts.FileOptions.Compress,\n\t\t}\n\tcase logger.BothOutput:\n\t\tconsoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}\n\t\tfileWriter := &lumberjack.Logger{\n\t\t\tFilename:   opts.FileOptions.Filename,\n\t\t\tMaxSize:    opts.FileOptions.MaxSize,\n\t\t\tMaxBackups: opts.FileOptions.MaxBackups,\n\t\t\tMaxAge:     opts.FileOptions.MaxAge,\n\t\t\tCompress:   opts.FileOptions.Compress,\n\t\t}\n\t\toutput = zerolog.MultiLevelWriter(consoleWriter, fileWriter)\n\tdefault:\n\t\toutput = os.Stdout\n\t}\n\n\tzerolog.TimeFieldFormat = time.RFC3339\n\n\tzl := zerolog.New(output).With().Timestamp().Logger()\n\n\t// Set the log level\n\tzl = zl.Level(convertLogLevel(opts.Level))\n\n\treturn &zerologLogger{zl: zl}\n}\n\nfunc (l *zerologLogger) Debug(msg string, fields ...logger.Fields) {\n\tl.log(l.zl.Debug(), msg, fields...)\n}\n\nfunc (l *zerologLogger) Info(msg string, fields ...logger.Fields) {\n\tl.log(l.zl.Info(), msg, fields...)\n}\n\nfunc (l *zerologLogger) Warn(msg string, fields ...logger.Fields) {\n\tl.log(l.zl.Warn(), msg, fields...)\n}\n\nfunc (l *zerologLogger) Error(msg string, fields ...logger.Fields) {\n\tl.log(l.zl.Error(), msg, fields...)\n}\n\nfunc (l *zerologLogger) Fatal(msg string, fields ...logger.Fields) {\n\tl.log(l.zl.Fatal(), msg, fields...)\n}\n\nfunc (l *zerologLogger) WithFields(fields logger.Fields) logger.Logger {\n\treturn &zerologLogger{zl: l.zl.With().Fields(fields).Logger()}\n}\n\nfunc (l *zerologLogger) log(event *zerolog.Event, msg string, fields ...logger.Fields) {\n\tif len(fields) > 0 {\n\t\tevent.Fields(fields[0])\n\t}\n\tevent.Msg(msg)\n}\n\nfunc convertLogLevel(level logger.Level) zerolog.Level {\n\tswitch level {\n\tcase logger.DebugLevel:\n\t\treturn zerolog.DebugLevel\n\tcase logger.InfoLevel:\n\t\treturn zerolog.InfoLevel\n\tcase logger.WarnLevel:\n\t\treturn zerolog.WarnLevel\n\tcase logger.ErrorLevel:\n\t\treturn zerolog.ErrorLevel\n\tcase logger.FatalLevel:\n\t\treturn zerolog.FatalLevel\n\tdefault:\n\t\treturn zerolog.InfoLevel\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/README.md",
    "content": "# OCR Module Guide\n\nSimple guide for extracting text from documents using OCR (Optical Character Recognition).\n\n## Setup\n\nAdd to your `.env`:\n\n```bash\nMISTRAL_API_KEY=your-mistral-api-key-here\n```\n\nOptional:\n```bash\nMISTRAL_OCR_ENDPOINT=https://api.mistral.ai/v1/ocr  # Default\nOCR_TIMEOUT_SEC=120                                 # Default\n```\n\n## Usage in Your Module\n\n### 1. Inject the OCR Service\n\n```go\nimport \"github.com/moasq/go-b2b-starter/pkg/ocr/domain\"\n\ntype InvoiceService struct {\n    ocrService domain.OCRService\n}\n\nfunc NewInvoiceService(ocrService domain.OCRService) *InvoiceService {\n    return &InvoiceService{ocrService: ocrService}\n}\n```\n\n### 2. Extract Text from Document\n\n```go\nfunc (s *InvoiceService) ProcessDocument(ctx context.Context, base64File string, mimeType string) (string, error) {\n    // Extract text from the document\n    response, err := s.ocrService.ExtractText(ctx, base64File, mimeType)\n    if err != nil {\n        return \"\", fmt.Errorf(\"OCR failed: %w\", err)\n    }\n\n    // Use the extracted text\n    s.logger.Info(\"OCR completed\", map[string]any{\n        \"pages\":      response.Pages,\n        \"confidence\": response.Confidence,\n        \"text_length\": len(response.Text),\n    })\n\n    return response.Text, nil\n}\n```\n\n### 3. Real-World Example: Invoice Processing\n\n```go\nfunc (s *InvoiceService) ExtractInvoiceData(ctx context.Context, fileData []byte) (*Invoice, error) {\n    // 1. Convert file to base64\n    base64File := base64.StdEncoding.EncodeToString(fileData)\n\n    // 2. Extract text using OCR\n    ocrResponse, err := s.ocrService.ExtractText(ctx, base64File, \"application/pdf\")\n    if err != nil {\n        return nil, err\n    }\n\n    // 3. Check confidence score\n    if ocrResponse.Confidence < 0.7 {\n        return nil, fmt.Errorf(\"OCR confidence too low: %.2f\", ocrResponse.Confidence)\n    }\n\n    // 4. Parse the extracted text\n    invoice := s.parseInvoiceText(ocrResponse.Text)\n\n    return invoice, nil\n}\n```\n\n## Response Structure\n\nThe `OCRResponse` includes:\n\n```go\ntype OCRResponse struct {\n    Text       string  // Extracted text from the document\n    Pages      int     // Number of pages processed\n    Confidence float32 // OCR confidence (0.0 to 1.0)\n}\n```\n\n## Supported File Types\n\n- **PDF**: `application/pdf`\n- **Images**: `image/jpeg`, `image/png`\n- **Other formats** supported by Mistral OCR API\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `MISTRAL_API_KEY` | *required* | Your Mistral API key |\n| `MISTRAL_OCR_ENDPOINT` | `https://api.mistral.ai/v1/ocr` | OCR API endpoint |\n| `OCR_TIMEOUT_SEC` | `120` | Request timeout in seconds |\n\n## Best Practices\n\n**1. Validate confidence scores:**\n```go\nif response.Confidence < 0.8 {\n    // Low confidence - may need manual review\n}\n```\n\n**2. Handle timeouts:**\n```go\nctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)\ndefer cancel()\n\nresponse, err := s.ocrService.ExtractText(ctx, base64File, mimeType)\n```\n\n**3. Process large files in chunks:**\nFor multi-page PDFs, OCR processes all pages automatically. Monitor the `Pages` field in the response.\n\nThat's it! Just inject `OCRService` and extract text from any document.\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"go.uber.org/dig\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/ocr/domain\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/ocr/infra\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\nfunc Init(container *dig.Container) error {\n\treturn container.Provide(func(logger loggerDomain.Logger) (domain.OCRService, error) {\n\t\tconfig := infra.NewOCRConfig()\n\t\treturn infra.NewMistralOCRClient(config, logger)\n\t})\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/domain/entity.go",
    "content": "package domain\n\n// OCRResponse represents the result of OCR text extraction\ntype OCRResponse struct {\n\tText       string  `json:\"text\"`       // Extracted text\n\tPages      int     `json:\"pages\"`      // Number of pages processed\n\tConfidence float32 `json:\"confidence\"` // OCR confidence score (0.0 to 1.0)\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/domain/errors.go",
    "content": "package domain\n\nimport \"errors\"\n\nvar (\n\tErrInvalidInput    = errors.New(\"invalid OCR input\")\n\tErrQuotaExceeded   = errors.New(\"OCR quota exceeded\") \n\tErrUnsupportedFile = errors.New(\"unsupported file type\")\n\tErrAsyncJobFailed  = errors.New(\"async OCR job failed\")\n\tErrJobNotFound     = errors.New(\"OCR job not found\")\n\tErrAuthFailed      = errors.New(\"OCR authentication failed\")\n\tErrTransientError  = errors.New(\"OCR transient error\")\n\tErrNotFound        = errors.New(\"OCR resource not found\")\n)"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/domain/service.go",
    "content": "package domain\n\nimport \"context\"\n\n// OCRService provides text extraction from files\ntype OCRService interface {\n\tExtractText(ctx context.Context, base64File string, mimeType string) (*OCRResponse, error)\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/infra/config.go",
    "content": "package infra\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n)\n\ntype Config struct {\n\tMistralAPIKey string\n\tAPIEndpoint   string\n\tTimeoutSec    int\n}\n\nfunc (c Config) Validate() error {\n\tif c.MistralAPIKey == \"\" {\n\t\treturn fmt.Errorf(\"Mistral API key is required\")\n\t}\n\tif c.APIEndpoint == \"\" {\n\t\treturn fmt.Errorf(\"API endpoint is required\")\n\t}\n\treturn nil\n}\n\nfunc NewOCRConfig() Config {\n\ttimeoutSec, _ := strconv.Atoi(getEnvOrDefault(\"OCR_TIMEOUT_SEC\", \"120\"))\n\n\treturn Config{\n\t\tMistralAPIKey: os.Getenv(\"MISTRAL_API_KEY\"),\n\t\tAPIEndpoint:   getEnvOrDefault(\"MISTRAL_OCR_ENDPOINT\", \"https://api.mistral.ai/v1/ocr\"),\n\t\tTimeoutSec:    timeoutSec,\n\t}\n}\n\nfunc getEnvOrDefault(key, defaultValue string) string {\n\tif value := os.Getenv(key); value != \"\" {\n\t\treturn value\n\t}\n\treturn defaultValue\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/infra/mistral_ocr_client.go",
    "content": "package infra\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/ocr/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\ntype MistralOCRClient struct {\n\tconfig Config\n\tclient *http.Client\n\tlogger loggerDomain.Logger\n}\n\n// Mistral API request/response structures\ntype MistralOCRRequest struct {\n\tModel              string              `json:\"model\"`\n\tDocument           MistralDocument     `json:\"document\"`\n\tIncludeImageBase64 bool                `json:\"include_image_base64\"`\n}\n\ntype MistralDocument struct {\n\tType        string `json:\"type\"`         // \"document_url\" or \"image_url\"\n\tDocumentURL string `json:\"document_url,omitempty\"`\n\tImageURL    string `json:\"image_url,omitempty\"`\n}\n\ntype MistralOCRResponse struct {\n\tPages []MistralPage `json:\"pages\"`\n}\n\ntype MistralPage struct {\n\tIndex    int                    `json:\"index\"`\n\tMarkdown string                 `json:\"markdown\"`\n\tImages   []MistralImage        `json:\"images,omitempty\"`\n\tBboxes   []MistralBoundingBox  `json:\"bboxes,omitempty\"`\n}\n\ntype MistralImage struct {\n\tBase64 string `json:\"base64,omitempty\"`\n}\n\ntype MistralBoundingBox struct {\n\tX      float32 `json:\"x\"`\n\tY      float32 `json:\"y\"`\n\tWidth  float32 `json:\"width\"`\n\tHeight float32 `json:\"height\"`\n\tText   string  `json:\"text,omitempty\"`\n}\n\nfunc NewMistralOCRClient(config Config, logger loggerDomain.Logger) (domain.OCRService, error) {\n\tif err := config.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: time.Duration(config.TimeoutSec) * time.Second,\n\t}\n\n\treturn &MistralOCRClient{\n\t\tconfig: config,\n\t\tclient: client,\n\t\tlogger: logger,\n\t}, nil\n}\n\n\nfunc (m *MistralOCRClient) ExtractText(ctx context.Context, base64File string, mimeType string) (*domain.OCRResponse, error) {\n\tm.logger.Info(\"Starting Mistral OCR extraction\", map[string]any{\n\t\t\"mime_type\": mimeType,\n\t})\n\n\t// Validate file constraints\n\tif err := m.validateInput(base64File, mimeType); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Build Mistral API request\n\tmistralRequest := m.buildMistralRequest(base64File, mimeType)\n\n\t// Make API call with retries\n\tmistralResponse, err := m.callMistralAPI(ctx, mistralRequest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Convert response to domain format\n\tresponse := m.convertResponse(mistralResponse)\n\n\tm.logger.Info(\"Mistral OCR extraction completed\", map[string]any{\n\t\t\"pages\":       response.Pages,\n\t\t\"text_length\": len(response.Text),\n\t\t\"confidence\":  response.Confidence,\n\t})\n\n\treturn response, nil\n}\n\n\nfunc (m *MistralOCRClient) validateInput(base64File string, mimeType string) error {\n\t// Validate base64 file is not empty\n\tif base64File == \"\" {\n\t\treturn domain.ErrInvalidInput\n\t}\n\n\t// Validate supported MIME types\n\tif !m.isSupportedMimeType(mimeType) {\n\t\treturn domain.ErrUnsupportedFile\n\t}\n\n\treturn nil\n}\n\nfunc (m *MistralOCRClient) isSupportedMimeType(mimeType string) bool {\n\tsupportedTypes := []string{\n\t\t\"application/pdf\",\n\t\t\"image/jpeg\",\n\t\t\"image/jpg\", \n\t\t\"image/png\",\n\t\t\"image/tiff\",\n\t\t\"image/avif\",\n\t\t\"image/webp\",\n\t}\n\n\tfor _, supported := range supportedTypes {\n\t\tif mimeType == supported {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (m *MistralOCRClient) buildMistralRequest(base64File string, mimeType string) MistralOCRRequest {\n\tmistralRequest := MistralOCRRequest{\n\t\tModel:              \"mistral-ocr-latest\",\n\t\tIncludeImageBase64: false, // Simplified - no layout extraction\n\t}\n\n\t// Determine document type based on MIME type and format as data URI\n\tif mimeType == \"application/pdf\" {\n\t\tdataURI := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, base64File)\n\t\tmistralRequest.Document = MistralDocument{\n\t\t\tType:        \"document_url\",\n\t\t\tDocumentURL: dataURI,\n\t\t}\n\t} else if strings.HasPrefix(mimeType, \"image/\") {\n\t\tdataURI := fmt.Sprintf(\"data:%s;base64,%s\", mimeType, base64File)\n\t\tmistralRequest.Document = MistralDocument{\n\t\t\tType:     \"image_url\",\n\t\t\tImageURL: dataURI,\n\t\t}\n\t}\n\n\treturn mistralRequest\n}\n\nfunc (m *MistralOCRClient) callMistralAPI(ctx context.Context, mistralRequest MistralOCRRequest) (*MistralOCRResponse, error) {\n\trequestBody, err := json.Marshal(mistralRequest)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", m.config.APIEndpoint, bytes.NewBuffer(requestBody))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+m.config.MistralAPIKey)\n\n\tresp, err := m.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode == http.StatusUnauthorized {\n\t\treturn nil, domain.ErrAuthFailed\n\t}\n\tif resp.StatusCode == http.StatusBadRequest {\n\t\treturn nil, domain.ErrInvalidInput\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, resp.Status)\n\t}\n\n\tvar response MistralOCRResponse\n\tif err := json.Unmarshal(body, &response); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\treturn &response, nil\n}\n\n\nfunc (m *MistralOCRClient) convertResponse(mistralResponse *MistralOCRResponse) *domain.OCRResponse {\n\t// Concatenate all page markdown with form feed separators\n\tvar fullText strings.Builder\n\tfor i, page := range mistralResponse.Pages {\n\t\tif i > 0 {\n\t\t\tfullText.WriteString(\"\\f\") // Page separator\n\t\t}\n\t\tfullText.WriteString(page.Markdown)\n\t}\n\n\t// Calculate confidence based on content quality\n\tconfidence := m.calculateConfidence(fullText.String(), len(mistralResponse.Pages))\n\n\treturn &domain.OCRResponse{\n\t\tText:       fullText.String(),\n\t\tPages:      len(mistralResponse.Pages),\n\t\tConfidence: confidence,\n\t}\n}\n\nfunc (m *MistralOCRClient) calculateConfidence(text string, pages int) float32 {\n\tif len(text) == 0 {\n\t\treturn 0.0\n\t}\n\n\t// Base confidence for Mistral OCR\n\tconfidence := float32(0.90)\n\n\t// Adjust based on text length (more text usually means better OCR)\n\ttextLength := len(text)\n\tif textLength > 1000 {\n\t\tconfidence += 0.05\n\t}\n\tif textLength > 5000 {\n\t\tconfidence += 0.03\n\t}\n\n\t// Multi-page documents might have slightly lower confidence\n\tif pages > 5 {\n\t\tconfidence -= 0.02\n\t}\n\n\t// Cap at 1.0\n\tif confidence > 1.0 {\n\t\tconfidence = 1.0\n\t}\n\n\treturn confidence\n}"
  },
  {
    "path": "go-b2b-starter/internal/platform/ocr/infra/mock_ocr_client.go",
    "content": "package infra\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/ocr/domain\"\n\tloggerDomain \"github.com/moasq/go-b2b-starter/internal/platform/logger/domain\"\n)\n\n// MockOCRClient is a mock implementation for development/testing\n// In production, this would be replaced with actual Google Vision client\ntype MockOCRClient struct {\n\tconfig Config\n\tlogger loggerDomain.Logger\n}\n\nfunc NewMockOCRClient(config Config, logger loggerDomain.Logger) (domain.OCRService, error) {\n\tif err := config.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &MockOCRClient{\n\t\tconfig: config,\n\t\tlogger: logger,\n\t}, nil\n}\n\nfunc (m *MockOCRClient) ExtractText(ctx context.Context, base64File string, mimeType string) (*domain.OCRResponse, error) {\n\tm.logger.Info(\"Mock OCR extraction starting\", map[string]any{\n\t\t\"mime_type\": mimeType,\n\t})\n\n\t// Simulate processing time\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Mock extracted text based on file type\n\tvar mockText string\n\tvar pages int = 1\n\n\tif mimeType == \"application/pdf\" {\n\t\tpages = 2\n\t\tmockText = `INVOICE\nInvoice Number: INV-2024-001\nDate: January 15, 2024\n\nBill To:\nABC Company\n123 Main Street\nCity, State 12345\n\nDescription                    Qty    Unit Price    Total\nProfessional Services          10     $150.00      $1,500.00\nConsulting                      5     $200.00      $1,000.00\n\n                              Subtotal: $2,500.00\n                                   Tax: $250.00\n                                 Total: $2,750.00\n\nPayment Terms: Net 30\nDue Date: February 15, 2024`\n\t} else if strings.HasPrefix(mimeType, \"image/\") {\n\t\tmockText = `RECEIPT\nStore: Tech Solutions Inc.\nDate: 2024-01-10\nReceipt #: R-789456\n\nItems:\n- Software License    $299.99\n- Support Package     $99.99\n\nSubtotal: $399.98\nTax: $32.00\nTotal: $431.98\n\nThank you for your business!`\n\t} else {\n\t\treturn nil, domain.ErrUnsupportedFile\n\t}\n\n\tresponse := &domain.OCRResponse{\n\t\tText:       mockText,\n\t\tPages:      pages,\n\t\tConfidence: 0.95,\n\t}\n\n\tm.logger.Info(\"Mock OCR extraction completed\", map[string]any{\n\t\t\"pages\":       pages,\n\t\t\"text_length\": len(mockText),\n\t\t\"confidence\":  response.Confidence,\n\t})\n\n\treturn response, nil\n}\n\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/polar/client.go",
    "content": "package polar\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// Client provides a low-level HTTP client for Polar API\n// This is a generic HTTP wrapper - business logic should be in higher layers\ntype Client struct {\n\taccessToken string\n\tbaseURL     string\n\thttpClient  *http.Client\n\tdebug       bool\n}\n\nfunc NewClient(config *Config) (*Client, error) {\n\tif err := config.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid configuration: %w\", err)\n\t}\n\n\treturn &Client{\n\t\taccessToken: config.AccessToken,\n\t\tbaseURL:     config.BaseURL,\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t\tdebug: config.Debug,\n\t}, nil\n}\n\n// Get performs a GET request to the Polar API\nfunc (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {\n\treturn c.doRequest(ctx, \"GET\", path, nil)\n}\n\n// Patch performs a PATCH request to the Polar API\nfunc (c *Client) Patch(ctx context.Context, path string, body interface{}) (*http.Response, error) {\n\treturn c.doRequest(ctx, \"PATCH\", path, body)\n}\n\n// Post performs a POST request to the Polar API\nfunc (c *Client) Post(ctx context.Context, path string, body interface{}) (*http.Response, error) {\n\treturn c.doRequest(ctx, \"POST\", path, body)\n}\n\n// doRequest performs an HTTP request to the Polar API\nfunc (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {\n\turl := c.baseURL + path\n\n\tvar bodyReader io.Reader\n\tif body != nil {\n\t\tbodyBytes, err := json.Marshal(body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to marshal request body: %w\", err)\n\t\t}\n\t\tbodyReader = bytes.NewReader(bodyBytes)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, method, url, bodyReader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\t// Set required headers\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.accessToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tif c.debug {\n\t\tfmt.Printf(\"[Polar Client] %s %s\\n\", method, url)\n\t}\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\n\t// Check for HTTP errors\n\tif resp.StatusCode >= 400 {\n\t\tdefer resp.Body.Close()\n\t\tbodyBytes, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"Polar API error (HTTP %d): %s\", resp.StatusCode, string(bodyBytes))\n\t}\n\n\treturn resp, nil\n}\n\n// DecodeJSON is a helper to decode JSON response\nfunc DecodeJSON(resp *http.Response, v interface{}) error {\n\tdefer resp.Body.Close()\n\tif err := json.NewDecoder(resp.Body).Decode(v); err != nil {\n\t\treturn fmt.Errorf(\"failed to decode JSON response: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/polar/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/polar\"\n\t\"go.uber.org/dig\"\n)\n\nfunc Init(container *dig.Container) error {\n\t// Provide Polar configuration using viper\n\tif err := container.Provide(func() (*polar.Config, error) {\n\t\tconfig, err := polar.LoadConfig()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to load Polar configuration: %w\", err)\n\t\t}\n\n\t\treturn &config, nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide Polar config: %w\", err)\n\t}\n\n\t// Register Polar client\n\tif err := polar.Module(container); err != nil {\n\t\treturn fmt.Errorf(\"failed to register Polar module: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/polar/config.go",
    "content": "package polar\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/viper\"\n)\n\n// Config holds configuration for the Polar client\ntype Config struct {\n\t// AccessToken is the Polar Organization Access Token (OAT)\n\t// Required for all API requests\n\tAccessToken string `mapstructure:\"POLAR_ACCESS_TOKEN\"`\n\n\t// BaseURL is the Polar API endpoint\n\t// Use \"https://api.polar.sh\" for production\n\t// Use \"https://sandbox-api.polar.sh\" for testing\n\tBaseURL string `mapstructure:\"POLAR_BASE_URL\"`\n\n\t// WebhookSecret is the secret used to verify webhook signatures\n\t// Get this from Polar Dashboard → Settings → Webhooks\n\tWebhookSecret string `mapstructure:\"WEBHOOK_SECRET\"`\n\n\t// Debug enables debug logging\n\tDebug bool `mapstructure:\"POLAR_DEBUG\"`\n}\n\n// LoadConfig reads configuration from file or environment variables\nfunc LoadConfig() (Config, error) {\n\tvar cfg Config\n\n\tviper.SetConfigName(\"app\")\n\tviper.SetConfigType(\"env\")\n\tviper.AddConfigPath(\".\")\n\tviper.AutomaticEnv()\n\n\t// Set default values\n\tviper.SetDefault(\"POLAR_BASE_URL\", \"https://api.polar.sh\")\n\tviper.SetDefault(\"POLAR_DEBUG\", false)\n\n\t// Best-effort: ignore missing file, allow env-only usage\n\tif err := viper.ReadInConfig(); err == nil {\n\t\t_ = err\n\t}\n\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn cfg, fmt.Errorf(\"unable to decode polar config: %w\", err)\n\t}\n\n\t// Validate required fields\n\tif err := cfg.Validate(); err != nil {\n\t\treturn cfg, err\n\t}\n\n\treturn cfg, nil\n}\n\n// Validate checks if the configuration is valid\nfunc (c *Config) Validate() error {\n\tif c.AccessToken == \"\" {\n\t\treturn fmt.Errorf(\"polar access token is required (POLAR_ACCESS_TOKEN)\")\n\t}\n\n\tif c.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"polar base URL is required (POLAR_BASE_URL)\")\n\t}\n\n\t// WebhookSecret is optional - only needed for webhook verification\n\t// If not provided, webhook signature verification will be skipped (with warning)\n\n\treturn nil\n}\n\n// DefaultConfig returns a configuration with sane defaults for production\nfunc DefaultConfig() *Config {\n\treturn &Config{\n\t\tBaseURL: \"https://api.polar.sh\",\n\t\tDebug:   false,\n\t}\n}\n\n// SandboxConfig returns a configuration with defaults for sandbox environment\nfunc SandboxConfig() *Config {\n\treturn &Config{\n\t\tBaseURL: \"https://sandbox-api.polar.sh\",\n\t\tDebug:   true,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/polar/inject.go",
    "content": "package polar\n\nimport (\n\t\"fmt\"\n\n\t\"go.uber.org/dig\"\n)\n\n// Module registers Polar package dependencies in the DI container\nfunc Module(container *dig.Container) error {\n\t// Register Polar client\n\tif err := container.Provide(NewClient); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide Polar client: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/polar/webhook.go",
    "content": "package polar\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// VerifyWebhookSignature verifies that a webhook request came from Polar\n// by validating the HMAC-SHA256 signature using the Standard Webhooks specification\n//\n// Polar.sh uses the Standard Webhooks format (same as Svix) where the signed content is:\n// {webhook-id}.{webhook-timestamp}.{body}\n//\n// Parameters:\n//   - secret: The webhook secret from Polar Dashboard\n//   - webhookID: The Webhook-Id header value\n//   - timestamp: The Webhook-Timestamp header value\n//   - payload: The raw request body (must be the exact bytes received)\n//   - signature: The signature from the Webhook-Signature header\n//\n// Returns:\n//   - error if verification fails, nil if successful\nfunc VerifyWebhookSignature(secret string, webhookID string, timestamp string, payload []byte, signature string) error {\n\tif secret == \"\" {\n\t\treturn fmt.Errorf(\"webhook secret is not configured\")\n\t}\n\n\tif signature == \"\" {\n\t\treturn fmt.Errorf(\"webhook signature is missing from request\")\n\t}\n\n\tif webhookID == \"\" {\n\t\treturn fmt.Errorf(\"webhook ID is missing from request\")\n\t}\n\n\tif timestamp == \"\" {\n\t\treturn fmt.Errorf(\"webhook timestamp is missing from request\")\n\t}\n\n\t// Strip version prefix (e.g., \"v1,\") from Polar's signature\n\tif strings.Contains(signature, \",\") {\n\t\tparts := strings.Split(signature, \",\")\n\t\tif len(parts) == 2 {\n\t\t\tsignature = parts[1] // Get the actual signature after \"v1,\"\n\t\t}\n\t}\n\n\t// Decode base64 signature to bytes (Polar sends base64-encoded HMAC)\n\tsignatureBytes, err := base64.StdEncoding.DecodeString(signature)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to decode signature: %w\", err)\n\t}\n\n\t// Construct the signed content according to Standard Webhooks spec\n\t// Format: {webhook-id}.{webhook-timestamp}.{body}\n\tsignedContent := webhookID + \".\" + timestamp + \".\" + string(payload)\n\n\t// Compute HMAC-SHA256 of the signed content\n\tmac := hmac.New(sha256.New, []byte(secret))\n\tmac.Write([]byte(signedContent))\n\texpectedSignatureBytes := mac.Sum(nil)\n\n\t// Use constant-time comparison to prevent timing attacks\n\tif !hmac.Equal(signatureBytes, expectedSignatureBytes) {\n\t\treturn fmt.Errorf(\"webhook signature verification failed: signature mismatch\")\n\t}\n\n\treturn nil\n}\n\n// ComputeWebhookSignature computes the HMAC-SHA256 signature for a payload\n// using the Standard Webhooks format\n// This is useful for testing webhook signature verification\n// Returns the signature in base64 format to match Polar's format\nfunc ComputeWebhookSignature(secret string, webhookID string, timestamp string, payload []byte) string {\n\t// Construct signed content: {webhook-id}.{webhook-timestamp}.{body}\n\tsignedContent := webhookID + \".\" + timestamp + \".\" + string(payload)\n\tmac := hmac.New(sha256.New, []byte(secret))\n\tmac.Write([]byte(signedContent))\n\treturn base64.StdEncoding.EncodeToString(mac.Sum(nil))\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/README.md",
    "content": "# Redis Module Guide\n\nSimple guide for using Redis cache in your modules.\n\n## Setup\n\nAdd to your `.env`:\n\n```bash\nREDIS_HOST=localhost\nREDIS_PORT=6379\nREDIS_PASSWORD=          # Optional\nREDIS_DB=0               # Default database\n```\n\nFor local development, start Redis with Docker:\n```bash\nmake run-deps  # Starts Redis and other dependencies\n```\n\n## Usage in Your Module\n\n### 1. Inject the Redis Client\n\n```go\nimport (\n    \"github.com/moasq/go-b2b-starter/pkg/redis\"\n)\n\ntype UserService struct {\n    cache redis.Client\n}\n\nfunc NewUserService(cache redis.Client) *UserService {\n    return &UserService{cache: cache}\n}\n```\n\n### 2. Basic Operations\n\n**Set a value with TTL:**\n```go\nfunc (s *UserService) CacheUser(ctx context.Context, userID string, data string) error {\n    key := fmt.Sprintf(\"user:%s\", userID)\n    ttl := 1 * time.Hour\n\n    return s.cache.Set(ctx, key, data, ttl)\n}\n```\n\n**Get a value:**\n```go\nfunc (s *UserService) GetCachedUser(ctx context.Context, userID string) (string, error) {\n    key := fmt.Sprintf(\"user:%s\", userID)\n\n    value, err := s.cache.Get(ctx, key)\n    if err != nil {\n        return \"\", err // redis.Nil if key doesn't exist\n    }\n\n    return value, nil\n}\n```\n\n**Check if key exists:**\n```go\nexists, err := s.cache.Exists(ctx, \"user:123\")\nif exists {\n    // Key is in cache\n}\n```\n\n**Delete a value:**\n```go\nerr := s.cache.Delete(ctx, \"user:123\")\n```\n\n### 3. Real-World Example: Cache-Aside Pattern\n\n```go\nfunc (s *UserService) GetUser(ctx context.Context, userID int32) (*User, error) {\n    cacheKey := fmt.Sprintf(\"user:%d\", userID)\n\n    // 1. Try to get from cache first\n    cached, err := s.cache.Get(ctx, cacheKey)\n    if err == nil {\n        // Cache hit - deserialize and return\n        var user User\n        json.Unmarshal([]byte(cached), &user)\n        return &user, nil\n    }\n\n    // 2. Cache miss - get from database\n    user, err := s.repo.GetUserByID(ctx, userID)\n    if err != nil {\n        return nil, err\n    }\n\n    // 3. Store in cache for next time\n    userJSON, _ := json.Marshal(user)\n    s.cache.Set(ctx, cacheKey, string(userJSON), 5*time.Minute)\n\n    return user, nil\n}\n```\n\n### 4. Invalidation on Update\n\n```go\nfunc (s *UserService) UpdateUser(ctx context.Context, userID int32, updates *UserUpdates) error {\n    // 1. Update database\n    err := s.repo.UpdateUser(ctx, userID, updates)\n    if err != nil {\n        return err\n    }\n\n    // 2. Invalidate cache\n    cacheKey := fmt.Sprintf(\"user:%d\", userID)\n    s.cache.Delete(ctx, cacheKey)\n\n    return nil\n}\n```\n\n## Available Methods\n\n```go\ntype Client interface {\n    Set(ctx context.Context, key string, value any, ttl time.Duration) error\n    Get(ctx context.Context, key string) (string, error)\n    Delete(ctx context.Context, key string) error\n    Exists(ctx context.Context, key string) (bool, error)\n}\n```\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `REDIS_HOST` | `localhost` | Redis server host |\n| `REDIS_PORT` | `6379` | Redis server port |\n| `REDIS_PASSWORD` | `` | Redis password (optional) |\n| `REDIS_DB` | `0` | Redis database number |\n\n## Common Patterns\n\n**Session storage:**\n```go\nsessionKey := fmt.Sprintf(\"session:%s\", sessionID)\ns.cache.Set(ctx, sessionKey, userID, 24*time.Hour)\n```\n\n**Rate limiting:**\n```go\nkey := fmt.Sprintf(\"ratelimit:%s\", userID)\ns.cache.Set(ctx, key, \"1\", 1*time.Minute)\n```\n\n**Temporary data:**\n```go\nkey := fmt.Sprintf(\"temp:%s\", requestID)\ns.cache.Set(ctx, key, data, 5*time.Minute)\n```\n\n## TTL Guidelines\n\n- **User sessions**: 24 hours\n- **API responses**: 5-15 minutes\n- **Rate limit counters**: 1 minute\n- **Temporary data**: 5-10 minutes\n\nThat's it! Just inject `redis.Client` and start caching.\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/cmd/init.go",
    "content": "package cmd\n\nimport (\n\t\"log\"\n\n\t\"go.uber.org/dig\"\n)\n\nfunc Init(dig *dig.Container) error {\n\tif err := provideRedisDependencies(dig); err != nil {\n\t\tlog.Fatalf(\"Failed to provide Redis dependencies: %v\", err)\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/cmd/provider.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"go.uber.org/dig\"\n)\n\nfunc provideRedisDependencies(container *dig.Container) error {\n\tproviders := []any{\n\t\tredis.LoadConfig,\n\t\tprovideRedisStore,\n\t}\n\n\tfor _, provider := range providers {\n\t\tif err := container.Provide(provider); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to provide Redis dependency: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc provideRedisStore() (redis.Client, error) {\n\treturn redis.InitRedis()\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/config.go",
    "content": "package redis\n\nimport (\n\t\"github.com/spf13/viper\"\n)\n\ntype Config struct {\n\tHost     string `mapstructure:\"REDIS_HOST\"`\n\tPort     string `mapstructure:\"REDIS_PORT\"`\n\tPassword string `mapstructure:\"REDIS_PASSWORD\"`\n\tDB       int    `mapstructure:\"REDIS_DB\"`\n}\n\n// LoadConfig reads configuration from file or environment variables.\nfunc LoadConfig() (Config, error) {\n\tvar cfg Config\n\n\tviper.SetConfigName(\"app\")\n\tviper.SetConfigType(\"env\")\n\tviper.AddConfigPath(\".\")\n\tviper.AutomaticEnv()\n\n\t// Set default values\n\tviper.SetDefault(\"REDIS_HOST\", \"localhost\")\n\tviper.SetDefault(\"REDIS_PORT\", \"6379\")\n\tviper.SetDefault(\"REDIS_PASSWORD\", \"\")\n\tviper.SetDefault(\"REDIS_DB\", 0)\n\n\tif err := viper.ReadInConfig(); err == nil {\n\t\t_ = err\n\t}\n\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn cfg, err\n\t}\n\n\treturn cfg, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/init.go",
    "content": "package redis\n\nimport \"log\"\n\nfunc InitRedis() (Client, error) {\n\tcfg, err := LoadConfig()\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to load Redis configuration: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tclient, err := newRedisClient(cfg)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to initialize Redis connection: %v\", err)\n\t\treturn nil, err\n\t}\n\n\treturn client, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/redis.go",
    "content": "package redis\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype redisClient struct {\n\trdb *redis.Client\n}\n\nfunc newRedisClient(cfg Config) (*redisClient, error) {\n\trdb := redis.NewClient(&redis.Options{\n\t\tAddr:     fmt.Sprintf(\"%s:%s\", cfg.Host, cfg.Port),\n\t\tPassword: cfg.Password,\n\t\tDB:       cfg.DB,\n\t})\n\n\tctx := context.Background()\n\tif err := rdb.Ping(ctx).Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to connect to Redis: %w\", err)\n\t}\n\n\treturn &redisClient{rdb: rdb}, nil\n}\n\nfunc (c *redisClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {\n\treturn c.rdb.Set(ctx, key, value, ttl).Err()\n}\n\nfunc (c *redisClient) Get(ctx context.Context, key string) (string, error) {\n\treturn c.rdb.Get(ctx, key).Result()\n}\n\nfunc (c *redisClient) Delete(ctx context.Context, key string) error {\n\treturn c.rdb.Del(ctx, key).Err()\n}\n\nfunc (c *redisClient) Exists(ctx context.Context, key string) (bool, error) {\n\tresult, err := c.rdb.Exists(ctx, key).Result()\n\treturn result > 0, err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/redis/store.go",
    "content": "package redis\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype Client interface {\n\tSet(ctx context.Context, key string, value any, ttl time.Duration) error\n\tGet(ctx context.Context, key string) (string, error)\n\tDelete(ctx context.Context, key string) error\n\tExists(ctx context.Context, key string) (bool, error)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/cmd/di.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/auth\"\n\t\"github.com/moasq/go-b2b-starter/internal/modules/paywall\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/config\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/domain\"\n\tginP \"github.com/moasq/go-b2b-starter/internal/platform/server/gin\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/logging\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/middleware\"\n\t\"go.uber.org/dig\"\n)\n\n// serverMiddlewareAdapter adapts domain.Server to auth.ServerMiddlewareRegistrar\ntype serverMiddlewareAdapter struct {\n\tserver domain.Server\n}\n\nfunc (a *serverMiddlewareAdapter) RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) {\n\t// Convert func() gin.HandlerFunc to domain.MiddlewareFunc\n\ta.server.RegisterNamedMiddleware(name, domain.MiddlewareFunc(middleware))\n}\n\nfunc SetupDependencies(container *dig.Container) {\n\tcontainer.Provide(config.LoadConfig)\n\tcontainer.Provide(logging.InitLogger)\n\tcontainer.Provide(middleware.InitValidator)\n\tcontainer.Provide(func(cfg *config.Config) *gin.Engine {\n\t\treturn ginP.NewGinRouter(cfg).GetHandler()\n\t})\n\tcontainer.Provide(domain.NewHTTPServer)\n\n\t// Provide server as auth.ServerMiddlewareRegistrar for auth package\n\tcontainer.Provide(func(srv domain.Server) auth.ServerMiddlewareRegistrar {\n\t\treturn &serverMiddlewareAdapter{server: srv}\n\t})\n\n\t// Provide server as paywall.ServerMiddlewareRegistrar for paywall package\n\tcontainer.Provide(func(srv domain.Server) paywall.ServerMiddlewareRegistrar {\n\t\treturn &serverMiddlewareAdapter{server: srv}\n\t})\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/cmd/init.go",
    "content": "package cmd\n\nimport \"go.uber.org/dig\"\n\nfunc Init(container *dig.Container) {\n\n\tSetupDependencies(container)\n\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype Environment string\n\nconst (\n\tDEV  Environment = \"DEV\"\n\tPROD Environment = \"PROD\"\n)\n\ntype Config struct {\n\t// Environment (cannot be disabled in production)\n\tEnv Environment `mapstructure:\"ENV\"`\n\n\t// Server settings\n\tServerAddress string `mapstructure:\"SERVER_ADDRESS\"`\n\n\t// Security settings (cannot be disabled in production)\n\tEnableTLS   bool   `mapstructure:\"ENABLE_TLS\"`    // Must be true in production\n\tTLSCertPath string `mapstructure:\"TLS_CERT_PATH\"` // Required in production\n\tTLSKeyPath  string `mapstructure:\"TLS_KEY_PATH\"`  // Required in production\n\n\t// Rate limiting (cannot be disabled in production)\n\tRateLimitPerSecond int `mapstructure:\"RATE_LIMIT_PER_SECOND\"`\n\n\t// CORS settings (more restrictive in production)\n\tAllowedOrigins []string `mapstructure:\"ALLOWED_ORIGINS\"`\n\n\t// Logging (always enabled in production)\n\tLogLevel string `mapstructure:\"LOG_LEVEL\"`\n\n\t// Optional security features\n\tTrustedProxies []string `mapstructure:\"TRUSTED_PROXIES\"`\n\tMaxRequestSize int      `mapstructure:\"MAX_REQUEST_SIZE\"`\n\n\t// IP Protection Settings\n\tIPWhitelist       []string `mapstructure:\"IP_WHITELIST\"`\n\tIPBlacklist       []string `mapstructure:\"IP_BLACKLIST\"`\n\tMaxFailedAttempts int      `mapstructure:\"MAX_FAILED_ATTEMPTS\"`\n\tBlockDuration     string   `mapstructure:\"BLOCK_DURATION\"`\n\n\t// Request Sanitization\n\tDisableXSS           bool `mapstructure:\"DISABLE_XSS\"`\n\tDisableSQLInjection  bool `mapstructure:\"DISABLE_SQL_INJECTION\"`\n\tDisablePathTraversal bool `mapstructure:\"DISABLE_PATH_TRAVERSAL\"`\n\n\t// Security Logging\n\tSecurityLogPath  string `mapstructure:\"SECURITY_LOG_PATH\"`\n\tLogRetentionDays int    `mapstructure:\"LOG_RETENTION_DAYS\"`\n\n\t// Processing Settings\n\tExtractionTimeoutSeconds int `mapstructure:\"EXTRACTION_TIMEOUT_SECONDS\"`\n\t\n\t// Duplicate Detection Settings\n\tDuplicateSimilarityThreshold float64 `mapstructure:\"DUPLICATE_SIMILARITY_THRESHOLD\"`\n\tDuplicateSearchLimit         int32   `mapstructure:\"DUPLICATE_SEARCH_LIMIT\"`\n}\n\n// SanitizationConfig represents security sanitization settings\ntype SanitizationConfig struct {\n\tDisableXSS           bool\n\tDisableSQLInjection  bool\n\tDisablePathTraversal bool\n}\n\n// GetSanitizationConfig returns sanitization configuration\nfunc (c *Config) GetSanitizationConfig() SanitizationConfig {\n\treturn SanitizationConfig{\n\t\tDisableXSS:           c.DisableXSS,\n\t\tDisableSQLInjection:  c.DisableSQLInjection,\n\t\tDisablePathTraversal: c.DisablePathTraversal,\n\t}\n}\n\nfunc (c *Config) IsProd() bool {\n\treturn c.Env == PROD\n}\n\n// LoadConfig reads configuration from environment variables or .env files.\nfunc LoadConfig() (*Config, error) {\n\tvar cfg *Config\n\n\tviper.SetConfigName(\"app\")\n\tviper.SetConfigType(\"env\")\n\tviper.AddConfigPath(\".\")\n\tviper.AutomaticEnv()\n\n\t// Set default values\n\tviper.SetDefault(\"ENV\", \"DEV\")\n\tviper.SetDefault(\"SERVER_ADDRESS\", \":8080\")\n\tviper.SetDefault(\"RATE_LIMIT_PER_SECOND\", 100)\n\tviper.SetDefault(\"MAX_REQUEST_SIZE\", 1024*1024*10) // 10MB\n\tviper.SetDefault(\"LOG_LEVEL\", \"info\")\n\tviper.SetDefault(\"MAX_FAILED_ATTEMPTS\", 5)\n\tviper.SetDefault(\"BLOCK_DURATION\", \"15m\")\n\tviper.SetDefault(\"DISABLE_XSS\", false)\n\tviper.SetDefault(\"DISABLE_SQL_INJECTION\", false)\n\tviper.SetDefault(\"DISABLE_PATH_TRAVERSAL\", false)\n\tviper.SetDefault(\"SECURITY_LOG_PATH\", \"logs/security.log\")\n\tviper.SetDefault(\"LOG_RETENTION_DAYS\", 30)\n\tviper.SetDefault(\"EXTRACTION_TIMEOUT_SECONDS\", 60)\n\tviper.SetDefault(\"DUPLICATE_SIMILARITY_THRESHOLD\", 0.85)\n\tviper.SetDefault(\"DUPLICATE_SEARCH_LIMIT\", 10)\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := viper.Unmarshal(&cfg); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Validate production configuration\n\tif cfg.Env == PROD {\n\t\tif err := validateProductionConfig(cfg); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn cfg, nil\n}\n\nfunc validateProductionConfig(cfg *Config) error {\n\tvar errors []string\n\n\t// TLS must be enabled in production\n\tif !cfg.EnableTLS {\n\t\terrors = append(errors, \"TLS must be enabled in production\")\n\t}\n\n\t// TLS certificates must be provided in production\n\tif cfg.EnableTLS {\n\t\tif cfg.TLSCertPath == \"\" {\n\t\t\terrors = append(errors, \"TLS certificate path must be provided in production\")\n\t\t}\n\t\tif cfg.TLSKeyPath == \"\" {\n\t\t\terrors = append(errors, \"TLS key path must be provided in production\")\n\t\t}\n\t}\n\n\t// Allowed origins must be set in production\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\terrors = append(errors, \"Allowed origins must be set in production\")\n\t}\n\n\t// Rate limiting must be reasonable in production\n\tif cfg.RateLimitPerSecond > 1000 {\n\t\terrors = append(errors, \"Rate limit per second cannot exceed 1000 in production\")\n\t}\n\n\tif len(errors) > 0 {\n\t\treturn fmt.Errorf(\"invalid production configuration: %s\", strings.Join(errors, \"; \"))\n\t}\n\n\t// if cfg.DisableXSS || cfg.DisableSQLInjection || cfg.DisablePathTraversal {\n\t// \terrors = append(errors, \"Security sanitization cannot be disabled in production\")\n\t// }\n\n\t// if cfg.MaxFailedAttempts < 3 {\n\t// \terrors = append(errors, \"MaxFailedAttempts must be at least 3 in production\")\n\t// }\n\n\t// if cfg.SecurityLogPath == \"\" {\n\t// \terrors = append(errors, \"SecurityLogPath must be set in production\")\n\t// }\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/domain/health.go",
    "content": "package domain\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc (s *HTTPServer) setupHealthCheck() {\n\thealthHandler := func(c *gin.Context) {\n\t\tif s.config.IsProd() {\n\t\t\tc.JSON(http.StatusOK, gin.H{\"status\": \"OK\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"status\":      \"OK\",\n\t\t\t\"environment\": s.config.Env,\n\t\t\t\"version\":     \"1.0.0\",\n\t\t\t\"timestamp\":   time.Now().UTC(),\n\t\t})\n\t}\n\n\t// Register health endpoint at both paths\n\ts.router.GET(\"/health\", healthHandler)\n\ts.router.GET(\"/api/health\", healthHandler)\n\ts.logger.Info(\"Health check endpoints set up at /health and /api/health\")\n}\n\nfunc (s *HTTPServer) setupRootEndpoint() {\n\ts.router.GET(\"/\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"service\":   \"B2B SaaS Starter API\",\n\t\t\t\"version\":   \"1.0.0\",\n\t\t\t\"status\":    \"running\",\n\t\t\t\"health\":    \"/api/health\",\n\t\t\t\"docs\":      \"/api/docs\",\n\t\t\t\"timestamp\": time.Now().UTC(),\n\t\t})\n\t})\n\ts.logger.Info(\"Root endpoint set up at /\")\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/domain/http_server.go",
    "content": "package domain\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\tconfig \"github.com/moasq/go-b2b-starter/internal/platform/server/config\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/logging\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/middleware\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype HTTPServer struct {\n\tconfig           *config.Config\n\trouter           *gin.Engine\n\tlogger           *logging.Logger\n\tsecurityLogger   *logging.SecurityLogger\n\tregistrars       map[string][]RouteRegistrar\n\tnamedMiddlewares map[string]MiddlewareFunc\n\tipProtection     *middleware.IPProtection\n}\n\nfunc NewHTTPServer(\n\tconfig *config.Config,\n\trouter *gin.Engine,\n\tlogger *logging.Logger,\n) Server {\n\tif config.IsProd() {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\n\tipProtection := middleware.NewIPProtection()\n\n\tserver := &HTTPServer{\n\t\tconfig:           config,\n\t\trouter:           router,\n\t\tlogger:           logger,\n\t\tsecurityLogger:   logging.NewSecurityLogger(logger.SugaredLogger),\n\t\tregistrars:       make(map[string][]RouteRegistrar),\n\t\tnamedMiddlewares: make(map[string]MiddlewareFunc),\n\t\tipProtection:     ipProtection,\n\t}\n\n\tserver.setupMiddleware()\n\treturn server\n}\n\n// Start initializes and starts the HTTP server\nfunc (s *HTTPServer) Start() error {\n\tsrv := s.createHTTPServer()\n\ts.setupHealthCheck()\n\ts.setupRootEndpoint()\n\n\tgo s.startServer(srv)\n\treturn s.handleGracefulShutdown(srv)\n}\n\nfunc (s *HTTPServer) MiddlewareResolver() MiddlewareResolver {\n\treturn s\n}\n\n// RegisterRoutes registers route handlers with version support\nfunc (s *HTTPServer) RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) {\n\tv := \"\"\n\tif len(version) > 0 {\n\t\tv = version[0]\n\t}\n\n\tgroup := s.router.Group(prefix)\n\tif v != \"\" {\n\t\tgroup = group.Group(\"/\" + v)\n\t}\n\n\t// Register routes immediately instead of storing for later\n\tregistrar(group, s)\n}\n\n\n// RegisterNamedMiddleware registers a named middleware for later use\nfunc (s *HTTPServer) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) {\n\ts.namedMiddlewares[name] = middleware\n\ts.logger.Info(\"Named middleware registered: \" + name)\n}\n\nfunc (s *HTTPServer) createHTTPServer() *http.Server {\n\treturn &http.Server{\n\t\tAddr:              s.config.ServerAddress,\n\t\tHandler:           s.router,\n\t\tReadTimeout:       15 * time.Second,\n\t\tWriteTimeout:      30 * time.Second, // Increased to accommodate auto-extraction processing\n\t\tIdleTimeout:       60 * time.Second,\n\t\tReadHeaderTimeout: 5 * time.Second,\n\t\tMaxHeaderBytes:    s.config.MaxRequestSize,\n\t}\n}\n\nfunc (s *HTTPServer) startServer(srv *http.Server) {\n\ts.logger.Info(\"Starting server on \" + s.config.ServerAddress)\n\tvar err error\n\n\tif s.config.IsProd() {\n\t\terr = srv.ListenAndServeTLS(\n\t\t\ts.config.TLSCertPath,\n\t\t\ts.config.TLSKeyPath,\n\t\t)\n\t} else {\n\t\terr = srv.ListenAndServe()\n\t}\n\n\tif err != nil && err != http.ErrServerClosed {\n\t\ts.logger.Fatal(\"Failed to start server\", err)\n\t}\n}\n\nfunc (s *HTTPServer) handleGracefulShutdown(srv *http.Server) error {\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)\n\t<-quit\n\ts.logger.Info(\"Shutting down server...\")\n\n\t// Stop IP Protection cleanup goroutine\n\tif s.ipProtection != nil {\n\t\ts.ipProtection.Stop()\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif err := srv.Shutdown(ctx); err != nil {\n\t\ts.logger.Fatal(\"Server forced to shutdown\", err)\n\t}\n\n\ts.logger.Info(\"Server exited gracefully\")\n\treturn nil\n}\n\n// Get implements the MiddlewareResolver interface\nfunc (s *HTTPServer) Get(name string) gin.HandlerFunc {\n\tif middleware, exists := s.namedMiddlewares[name]; exists {\n\t\treturn middleware()\n\t}\n\t// Return a no-op middleware if not found\n\treturn func(c *gin.Context) {\n\t\ts.logger.Warnw(\"Middleware not found\", \"name\", name)\n\t\tc.Next()\n\t}\n}\n\n// GetMiddleware returns a middleware by name (compatibility method)\nfunc (s *HTTPServer) GetMiddleware(name string) gin.HandlerFunc {\n\treturn s.Get(name)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/domain/middleware.go",
    "content": "package domain\n\nimport (\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/middleware\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc (s *HTTPServer) setupMiddleware() {\n\tipProtection := middleware.NewIPProtection()\n\n\t// Calculate timeout based on extraction timeout + buffer\n\trequestTimeout := time.Duration(s.config.ExtractionTimeoutSeconds+10) * time.Second // Add 10s buffer\n\t\n\ts.router.Use(\n\t\tmiddleware.RequestID(),\n\t\tipProtection.Protect(),\n\t\tmiddleware.RequestSanitization(s.config.GetSanitizationConfig()),\n\t\tmiddleware.Recovery(s.logger),\n\t\tmiddleware.RequestSizeLimit(int64(s.config.MaxRequestSize)),\n\t\tmiddleware.Timeout(requestTimeout),\n\t\tmiddleware.RateLimiter(s.config.RateLimitPerSecond),\n\t\tmiddleware.CORS(s.config.AllowedOrigins),\n\t\ts.requestLoggingMiddleware(),\n\t)\n\n\t// production only middleware\n\tif s.config.IsProd() {\n\t\ts.router.Use(\n\t\t\tmiddleware.SecurityHeaders(),\n\t\t)\n\t}\n\n\tif len(s.config.TrustedProxies) > 0 {\n\t\ts.router.SetTrustedProxies(s.config.TrustedProxies)\n\t}\n}\n\nfunc (s *HTTPServer) requestLoggingMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Skip health check logging in production\n\t\tif s.config.IsProd() && c.Request.URL.Path == \"/health\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tquery := c.Request.URL.RawQuery\n\t\trequestID := middleware.GetRequestID(c) // Get request ID\n\n\t\tc.Next()\n\n\t\ts.logger.Infow(\"Request completed\",\n\t\t\t\"request_id\", requestID,\n\t\t\t\"status\", c.Writer.Status(),\n\t\t\t\"method\", c.Request.Method,\n\t\t\t\"path\", path,\n\t\t\t\"query\", query,\n\t\t\t\"ip\", c.ClientIP(),\n\t\t\t\"latency\", time.Since(start),\n\t\t\t\"user-agent\", c.Request.UserAgent(),\n\t\t\t\"bytes-out\", c.Writer.Size(),\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/domain/middleware_resolver.go",
    "content": "package domain\n\nimport \"github.com/gin-gonic/gin\"\n\n// MiddlewareResolver provides access to named middleware functions\ntype MiddlewareResolver interface {\n\tGet(name string) gin.HandlerFunc\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/domain/server.go",
    "content": "package domain\n\nimport \"github.com/gin-gonic/gin\"\n\n// Constants for API versioning\nconst (\n\tApiPrefix   = \"/api\"\n\tApiVersion1 = \"v1\"\n)\n\n// RouteRegistrar is a function type for registering routes to a router group\n// domain/server.go\ntype RouteRegistrar func(*gin.RouterGroup, MiddlewareResolver)\n\n// MiddlewareFunc is a function type that returns a Gin middleware handler\ntype MiddlewareFunc func() gin.HandlerFunc\n\n// Server defines the interface for HTTP server operations\n// domain/server.go - Add to the Server interface\n// Server defines the interface for HTTP server operations\ntype Server interface {\n\tStart() error\n\tRegisterRoutes(registrar RouteRegistrar, prefix string, version ...string)\n\tRegisterNamedMiddleware(name string, middleware MiddlewareFunc)\n\tMiddlewareResolver() MiddlewareResolver\n\tGetMiddleware(name string) gin.HandlerFunc // Keep this method for compatibility\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/domain/server_.go",
    "content": "package domain\n\n// import (\n// \t\"context\"\n// \t\"fmt\"\n// \t\"net/http\"\n// \t\"os\"\n// \t\"os/signal\"\n// \t\"syscall\"\n// \t\"time\"\n\n// \t\"github.com/gin-gonic/gin\"\n// \tconfig \"github.com/moasq/go-b2b-starter/internal/platform/server/config\"\n// \t\"github.com/moasq/go-b2b-starter/internal/platform/server/logging\"\n// \t\"github.com/moasq/go-b2b-starter/internal/platform/server/middleware\"\n// )\n\n// // Constants\n// const (\n// \tApiPrefix   = \"/api\"\n// \tApiVersion1 = \"v1\"\n// )\n\n// // Types and interfaces\n// type RouteRegistrar func(*gin.RouterGroup)\n// type MiddlewareFunc func() gin.HandlerFunc\n\n// type Server interface {\n// \tStart() error\n// \tRegisterRoutes(registrar RouteRegistrar, prefix string, version ...string)\n// \tRegisterNamedMiddleware(name string, middleware MiddlewareFunc)\n// }\n\n// type HTTPServer struct {\n// \tconfig           *config.Config\n// \trouter           *gin.Engine\n// \tlogger           *logging.Logger\n// \tsecurityLogger   *logging.SecurityLogger\n// \tregistrars       map[string][]RouteRegistrar\n// \tnamedMiddlewares map[string]MiddlewareFunc\n// }\n\n// // Constructor\n// func NewHTTPServer(config *config.Config, router *gin.Engine, logger *logging.Logger) Server {\n// \tif config.IsProd() {\n// \t\tgin.SetMode(gin.ReleaseMode)\n// \t}\n\n// \tserver := &HTTPServer{\n// \t\tconfig:           config,\n// \t\trouter:           router,\n// \t\tlogger:           logger,\n// \t\tsecurityLogger:   logging.NewSecurityLogger(logger.SugaredLogger),\n// \t\tregistrars:       make(map[string][]RouteRegistrar),\n// \t\tnamedMiddlewares: make(map[string]MiddlewareFunc),\n// \t}\n\n// \tserver.setupMiddleware()\n// \treturn server\n// }\n\n// // Public methods\n// func (s *HTTPServer) Start() error {\n// \tsrv := s.createHTTPServer()\n\n// \t// Register all routes\n// \ts.registerAllRoutes()\n\n// \t// Setup health check\n// \ts.setupHealthCheck()\n\n// \t// Start server\n// \tgo s.startServer(srv)\n\n// \treturn s.handleGracefulShutdown(srv)\n// }\n\n// func (s *HTTPServer) RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) {\n// \tv := \"\"\n// \tif len(version) > 0 {\n// \t\tv = version[0]\n// \t}\n\n// \tgroup := s.router.Group(prefix)\n// \tif v != \"\" {\n// \t\tgroup = group.Group(\"/\" + v)\n// \t}\n\n// \ts.registrars[v] = append(s.registrars[v], func(g *gin.RouterGroup) {\n// \t\tregistrar(group)\n// \t})\n// }\n\n// func (s *HTTPServer) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) {\n// \ts.namedMiddlewares[name] = middleware\n// \ts.logger.Info(\"Named middleware registered: \" + name)\n// }\n\n// // Private methods - Server setup and management\n// func (s *HTTPServer) createHTTPServer() *http.Server {\n// \treturn &http.Server{\n// \t\tAddr:              s.config.ServerAddress,\n// \t\tHandler:           s.router,\n// \t\tReadTimeout:       15 * time.Second,\n// \t\tWriteTimeout:      15 * time.Second,\n// \t\tIdleTimeout:       60 * time.Second,\n// \t\tReadHeaderTimeout: 5 * time.Second,\n// \t\tMaxHeaderBytes:    s.config.MaxRequestSize,\n// \t}\n// }\n\n// func (s *HTTPServer) startServer(srv *http.Server) {\n// \ts.logger.Info(\"Starting server on \" + s.config.ServerAddress)\n// \tvar err error\n\n// \tif s.config.IsProd() {\n// \t\terr = srv.ListenAndServeTLS(\n// \t\t\ts.config.TLSCertPath,\n// \t\t\ts.config.TLSKeyPath,\n// \t\t)\n// \t} else {\n// \t\terr = srv.ListenAndServe()\n// \t}\n\n// \tif err != nil && err != http.ErrServerClosed {\n// \t\ts.logger.Fatal(\"Failed to start server\", err)\n// \t}\n// }\n\n// func (s *HTTPServer) handleGracefulShutdown(srv *http.Server) error {\n// \tquit := make(chan os.Signal, 1)\n// \tsignal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)\n// \t<-quit\n// \ts.logger.Info(\"Shutting down server...\")\n\n// \tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n// \tdefer cancel()\n\n// \tif err := srv.Shutdown(ctx); err != nil {\n// \t\ts.logger.Fatal(\"Server forced to shutdown\", err)\n// \t}\n\n// \ts.logger.Info(\"Server exited gracefully\")\n// \treturn nil\n// }\n\n// func (s *HTTPServer) registerAllRoutes() {\n// \tfor version, registrars := range s.registrars {\n// \t\tgroup := s.router.Group(\"/api\")\n// \t\tif version != \"\" {\n// \t\t\tgroup = group.Group(\"/\" + version)\n// \t\t}\n// \t\tfor _, registrar := range registrars {\n// \t\t\tregistrar(group)\n// \t\t}\n// \t}\n// }\n\n// // Private methods - Middleware setup\n// func (s *HTTPServer) setupMiddleware() {\n// \tipProtection := middleware.NewIPProtection()\n\n// \t// Add core middleware\n// \ts.router.Use(\n// \t\tmiddleware.SecurityHeaders(),\n// \t\tipProtection.Protect(),\n// \t\tmiddleware.RequestSanitization(s.config.GetSanitizationConfig()),\n// \t\ts.recoveryMiddleware(),\n// \t\tmiddleware.RequestSizeLimit(int64(s.config.MaxRequestSize)),\n// \t\tmiddleware.Timeout(10*time.Second),\n// \t\tmiddleware.RateLimiter(s.config.RateLimitPerSecond),\n// \t\tmiddleware.CORS(s.config.AllowedOrigins),\n// \t\ts.requestLoggingMiddleware(),\n// \t)\n\n// \tif len(s.config.TrustedProxies) > 0 {\n// \t\ts.router.SetTrustedProxies(s.config.TrustedProxies)\n// \t}\n// }\n\n// func (s *HTTPServer) recoveryMiddleware() gin.HandlerFunc {\n// \treturn func(c *gin.Context) {\n// \t\tdefer func() {\n// \t\t\tif err := recover(); err != nil {\n// \t\t\t\ts.securityLogger.LogSecurityEvent(logging.SecurityEvent{\n// \t\t\t\t\tEventType:   \"PANIC_RECOVERED\",\n// \t\t\t\t\tIP:          c.ClientIP(),\n// \t\t\t\t\tDescription: fmt.Sprintf(\"Panic recovered: %v\", err),\n// \t\t\t\t\tSeverity:    \"HIGH\",\n// \t\t\t\t\tTimestamp:   time.Now(),\n// \t\t\t\t\tRequestPath: c.Request.URL.Path,\n// \t\t\t\t\tRequestID:   c.GetHeader(\"X-Request-ID\"),\n// \t\t\t\t})\n// \t\t\t\tc.AbortWithStatus(http.StatusInternalServerError)\n// \t\t\t}\n// \t\t}()\n// \t\tc.Next()\n// \t}\n// }\n\n// func (s *HTTPServer) requestLoggingMiddleware() gin.HandlerFunc {\n// \treturn func(c *gin.Context) {\n// \t\tif s.config.IsProd() && c.Request.URL.Path == \"/health\" {\n// \t\t\tc.Next()\n// \t\t\treturn\n// \t\t}\n\n// \t\tstart := time.Now()\n// \t\tpath := c.Request.URL.Path\n// \t\tquery := c.Request.URL.RawQuery\n\n// \t\tc.Next()\n\n// \t\ts.logger.Infow(\"Request completed\",\n// \t\t\t\"status\", c.Writer.Status(),\n// \t\t\t\"method\", c.Request.Method,\n// \t\t\t\"path\", path,\n// \t\t\t\"query\", query,\n// \t\t\t\"ip\", c.ClientIP(),\n// \t\t\t\"latency\", time.Since(start),\n// \t\t\t\"user-agent\", c.Request.UserAgent(),\n// \t\t\t\"request-id\", c.GetHeader(\"X-Request-ID\"),\n// \t\t\t\"bytes-out\", c.Writer.Size(),\n// \t\t)\n// \t}\n// }\n\n// // Private methods - Health check\n// func (s *HTTPServer) setupHealthCheck() {\n// \ts.router.GET(\"/health\", func(c *gin.Context) {\n// \t\tif s.config.IsProd() {\n// \t\t\tc.JSON(http.StatusOK, gin.H{\"status\": \"OK\"})\n// \t\t\treturn\n// \t\t}\n\n// \t\tc.JSON(http.StatusOK, gin.H{\n// \t\t\t\"status\":      \"OK\",\n// \t\t\t\"environment\": s.config.Env,\n// \t\t\t\"version\":     \"1.0.0\",\n// \t\t\t\"timestamp\":   time.Now().UTC(),\n// \t\t})\n// \t})\n// \ts.logger.Info(\"Health check endpoint set up\")\n// }\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/errors/errors.go",
    "content": "package errors\n\ntype APIError struct {\n\tCode    int\n\tMessage string\n}\n\nfunc NewAPIError(code int, message string) *APIError {\n\treturn &APIError{Code: code, Message: message}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/gin/gin.go",
    "content": "package gin\n\nimport (\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype GinRouter struct {\n\tengine *gin.Engine\n\tv1     *gin.RouterGroup\n}\n\nfunc NewGinRouter(cfg *config.Config) *GinRouter {\n\trouter := gin.New()\n\trouter.Use(gin.Recovery())\n\treturn &GinRouter{\n\t\tengine: router,\n\t\tv1:     router.Group(\"/api/v1\"),\n\t}\n}\n\nfunc (g *GinRouter) GetHandler() *gin.Engine {\n\treturn g.engine\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/logging/logger.go",
    "content": "package logging\n\nimport (\n\t\"go.uber.org/zap\"\n)\n\ntype Logger struct {\n\t*zap.SugaredLogger\n}\n\nfunc InitLogger() (*Logger, error) {\n\tzapLogger, err := zap.NewProduction()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsugar := zapLogger.Sugar()\n\treturn &Logger{sugar}, nil\n}\n\nfunc (l *Logger) Error(msg string, err error) {\n\tl.SugaredLogger.Errorw(msg, \"error\", err)\n}\n\nfunc (l *Logger) Fatal(msg string, err error) {\n\tl.SugaredLogger.Fatalw(msg, \"error\", err)\n}\n\n// Helper function to create a zap.Field for errors\nfunc Error(err error) zap.Field {\n\treturn zap.Error(err)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/logging/security_logger.go",
    "content": "// logging/security_logger.go\n\npackage logging\n\nimport (\n\t\"time\"\n\n\t\"go.uber.org/zap\"\n)\n\ntype SecurityLogger struct {\n\tlogger *zap.SugaredLogger\n}\n\ntype SecurityEvent struct {\n\tEventType   string\n\tIP          string\n\tUserID      string\n\tDescription string\n\tSeverity    string\n\tTimestamp   time.Time\n\tRequestPath string\n\tRequestID   string\n}\n\nfunc NewSecurityLogger(baseLogger *zap.SugaredLogger) *SecurityLogger {\n\treturn &SecurityLogger{\n\t\tlogger: baseLogger,\n\t}\n}\n\nfunc (sl *SecurityLogger) LogSecurityEvent(event SecurityEvent) {\n\tsl.logger.Warnw(\"Security Event\",\n\t\t\"event_type\", event.EventType,\n\t\t\"ip\", event.IP,\n\t\t\"user_id\", event.UserID,\n\t\t\"description\", event.Description,\n\t\t\"severity\", event.Severity,\n\t\t\"timestamp\", event.Timestamp,\n\t\t\"request_path\", event.RequestPath,\n\t\t\"request_id\", event.RequestID,\n\t)\n}\n\nfunc (sl *SecurityLogger) LogFailedAuth(ip string, userID string, reason string) {\n\tsl.LogSecurityEvent(SecurityEvent{\n\t\tEventType:   \"AUTH_FAILED\",\n\t\tIP:          ip,\n\t\tUserID:      userID,\n\t\tDescription: reason,\n\t\tSeverity:    \"WARNING\",\n\t\tTimestamp:   time.Now(),\n\t})\n}\n\nfunc (sl *SecurityLogger) LogSuspiciousActivity(ip string, description string) {\n\tsl.LogSecurityEvent(SecurityEvent{\n\t\tEventType:   \"SUSPICIOUS_ACTIVITY\",\n\t\tIP:          ip,\n\t\tDescription: description,\n\t\tSeverity:    \"WARNING\",\n\t\tTimestamp:   time.Now(),\n\t})\n}\n\nfunc (sl *SecurityLogger) LogBlacklisted(ip string) {\n\tsl.LogSecurityEvent(SecurityEvent{\n\t\tEventType:   \"IP_BLACKLISTED\",\n\t\tIP:          ip,\n\t\tDescription: \"IP address has been blacklisted\",\n\t\tSeverity:    \"HIGH\",\n\t\tTimestamp:   time.Now(),\n\t})\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/metrics/prometheus.go",
    "content": "package metrics\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\nfunc SetupPrometheus(router *gin.Engine) {\n\trouter.GET(\"/metrics\", gin.WrapH(promhttp.Handler()))\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/cors.go",
    "content": "package middleware\n\nimport (\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc CORS(allowedOrigins []string) gin.HandlerFunc {\n\treturn cors.New(cors.Config{\n\t\tAllowOrigins:     allowedOrigins,\n\t\tAllowMethods:     []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"},\n\t\tAllowHeaders:     []string{\"Origin\", \"Content-Type\", \"Accept\", \"Authorization\", \"X-Organization-ID\", \"X-Account-ID\"},\n\t\tExposeHeaders:    []string{\"Content-Length\"},\n\t\tAllowCredentials: true,\n\t\tMaxAge:           12 * time.Hour,\n\t\tAllowWildcard:    false,\n\t\tAllowFiles:       false,\n\t})\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/ip_protection.go",
    "content": "// middleware/ip_protection.go\n\npackage middleware\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype IPProtection struct {\n\twhitelist      map[string]bool\n\tblacklist      map[string]struct{}\n\tfailedAttempts map[string]*FailedAttempt\n\tmu             sync.RWMutex\n\tcleanupTicker  *time.Ticker\n\tdone           chan struct{}\n}\n\ntype FailedAttempt struct {\n\tcount     int\n\tfirstFail time.Time\n\tlastSeen  time.Time\n}\n\nfunc NewIPProtection() *IPProtection {\n\tip := &IPProtection{\n\t\twhitelist:      make(map[string]bool),\n\t\tblacklist:      make(map[string]struct{}),\n\t\tfailedAttempts: make(map[string]*FailedAttempt),\n\t\tcleanupTicker:  time.NewTicker(5 * time.Minute),\n\t\tdone:           make(chan struct{}),\n\t}\n\n\t// Start cleanup goroutine\n\tgo ip.periodicCleanup()\n\n\treturn ip\n}\n\n// Stop should be called when the server is shutting down\nfunc (ip *IPProtection) Stop() {\n\tip.cleanupTicker.Stop()\n\tclose(ip.done)\n}\n\n// periodicCleanup removes old entries from maps to prevent memory leaks\nfunc (ip *IPProtection) periodicCleanup() {\n\tfor {\n\t\tselect {\n\t\tcase <-ip.cleanupTicker.C:\n\t\t\tip.cleanupFailedAttempts()\n\t\tcase <-ip.done:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// cleanupFailedAttempts removes old entries from the failedAttempts map\nfunc (ip *IPProtection) cleanupFailedAttempts() {\n\tcutoff := time.Now().Add(-15 * time.Minute)\n\n\tip.mu.Lock()\n\tdefer ip.mu.Unlock()\n\n\tfor clientIP, attempt := range ip.failedAttempts {\n\t\t// Remove entries that haven't been seen in the last 15 minutes\n\t\tif attempt.lastSeen.Before(cutoff) {\n\t\t\tdelete(ip.failedAttempts, clientIP)\n\t\t}\n\t}\n}\n\nfunc (ip *IPProtection) Protect() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tclientIP := c.ClientIP()\n\n\t\t// Check if IP is whitelisted\n\t\tif ip.isWhitelisted(clientIP) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Check if IP is blacklisted\n\t\tif ip.isBlacklisted(clientIP) {\n\t\t\tc.AbortWithStatusJSON(403, gin.H{\"error\": \"Access denied\"})\n\t\t\treturn\n\t\t}\n\n\t\t// Check for suspicious activity\n\t\tif ip.isSuspicious(clientIP) {\n\t\t\tip.addToBlacklist(clientIP)\n\t\t\tc.AbortWithStatusJSON(403, gin.H{\"error\": \"Suspicious activity detected\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\nfunc (ip *IPProtection) isWhitelisted(clientIP string) bool {\n\tip.mu.RLock()\n\tdefer ip.mu.RUnlock()\n\treturn ip.whitelist[clientIP]\n}\n\nfunc (ip *IPProtection) isBlacklisted(clientIP string) bool {\n\tip.mu.RLock()\n\tdefer ip.mu.RUnlock()\n\t_, exists := ip.blacklist[clientIP]\n\treturn exists\n}\n\nfunc (ip *IPProtection) isSuspicious(clientIP string) bool {\n\tip.mu.Lock()\n\tdefer ip.mu.Unlock()\n\n\tattempt, exists := ip.failedAttempts[clientIP]\n\tif !exists {\n\t\treturn false\n\t}\n\n\t// Check if more than 10 failed attempts in 5 minutes\n\tif attempt.count > 10 && time.Since(attempt.firstFail) < 5*time.Minute {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (ip *IPProtection) RecordFailedAttempt(clientIP string) {\n\tip.mu.Lock()\n\tdefer ip.mu.Unlock()\n\n\tnow := time.Now()\n\tattempt, exists := ip.failedAttempts[clientIP]\n\tif !exists {\n\t\tip.failedAttempts[clientIP] = &FailedAttempt{\n\t\t\tcount:     1,\n\t\t\tfirstFail: now,\n\t\t\tlastSeen:  now,\n\t\t}\n\t\treturn\n\t}\n\n\t// Reset if last attempt was more than 5 minutes ago\n\tif time.Since(attempt.firstFail) > 5*time.Minute {\n\t\tattempt.count = 1\n\t\tattempt.firstFail = now\n\t} else {\n\t\tattempt.count++\n\t}\n\tattempt.lastSeen = now\n}\n\nfunc (ip *IPProtection) addToBlacklist(clientIP string) {\n\tip.mu.Lock()\n\tdefer ip.mu.Unlock()\n\tip.blacklist[clientIP] = struct{}{}\n}\n\n// AddToWhitelist adds an IP to the whitelist\nfunc (ip *IPProtection) AddToWhitelist(clientIP string) {\n\tip.mu.Lock()\n\tdefer ip.mu.Unlock()\n\tip.whitelist[clientIP] = true\n}\n\n// RemoveFromBlacklist removes an IP from the blacklist\nfunc (ip *IPProtection) RemoveFromBlacklist(clientIP string) {\n\tip.mu.Lock()\n\tdefer ip.mu.Unlock()\n\tdelete(ip.blacklist, clientIP)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/ratelimit.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"golang.org/x/time/rate\"\n)\n\nfunc RateLimiter(rateLimitPerSecond int) gin.HandlerFunc {\n\tlimiter := rate.NewLimiter(rate.Limit(rateLimitPerSecond), rateLimitPerSecond)\n\treturn func(c *gin.Context) {\n\t\tif !limiter.Allow() {\n\t\t\tc.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{\"error\": \"rate limit exceeded\"})\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/recovery.go",
    "content": "// middleware/recovery.go\n\npackage middleware\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/logging\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nconst (\n\t// Size of the stack buffer\n\tstackSize = 4 << 10 // 4 KB\n)\n\nfunc Recovery(logger *logging.Logger) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tdefer func() {\n\t\t\tif err := recover(); err != nil {\n\t\t\t\t// Get stack trace\n\t\t\t\tstack := stack(3)\n\n\t\t\t\t// Get request details\n\t\t\t\thttpRequest := c.Request\n\t\t\t\theaders := make(map[string]string)\n\t\t\t\tfor k, v := range httpRequest.Header {\n\t\t\t\t\theaders[k] = v[0]\n\t\t\t\t}\n\n\t\t\t\t// Log the error with context\n\t\t\t\tlogger.Errorw(\"Panic recovered\",\n\t\t\t\t\t\"error\", err,\n\t\t\t\t\t\"stack\", string(stack),\n\t\t\t\t\t\"request_id\", GetRequestID(c),\n\t\t\t\t\t\"method\", httpRequest.Method,\n\t\t\t\t\t\"path\", httpRequest.URL.Path,\n\t\t\t\t\t\"query\", httpRequest.URL.RawQuery,\n\t\t\t\t\t\"ip\", c.ClientIP(),\n\t\t\t\t\t\"user_agent\", httpRequest.UserAgent(),\n\t\t\t\t\t\"time\", time.Now().UTC(),\n\t\t\t\t)\n\n\t\t\t\t// Return safe error to client\n\t\t\t\tc.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{\n\t\t\t\t\t\"error\":      \"Internal Server Error\",\n\t\t\t\t\t\"request_id\": GetRequestID(c),\n\t\t\t\t\t\"code\":       \"SERVER_ERROR\",\n\t\t\t\t})\n\t\t\t}\n\t\t}()\n\t\tc.Next()\n\t}\n}\n\n// stack returns a formatted stack trace of the goroutine that panicked\nfunc stack(_ int) []byte {\n\tbuf := new(bytes.Buffer)\n\n\t// Get runtime stack\n\tvar stackBuf [stackSize]byte\n\tn := runtime.Stack(stackBuf[:], false)\n\n\t// Write stack to buffer\n\t_, _ = io.WriteString(buf, \"Stack Trace:\\n\")\n\t_, _ = buf.Write(stackBuf[:n])\n\n\treturn buf.Bytes()\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/request_id.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n)\n\nconst (\n\t// Headers\n\tRequestIDHeader = \"X-Request-ID\"\n\n\t// Context keys\n\tRequestIDKey = \"request_id\"\n)\n\n// RequestID middleware ensures each request has a unique ID for tracing\nfunc RequestID() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Check if request already has an ID\n\t\trequestID := c.GetHeader(RequestIDHeader)\n\n\t\t// Generate new ID if none exists\n\t\tif requestID == \"\" {\n\t\t\trequestID = generateRequestID()\n\t\t}\n\n\t\t// Set ID in context and response header\n\t\tc.Set(RequestIDKey, requestID)\n\t\tc.Header(RequestIDHeader, requestID)\n\n\t\tc.Next()\n\t}\n}\n\n// generateRequestID creates a new UUID v4 for request tracking\nfunc generateRequestID() string {\n\treturn uuid.New().String()\n}\n\n// GetRequestID retrieves request ID from context\nfunc GetRequestID(c *gin.Context) string {\n\tif id, exists := c.Get(RequestIDKey); exists {\n\t\treturn id.(string)\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/request_size_limit.go",
    "content": "// middleware/request_size.go\n\npackage middleware\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc RequestSizeLimit(maxSize int64) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Skip check for GET, HEAD, OPTIONS methods\n\t\tif c.Request.Method == http.MethodGet ||\n\t\t\tc.Request.Method == http.MethodHead ||\n\t\t\tc.Request.Method == http.MethodOptions {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Check Content-Length header\n\t\tcontentLength := c.Request.ContentLength\n\t\tif contentLength > maxSize {\n\t\t\tc.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{\n\t\t\t\t\"error\": fmt.Sprintf(\"request size limit exceeded: %d bytes > %d bytes\", contentLength, maxSize),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// Set body size limit for the request\n\t\tc.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)\n\n\t\tc.Next()\n\n\t\t// Check if the request was aborted due to size limit\n\t\tif c.Errors.Last() != nil && c.Errors.Last().Err == http.ErrMissingFile {\n\t\t\tc.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{\n\t\t\t\t\"error\": \"request size limit exceeded during processing\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/sanatization.go",
    "content": "// middleware/sanitization.go\n\npackage middleware\n\nimport (\n\t\"html\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/server/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc RequestSanitization(config config.SanitizationConfig) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Sanitize Path Parameters\n\t\tfor _, param := range c.Params {\n\t\t\tif containsPathTraversal(param.Value) {\n\t\t\t\tc.AbortWithStatusJSON(http.StatusBadRequest, gin.H{\n\t\t\t\t\t\"error\": \"Invalid path parameter detected\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Sanitize Query Parameters\n\t\tfor _, values := range c.Request.URL.Query() {\n\t\t\tfor _, value := range values {\n\t\t\t\tif !config.DisableXSS && containsXSS(value) {\n\t\t\t\t\tc.AbortWithStatusJSON(http.StatusBadRequest, gin.H{\n\t\t\t\t\t\t\"error\": \"Potential XSS detected in query parameter\",\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !config.DisableSQLInjection && containsSQLInjection(value) {\n\t\t\t\t\tc.AbortWithStatusJSON(http.StatusBadRequest, gin.H{\n\t\t\t\t\t\t\"error\": \"Potential SQL injection detected in query parameter\",\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Add a custom header indicating security checks passed\n\t\tc.Header(\"X-Content-Security\", \"sanitized\")\n\n\t\tc.Next()\n\t}\n}\n\nfunc containsPathTraversal(path string) bool {\n\tsuspicious := []string{\"..\", \"//\", \"\\\\\\\\\"}\n\tfor _, pattern := range suspicious {\n\t\tif strings.Contains(path, pattern) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc containsXSS(input string) bool {\n\tsuspicious := []string{\n\t\t\"<script>\", \"</script>\",\n\t\t\"javascript:\", \"vbscript:\",\n\t\t\"onload=\", \"onerror=\",\n\t}\n\n\tsanitized := html.EscapeString(input)\n\tfor _, pattern := range suspicious {\n\t\tif strings.Contains(strings.ToLower(sanitized), strings.ToLower(pattern)) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc containsSQLInjection(input string) bool {\n\tsuspicious := []string{\n\t\t\"DROP TABLE\",\n\t\t\"DELETE FROM\",\n\t\t\"INSERT INTO\",\n\t\t\"UPDATE\",\n\t\t\"--\",\n\t\t\"UNION\",\n\t\t\"SELECT\",\n\t}\n\n\tfor _, pattern := range suspicious {\n\t\tif strings.Contains(strings.ToUpper(input), pattern) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/security_headers.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc SecurityHeaders() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Security headers\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-XSS-Protection\", \"1; mode=block\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-no-referrer\")\n\t\tc.Header(\"Content-Security-Policy\", \"default-src 'self'; script-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline';\")\n\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\tc.Header(\"X-Permitted-Cross-Domain-Policies\", \"none\")\n\n\t\t// Remove sensitive headers\n\t\tc.Header(\"Server\", \"\")\n\t\tc.Header(\"X-Powered-By\", \"\")\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/timeout.go",
    "content": "// middleware/timeout.go\n\npackage middleware\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc Timeout(timeout time.Duration) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Create a context with timeout\n\t\tctx, cancel := context.WithTimeout(c.Request.Context(), timeout)\n\t\tdefer cancel() // Ensure we call cancel to prevent context leak\n\n\t\t// Create a request with the new context\n\t\tc.Request = c.Request.WithContext(ctx)\n\n\t\t// Create a channel to signal completion\n\t\tfinished := make(chan struct{}, 1)\n\n\t\t// Use a WaitGroup to wait for the goroutine to finish\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\n\t\t// Create a copy of the context writer\n\t\twriter := c.Writer\n\n\t\t// Handle the request in a goroutine\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\n\t\t\t// Create a wrapped writer to capture the response status\n\t\t\tblw := &bodyLogWriter{ResponseWriter: c.Writer}\n\t\t\tc.Writer = blw\n\n\t\t\t// Process the handler chain\n\t\t\tc.Next()\n\n\t\t\t// Signal that the handler is complete\n\t\t\tfinished <- struct{}{}\n\t\t}()\n\n\t\t// Wait for either completion or timeout\n\t\tselect {\n\t\tcase <-finished:\n\t\t\t// Handler completed before timeout\n\t\tcase <-ctx.Done():\n\t\t\t// Timeout occurred or parent context was cancelled\n\t\t\tif ctx.Err() == context.DeadlineExceeded {\n\t\t\t\t// Reset the original writer to avoid modifying response after timeout\n\t\t\t\tc.Writer = writer\n\t\t\t\tc.AbortWithStatusJSON(http.StatusGatewayTimeout, gin.H{\"error\": \"request timeout\"})\n\t\t\t}\n\t\t}\n\n\t\t// Wait for the goroutine to finish to prevent potential leaks\n\t\twg.Wait()\n\t}\n}\n\n// bodyLogWriter is a wrapper around ResponseWriter to capture the response status\ntype bodyLogWriter struct {\n\tgin.ResponseWriter\n\tstatusCode int\n}\n\nfunc (w *bodyLogWriter) WriteHeader(code int) {\n\tw.statusCode = code\n\tw.ResponseWriter.WriteHeader(code)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/server/middleware/validator.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-playground/validator/v10\"\n)\n\nvar Validate *validator.Validate\n\nfunc InitValidator() *validator.Validate {\n\tValidate = validator.New()\n\treturn Validate\n}\n\nfunc ValidateRequest(structPtr interface{}) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif err := c.ShouldBindJSON(structPtr); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t\tif err := Validate.Struct(structPtr); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/stytch/client.go",
    "content": "package stytch\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi\"\n)\n\n// Client is a thin wrapper around the autogenerated Stytch B2B API client.\ntype Client struct {\n\tapi    *b2bstytchapi.API\n\tconfig Config\n}\n\n// NewClient constructs the shared Stytch API client using the supplied configuration.\nfunc NewClient(cfg Config) (*Client, error) {\n\thttpClient := &http.Client{\n\t\tTimeout: cfg.APITimeout,\n\t}\n\n\tvar opts []b2bstytchapi.Option\n\topts = append(opts, b2bstytchapi.WithHTTPClient(httpClient))\n\n\tbaseURI := strings.TrimSuffix(cfg.BaseURL, \"/\")\n\tif baseURI != \"\" {\n\t\topts = append(opts, b2bstytchapi.WithBaseURI(baseURI))\n\t}\n\n\tapiClient, err := b2bstytchapi.NewClient(cfg.ProjectID, cfg.Secret, opts...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create stytch client: %w\", err)\n\t}\n\n\treturn &Client{\n\t\tapi:    apiClient,\n\t\tconfig: cfg,\n\t}, nil\n}\n\n// API exposes the underlying autogenerated API for advanced use-cases.\nfunc (c *Client) API() *b2bstytchapi.API {\n\treturn c.api\n}\n\n// Config returns the hydrated configuration.\nfunc (c *Client) Config() Config {\n\treturn c.config\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/stytch/cmd/provider.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/stytch\"\n\t\"go.uber.org/dig\"\n)\n\n// ProvideStytchDependencies wires the Stytch configuration and client into the DI container.\nfunc ProvideStytchDependencies(container *dig.Container) error {\n\tproviders := []any{\n\t\tstytch.LoadConfig,\n\t\tprovideStytchClient,\n\t\tprovideRBACPolicyService,\n\t}\n\n\tfor _, provider := range providers {\n\t\tif err := container.Provide(provider); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to provide Stytch dependency: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc provideStytchClient(cfg *stytch.Config, log logger.Logger) (*stytch.Client, error) {\n\t// Check for placeholder credentials (development mode)\n\tif isPlaceholderCredentials(cfg) {\n\t\tlog.Warn(\"Stytch credentials are placeholders - Stytch client will be nil (development mode)\", map[string]any{\n\t\t\t\"project_id\": cfg.ProjectID,\n\t\t\t\"message\":    \"Organization/member management features will not work. Update STYTCH_PROJECT_ID and STYTCH_SECRET in app.env\",\n\t\t})\n\t\t// Return nil client for development mode - app/auth repositories should handle nil gracefully\n\t\treturn nil, nil\n\t}\n\n\treturn stytch.NewClient(*cfg)\n}\n\n// isPlaceholderCredentials checks if the Stytch credentials are placeholder values.\nfunc isPlaceholderCredentials(cfg *stytch.Config) bool {\n\treturn strings.Contains(cfg.ProjectID, \"REPLACE\") ||\n\t\tstrings.Contains(cfg.Secret, \"REPLACE\") ||\n\t\tcfg.ProjectID == \"\" ||\n\t\tcfg.Secret == \"\"\n}\n\nfunc provideRBACPolicyService(\n\tclient *stytch.Client,\n\tredisClient redis.Client,\n\tlog logger.Logger,\n) *stytch.RBACPolicyService {\n\t// If client is nil (development mode), return nil for RBAC service too\n\tif client == nil {\n\t\treturn nil\n\t}\n\treturn stytch.NewRBACPolicyService(client, redisClient, log)\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/stytch/config.go",
    "content": "package stytch\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/spf13/viper\"\n)\n\n// Environment constants supported by the Stytch B2B API.\nconst (\n\tEnvTest = \"test\"\n\tEnvLive = \"live\"\n)\n\n// Config captures the runtime knobs required to communicate with Stytch.\ntype Config struct {\n\tProjectID                  string        `mapstructure:\"STYTCH_PROJECT_ID\"`\n\tSecret                     string        `mapstructure:\"STYTCH_SECRET\"`\n\tEnv                        string        `mapstructure:\"STYTCH_ENV\"`\n\tBaseURL                    string        `mapstructure:\"STYTCH_BASE_URL\"`\n\tCustomDomain               string        `mapstructure:\"STYTCH_CUSTOM_DOMAIN\"`\n\tJWKSURL                    string        `mapstructure:\"STYTCH_JWKS_URL\"`\n\tSessionDurationMinutes     int32         `mapstructure:\"STYTCH_SESSION_DURATION_MINUTES\"`\n\tDisableSessionVerification bool          `mapstructure:\"STYTCH_DISABLE_SESSION_VERIFICATION\"`\n\tOwnerRoleSlug              string        `mapstructure:\"STYTCH_OWNER_ROLE_SLUG\"`\n\tInviteRedirectURL          string        `mapstructure:\"STYTCH_INVITE_REDIRECT_URL\"`\n\tLoginRedirectURL           string        `mapstructure:\"STYTCH_LOGIN_REDIRECT_URL\"`\n\tAPITimeout                 time.Duration `mapstructure:\"STYTCH_API_TIMEOUT\"`\n}\n\n// LoadConfig hydrates the Stytch configuration from app.env + process environment.\nfunc LoadConfig() (*Config, error) {\n\tv := viper.New()\n\tv.SetConfigName(\"app\")\n\tv.SetConfigType(\"env\")\n\tv.AddConfigPath(\".\")\n\tv.AutomaticEnv()\n\n\t// Defaults mirror the Auth0 integration to minimize surprises.\n\tv.SetDefault(\"STYTCH_ENV\", EnvTest)\n\tv.SetDefault(\"STYTCH_SESSION_DURATION_MINUTES\", 1440) // 24 hours (previously 60 minutes)\n\tv.SetDefault(\"STYTCH_API_TIMEOUT\", \"15s\")\n\tv.SetDefault(\"STYTCH_DISABLE_SESSION_VERIFICATION\", false)\n\n\t// Best-effort: ignore missing file, allow env-only usage\n\tif err := v.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); !ok {\n\t\t\treturn nil, fmt.Errorf(\"failed to read config: %w\", err)\n\t\t}\n\t}\n\n\tvar cfg *Config\n\tif err := v.Unmarshal(&cfg); err != nil {\n\t\treturn cfg, fmt.Errorf(\"unable to decode stytch config: %w\", err)\n\t}\n\n\tcfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env))\n\tif cfg.Env == \"\" {\n\t\tcfg.Env = EnvTest\n\t}\n\n\tif cfg.ProjectID == \"\" {\n\t\treturn cfg, fmt.Errorf(\"stytch configuration invalid: STYTCH_PROJECT_ID is required\")\n\t}\n\tif cfg.Secret == \"\" {\n\t\treturn cfg, fmt.Errorf(\"stytch configuration invalid: STYTCH_SECRET is required\")\n\t}\n\n\t// Normalize timeout (viper unmarshals duration strings automatically).\n\tif cfg.APITimeout <= 0 {\n\t\tcfg.APITimeout = 15 * time.Second\n\t}\n\n\t// Derive base URL if none supplied.\n\tif cfg.BaseURL == \"\" {\n\t\tswitch cfg.Env {\n\t\tcase EnvLive:\n\t\t\tcfg.BaseURL = \"https://api.stytch.com\"\n\t\tdefault:\n\t\t\tcfg.BaseURL = \"https://test.stytch.com\"\n\t\t}\n\t}\n\n\t// Custom domain overrides base URL for API + JWKS.\n\tif cfg.CustomDomain != \"\" {\n\t\tcfg.BaseURL = fmt.Sprintf(\"https://%s\", strings.TrimSuffix(cfg.CustomDomain, \"/\"))\n\t}\n\n\t// Derive JWKS URL if absent.\n\tif cfg.JWKSURL == \"\" {\n\t\tif cfg.CustomDomain != \"\" {\n\t\t\tcfg.JWKSURL = fmt.Sprintf(\"https://%s/.well-known/jwks.json\", strings.TrimSuffix(cfg.CustomDomain, \"/\"))\n\t\t} else {\n\t\t\tcfg.JWKSURL = fmt.Sprintf(\"%s/v1/b2b/sessions/jwks/%s\", strings.TrimSuffix(cfg.BaseURL, \"/\"), cfg.ProjectID)\n\t\t}\n\t}\n\n\treturn cfg, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/stytch/errors.go",
    "content": "package stytch\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/stytchauth/stytch-go/v16/stytch/stytcherror\"\n)\n\nvar (\n\t// ErrUnauthorized mirrors HTTP 401 responses.\n\tErrUnauthorized = errors.New(\"stytch: unauthorized\")\n\t// ErrForbidden mirrors HTTP 403 responses.\n\tErrForbidden = errors.New(\"stytch: forbidden\")\n\t// ErrNotFound mirrors HTTP 404 responses.\n\tErrNotFound = errors.New(\"stytch: resource not found\")\n\t// ErrConflict mirrors HTTP 409 responses.\n\tErrConflict = errors.New(\"stytch: conflict\")\n\t// ErrRateLimited mirrors HTTP 429 responses.\n\tErrRateLimited = errors.New(\"stytch: rate limit exceeded\")\n\t// ErrBadRequest mirrors HTTP 400 responses.\n\tErrBadRequest = errors.New(\"stytch: bad request\")\n\t// ErrInternal mirrors HTTP 5xx responses.\n\tErrInternal = errors.New(\"stytch: internal server error\")\n\t// ErrInvalidConfig surfaces configuration validation issues.\n\tErrInvalidConfig = errors.New(\"stytch: invalid configuration\")\n\t// ErrDuplicateSlug indicates organization slug already exists.\n\tErrDuplicateSlug = errors.New(\"stytch: organization slug already exists\")\n)\n\n// IsDuplicateSlugError checks if the error is a duplicate organization slug error\nfunc IsDuplicateSlugError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tvar stErr *stytcherror.Error\n\tif errors.As(err, &stErr) {\n\t\treturn string(stErr.ErrorType) == \"organization_slug_already_used\"\n\t}\n\n\treturn false\n}\n\n// MapError inspects a returned error and maps it to one of the sentinel errors above.\nfunc MapError(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\tvar stErr *stytcherror.Error\n\tif errors.As(err, &stErr) {\n\t\t// Check specific error types first\n\t\tif string(stErr.ErrorType) == \"organization_slug_already_used\" {\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrDuplicateSlug, stErr.Error())\n\t\t}\n\n\t\t// Then check status codes\n\t\tswitch stErr.StatusCode {\n\t\tcase 400:\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrBadRequest, stErr.Error())\n\t\tcase 401:\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrUnauthorized, stErr.Error())\n\t\tcase 403:\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrForbidden, stErr.Error())\n\t\tcase 404:\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrNotFound, stErr.Error())\n\t\tcase 409:\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrConflict, stErr.Error())\n\t\tcase 429:\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrRateLimited, stErr.Error())\n\t\tdefault:\n\t\t\tif stErr.StatusCode >= 500 {\n\t\t\t\treturn fmt.Errorf(\"%w: %s\", ErrInternal, stErr.Error())\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"stytch: unexpected status %d: %w\", stErr.StatusCode, stErr)\n\t\t}\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/stytch/inject.go",
    "content": "package stytch\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"go.uber.org/dig\"\n)\n\n// ProvideDependencies registers Stytch package dependencies in the DI container\nfunc ProvideDependencies(container *dig.Container) error {\n\t// Provide Stytch client\n\tif err := container.Provide(func(cfg Config) (*Client, error) {\n\t\treturn NewClient(cfg)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide stytch client: %w\", err)\n\t}\n\n\t// Provide RBAC policy service\n\tif err := container.Provide(func(\n\t\tclient *Client,\n\t\tredisClient redis.Client,\n\t\tlogger logger.Logger,\n\t) *RBACPolicyService {\n\t\treturn NewRBACPolicyService(client, redisClient, logger)\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to provide RBAC policy service: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/internal/platform/stytch/rbac_policy.go",
    "content": "package stytch\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/moasq/go-b2b-starter/internal/platform/logger\"\n\t\"github.com/moasq/go-b2b-starter/internal/platform/redis\"\n\t\"github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac\"\n)\n\nconst (\n\t// Redis cache key for RBAC policy\n\trbacPolicyCacheKey = \"stytch:rbac:policy\"\n\t// Cache TTL matches Stytch SDK default (5 minutes)\n\trbacPolicyCacheTTL = 5 * time.Minute\n)\n\n// RBACPolicyService fetches and caches Stytch RBAC policy\ntype RBACPolicyService struct {\n\tclient *Client\n\tredis  redis.Client\n\tlogger logger.Logger\n}\n\nfunc NewRBACPolicyService(\n\tclient *Client,\n\tredisClient redis.Client,\n\tlogger logger.Logger,\n) *RBACPolicyService {\n\treturn &RBACPolicyService{\n\t\tclient: client,\n\t\tredis:  redisClient,\n\t\tlogger: logger,\n\t}\n}\n\n// GetRolePermissions returns all permissions for a given role from Stytch RBAC policy\n// Returns permissions in \"resource:action\" format (e.g., \"invoice:create\")\nfunc (s *RBACPolicyService) GetRolePermissions(ctx context.Context, roleID string) ([]string, error) {\n\t// Normalize role ID (remove stytch_ prefix)\n\tnormalizedRoleID := normalizeRoleID(roleID)\n\n\t// Get policy from cache or Stytch\n\tpolicy, err := s.getPolicy(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get RBAC policy: %w\", err)\n\t}\n\n\t// Find role in policy\n\tfor _, role := range policy.Roles {\n\t\tif strings.EqualFold(role.RoleID, normalizedRoleID) {\n\t\t\treturn s.convertPermissions(role.Permissions, policy), nil\n\t\t}\n\t}\n\n\t// Role not found in policy\n\treturn nil, nil\n}\n\n// getPolicy fetches policy from Redis cache or Stytch API\nfunc (s *RBACPolicyService) getPolicy(ctx context.Context) (*rbac.Policy, error) {\n\t// Try cache first\n\tcached, err := s.redis.Get(ctx, rbacPolicyCacheKey)\n\tif err == nil && cached != \"\" {\n\t\tvar policy rbac.Policy\n\t\tif err := json.Unmarshal([]byte(cached), &policy); err == nil {\n\t\t\ts.logger.Debug(\"RBAC policy fetched from cache\", logger.Fields{})\n\t\t\treturn &policy, nil\n\t\t} else {\n\t\t\ts.logger.Warn(\"Failed to unmarshal cached RBAC policy, fetching from Stytch\", logger.Fields{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\t// Cache miss or error - fetch from Stytch\n\tpolicy, err := s.fetchPolicyFromStytch(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Cache the policy\n\ts.cachePolicy(ctx, policy)\n\n\treturn policy, nil\n}\n\n// fetchPolicyFromStytch fetches RBAC policy from Stytch API\nfunc (s *RBACPolicyService) fetchPolicyFromStytch(ctx context.Context) (*rbac.Policy, error) {\n\ts.logger.Info(\"Fetching RBAC policy from Stytch\", logger.Fields{})\n\n\tresp, err := s.client.API().RBAC.Policy(ctx, &rbac.PolicyParams{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"stytch RBAC policy API call failed: %w\", err)\n\t}\n\n\tif resp.Policy == nil {\n\t\treturn nil, fmt.Errorf(\"stytch returned empty policy\")\n\t}\n\n\ts.logger.Info(\"Successfully fetched RBAC policy from Stytch\", logger.Fields{\n\t\t\"roles_count\":     len(resp.Policy.Roles),\n\t\t\"resources_count\": len(resp.Policy.Resources),\n\t})\n\n\treturn resp.Policy, nil\n}\n\n// cachePolicy stores policy in Redis\nfunc (s *RBACPolicyService) cachePolicy(ctx context.Context, policy *rbac.Policy) {\n\tdata, err := json.Marshal(policy)\n\tif err != nil {\n\t\ts.logger.Warn(\"Failed to marshal RBAC policy for caching\", logger.Fields{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tif err := s.redis.Set(ctx, rbacPolicyCacheKey, string(data), rbacPolicyCacheTTL); err != nil {\n\t\ts.logger.Warn(\"Failed to cache RBAC policy in Redis\", logger.Fields{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\t// Non-fatal error, continue without cache\n\t} else {\n\t\ts.logger.Debug(\"RBAC policy cached in Redis\", logger.Fields{\n\t\t\t\"ttl\": rbacPolicyCacheTTL.String(),\n\t\t})\n\t}\n}\n\n// convertPermissions converts Stytch permission format to flat list, expanding wildcards\n//\n//\tInput: []PolicyRolePermission{\n//\t  {ResourceID: \"Invoice\", Actions: [\"view\", \"create\"]},\n//\t  {ResourceID: \"approval\", Actions: [\"*\"]},\n//\t}, policy with Resources\n//\n// Output: [\"invoice:view\", \"invoice:create\", \"approval:view\", \"approval:approve\", \"approval:assign\"]\n// (wildcards expanded to all actions defined in policy for that resource)\nfunc (s *RBACPolicyService) convertPermissions(permissions []rbac.PolicyRolePermission, policy *rbac.Policy) []string {\n\tif len(permissions) == 0 {\n\t\treturn nil\n\t}\n\n\tresult := make([]string, 0, len(permissions)*5) // Estimate 5 actions per resource\n\n\tfor _, perm := range permissions {\n\t\tresourceID := strings.ToLower(perm.ResourceID)\n\n\t\t// Handle empty or invalid resource\n\t\tif resourceID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Expand wildcard actions using resource definitions from policy\n\t\texpandedActions := s.expandWildcardActions(perm.ResourceID, perm.Actions, policy)\n\n\t\t// Convert each action to \"resource:action\" format\n\t\tfor _, action := range expandedActions {\n\t\t\tif action == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tactionLower := strings.ToLower(action)\n\t\t\tresult = append(result, fmt.Sprintf(\"%s:%s\", resourceID, actionLower))\n\t\t}\n\t}\n\n\treturn result\n}\n\n// expandWildcardActions expands wildcard (*) to all resource actions from Stytch policy\n// This ensures permissions come entirely from Stytch configuration, not local code\nfunc (s *RBACPolicyService) expandWildcardActions(resourceID string, actions []string, policy *rbac.Policy) []string {\n\t// Check if actions contain wildcard\n\thasWildcard := false\n\tfor _, action := range actions {\n\t\tif action == \"*\" {\n\t\t\thasWildcard = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// If no wildcard, return actions as-is\n\tif !hasWildcard {\n\t\treturn actions\n\t}\n\n\t// Find resource definition in policy to get all possible actions\n\tfor _, resource := range policy.Resources {\n\t\tif strings.EqualFold(resource.ResourceID, resourceID) {\n\t\t\t// Return all actions defined for this resource in Stytch\n\t\t\tif len(resource.Actions) > 0 {\n\t\t\t\ts.logger.Debug(\"Expanded wildcard permission\", logger.Fields{\n\t\t\t\t\t\"resource\":      resourceID,\n\t\t\t\t\t\"actions_count\": len(resource.Actions),\n\t\t\t\t\t\"actions\":       resource.Actions,\n\t\t\t\t})\n\t\t\t\treturn resource.Actions\n\t\t\t}\n\t\t}\n\t}\n\n\t// Resource not found in policy, keep wildcard as-is (shouldn't happen)\n\ts.logger.Warn(\"Resource not found in policy, keeping wildcard\", logger.Fields{\n\t\t\"resource\": resourceID,\n\t})\n\treturn actions\n}\n\n// normalizeRoleID removes common prefixes from role IDs\n// \"stytch_member\" -> \"stytch_member\"\n// \"owner\" -> \"owner\"\n// \"stytch_admin\" -> \"stytch_admin\"\nfunc normalizeRoleID(roleID string) string {\n\troleID = strings.TrimSpace(roleID)\n\t// Keep stytch_ prefix for default roles, but remove \"member\" suffix\n\troleID = strings.TrimPrefix(roleID, \"member\")\n\troleID = strings.TrimSpace(roleID)\n\treturn roleID\n}\n"
  },
  {
    "path": "go-b2b-starter/pkg/httperr/errors.go",
    "content": "package httperr\n\nimport (\n\t\"net/http\"\n)\n\n// IsNotFoundError checks if the error is a NotFoundError\nfunc IsNotFoundError(err error) bool {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.StatusCode == http.StatusNotFound\n\t}\n\treturn false\n}\n\n// IsConflictError checks if the error is a ConflictError (e.g., duplicate username)\nfunc IsConflictError(err error) bool {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.StatusCode == http.StatusConflict\n\t}\n\treturn false\n}\n\n// IsBadRequestError checks if the error is a BadRequestError\nfunc IsBadRequestError(err error) bool {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.StatusCode == http.StatusBadRequest\n\t}\n\treturn false\n}\n\n// IsAuthenticationError checks if the error is an authentication error\nfunc IsAuthenticationError(err error) bool {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.StatusCode == http.StatusUnauthorized\n\t}\n\treturn false\n}\n\n// IsAuthorizationError checks if the error is an authorization error\nfunc IsAuthorizationError(err error) bool {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.StatusCode == http.StatusForbidden\n\t}\n\treturn false\n}\n\n// IsInternalServerError checks if the error is an internal server error\nfunc IsInternalServerError(err error) bool {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.StatusCode == http.StatusInternalServerError\n\t}\n\treturn false\n}\n\n// GetErrorCode extracts the error code from an HTTPError, or returns empty string\nfunc GetErrorCode(err error) string {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.Code\n\t}\n\treturn \"\"\n}\n\n// GetErrorMessage extracts the error message from an HTTPError, or returns the error's message\nfunc GetErrorMessage(err error) string {\n\tif httpErr, ok := err.(*HTTPError); ok {\n\t\treturn httpErr.Message\n\t}\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "go-b2b-starter/pkg/httperr/http_error.go",
    "content": "package httperr\n\ntype HTTPError struct {\n\tStatusCode int    `json:\"-\"`\n\tCode       string `json:\"code\"`\n\tMessage    string `json:\"message\"`\n}\n\nfunc (e HTTPError) Error() string {\n\treturn e.Message\n}\n\nfunc NewHTTPError(statusCode int, code, message string) HTTPError {\n\treturn HTTPError{\n\t\tStatusCode: statusCode,\n\t\tCode:       code,\n\t\tMessage:    message,\n\t}\n}\n"
  },
  {
    "path": "go-b2b-starter/pkg/pagination/pagination.go",
    "content": "package listingshared\n\nimport \"fmt\"\n\ntype PagePagination[T any] struct {\n\tItems []T  `json:\"items\"`\n\tMeta  Meta `json:\"meta\"`\n}\n\ntype Meta struct {\n\tTotalItems         int    `json:\"total_items\"`\n\tPage               int    `json:\"page\"`\n\tPageSize           int    `json:\"page_size\"`\n\tReturnedItemsCount int    `json:\"returned_items_count\"`\n\tHasMore            bool   `json:\"has_more\"`\n\tFirstPageURL       string `json:\"first_page_url\"`\n\tPreviousPageURL    string `json:\"previous_page_url\"`\n\tNextPageURL        string `json:\"next_page_url\"`\n\tLastPageURL        string `json:\"last_page_url\"`\n\tTotalPages         int    `json:\"total_pages\"`\n}\n\nfunc NewPagePagination[T any](totalItems, page, pageSize int, items []T) *PagePagination[T] {\n\t// Calculate total pages properly\n\ttotalPages := (totalItems + pageSize - 1) / pageSize\n\n\tp := &PagePagination[T]{\n\t\tMeta: Meta{\n\t\t\tTotalItems:         totalItems,\n\t\t\tPage:               page,\n\t\t\tPageSize:           pageSize,\n\t\t\tReturnedItemsCount: len(items),\n\t\t\tHasMore:            page < totalPages, // Set HasMore\n\t\t\tTotalPages:         totalPages,\n\t\t},\n\t\tItems: items,\n\t}\n\n\t// First page URL only if we're not on first page\n\tif page > 1 {\n\t\tp.Meta.FirstPageURL = fmt.Sprintf(\"?page=%d&pageSize=%d\", 1, pageSize)\n\t}\n\n\t// Last page URL only if there are multiple pages and we're not on last page\n\tif page < totalPages {\n\t\tp.Meta.LastPageURL = fmt.Sprintf(\"?page=%d&pageSize=%d\", totalPages, pageSize)\n\t}\n\n\t// Previous page URL only if we're not on first page\n\tif page > 1 {\n\t\tp.Meta.PreviousPageURL = fmt.Sprintf(\"?page=%d&pageSize=%d\", page-1, pageSize)\n\t}\n\n\t// Next page URL only if there are more pages\n\tif page < totalPages {\n\t\tp.Meta.NextPageURL = fmt.Sprintf(\"?page=%d&pageSize=%d\", page+1, pageSize)\n\t}\n\n\treturn p\n}\n\nfunc PageToOffset(page int, limit int) (int, error) {\n\tif page < 1 {\n\t\treturn 0, fmt.Errorf(\"page must be greater than 0\")\n\t}\n\tif limit < 1 {\n\t\treturn 0, fmt.Errorf(\"limit must be greater than 0\")\n\t}\n\n\treturn (page - 1) * limit, nil\n}\n"
  },
  {
    "path": "go-b2b-starter/pkg/pagination/pramas.go",
    "content": "package listingshared\n\ntype SearchableParams struct {\n\tQ     string `form:\"q\" binding:\"required,min=1,max=100\"`\n\tPage  int    `form:\"page\" binding:\"omitempty,min=1\" default:\"1\"`\n\tLimit int    `form:\"limit\" binding:\"omitempty,min=1,max=100\" default:\"10\"`\n\tLang  string `form:\"lang\" binding:\"omitempty,oneof=en ar\" default:\"en\"`\n}\n\nfunc (s *SearchableParams) Validate() error {\n\n\t// Then, set default values for empty fields\n\tif s.Page == 0 {\n\t\ts.Page = 1\n\t}\n\tif s.Limit == 0 {\n\t\ts.Limit = 10\n\t}\n\tif s.Lang == \"\" {\n\t\ts.Lang = \"en\"\n\t}\n\n\treturn nil\n}\n\ntype ListableParams struct {\n\tPage  int    `form:\"page\" binding:\"omitempty,min=1\" default:\"1\"`\n\tLimit int    `form:\"limit\" binding:\"omitempty,min=1,max=100\" default:\"10\"`\n\tLang  string `form:\"lang\" binding:\"omitempty,oneof=en ar\" default:\"en\"`\n}\n\nfunc (s *ListableParams) Validate() error {\n\n\t// Then, set default values for empty fields\n\tif s.Page == 0 {\n\t\ts.Page = 1\n\t}\n\tif s.Limit == 0 {\n\t\ts.Limit = 10\n\t}\n\tif s.Lang == \"\" {\n\t\ts.Lang = \"en\"\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "go-b2b-starter/pkg/pagination/util.go",
    "content": "package listingshared\n\n// PaginationCalc converts offset and limit to page number and page size.\n// Assumes default values if not specified.\nfunc PaginationCalc(offset, limit int) (page, pageSize int) {\n\tif limit <= 0 {\n\t\tlimit = 20 // Default limit\n\t}\n\tif offset < 0 {\n\t\toffset = 0 // Default offset\n\t}\n\n\tpage = (offset / limit) + 1\n\tpageSize = limit\n\n\treturn page, pageSize\n}\n"
  },
  {
    "path": "go-b2b-starter/pkg/response/response.go",
    "content": "package response\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/moasq/go-b2b-starter/pkg/httperr\"\n)\n\n// Success sends a successful response\nfunc Success(c *gin.Context, statusCode int, data interface{}) {\n\tc.JSON(statusCode, gin.H{\n\t\t\"success\": true,\n\t\t\"data\":    data,\n\t})\n}\n\n// Error sends an error response\nfunc Error(c *gin.Context, statusCode int, message string, err error) {\n\tc.JSON(statusCode, httperr.NewHTTPError(\n\t\tstatusCode,\n\t\t\"error\",\n\t\tmessage,\n\t))\n}"
  },
  {
    "path": "go-b2b-starter/pkg/slugify/slugify.go",
    "content": "package slugify\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Slugify converts a string into a URL-friendly slug\n// Example: \"My Organization Name!\" -> \"my-organization-name\"\nfunc Slugify(s string) string {\n\t// Convert to lowercase\n\ts = strings.ToLower(s)\n\n\t// Replace spaces and underscores with hyphens\n\ts = strings.ReplaceAll(s, \" \", \"-\")\n\ts = strings.ReplaceAll(s, \"_\", \"-\")\n\n\t// Remove all non-alphanumeric characters except hyphens\n\treg := regexp.MustCompile(`[^a-z0-9-]+`)\n\ts = reg.ReplaceAllString(s, \"\")\n\n\t// Replace multiple consecutive hyphens with a single hyphen\n\treg = regexp.MustCompile(`-+`)\n\ts = reg.ReplaceAllString(s, \"-\")\n\n\t// Trim hyphens from start and end\n\ts = strings.Trim(s, \"-\")\n\n\treturn s\n}\n"
  },
  {
    "path": "go-b2b-starter/scripts/migrate_down.sh",
    "content": "#!/bin/bash\n\n# Load environment variables from a file if it exists\nENV_FILE=\"app.env\"\nif [ -f \"$ENV_FILE\" ]; then\n    source \"$ENV_FILE\"\nelse\n    echo \"Environment file not found, ensure $ENV_FILE exists or set the variables manually.\"\n    exit 1\nfi\n\n# Define migration paths\nMIGRATION_PATHS=(\n    \"src/pkg/db/postgres/sqlc/migrations\"\n\n)\n\n# Perform migrations\nfor path in \"${MIGRATION_PATHS[@]}\"; do\n    echo \"Migrating up in $path...\"\n    migrate -path $path -database \"postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable\" -verbose down\n    if [ $? -ne 0 ]; then\n        echo \"Migration failed for $path\"\n        exit 1\n    else\n        echo \"Migration completed for $path\"\n    fi\ndone\n"
  },
  {
    "path": "go-b2b-starter/scripts/migrate_up.sh",
    "content": "#!/bin/bash\n\n# Load environment variables from a file if it exists\nENV_FILE=\"app.env\"\nif [ -f \"$ENV_FILE\" ]; then\n    source \"$ENV_FILE\"\nelse\n    echo \"Environment file not found, ensure $ENV_FILE exists or set the variables manually.\"\n    exit 1\nfi\n\n# Define migration paths\nMIGRATION_PATHS=(\n    \"src/pkg/db/postgres/sqlc/migrations\"\n)\n\n# Perform migrations\nfor path in \"${MIGRATION_PATHS[@]}\"; do\n    echo \"Migrating up in $path...\"\n    migrate -path $path -database \"postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable\" -verbose up\n    if [ $? -ne 0 ]; then\n        echo \"Migration failed for $path\"\n        exit 1\n    else\n        echo \"Migration completed for $path\"\n    fi\ndone\n"
  },
  {
    "path": "go-b2b-starter/scripts/run_tests_with_coverage.sh",
    "content": "#!/bin/bash\n\n# File: scripts/run_tests_with_coverage.sh\n\necho \"Running tests with coverage for all modules...\"\n\n# Create coverage directory\nmkdir -p coverage\nrm -f coverage/coverage.txt\n\n# Find all go.mod files and run tests\nfind ./src -name go.mod | while read -r mod_file; do\n    mod_dir=$(dirname \"$mod_file\")\n    mod_name=$(basename \"$mod_dir\")\n    \n    echo \"Testing module: $mod_name\"\n    \n    (\n        cd \"$mod_dir\"\n        if go test -v -coverprofile=coverage.out ./...; then\n            if [ -s coverage.out ]; then\n                echo \"mode: atomic\" > \"../../coverage/coverage.$mod_name.txt\"\n                tail -n +2 coverage.out >> \"../../coverage/coverage.$mod_name.txt\"\n            else\n                echo \"No coverage data generated for $mod_name\"\n            fi\n        else\n            echo \"Tests failed for $mod_name\"\n        fi\n        rm -f coverage.out\n    )\ndone\n\n# Combine all coverage files\necho \"mode: atomic\" > coverage/coverage.txt\nfind coverage -name 'coverage.*.txt' -print0 | xargs -0 tail -q -n +2 >> coverage/coverage.txt\n\n# Remove any non-coverage lines (like file headers)\nsed -i '/^[^[:space:]]*:/!d' coverage/coverage.txt\n\n# Generate coverage reports\nif [ -s coverage/coverage.txt ]; then\n    go tool cover -func=coverage/coverage.txt\n    go tool cover -html=coverage/coverage.txt -o coverage/coverage.html\n    echo \"Coverage report generated in coverage/coverage.html\"\nelse\n    echo \"No coverage data generated\"\nfi"
  },
  {
    "path": "next_b2b_starter/.claude/CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\n**B2B SaaS Starter** - A production-ready Next.js 16 B2B SaaS application with Stytch B2B authentication, Polar.sh billing, and comprehensive RBAC system. Built for scalability, security, and developer experience.\n\n## Development Commands\n\n- **Development**: `pnpm dev` (uses Turbopack)\n- **Build**: `pnpm build`\n- **Production**: `pnpm start`\n- **Lint**: `pnpm lint`\n- **Package Manager**: `pnpm` only\n\n## Tech Stack & Architecture\n\n- **Framework**: Next.js 16.0.10 + App Router\n- **Language**: TypeScript (strict mode)\n- **Styling**: Tailwind CSS\n- **Components**: shadcn/ui for consistent, accessible UI components\n- **State Management**: TanStack Query (React Query) for server state, Zustand for client state\n- **Authentication**: Stytch B2B with magic links, session management, and RBAC\n- **Billing**: Polar.sh for subscriptions, usage metering, and webhooks\n- **Data Fetching**: Server Actions + TanStack Query + Repository pattern\n- **Philosophy**: Production-ready, secure, maintainable, performant\n\n## Key Principles\n\n- **Security First**: Authentication guards, permission checks, subscription validation on every sensitive operation\n- **Modern Architecture**: Server Actions for mutations, React Query for caching, Next.js 16 App Router for routing\n- **Type Safety**: Strict TypeScript throughout the application with comprehensive type definitions\n- **Performance**: Optimized bundle size, fast load times, efficient caching strategies\n- **Maintainability**: Clear separation of concerns, consistent patterns, comprehensive documentation\n\n## Project Structure\n\n```\napp/\n├── layout.tsx                    # Root layout with providers\n├── page.tsx                      # Landing page\n├── auth/                         # Authentication pages\n├── authenticate/                 # Magic link callback\n├── signup/                       # Organization signup\n├── dashboard/                    # Protected dashboard routes\n│   ├── page.tsx                 # Dashboard home (redirects to settings)\n│   ├── settings/                # Settings page with tabs\n│   └── knowledge/               # Knowledge/chat feature\n└── api/                         # API routes (minimal - 2 routes)\n    ├── auth/session/refresh/    # JWT refresh endpoint\n    └── billing/webhook/         # Polar webhook receiver\n\ncomponents/\n├── ui/                          # shadcn/ui components\n├── layout/                      # Layout components (header, sidebar, user menu)\n├── billing/                     # Billing components (plans modal, subscription status)\n├── members/                     # Member management components\n└── cognitive/                   # AI chat components\n\nlib/\n├── actions/                     # Server Actions\n│   ├── auth/                   # Auth actions (send magic link, consume, logout)\n│   └── billing/                # Billing actions (checkout, cancel, verify payment)\n├── api/                        # API client and repositories\n│   └── api/\n│       ├── client/             # API client with auth, retry, error handling\n│       └── repositories/       # Repository pattern for Go backend\n├── auth/                       # Authentication utilities\n│   ├── stytch/                # Stytch B2B client setup\n│   ├── constants.ts           # Cookie names, routes\n│   ├── server-permissions.ts  # Permission checking\n│   └── token-utils.ts         # JWT utilities\n├── polar/                      # Polar billing integration\n│   ├── client.ts              # Polar SDK client\n│   ├── subscription.ts        # Subscription fetching\n│   ├── current-subscription.ts # Subscription state resolution\n│   ├── plans.ts               # Plan definitions\n│   └── usage.ts               # Usage metering\n├── contexts/                   # React contexts\n│   └── auth-context.tsx       # Auth state management\n├── hooks/                      # Custom hooks\n│   ├── queries/               # TanStack Query hooks\n│   └── mutations/             # TanStack Mutation hooks\n├── models/                     # TypeScript type definitions\n├── providers/                  # Provider components\n├── stores/                     # Zustand stores\n└── utils/                      # Utility functions\n    └── server-action-helpers.ts # ActionResult type and helpers\n\ndocs/                           # Comprehensive documentation\n├── 01-getting-started.md\n├── 02-authentication.md\n├── 03-permissions-and-roles.md\n├── 04-payments-and-billing.md\n├── 05-making-api-requests.md\n├── 06-creating-pages.md\n├── 07-creating-apis.md\n├── 08-using-hooks.md\n├── 09-adding-a-feature.md\n├── 10-server-actions.md\n├── 11-feature-guards.md\n├── 12-subscription-patterns.md\n└── API-LOGGING.md\n```\n\n## Documentation\n\nComprehensive guides in `docs/`:\n- **01-10**: Core guides (getting started, auth, permissions, billing, APIs, hooks, etc.)\n- **11-feature-guards.md**: Protecting features with auth, permission, and subscription guards\n- **12-subscription-patterns.md**: Managing subscriptions, checkout, and billing with Polar.sh\n\n## Current State\n\n- **Production-ready** authentication with Stytch B2B (magic links, sessions, RBAC)\n- **Subscription billing** with Polar.sh (checkout, webhooks, usage metering)\n- **Server Actions** for secure mutations with auth/permission/subscription guards\n- **TanStack Query** for optimized data fetching and caching\n- **Repository pattern** for Go backend integration via API client\n- **Comprehensive documentation** for all major features\n\n## Development Guidelines\n\n### Components\n- Use shadcn/ui for UI components\n- Create custom components in appropriate directories (e.g., `components/billing/`, `components/members/`)\n- Always add proper TypeScript types\n\n### State Management\n- **Server State**: TanStack Query (React Query) for API data\n- **Client State**: Zustand for global UI state, React built-ins for local component state\n- **Auth State**: AuthContext with sessionStorage persistence\n\n### Data Fetching\n- **Queries**: Use TanStack Query hooks (e.g., `useProfileQuery()`, `useSubscriptionQuery()`)\n- **Mutations**: Use TanStack Mutation hooks (e.g., `useInviteMember()`, `useUpdateProfile()`)\n- **Server Actions**: For operations that need server-side logic (auth, billing, etc.)\n\n### Authentication & Authorization\n- **Server Components**: Use `getMemberSession()` and `getServerPermissions()`\n- **Client Components**: Use `useAuth()` and `usePermissions()` hooks\n- **Server Actions**: Always check auth, permissions, and subscription status\n\n### Styling\n- Tailwind CSS for all styling\n- Follow design system patterns from shadcn/ui\n- Use utility classes, avoid custom CSS\n\n### Type Safety\n- Maintain strict TypeScript throughout\n- Define types in `lib/models/` directory\n- Use `ActionResult<T>` type for Server Action return values\n\n## Before Adding Any Package\n\n1. Ask: \"Does this solve a real problem better than existing solutions?\"\n2. Check: \"Is this package well-maintained and widely adopted?\"\n3. Verify: \"Does this align with our tech stack and architecture?\"\n4. Consider: \"What's the bundle size impact and maintenance overhead?\"\n\n## Key Files\n\n- **docs/** - Comprehensive feature documentation\n- **STYTCH_CONFIGURATION.md** - Stytch B2B setup and configuration\n- **package.json** - Project dependencies and scripts\n- **tailwind.config.ts** - Tailwind configuration with design tokens\n- **components.json** - shadcn/ui configuration\n- **lib/utils/server-action-helpers.ts** - Server Action utilities\n- **lib/auth/server-permissions.ts** - Permission system\n- **lib/polar/current-subscription.ts** - Subscription state resolution\n\n## Architecture Notes\n\n### Authentication Flow\n1. User enters email → `sendMagicLink()` Server Action\n2. Stytch sends magic link email\n3. User clicks link → `/authenticate` page\n4. `consumeMagicLink()` Server Action validates token\n5. Session cookies set (httpOnly, secure)\n6. User redirected to dashboard\n\n### Permission System\n- Roles: `owner`, `admin`, `member`, `approver`\n- Permissions: `org:view`, `org:manage`, `resource:view`, `resource:create`, `resource:edit`, `resource:delete`\n- Check via `getServerPermissions()` server-side or `usePermissions()` client-side\n\n### Subscription Guards\n- Check subscription status with `resolveCurrentSubscription()` or `useSubscriptionQuery()`\n- Gate premium features behind active subscription checks\n- Handle subscription states: active, inactive, no customer, authentication required\n\n### Server Actions Pattern\n```typescript\n'use server';\n\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { createActionError, createActionSuccess } from '@/lib/utils/server-action-helpers';\n\nexport async function myAction() {\n  // 1. Auth check\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    return createActionError('Authentication required.');\n  }\n\n  // 2. Permission check\n  const permissions = await getServerPermissions(session);\n  if (!permissions.canDoSomething) {\n    return createActionError('Insufficient permissions.');\n  }\n\n  // 3. Business logic\n  // ...\n\n  return createActionSuccess(data);\n}\n```\n\nThe goal is to build production-ready B2B SaaS applications with enterprise-grade authentication, billing, and permission systems using modern tools and patterns.\n"
  },
  {
    "path": "next_b2b_starter/.dockerignore",
    "content": "# =============================================================================\n# Frontend - Docker Ignore Configuration\n# =============================================================================\n# Excludes unnecessary files from Docker build context\n# Reduces build time, image size, and improves security\n# =============================================================================\n\n# Dependencies\nnode_modules\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n# Next.js build output\n.next/\nout/\nbuild/\ndist/\n\n# Testing\ncoverage/\n.nyc_output/\n*.test.js\n*.test.ts\n*.test.tsx\n*.spec.js\n*.spec.ts\n*.spec.tsx\n__tests__/\n__mocks__/\n\n# Environment files (security)\n.env\n.env*.local\n.env.development\n.env.test\n.env.production.local\n.env.development.local\n\n# Git\n.git/\n.gitignore\n.gitattributes\n\n# IDE and Editor files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n.DS_Store\n\n# Documentation and non-essential files\n*.md\n!README.md\ndocs/\n.github/\n\n# Deployment files (keep only what's needed)\ndeployment/\n# Note: deployments/ folder is kept for .env templates\n\n# Logs\nlogs/\n*.log\n\n# Misc\n.cache/\n.temp/\n.tmp/\n*.pid\n*.seed\n*.pid.lock\n\n# TypeScript\n*.tsbuildinfo\n\n# Linting\n.eslintcache\n\n# Testing\n.jest/\nplaywright-report/\ntest-results/\n\n# Scripts (if not needed in production)\nscripts/\n\n# Storybook\n.storybook/\nstorybook-static/\n\n# Turbo\n.turbo/\n"
  },
  {
    "path": "next_b2b_starter/.env.example",
    "content": "# Database Configuration\nPOSTGRES_URL=\nPOSTGRES_PRISMA_URL=\nPOSTGRES_URL_NON_POOLING=\nPOSTGRES_USER=\nPOSTGRES_HOST=\nPOSTGRES_PASSWORD=\nPOSTGRES_DATABASE=\n\n# `openssl rand -base64 32`\n# Application base URL for redirects\nAPP_BASE_URL=http://localhost:3000\nNEXT_PUBLIC_APP_BASE_URL=http://localhost:3000\nNOTIFICATION_EMAIL=\nNEXT_PUBLIC_CONTACT_EMAIL=\n\n# Stytch B2B Authentication\nSTYTCH_PROJECT_ID=\nSTYTCH_SECRET=\nSTYTCH_PROJECT_ENV=test\nNEXT_PUBLIC_STYTCH_PROJECT_ENV=test\nNEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=\nNEXT_PUBLIC_STYTCH_LOGIN_PATH=/auth\nNEXT_PUBLIC_STYTCH_REDIRECT_PATH=/authenticate\nNEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES=43200\nSTYTCH_ALLOWED_ORGANIZATION_IDS=\n\n# Polar Billing (sandbox defaults)\nPOLAR_ACCESS_TOKEN=\nPOLAR_WEBHOOK_SECRET=\nNEXT_PUBLIC_POLAR_PRODUCT_ID=523c6265-6eed-4ec0-b202-32e03ddd9a67\nNEXT_PUBLIC_POLAR_BUSINESS_PRODUCT_ID=523c6265-6eed-4ec0-b202-32e03ddd9a67\nNEXT_PUBLIC_POLAR_SCALE_PRODUCT_ID=\nNEXT_PUBLIC_POLAR_METER_ID=74f6f057-f061-4d20-8dc0-43ff9c8704af\n\n# Umami Analytics\nNEXT_PUBLIC_UMAMI_WEBSITE_ID=de101dd2-de68-4c6a-9b6a-7358e59a8cb0\nNEXT_PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js\n\n# SMTP Configuration (Email Sending)\n# For Gmail: smtp.gmail.com, for Resend: smtp.resend.com, for SendGrid: smtp.sendgrid.net\nSMTP_HOST=\nSMTP_PORT=587\nSMTP_SECURE=true\nSMTP_USER=\nSMTP_PASS=\nSMTP_FROM=\n"
  },
  {
    "path": "next_b2b_starter/.eslintrc.json",
    "content": "{\n  \"extends\": [\"next/core-web-vitals\"]\n}"
  },
  {
    "path": "next_b2b_starter/.npmrc",
    "content": "# pnpm configuration\n# Allow trusted packages to run build scripts (required for sharp, Next.js image optimization, etc.)\nenable-pre-post-scripts=true\n\n# Auto-install peer dependencies\nauto-install-peers=true\nstrict-peer-dependencies=false\n"
  },
  {
    "path": "next_b2b_starter/Dockerfile",
    "content": "# =============================================================================\n# Frontend - Production Dockerfile\n# =============================================================================\n# Multi-stage build for optimized Next.js 15 deployment with SSR support\n# Final image size: ~120-150MB (vs ~500MB+ without optimization)\n# Node.js 22 LTS (Jod) with Alpine Linux 3.22\n# =============================================================================\n\n# -----------------------------------------------------------------------------\n# Stage 1: Dependencies Installation\n# -----------------------------------------------------------------------------\nFROM node:22-alpine3.22 AS deps\n\n# Install necessary system dependencies\nRUN apk add --no-cache libc6-compat\n\nWORKDIR /app\n\n# Install pnpm globally\nRUN corepack enable && corepack prepare pnpm@latest --activate\n\n# Copy package management files\nCOPY package.json pnpm-lock.yaml .npmrc* ./\n\n# Install dependencies using pnpm\n# .npmrc ensures sharp is built correctly for image optimization\nRUN pnpm install --frozen-lockfile --prod=false\n\n# -----------------------------------------------------------------------------\n# Stage 2: Application Builder\n# -----------------------------------------------------------------------------\nFROM node:22-alpine3.22 AS builder\n\nWORKDIR /app\n\n# Install pnpm\nRUN corepack enable && corepack prepare pnpm@latest --activate\n\n# Copy dependencies from deps stage\nCOPY --from=deps /app/node_modules ./node_modules\n\n# Copy application source\nCOPY . .\n\n# Build arguments for NEXT_PUBLIC_* environment variables\n# These must be set at build time for client-side code\nARG NEXT_PUBLIC_APP_BASE_URL\nARG NEXT_PUBLIC_API_BASE_URL\nARG NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN\nARG NEXT_PUBLIC_STYTCH_PROJECT_ID\nARG NEXT_PUBLIC_STYTCH_PROJECT_ENV\nARG NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN\nARG NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN\nARG NEXT_PUBLIC_UMAMI_WEBSITE_ID\nARG NEXT_PUBLIC_UMAMI_SCRIPT_URL\n\n# Set build-time environment variables\nENV NEXT_PUBLIC_APP_BASE_URL=$NEXT_PUBLIC_APP_BASE_URL\nENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL\nENV NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=$NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN\nENV NEXT_PUBLIC_STYTCH_PROJECT_ID=$NEXT_PUBLIC_STYTCH_PROJECT_ID\nENV NEXT_PUBLIC_STYTCH_PROJECT_ENV=$NEXT_PUBLIC_STYTCH_PROJECT_ENV\nENV NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN=$NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN\nENV NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN=$NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN\nENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID\nENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL\n\n# Disable Next.js telemetry\nENV NEXT_TELEMETRY_DISABLED=1\n\n# Build the application\n# next.config.ts already has output: \"standalone\" configured\nRUN pnpm build\n\n# -----------------------------------------------------------------------------\n# Stage 3: Production Runtime\n# -----------------------------------------------------------------------------\nFROM node:22-alpine3.22 AS runner\n\nWORKDIR /app\n\n# Set production environment\nENV NODE_ENV=production\nENV NEXT_TELEMETRY_DISABLED=1\n\n# Create non-root user for security\nRUN addgroup --system --gid 1001 nodejs && \\\n    adduser --system --uid 1001 nextjs\n\n# Copy public assets\nCOPY --from=builder /app/public ./public\n\n# Copy standalone build output (optimized by Next.js)\n# This includes only necessary dependencies and files\nCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./\nCOPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static\n\n# Switch to non-root user\nUSER nextjs\n\n# Expose application port\nEXPOSE 3000\n\n# Set runtime environment variables\nENV PORT=3000\nENV HOSTNAME=\"0.0.0.0\"\n\n# Health check for container orchestration\nHEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \\\n  CMD node -e \"require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\"\n\n# Start the application\n# Use node directly (not npm/pnpm) for proper signal handling and graceful shutdown\nCMD [\"node\", \"server.js\"]\n"
  },
  {
    "path": "next_b2b_starter/README.md",
    "content": "# B2B SaaS Starter\n \n A modern B2B SaaS starter kit with Authentication, Billing, and RBAC.\n \n ## 🚀 Quick Start\n \n ```bash\n ./setup.sh\n cd next_b2b_starter\n pnpm dev\n ```\n \n Visit `http://localhost:3000`.\n \n ## 📚 Documentation\n \n - **[Getting Started](./docs/01-getting-started.md)** - Setup and installation\n - **[Authentication](./docs/02-authentication.md)** - How auth works\n - **[Permissions](./docs/03-permissions-and-roles.md)** - RBAC system\n - **[Payments](./docs/04-payments-and-billing.md)** - Subscriptions\n - **[Full Documentation](./docs/README.md)** - Complete guide index\n \n ## 🔧 Advanced\n \n - **[Stytch Configuration](./STYTCH_CONFIGURATION.md)** - Advanced auth security settings\n - **[Claude Guide](./CLAUDE.md)** - Development rules\n \n ## Features\n \n - **Stack**: Next.js 16, TypeScript, Tailwind, shadcn/ui\n - **Auth**: Stytch B2B (passwordless)\n - **Billing**: Polar.sh integration\n - **State**: React Query + Zustand\n \n ## Project Structure\n \n - `app/`: Next.js App Router\n - `components/`: UI Components\n - `lib/`: Business logic, hooks, API clients\n - `middleware.ts`: Auth protection\n \n ## Support\n \n Open an issue on GitHub for support. License: MIT.\n"
  },
  {
    "path": "next_b2b_starter/STYTCH_CONFIGURATION.md",
    "content": "# Stytch Configuration Guide\n\nThis document explains how to configure Stytch B2B to prevent unknown users from receiving magic link emails and creating accounts.\n\n## Overview\n\nWe've implemented a custom solution to address two critical security requirements:\n\n1. **Prevent emails being sent to non-existent users**\n2. **Block unknown email addresses from creating accounts**\n\n## How It Works\n\n### Custom Backend Validation\n\nInstead of using Stytch's UI component directly (which always sends emails), we've created a custom flow:\n\n1. **Frontend**: Custom email form in `app/auth/page.tsx`\n2. **Backend API**: `/api/auth/magic-link` validates membership before sending\n3. **Stytch API**: Only called if user is an existing member\n\n### Security Features\n\n✅ **Email validation**: Backend checks if email exists in any organization before sending magic link\n✅ **No user enumeration**: Returns same message for existing and non-existing users\n✅ **JIT provisioning blocked**: Organization settings prevent auto-creation of new members\n✅ **Discovery flow restricted**: Users can only join organizations they're invited to\n\n## Required Stytch Dashboard Configuration\n\n### Step 1: Disable Self-Service Organization Creation\n\n1. Log into your [Stytch Dashboard](https://stytch.com/dashboard)\n2. Navigate to **Frontend SDK** settings\n3. Find **\"Create Organizations\"** toggle under **Enabled methods**\n4. **Disable** this toggle\n\n**Result**: Users cannot create new organizations via the discovery flow\n\n### Step 2: Configure Organization Settings (Per Organization)\n\nFor each organization in your Stytch project:\n\n1. Navigate to **Organizations** in the dashboard\n2. Select your organization\n3. Go to **Settings** → **Authentication**\n4. Configure the following:\n\n```json\n{\n  \"email_jit_provisioning\": \"NOT_ALLOWED\",\n  \"email_invites\": \"RESTRICTED\",\n  \"email_allowed_domains\": [\"your-company.com\"]  // Optional: restrict by domain\n}\n```\n\n**What each setting does:**\n\n- `email_jit_provisioning: \"NOT_ALLOWED\"` - Prevents new members from being auto-created via magic link\n- `email_invites: \"RESTRICTED\"` - Requires explicit invitation to join\n- `email_allowed_domains` - (Optional) Only allows specific email domains\n\n### Step 2a: (Optional) Configure Allowed Organization IDs\n\nThe backend validates emails by searching members across a specific list of organizations. To avoid an extra API call during login, you can provide a comma-separated allowlist:\n\n```bash\nSTYTCH_ALLOWED_ORGANIZATION_IDS=org-test-123,org-test-456\n```\n\nIf this variable is not set, we automatically fetch all organizations in the workspace and cache the IDs in memory.\n\n### Step 3: Verify API Permissions\n\nEnsure your Stytch API credentials have permission to:\n- Search members (`organizations.members.search`)\n- Send magic links (`magicLinks.email.discovery.send`)\n\n## Testing the Implementation\n\n### Test Case 1: Unknown Email\n\n1. Enter an email that doesn't exist in any organization\n2. Click \"Send magic link\"\n3. **Expected**: Message says \"If an account exists with that email, a magic link has been sent.\"\n4. **Verify**: No email is actually sent\n5. **Check backend logs**: Should see \"No members found\" for the email\n\n### Test Case 2: Existing Member\n\n1. Enter an email of an existing organization member\n2. Click \"Send magic link\"\n3. **Expected**: Same message as above\n4. **Verify**: Email IS sent with magic link\n5. **Check inbox**: Magic link email received\n\n### Test Case 3: Magic Link Authentication\n\n1. Click the magic link from Test Case 2\n2. **Expected**: User is authenticated and redirected to dashboard\n3. **Verify**: Session is created with correct organization\n\n### Test Case 4: Unknown User Clicks Link (if they somehow got one)\n\n1. If someone gets a magic link URL (e.g., from a legitimate user)\n2. **Expected**: Authentication fails with error\n3. **Verify**: No session is created, user cannot access dashboard\n\n## API Endpoint Documentation\n\n### POST `/api/auth/magic-link`\n\nValidates email and sends magic link to existing members only.\n\n**Request:**\n```json\n{\n  \"email\": \"user@company.com\"\n}\n```\n\n**Response (Success):**\n```json\n{\n  \"success\": true,\n  \"message\": \"If an account exists with that email, a magic link has been sent.\"\n}\n```\n\n**Response (Error):**\n```json\n{\n  \"error\": \"Unable to process request. Please try again later.\"\n}\n```\n\n**Note**: Response is the same whether user exists or not (prevents enumeration)\n\n## How to Add New Members\n\nSince self-service signup is disabled, use one of these methods:\n\n### Method 1: Invite via Stytch Dashboard\n\n1. Go to **Organizations** → Select org → **Members**\n2. Click **Invite Member**\n3. Enter email and assign roles\n4. User receives invitation email\n\n### Method 2: Programmatic Invite\n\n```typescript\nimport { getStytchB2BClient } from \"@/lib/auth/stytch/server\";\n\nconst client = getStytchB2BClient();\n\nawait client.magicLinks.email.invite.send({\n  organization_id: \"org-test-...\",\n  email_address: \"newuser@company.com\",\n  invited_by_member_id: \"member-test-...\",\n});\n```\n\n### Method 3: Create Member via API\n\n```typescript\nawait client.organizations.members.create({\n  organization_id: \"org-test-...\",\n  email_address: \"newuser@company.com\",\n  name: \"New User\",\n  roles: [\"member\"],\n});\n```\n\n## Troubleshooting\n\n### Issue: Existing users not receiving emails\n\n**Check:**\n1. Email is verified in Stytch\n2. Member status is \"active\" (not \"pending\" or \"invited\")\n3. Backend logs for member search results\n4. Stytch API credentials are correct\n\n### Issue: Unknown users still getting emails\n\n**Check:**\n1. Using `/api/auth/magic-link` endpoint (not direct Stytch SDK call)\n2. Backend search is working correctly\n3. No caching issues in API route\n\n### Issue: Users can't create organizations\n\n**This is expected!** Self-service organization creation is disabled.\n\n**Solution:** Create organizations manually via:\n- Stytch Dashboard\n- Stytch API programmatically\n\n## Environment Variables\n\nRequired in `.env.local`:\n\n```bash\n# Stytch B2B Authentication\nSTYTCH_PROJECT_ID=project-test-...\nSTYTCH_SECRET=secret-test-...\nNEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=public-token-test-...\n\n# Session configuration\nNEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES=43200  # 30 days\n\n# App URLs\nNEXT_PUBLIC_APP_BASE_URL=http://localhost:3000\nNEXT_PUBLIC_STYTCH_REDIRECT_PATH=/authenticate\n```\n\n## Additional Security Recommendations\n\n1. **Enable MFA**: Require multi-factor authentication for sensitive organizations\n2. **Monitor failed attempts**: Track authentication failures in your logs\n3. **Rate limiting**: Add rate limiting to `/api/auth/magic-link` endpoint\n4. **Email verification**: Ensure all members have verified emails\n5. **Session duration**: Keep session duration appropriate for your security requirements\n\n## Migration from Discovery Flow\n\nIf you were previously using the Discovery flow with self-service signup:\n\n1. **Export existing members**: Get list of all current members\n2. **Notify users**: Inform them that signup is now invite-only\n3. **Update documentation**: Update user docs about the new auth flow\n4. **Monitor support requests**: Users may try to sign up and fail\n\n## Questions?\n\nFor Stytch-specific configuration questions:\n- [Stytch B2B Documentation](https://stytch.com/docs/b2b)\n- [Stytch Support](https://stytch.com/contact)\n\nFor implementation questions related to this codebase:\n- Review `app/api/auth/magic-link/route.ts` for backend logic\n- Review `app/auth/page.tsx` for frontend implementation\n"
  },
  {
    "path": "next_b2b_starter/app/api/auth/session/refresh/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { cookies } from \"next/headers\";\n\nimport { getStytchB2BClient } from \"@/lib/auth/stytch/server\";\nimport {\n  SESSION_COOKIE_NAME,\n  SESSION_JWT_COOKIE_NAME,\n} from \"@/lib/auth/constants\";\nimport {\n  getSessionDurationMinutes,\n  getCookieConfig,\n} from \"@/lib/auth/server-constants\";\nimport { isTokenExpired } from \"@/lib/auth/token-utils\";\n\nexport async function POST() {\n  try {\n    const cookieStore = await cookies();\n\n    // First, check if we already have a valid JWT\n    const existingJwt = cookieStore.get(SESSION_JWT_COOKIE_NAME)?.value ?? null;\n    if (existingJwt && !isTokenExpired(existingJwt)) {\n      return NextResponse.json({ sessionJwt: existingJwt });\n    }\n\n    // Try to get session token to exchange for JWT\n    const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;\n\n    if (!sessionToken) {\n      return NextResponse.json(\n        { sessionJwt: null, error: \"session_not_found\" },\n        { status: 401 }\n      );\n    }\n\n    const client = getStytchB2BClient();\n\n    try {\n      const response = await client.sessions.authenticate({\n        session_token: sessionToken,\n        session_duration_minutes: getSessionDurationMinutes(),\n      });\n\n      const sessionJwt = (response as any)?.session_jwt ?? null;\n\n      if (!sessionJwt) {\n        return NextResponse.json(\n          { sessionJwt: null, error: \"session_missing_jwt\" },\n          { status: 401 }\n        );\n      }\n\n      // Validate the new JWT before returning it\n      if (isTokenExpired(sessionJwt)) {\n        return NextResponse.json(\n          { sessionJwt: null, error: \"session_jwt_expired\" },\n          { status: 401 }\n        );\n      }\n\n      const res = NextResponse.json({ sessionJwt });\n      const maxAgeSeconds = getSessionDurationMinutes() * 60;\n\n      res.cookies.set(SESSION_JWT_COOKIE_NAME, sessionJwt, {\n        ...getCookieConfig(),\n        maxAge: maxAgeSeconds,\n      });\n\n      return res;\n    } catch {\n      // Clear invalid session cookies\n      const response = NextResponse.json(\n        {\n          sessionJwt: null,\n          error: \"session_invalid\"\n        },\n        { status: 401 }\n      );\n\n      // Clear the invalid cookies\n      response.cookies.delete(SESSION_COOKIE_NAME);\n      response.cookies.delete(SESSION_JWT_COOKIE_NAME);\n\n      return response;\n    }\n  } catch {\n    return NextResponse.json(\n      { sessionJwt: null, error: \"refresh_failed\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/app/api/billing/webhook/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { Webhooks } from \"@polar-sh/nextjs\";\n\nconst webhookSecret = process.env.POLAR_WEBHOOK_SECRET;\n\nasync function handleSubscriptionEvent(_eventType: string, _payload: unknown) {\n  // TODO: Forward webhook events to Go backend for persistence.\n  // Options:\n  // 1. Call backend API: POST /api/webhooks/polar with { eventType, payload }\n  // 2. Configure Polar.sh to send webhooks directly to Go backend\n  //\n  // Backend already has ProcessWebhookEvent service ready in:\n  // src/app/billing/app/services/process_webhook_event_service.go\n}\n\nexport const POST = webhookSecret\n  ? Webhooks({\n      webhookSecret,\n      onSubscriptionCreated: async (subscription) => {\n        await handleSubscriptionEvent(\"subscription.created\", subscription);\n      },\n      onSubscriptionUpdated: async (subscription) => {\n        await handleSubscriptionEvent(\"subscription.updated\", subscription);\n      },\n      onSubscriptionCanceled: async (subscription) => {\n        await handleSubscriptionEvent(\"subscription.canceled\", subscription);\n      },\n      onOrderPaid: async (order) => {\n        await handleSubscriptionEvent(\"order.paid\", order);\n      },\n    })\n  : async () =>\n      NextResponse.json(\n        { error: \"Polar webhook secret not configured.\" },\n        { status: 503 }\n      );\n"
  },
  {
    "path": "next_b2b_starter/app/auth/page.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport Link from \"next/link\";\nimport { useStytchMember } from \"@stytch/nextjs/b2b\";\nimport { ArrowRight, CheckCircle2, Home, Inbox, Mail } from \"lucide-react\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { sendMagicLink } from \"@/lib/actions/auth/send-magic-link\";\n\n\nconst highlights = [\n  \"Single workspace to review invoices, approvals, and exports.\",\n  \"Ready-made controls that plug into your existing banking stack.\",\n  \"Sessions scoped to your organization with role-aware access.\",\n];\n\nconst emailProviders = [\n  {\n    label: \"Open Gmail\",\n    href: \"https://mail.google.com/\",\n  },\n  {\n    label: \"Open Outlook\",\n    href: \"https://outlook.office.com/mail/\",\n  },\n  {\n    label: \"Open iCloud Mail\",\n    href: \"https://www.icloud.com/mail\",\n  },\n  {\n    label: \"Open Yahoo Mail\",\n    href: \"https://mail.yahoo.com/\",\n  },\n];\n\nexport default function AuthPage() {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const { member, isInitialized } = useStytchMember();\n  const [email, setEmail] = useState(\"\");\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [status, setStatus] = useState<{\n    type: \"info\" | \"error\" | \"success\";\n    message: string;\n  } | null>(null);\n  const [isRedirecting, setIsRedirecting] = useState(false);\n  const [view, setView] = useState<\"form\" | \"success\">(\"form\");\n  const [lastSubmittedEmail, setLastSubmittedEmail] = useState(\"\");\n  const hasRedirectedRef = useRef(false);\n  const redirectTimeoutRef = useRef<number | null>(null);\n\n  const targetAfterLogin = useMemo(() => {\n    const returnTo = searchParams.get(\"returnTo\") || \"/dashboard\";\n    // Validate returnTo is a safe relative path:\n    // - Must start with single /\n    // - Cannot be protocol-relative (//), contain backslash (\\), or have : before first /\n    const isSafeRelativePath =\n      returnTo.startsWith(\"/\") &&\n      !returnTo.startsWith(\"//\") &&\n      !returnTo.includes(\"\\\\\") &&\n      !returnTo.slice(1).includes(\":\");\n    return isSafeRelativePath ? returnTo : \"/dashboard\";\n  }, [searchParams]);\n\n  const handleAuthSuccess = useCallback(() => {\n    if (hasRedirectedRef.current) return;\n    hasRedirectedRef.current = true;\n    setIsRedirecting(true);\n    setStatus({\n      type: \"info\",\n      message: \"You’re signed in. Redirecting to your workspace…\",\n    });\n    router.replace(targetAfterLogin);\n    setTimeout(() => {\n      router.refresh();\n    }, 150);\n    if (typeof window !== \"undefined\") {\n      if (redirectTimeoutRef.current !== null) {\n        window.clearTimeout(redirectTimeoutRef.current);\n      }\n      redirectTimeoutRef.current = window.setTimeout(() => {\n        window.location.assign(targetAfterLogin);\n      }, 1500);\n    }\n  }, [router, targetAfterLogin]);\n\n  useEffect(() => {\n    if (!isInitialized) return;\n    if (member) {\n      handleAuthSuccess();\n    }\n  }, [isInitialized, member, handleAuthSuccess]);\n\n  useEffect(() => {\n    const hasMagicLinkParams =\n      searchParams.has(\"stytch_token\") ||\n      searchParams.has(\"token\") ||\n      searchParams.has(\"stytch_token_type\");\n\n    if (hasMagicLinkParams) {\n      setStatus({\n        type: \"info\",\n        message: \"We’re verifying your sign-in link. This usually takes just a moment.\",\n      });\n    }\n  }, [searchParams]);\n\n  const submitEmail = useCallback(\n    async (\n      rawEmail: string,\n      options: { resetField?: boolean; stayOnSuccessView?: boolean } = {}\n    ) => {\n      const { resetField = true, stayOnSuccessView = false } = options;\n      const trimmedEmail = rawEmail.trim().toLowerCase();\n\n      if (!trimmedEmail) {\n        setStatus({\n          type: \"error\",\n          message: \"Please enter a valid email address.\",\n        });\n        return;\n      }\n\n      setIsSubmitting(true);\n      setStatus({\n        type: \"info\",\n        message: \"Checking your workspace access…\",\n      });\n      if (!stayOnSuccessView) {\n        setView(\"form\");\n      }\n\n      try {\n        const query = new URLSearchParams({ email: trimmedEmail }).toString();\n        const apiBaseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || \"http://localhost:8080/api\").replace(/\\/$/, \"\");\n\n        const emailCheckResponse = await fetch(`${apiBaseUrl}/auth/check-email?${query}`);\n\n        if (emailCheckResponse.status === 404) {\n          const errorBody = await emailCheckResponse.json().catch(() => null);\n          setStatus({\n            type: \"error\",\n            message:\n              (errorBody && errorBody.message) ||\n              \"We couldn't find an account with that email. Try a different email or ask your admin to invite you.\",\n          });\n          return;\n        }\n\n        if (!emailCheckResponse.ok) {\n          const errorBody = await emailCheckResponse.json().catch(() => null);\n          throw new Error(\n            (errorBody && errorBody.message) ||\n              \"We couldn't verify that email right now. Please try again in a moment.\",\n          );\n        }\n\n        setStatus({\n          type: \"info\",\n          message: \"Sending your secure sign-in link…\",\n        });\n\n        // Call Server Action instead of API route\n        const result = await sendMagicLink(trimmedEmail);\n\n        if (!result.success) {\n          throw new Error(result.error || \"Failed to send sign-in link.\");\n        }\n\n        setLastSubmittedEmail(trimmedEmail);\n        setStatus({\n          type: \"success\",\n          message: \"Check your email for a secure link to sign in.\",\n        });\n        setView(\"success\");\n\n        if (resetField) {\n          setEmail(\"\");\n        }\n      } catch (error: any) {\n        setStatus({\n          type: \"error\",\n          message:\n            error?.message ||\n            \"Something went wrong while sending your sign-in link. Please try again.\",\n        });\n      } finally {\n        setIsSubmitting(false);\n      }\n    },\n    []\n  );\n\n  const handleSendMagicLink = async (e: React.FormEvent) => {\n    e.preventDefault();\n    await submitEmail(email, { resetField: true });\n  };\n\n  const handleResend = async () => {\n    if (!lastSubmittedEmail) return;\n    await submitEmail(lastSubmittedEmail, {\n      resetField: false,\n      stayOnSuccessView: true,\n    });\n  };\n\n  useEffect(() => {\n    router.prefetch(targetAfterLogin);\n  }, [router, targetAfterLogin]);\n\n  useEffect(() => {\n    return () => {\n      if (typeof window !== \"undefined\" && redirectTimeoutRef.current !== null) {\n        window.clearTimeout(redirectTimeoutRef.current);\n        redirectTimeoutRef.current = null;\n      }\n    };\n  }, []);\n\n  if (!isInitialized) {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center bg-gray-50\">\n        <div className=\"flex flex-col items-center gap-3\">\n          <div className=\"h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-primary-500\" />\n          <p className=\"text-sm text-gray-600\">\n            Checking your workspace session…\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  if (isRedirecting || member) {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center bg-gray-50\">\n        <div className=\"flex flex-col items-center gap-3 rounded-xl border border-gray-200 bg-white px-8 py-10 shadow-lg\">\n          <div className=\"h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-primary-500\" />\n          <div className=\"text-center\">\n            <p className=\"text-sm font-medium text-gray-900\">\n              Redirecting to your dashboard\n            </p>\n            <p className=\"text-sm text-gray-600\">\n              {(status && status.message) ||\n                \"We’re setting up your workspace now.\"}\n            </p>\n            <p className=\"mt-4 text-xs text-gray-500\">\n              Taking longer than expected?{\" \"}\n              <a\n                href={targetAfterLogin}\n                className=\"font-medium text-primary-600 hover:underline\"\n              >\n                Open your workspace\n              </a>\n              .\n            </p>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"min-h-screen bg-gradient-to-b from-gray-50 via-white to-gray-50 py-16\">\n      <div className=\"mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 lg:flex-row lg:items-start\">\n        <section className=\"flex-1 space-y-8\">\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center justify-between\">\n              <Badge\n                variant=\"outline\"\n                className=\"w-fit items-center gap-2 border-primary/30 bg-primary/5 text-xs font-medium text-primary-700\"\n              >\n                <Mail className=\"h-3.5 w-3.5\" aria-hidden />\n                Secure email sign-in\n              </Badge>\n              <Link href=\"/\" className=\"flex items-center gap-2 text-sm text-gray-600 hover:text-primary-600 transition-colors\">\n                <Home className=\"h-4 w-4\" />\n                <span>Home</span>\n              </Link>\n            </div>\n            <div className=\"space-y-4\">\n              <h1 className=\"text-3xl font-semibold text-gray-900 sm:text-4xl\">\n                Welcome back to Your App\n              </h1>\n              <p className=\"max-w-xl text-base text-gray-600\">\n                Use your work email to receive a one-time, organization-aware\n                sign-in link. We’ll land you back where you left off as soon as\n                you’re authenticated.\n              </p>\n            </div>\n          </div>\n          <dl className=\"space-y-3\">\n            {highlights.map((item) => (\n              <div\n                key={item}\n                className=\"flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm\"\n              >\n                <CheckCircle2\n                  className=\"mt-1 h-5 w-5 flex-none text-primary-600\"\n                  aria-hidden\n                />\n                <p className=\"text-sm text-gray-600\">{item}</p>\n              </div>\n            ))}\n          </dl>\n          <div className=\"rounded-xl border border-gray-200 bg-white p-5 shadow-sm\">\n            <h2 className=\"text-sm font-semibold text-gray-900\">\n              Need a hand?\n            </h2>\n            <p className=\"mt-2 text-sm text-gray-600\">\n              Reach out at{\" \"}\n              <a\n                className=\"font-medium text-primary-600 hover:underline\"\n                href=\"mailto:support@yourapp.com\"\n              >\n                support@yourapp.com\n              </a>{\" \"}\n              for support, or check the documentation in your workspace.\n            </p>\n          </div>\n        </section>\n\n        <aside className=\"w-full max-w-md lg:sticky lg:top-24\">\n          <div className=\"rounded-2xl border border-gray-200 bg-white p-8 shadow-lg\">\n            <div className=\"mb-6 space-y-2 text-center\">\n              <h2 className=\"text-2xl font-semibold text-gray-900\">\n                Sign in to Your App\n              </h2>\n              <p className=\"text-sm text-gray-600\">\n                Enter your work email to receive a secure sign-in link. We’ll\n                handle redirects automatically.\n              </p>\n            </div>\n            {view === \"form\" ? (\n              <>\n                {status && status.type !== \"success\" && (\n                  <Alert\n                    variant={status.type === \"error\" ? \"destructive\" : \"default\"}\n                    className=\"mb-6 text-left\"\n                  >\n                    <AlertTitle>\n                      {status.type === \"error\" ? \"We hit a snag\" : \"Hang tight\"}\n                    </AlertTitle>\n                    <AlertDescription>{status.message}</AlertDescription>\n                  </Alert>\n                )}\n                <form onSubmit={handleSendMagicLink} className=\"space-y-4\">\n                  <div className=\"space-y-2 text-left\">\n                    <label htmlFor=\"email\" className=\"text-sm font-medium text-gray-700\">\n                      Work email address\n                    </label>\n                    <Input\n                      id=\"email\"\n                      type=\"email\"\n                      placeholder=\"you@company.com\"\n                      value={email}\n                      onChange={(e) => setEmail(e.target.value)}\n                      disabled={isSubmitting}\n                      required\n                      className=\"w-full\"\n                      autoComplete=\"email\"\n                      inputMode=\"email\"\n                    />\n                  </div>\n                  <Button\n                    type=\"submit\"\n                    disabled={isSubmitting || !email.trim()}\n                    className=\"w-full\"\n                  >\n                    {isSubmitting ? \"Working…\" : \"Email me a sign-in link\"}\n                  </Button>\n                </form>\n                <p className=\"mt-6 text-center text-xs text-gray-400\">\n                  By continuing you agree to the terms of service and\n                  acknowledge the privacy notice.\n                </p>\n                <p className=\"mt-4 text-center text-sm text-gray-600\">\n                  Don't have an account?{\" \"}\n                  <Link href=\"/signup\" className=\"text-primary-600 hover:underline font-medium\">\n                    Sign up\n                  </Link>\n                </p>\n              </>\n            ) : (\n              <div className=\"space-y-6 text-center\">\n                <div className=\"mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-primary-600\">\n                  <Inbox className=\"h-7 w-7\" aria-hidden />\n                </div>\n                <div className=\"space-y-2\">\n                  <h3 className=\"text-xl font-semibold text-gray-900\">\n                    Check your email\n                  </h3>\n                  <p className=\"text-sm text-gray-600\">\n                    We sent a secure link to {\" \"}\n                    <span className=\"font-medium text-gray-900\">\n                      {lastSubmittedEmail}\n                    </span>\n                    . Open it on any device to finish signing in.\n                  </p>\n                </div>\n                <div className=\"space-y-2\">\n                  {emailProviders.map((provider) => (\n                    <Button key={provider.href} variant=\"secondary\" className=\"w-full justify-between\" asChild>\n                      <a href={provider.href} target=\"_blank\" rel=\"noreferrer\">\n                        <span>{provider.label}</span>\n                        <ArrowRight className=\"h-4 w-4\" aria-hidden />\n                      </a>\n                    </Button>\n                  ))}\n                  <p className=\"text-xs text-gray-500\">\n                    Prefer another inbox? Open your mail app and look for an\n                    email from Your App security.\n                  </p>\n                </div>\n                {status && status.type === \"error\" && (\n                  <Alert variant=\"destructive\" className=\"text-left\">\n                    <AlertTitle>We couldn’t send the link</AlertTitle>\n                    <AlertDescription>{status.message}</AlertDescription>\n                  </Alert>\n                )}\n                <div className=\"flex flex-col gap-3 sm:flex-row sm:justify-center\">\n                  <Button\n                    variant=\"outline\"\n                    onClick={() => {\n                      setView(\"form\");\n                      setStatus(null);\n                      setEmail(\"\");\n                    }}\n                    disabled={isSubmitting}\n                  >\n                    Use a different email\n                  </Button>\n                  <Button onClick={handleResend} disabled={isSubmitting}>\n                    Resend link\n                  </Button>\n                </div>\n                {status && status.type !== \"error\" && status.message && (\n                  <p className=\"text-xs text-gray-500\">{status.message}</p>\n                )}\n              </div>\n            )}\n            <div className=\"mt-8 flex items-center justify-center gap-2 text-xs text-gray-400\">\n              <span>Powered by</span>\n              <span className=\"font-semibold text-gray-600\">Stytch</span>\n            </div>\n          </div>\n        </aside>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/authenticate/page.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport Link from \"next/link\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { AlertCircle, CheckCircle2, Loader2 } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { consumeMagicLink } from \"@/lib/actions/auth/consume-magic-link\";\n\nconst SESSION_DURATION_MINUTES = Number(\n  process.env.NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES ?? \"60\"\n) || 60;\n\nconst DEFAULT_DESTINATION = \"/dashboard\";\n\ntype StatusState = {\n  state: \"verifying\" | \"success\" | \"error\";\n  headline: string;\n  message: string;\n};\n\nconst INITIAL_STATUS: StatusState = {\n  state: \"verifying\",\n  headline: \"We're verifying your magic link\",\n  message: \"Hang tight—this usually takes just a moment.\",\n};\n\nfunction extractErrorMessage(error: unknown): string {\n  if (error && typeof error === \"object\") {\n    const typed = error as any;\n\n    if (typed.error_message) {\n      return typed.error_message;\n    }\n\n    if (typed.message) {\n      return typed.message;\n    }\n  }\n\n  return \"We couldn't verify that link. Please request a new magic link from the login page.\";\n}\n\nexport default function AuthenticateRedirectPage() {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const [status, setStatus] = useState<StatusState>(INITIAL_STATUS);\n\n  const hasAttemptedAuthRef = useRef(false);\n\n  const magicLinkToken = searchParams.get(\"stytch_token\") || searchParams.get(\"token\");\n  const tokenType = searchParams.get(\"stytch_token_type\") || searchParams.get(\"token_type\");\n  const returnTo = searchParams.get(\"returnTo\")?.trim() || DEFAULT_DESTINATION;\n\n  const redirectToDestination = useCallback(() => {\n    router.push(returnTo);\n    router.refresh();\n  }, [returnTo, router]);\n\n  const exchangeMagicLink = useCallback(async () => {\n    if (!magicLinkToken) {\n      setStatus({\n        state: \"error\",\n        headline: \"Magic link is missing or invalid\",\n        message: \"This sign-in link is missing its token. Please request a new magic link.\",\n      });\n      return;\n    }\n\n    hasAttemptedAuthRef.current = true;\n    setStatus(INITIAL_STATUS);\n\n    try {\n      const result = await consumeMagicLink(\n        magicLinkToken,\n        SESSION_DURATION_MINUTES\n      );\n\n      if (!result.success) {\n        throw new Error(result.error || \"Failed to verify magic link.\");\n      }\n\n      if (!result.data.memberAuthenticated) {\n        setStatus({\n          state: \"error\",\n          headline: \"Additional verification required\",\n          message: \"We need a bit more information to finish signing you in. Please continue from the login page.\",\n        });\n        return;\n      }\n\n      setStatus({\n        state: \"success\",\n        headline: \"Magic link verified\",\n        message: \"You're all set. Redirecting you to your workspace…\",\n      });\n\n      redirectToDestination();\n    } catch (error) {\n      setStatus({\n        state: \"error\",\n        headline: \"We couldn't verify your link\",\n        message: extractErrorMessage(error),\n      });\n    }\n  }, [magicLinkToken, redirectToDestination]);\n\n  useEffect(() => {\n    if (hasAttemptedAuthRef.current) return;\n    void exchangeMagicLink();\n  }, [exchangeMagicLink]);\n\n  const icon =\n    status.state === \"success\" ? (\n      <CheckCircle2 className=\"h-10 w-10 text-green-500\" aria-hidden=\"true\" />\n    ) : status.state === \"error\" ? (\n      <AlertCircle className=\"h-10 w-10 text-red-500\" aria-hidden=\"true\" />\n    ) : (\n      <Loader2\n        className=\"h-10 w-10 animate-spin text-primary-500\"\n        aria-hidden=\"true\"\n      />\n    );\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center bg-gray-50 px-4\">\n      <div className=\"w-full max-w-md rounded-xl border border-gray-200 bg-white px-8 py-10 text-center shadow-lg\">\n        <div className=\"flex flex-col items-center gap-4\">\n          {icon}\n          <h1 className=\"text-lg font-semibold text-gray-900\" role=\"status\">\n            {status.headline}\n          </h1>\n          <p className=\"text-sm text-gray-600\">{status.message}</p>\n          {status.state === \"error\" ? (\n            <div className=\"mt-6 flex flex-col items-center gap-2\">\n              <Button asChild className=\"w-full justify-center\">\n                <Link href=\"/auth\">Back to login</Link>\n              </Button>\n              <p className=\"text-xs text-gray-500\">\n                Need help? Contact your workspace admin or request a new magic\n                link from the login page.\n              </p>\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/chat-interface.tsx",
    "content": "\"use client\";\n\nimport { useState, useRef, useEffect } from \"react\";\nimport { Send, Sparkles, Plus } from \"lucide-react\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { ChatMessage, TypingIndicator } from \"./chat-message\";\nimport type {\n  ChatMessage as ChatMessageType,\n  SimilarDocument,\n} from \"@/lib/models/cognitive.model\";\nimport { ChatHelpers } from \"@/lib/models/cognitive.model\";\n\ninterface ChatInterfaceProps {\n  messages: ChatMessageType[];\n  sessionTitle?: string;\n  isLoading?: boolean;\n  isSending?: boolean;\n  onSendMessage: (message: string, useRag: boolean) => Promise<void>;\n  onNewChat: () => void;\n  messageSources?: Record<number, SimilarDocument[]>;\n  documentCount?: number;\n}\n\nfunction EmptyState({ onSuggestionClick }: { onSuggestionClick?: (text: string) => void }) {\n  const suggestions = [\n    \"Summarize documents\",\n    \"Find key dates\",\n    \"Main topics\"\n  ];\n\n  return (\n    <div className=\"flex-1 flex flex-col items-center justify-center p-6\">\n      <div\n        className=\"h-12 w-12 rounded-full flex items-center justify-center\"\n        style={{ backgroundColor: \"#ede9fe\" }}\n      >\n        <Sparkles className=\"h-6 w-6\" style={{ color: \"#7c3aed\" }} />\n      </div>\n      <h3 className=\"mt-4 text-base font-medium\" style={{ color: \"#111827\" }}>\n        How can I help?\n      </h3>\n      <p className=\"mt-1 text-sm\" style={{ color: \"#6b7280\" }}>\n        Ask about your documents\n      </p>\n      <div className=\"mt-4 flex flex-wrap gap-2 justify-center\">\n        {suggestions.map((text, i) => (\n          <button\n            key={i}\n            onClick={() => onSuggestionClick?.(text)}\n            className=\"px-3 py-1.5 text-xs rounded-full border hover:bg-gray-50\"\n            style={{ borderColor: \"#e5e7eb\", color: \"#4b5563\" }}\n          >\n            {text}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction LoadingSkeleton() {\n  return (\n    <div className=\"flex-1 p-4 space-y-4\">\n      <div className=\"flex justify-end\">\n        <Skeleton className=\"h-10 w-48 rounded-lg\" />\n      </div>\n      <div className=\"flex gap-2\">\n        <Skeleton className=\"h-8 w-8 rounded-full flex-shrink-0\" />\n        <Skeleton className=\"h-16 w-64 rounded-lg\" />\n      </div>\n    </div>\n  );\n}\n\nexport function ChatInterface({\n  messages,\n  sessionTitle,\n  isLoading = false,\n  isSending = false,\n  onSendMessage,\n  onNewChat,\n  messageSources,\n}: ChatInterfaceProps) {\n  const [input, setInput] = useState(\"\");\n  const messagesEndRef = useRef<HTMLDivElement>(null);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n  useEffect(() => {\n    messagesEndRef.current?.scrollIntoView({ behavior: \"smooth\" });\n  }, [messages]);\n\n  const handleSend = async () => {\n    if (!input.trim() || isSending) return;\n    const message = input.trim();\n    setInput(\"\");\n    await onSendMessage(message, true);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      handleSend();\n    }\n  };\n\n  const title = sessionTitle ? ChatHelpers.truncateTitle(sessionTitle) : \"New Chat\";\n\n  return (\n    <div className=\"h-full flex flex-col min-h-0\">\n      {/* Header */}\n      <div className=\"h-14 px-4 border-b border-gray-200 flex items-center justify-between flex-shrink-0 bg-white\">\n        <div className=\"flex items-center gap-3\">\n          <div\n            className=\"h-8 w-8 rounded-full flex items-center justify-center\"\n            style={{ backgroundColor: \"#ede9fe\" }}\n          >\n            <Sparkles className=\"h-4 w-4\" style={{ color: \"#7c3aed\" }} />\n          </div>\n          <div>\n            <p className=\"text-sm font-medium\" style={{ color: \"#111827\" }}>{title}</p>\n            <p className=\"text-xs\" style={{ color: \"#16a34a\" }}>Online</p>\n          </div>\n        </div>\n        <button\n          onClick={onNewChat}\n          className=\"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm hover:bg-gray-100\"\n          style={{ color: \"#374151\" }}\n        >\n          <Plus className=\"h-4 w-4\" />\n          New\n        </button>\n      </div>\n\n      {/* Messages */}\n      {isLoading ? (\n        <LoadingSkeleton />\n      ) : messages.length === 0 ? (\n        <EmptyState onSuggestionClick={(text) => setInput(text)} />\n      ) : (\n        <div className=\"flex-1 min-h-0 overflow-y-auto p-4 space-y-3 bg-white\">\n          {messages.map((message) => (\n            <ChatMessage\n              key={message.id}\n              message={message}\n              sources={messageSources?.[message.id]}\n            />\n          ))}\n          {isSending && <TypingIndicator />}\n          <div ref={messagesEndRef} />\n        </div>\n      )}\n\n      {/* Input */}\n      <div className=\"p-4 border-t border-gray-200 flex-shrink-0 bg-white\">\n        <div className=\"flex gap-2 items-center\">\n          <textarea\n            ref={textareaRef}\n            value={input}\n            onChange={(e) => setInput(e.target.value)}\n            onKeyDown={handleKeyDown}\n            placeholder=\"Type a message...\"\n            disabled={isSending}\n            rows={1}\n            className=\"flex-1 resize-none rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent disabled:opacity-50\"\n          />\n          <button\n            onClick={handleSend}\n            disabled={!input.trim() || isSending}\n            className=\"h-10 w-10 rounded-lg flex-shrink-0 flex items-center justify-center disabled:opacity-50\"\n            style={{\n              backgroundColor: input.trim() ? \"#5b21b6\" : \"#f3f4f6\",\n              color: input.trim() ? \"white\" : \"#9ca3af\",\n            }}\n          >\n            <Send className=\"h-4 w-4\" />\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/chat-message.tsx",
    "content": "\"use client\";\n\nimport { Sparkles } from \"lucide-react\";\nimport type {\n  ChatMessage as ChatMessageType,\n  SimilarDocument,\n} from \"@/lib/models/cognitive.model\";\nimport { ChatHelpers } from \"@/lib/models/cognitive.model\";\nimport { DocumentSources } from \"./document-sources\";\n\ninterface ChatMessageProps {\n  message: ChatMessageType;\n  sources?: SimilarDocument[];\n}\n\nexport function ChatMessage({ message, sources }: ChatMessageProps) {\n  const isUser = message.role === \"user\";\n\n  if (isUser) {\n    return (\n      <div className=\"flex justify-end\">\n        <div\n          className=\"max-w-[70%] rounded-lg rounded-br-sm px-3 py-2\"\n          style={{ backgroundColor: \"#5b21b6\", color: \"white\" }}\n        >\n          <p className=\"text-sm whitespace-pre-wrap\">{message.content}</p>\n          <p className=\"text-xs mt-1\" style={{ color: \"rgba(255,255,255,0.7)\" }}>\n            {ChatHelpers.formatTimestamp(message.createdAt)}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex gap-2\">\n      <div\n        className=\"h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0\"\n        style={{ backgroundColor: \"#ede9fe\" }}\n      >\n        <Sparkles className=\"h-3.5 w-3.5\" style={{ color: \"#7c3aed\" }} />\n      </div>\n      <div className=\"max-w-[70%]\">\n        <div\n          className=\"rounded-lg rounded-tl-sm px-3 py-2\"\n          style={{ backgroundColor: \"#f3f4f6\" }}\n        >\n          <p className=\"text-sm whitespace-pre-wrap\" style={{ color: \"#111827\" }}>{message.content}</p>\n          <p className=\"text-xs mt-1\" style={{ color: \"#6b7280\" }}>\n            {ChatHelpers.formatTimestamp(message.createdAt)}\n          </p>\n        </div>\n        {sources && sources.length > 0 && <DocumentSources sources={sources} />}\n      </div>\n    </div>\n  );\n}\n\nexport function TypingIndicator() {\n  return (\n    <div className=\"flex gap-2\">\n      <div\n        className=\"h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0\"\n        style={{ backgroundColor: \"#ede9fe\" }}\n      >\n        <Sparkles className=\"h-3.5 w-3.5\" style={{ color: \"#7c3aed\" }} />\n      </div>\n      <div className=\"rounded-lg rounded-tl-sm px-3 py-2\" style={{ backgroundColor: \"#f3f4f6\" }}>\n        <div className=\"flex gap-1\">\n          <span className=\"h-2 w-2 rounded-full animate-bounce\" style={{ backgroundColor: \"#9ca3af\", animationDelay: \"0ms\" }} />\n          <span className=\"h-2 w-2 rounded-full animate-bounce\" style={{ backgroundColor: \"#9ca3af\", animationDelay: \"150ms\" }} />\n          <span className=\"h-2 w-2 rounded-full animate-bounce\" style={{ backgroundColor: \"#9ca3af\", animationDelay: \"300ms\" }} />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/document-list.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2, FileText, RefreshCcw, MoreVertical } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport type { Document } from \"@/lib/models/document.model\";\nimport { DocumentHelpers } from \"@/lib/models/document.model\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DocumentListProps {\n  documents: Document[];\n  isLoading?: boolean;\n  isFetching?: boolean;\n  onDelete: (documentId: number) => Promise<void>;\n  onRefresh: () => void;\n  compact?: boolean;\n}\n\nfunction DocumentCard({\n  document,\n  onDelete,\n  compact,\n}: {\n  document: Document;\n  onDelete: (doc: Document) => void;\n  compact?: boolean;\n}) {\n  const statusConfig = DocumentHelpers.getStatusConfig(document.status);\n\n  return (\n    <div\n      className={cn(\n        \"group relative flex items-start gap-3 rounded-lg border transition-all\",\n        compact\n          ? \"border-transparent bg-transparent p-2 hover:bg-violet-50\"\n          : \"border-gray-200 bg-white p-4 hover:border-violet-200 hover:shadow-sm\"\n      )}\n    >\n      <div\n        className={cn(\n          \"flex shrink-0 items-center justify-center rounded-lg\",\n          compact ? \"h-8 w-8\" : \"h-10 w-10\"\n        )}\n        style={{ backgroundColor: \"#fef2f2\" }}\n      >\n        <FileText className={cn(compact ? \"h-4 w-4\" : \"h-5 w-5\")} style={{ color: \"#ef4444\" }} />\n      </div>\n\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-start justify-between gap-2\">\n          <p className=\"truncate font-medium text-sm\" style={{ color: \"#111827\" }}>{document.title}</p>\n          {compact && (\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"h-6 w-6 shrink-0 text-gray-400 opacity-0 group-hover:opacity-100\"\n                >\n                  <MoreVertical className=\"h-3 w-3\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                <DropdownMenuItem\n                  className=\"text-red-600\"\n                  onClick={() => onDelete(document)}\n                >\n                  <Trash2 className=\"mr-2 h-4 w-4\" />\n                  Delete\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          )}\n        </div>\n        <p className=\"truncate text-xs\" style={{ color: \"#6b7280\" }}>{document.fileName}</p>\n\n        {!compact && (\n          <div className=\"mt-4 flex items-center justify-between border-t border-gray-100 pt-3\">\n            <div className=\"flex items-center gap-2\">\n              <Badge\n                  variant=\"outline\"\n                  className={cn(\n                  \"text-xs\",\n                  statusConfig.color,\n                  statusConfig.bgColor\n                  )}\n              >\n                  {statusConfig.label}\n              </Badge>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-xs\" style={{ color: \"#9ca3af\" }}>\n                {DocumentHelpers.formatFileSize(document.fileSize)}\n              </span>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                onClick={() => onDelete(document)}\n                className=\"h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100\"\n                style={{ color: \"#9ca3af\" }}\n              >\n                <Trash2 className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          </div>\n        )}\n        \n        {compact && (\n           <div className=\"mt-1 flex items-center gap-2\">\n              <div className=\"h-1.5 w-1.5 rounded-full\" style={{ backgroundColor: document.status === 'processed' ? \"#10b981\" : \"#eab308\" }} />\n              <span className=\"text-[10px]\" style={{ color: \"#9ca3af\" }}>\n                {DocumentHelpers.formatFileSize(document.fileSize)}\n              </span>\n           </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction DocumentListSkeleton({ compact }: { compact?: boolean }) {\n  return (\n    <div className={cn(\"grid gap-4\", compact ? \"grid-cols-1\" : \"sm:grid-cols-2 lg:grid-cols-3\")}>\n        {Array.from({ length: 6 }).map((_, i) => (\n            <div key={i} className={cn(\"rounded-xl p-4\", compact ? \"flex gap-3\" : \"border border-gray-200 bg-white\")}>\n                <Skeleton className={cn(\"rounded-lg\", compact ? \"h-8 w-8\" : \"h-10 w-10\")} />\n                <div className=\"flex-1 space-y-2\">\n                    <Skeleton className=\"h-4 w-3/4\" />\n                    <Skeleton className=\"h-3 w-1/2\" />\n                </div>\n            </div>\n        ))}\n    </div>\n  );\n}\n\nfunction EmptyDocumentsState({ compact }: { compact?: boolean }) {\n  if (compact) {\n     return (\n        <div className=\"flex flex-col items-center justify-center py-8 text-center rounded-xl border border-dashed\" style={{ backgroundColor: \"rgba(249,250,251,0.5)\", borderColor: \"#e5e7eb\" }}>\n             <div className=\"p-2 rounded-full mb-2\" style={{ backgroundColor: \"#f3f4f6\" }}>\n                <FileText className=\"h-4 w-4\" style={{ color: \"#9ca3af\" }} />\n             </div>\n             <p className=\"text-xs font-medium\" style={{ color: \"#6b7280\" }}>No documents yet</p>\n        </div>\n     )\n  }\n  return (\n    <div className=\"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-16\" style={{ borderColor: \"#e5e7eb\", backgroundColor: \"rgba(249,250,251,0.5)\" }}>\n      <div className=\"flex h-16 w-16 items-center justify-center rounded-full\" style={{ backgroundColor: \"#f3f4f6\" }}>\n        <FileText className=\"h-8 w-8\" style={{ color: \"#9ca3af\" }} />\n      </div>\n      <h3 className=\"mt-4 text-sm font-semibold\" style={{ color: \"#111827\" }}>\n        No documents yet\n      </h3>\n      <p className=\"mt-1 max-w-sm text-center text-sm\" style={{ color: \"#6b7280\" }}>\n        Upload your first PDF document to start building your knowledge base for\n        AI-powered search.\n      </p>\n    </div>\n  );\n}\n\nexport function DocumentList({\n  documents,\n  isLoading = false,\n  isFetching = false,\n  onDelete,\n  onRefresh,\n  compact = false,\n}: DocumentListProps) {\n  const [deleteTarget, setDeleteTarget] = useState<Document | null>(null);\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const handleDelete = async () => {\n    if (!deleteTarget) return;\n    setIsDeleting(true);\n    try {\n      await onDelete(deleteTarget.id);\n      setDeleteTarget(null);\n    } finally {\n      setIsDeleting(false);\n    }\n  };\n\n  if (isLoading) {\n    return <DocumentListSkeleton compact={compact} />;\n  }\n\n  if (documents.length === 0) {\n    return <EmptyDocumentsState compact={compact} />;\n  }\n\n  return (\n    <>\n      <div className=\"mb-4 flex items-center justify-between\">\n        <p className=\"text-sm font-medium\" style={{ color: \"#4b5563\" }}>\n           {compact ? \"Sources\" : `${documents.length} document${documents.length !== 1 ? \"s\" : \"\"}`}\n        </p>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onRefresh}\n          disabled={isFetching}\n          className=\"h-8 w-8 p-0\"\n          style={{ color: \"#4b5563\" }}\n        >\n          <RefreshCcw\n            className={cn(\"h-4 w-4\", isFetching && \"animate-spin\")}\n          />\n        </Button>\n      </div>\n\n      <div className={cn(\"grid gap-2\", compact ? \"grid-cols-1\" : \"sm:grid-cols-2 lg:grid-cols-3\")}>\n        {documents.map((doc) => (\n          <DocumentCard key={doc.id} document={doc} onDelete={setDeleteTarget} compact={compact} />\n        ))}\n      </div>\n\n      <Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>Delete Document</DialogTitle>\n            <DialogDescription>\n              Are you sure you want to delete &quot;{deleteTarget?.title}&quot;?\n              This action cannot be undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter className=\"gap-2 sm:gap-0\">\n            <Button\n              variant=\"outline\"\n              onClick={() => setDeleteTarget(null)}\n              disabled={isDeleting}\n            >\n              Cancel\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDelete}\n              disabled={isDeleting}\n            >\n              {isDeleting ? \"Deleting...\" : \"Delete\"}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/document-sources.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { FileText, ChevronDown } from \"lucide-react\";\nimport type { SimilarDocument } from \"@/lib/models/cognitive.model\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DocumentSourcesProps {\n  sources: SimilarDocument[];\n}\n\nexport function DocumentSources({ sources }: DocumentSourcesProps) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  if (!sources || sources.length === 0) return null;\n\n  return (\n    <div className=\"mt-2 overflow-hidden rounded-xl border\" style={{ borderColor: \"#e5e7eb\", backgroundColor: \"white\" }}>\n      <button\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"flex w-full items-center justify-between px-3 py-2 text-xs transition-colors hover:bg-gray-50\"\n        style={{ color: \"#4b5563\" }}\n      >\n        <span className=\"flex items-center gap-1.5\">\n          <FileText className=\"h-3.5 w-3.5\" />\n          {sources.length} source{sources.length !== 1 ? \"s\" : \"\"} referenced\n        </span>\n        <ChevronDown\n          className={cn(\n            \"h-3.5 w-3.5 transition-transform duration-200\",\n            isOpen && \"rotate-180\"\n          )}\n        />\n      </button>\n\n      {isOpen && (\n        <div className=\"space-y-2 border-t p-3\" style={{ borderColor: \"#f3f4f6\" }}>\n          {sources.map((source) => (\n            <div key={source.id} className=\"rounded-lg p-3\" style={{ backgroundColor: \"#f9fafb\" }}>\n              <div className=\"flex items-center justify-between\">\n                <span className=\"text-xs font-medium\" style={{ color: \"#374151\" }}>\n                  Document #{source.documentId}\n                </span>\n                <div className=\"flex items-center gap-2\">\n                  <div className=\"h-1.5 w-14 overflow-hidden rounded-full\" style={{ backgroundColor: \"#e5e7eb\" }}>\n                    <div\n                      className=\"h-full rounded-full transition-all\"\n                      style={{ width: `${source.similarityScore * 100}%`, backgroundColor: \"#10b981\" }}\n                    />\n                  </div>\n                  <span className=\"text-xs font-medium\" style={{ color: \"#6b7280\" }}>\n                    {Math.round(source.similarityScore * 100)}%\n                  </span>\n                </div>\n              </div>\n              <p className=\"mt-2 line-clamp-3 text-xs leading-relaxed\" style={{ color: \"#4b5563\" }}>\n                {source.contentPreview}\n              </p>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/document-upload.tsx",
    "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport { useDropzone } from \"react-dropzone\";\nimport { Upload, FileText, X, Check, Loader2, File } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { cn } from \"@/lib/utils\";\nimport { DocumentHelpers } from \"@/lib/models/document.model\";\n\ninterface DocumentUploadProps {\n  onUpload: (file: File, title: string) => Promise<void>;\n  isUploading?: boolean;\n}\n\nexport function DocumentUpload({\n  onUpload,\n  isUploading = false,\n}: DocumentUploadProps) {\n  const [selectedFile, setSelectedFile] = useState<File | null>(null);\n  const [title, setTitle] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n\n  const onDrop = useCallback(\n    (acceptedFiles: File[], rejectedFiles: unknown[]) => {\n      setError(null);\n      setSuccess(false);\n\n      if (rejectedFiles.length > 0) {\n        setError(\"Only PDF files are accepted\");\n        return;\n      }\n\n      if (acceptedFiles.length > 0) {\n        const file = acceptedFiles[0];\n        setSelectedFile(file);\n        const nameWithoutExt = file.name.replace(/\\.pdf$/i, \"\");\n        setTitle(nameWithoutExt);\n      }\n    },\n    []\n  );\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    onDrop,\n    accept: {\n      \"application/pdf\": [\".pdf\"],\n    },\n    maxFiles: 1,\n    disabled: isUploading,\n  });\n\n  const handleUpload = async () => {\n    if (!selectedFile || !title.trim()) return;\n\n    setError(null);\n    try {\n      await onUpload(selectedFile, title.trim());\n      setSuccess(true);\n      setSelectedFile(null);\n      setTitle(\"\");\n      setTimeout(() => setSuccess(false), 3000);\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"Upload failed\");\n    }\n  };\n\n  const clearSelection = () => {\n    setSelectedFile(null);\n    setTitle(\"\");\n    setError(null);\n  };\n\n  // If we have a file, show the confirmation/title form\n  if (selectedFile) {\n      return (\n        <div className=\"space-y-4\">\n             {error && (\n                <Alert variant=\"destructive\" className=\"border-red-200 bg-red-50\">\n                <AlertDescription className=\"text-red-700\">{error}</AlertDescription>\n                </Alert>\n            )}\n\n            <div className=\"rounded-xl border border-gray-200 bg-white p-4\">\n                <div className=\"flex items-start justify-between\">\n                    <div className=\"flex items-center gap-3\">\n                         <div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-50\">\n                            <FileText className=\"h-5 w-5 text-red-500\" />\n                        </div>\n                        <div>\n                             <p className=\"font-medium text-gray-900 line-clamp-1\">{selectedFile.name}</p>\n                             <p className=\"text-xs text-gray-500\">{DocumentHelpers.formatFileSize(selectedFile.size)}</p>\n                        </div>\n                    </div>\n                    <Button variant=\"ghost\" size=\"icon\" onClick={clearSelection} disabled={isUploading}>\n                        <X className=\"h-4 w-4 text-gray-500\" />\n                    </Button>\n                </div>\n\n                <div className=\"mt-4 space-y-2\">\n                    <Label htmlFor=\"doc-title\">Document Title</Label>\n                    <Input\n                        id=\"doc-title\"\n                        value={title}\n                        onChange={(e) => setTitle(e.target.value)}\n                        placeholder=\"My Document\"\n                        disabled={isUploading}\n                    />\n                </div>\n            </div>\n            \n            <div className=\"flex gap-2\">\n                 <Button variant=\"outline\" className=\"flex-1\" onClick={clearSelection} disabled={isUploading}>\n                    Cancel\n                 </Button>\n                 <Button \n                    className=\"flex-1 bg-gray-900 text-white hover:bg-gray-800\" \n                    onClick={handleUpload}\n                    disabled={isUploading || !title.trim()}\n                >\n                    {isUploading ? <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> : <Upload className=\"mr-2 h-4 w-4\" />}\n                    {isUploading ? \"Uploading...\" : \"Upload PDF\"}\n                 </Button>\n            </div>\n        </div>\n      )\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {success && (\n        <Alert className=\"border-emerald-200 bg-emerald-50 mb-4\">\n          <Check className=\"h-4 w-4 text-emerald-600\" />\n          <AlertDescription className=\"text-emerald-700\">\n            Document uploaded successfully!\n          </AlertDescription>\n        </Alert>\n      )}\n\n      <div\n        {...getRootProps()}\n        className={cn(\n          \"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-10 transition-all duration-200 cursor-pointer\",\n          isDragActive\n            ? \"border-gray-900 bg-gray-50\"\n            : \"border-gray-200 bg-gray-50/50 hover:border-gray-300 hover:bg-gray-50\",\n          isUploading && \"cursor-not-allowed opacity-60\"\n        )}\n      >\n        <input {...getInputProps()} />\n        <div className=\"flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 mb-4\">\n          <Upload className=\"h-6 w-6 text-gray-400\" />\n        </div>\n        <p className=\"text-sm font-medium text-gray-900 text-center\">\n          {isDragActive ? \"Drop PDF here\" : \"Click or drag PDF to upload\"}\n        </p>\n        <p className=\"mt-1 text-xs text-gray-500 text-center\">\n            Max file size 10MB\n        </p>\n      </div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/knowledge-content.tsx",
    "content": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { useSessionsQuery, useSessionMessagesQuery } from \"@/lib/hooks/queries/use-sessions-query\";\nimport { useChat } from \"@/lib/hooks/mutations/use-chat\";\nimport { useDocumentsQuery } from \"@/lib/hooks/queries/use-documents-query\";\nimport { useUploadDocument } from \"@/lib/hooks/mutations/use-upload-document\";\nimport { useDeleteDocument } from \"@/lib/hooks/mutations/use-delete-document\";\nimport { ChatInterface } from \"./chat-interface\";\nimport { KnowledgeSidebar } from \"./knowledge-sidebar\";\nimport type { ChatMessage, SimilarDocument } from \"@/lib/models/cognitive.model\";\n\nexport function KnowledgeContent() {\n  const {\n    data: documentsData,\n    isLoading: isDocumentsLoading,\n    isFetching: isDocumentsFetching,\n    refetch: refetchDocuments,\n  } = useDocumentsQuery();\n\n  const uploadMutation = useUploadDocument();\n  const deleteMutation = useDeleteDocument();\n\n  const documents = documentsData?.documents ?? [];\n  const processedDocCount = documents.filter((d) => d.status === \"processed\").length;\n\n  const handleUpload = async (file: File, title: string) => {\n    await uploadMutation.mutateAsync({ file, title });\n  };\n\n  const handleDeleteDocument = async (documentId: number) => {\n    await deleteMutation.mutateAsync({ documentId });\n  };\n\n  const [currentSessionId, setCurrentSessionId] = useState<number | null>(null);\n  const [optimisticMessages, setOptimisticMessages] = useState<ChatMessage[]>([]);\n  const [messageSources, setMessageSources] = useState<Record<number, SimilarDocument[]>>({});\n\n  const {\n    data: sessions,\n    isLoading: isSessionsLoading,\n  } = useSessionsQuery();\n\n  const { data: sessionMessages, isLoading: isMessagesLoading } = useSessionMessagesQuery({\n    sessionId: currentSessionId ?? 0,\n    enabled: currentSessionId !== null && currentSessionId > 0,\n  });\n\n  const chatMutation = useChat();\n  const messages = [...(sessionMessages ?? []), ...optimisticMessages];\n\n  useEffect(() => {\n    if (sessionMessages && sessionMessages.length > 0) {\n      setOptimisticMessages([]);\n    }\n  }, [sessionMessages]);\n\n  useEffect(() => {\n    if (!currentSessionId && sessions && sessions.length > 0) {\n      setCurrentSessionId(sessions[0].id);\n    }\n  }, [currentSessionId, sessions]);\n\n  const currentSession = sessions?.find((s) => s.id === currentSessionId);\n  const sessionTitle = currentSession?.title;\n\n  const handleSendMessage = async (message: string, useRag: boolean) => {\n    const optimisticUserMessage: ChatMessage = {\n      id: Date.now(),\n      sessionId: currentSessionId ?? 0,\n      role: \"user\",\n      content: message,\n      tokensUsed: 0,\n      createdAt: new Date(),\n    };\n    setOptimisticMessages((prev) => [...prev, optimisticUserMessage]);\n\n    try {\n      const response = await chatMutation.mutateAsync({\n        sessionId: currentSessionId ?? undefined,\n        message,\n        useRag,\n      });\n\n      if (response.sessionId && response.sessionId !== currentSessionId) {\n        setCurrentSessionId(response.sessionId);\n      }\n\n      if (response.referencedDocs && response.referencedDocs.length > 0) {\n        setMessageSources((prev) => ({\n          ...prev,\n          [response.message.id]: response.referencedDocs!,\n        }));\n      }\n      setOptimisticMessages([]);\n    } catch {\n      setOptimisticMessages((prev) => prev.filter((m) => m.id !== optimisticUserMessage.id));\n    }\n  };\n\n  const handleNewChat = () => {\n    setCurrentSessionId(null);\n    setOptimisticMessages([]);\n  };\n\n  const handleSelectSession = (sessionId: number) => {\n    setCurrentSessionId(sessionId);\n    setOptimisticMessages([]);\n  };\n\n  return (\n    <div className=\"flex h-[600px] rounded-lg border border-gray-200 bg-white overflow-hidden\">\n      {/* Fixed Sidebar */}\n      <div className=\"w-64 border-r border-gray-200 flex-shrink-0 h-full overflow-hidden\">\n        <KnowledgeSidebar\n          sessions={sessions ?? []}\n          documents={documents}\n          currentSessionId={currentSessionId}\n          isSessionsLoading={isSessionsLoading}\n          isDocumentsLoading={isDocumentsLoading}\n          isDocumentsFetching={isDocumentsFetching}\n          onSelectSession={handleSelectSession}\n          onNewChat={handleNewChat}\n          onUploadDocument={handleUpload}\n          onDeleteDocument={handleDeleteDocument}\n          onRefreshDocuments={() => refetchDocuments()}\n          isUploading={uploadMutation.isPending}\n        />\n      </div>\n\n      {/* Chat Area */}\n      <div className=\"flex-1 min-w-0 h-full overflow-hidden\">\n        <ChatInterface\n          messages={messages}\n          sessionTitle={sessionTitle}\n          isLoading={isSessionsLoading || isMessagesLoading}\n          isSending={chatMutation.isPending}\n          onSendMessage={handleSendMessage}\n          onNewChat={handleNewChat}\n          messageSources={messageSources}\n          documentCount={processedDocCount}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/components/knowledge-sidebar.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { MessageSquare, FileText, Upload, Plus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ui/dialog\";\nimport { DocumentList } from \"./document-list\";\nimport { DocumentUpload } from \"./document-upload\";\nimport type { ChatSession } from \"@/lib/models/cognitive.model\";\nimport type { Document } from \"@/lib/models/document.model\";\nimport { ChatHelpers } from \"@/lib/models/cognitive.model\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\ninterface KnowledgeSidebarProps {\n  sessions: ChatSession[];\n  documents: Document[];\n  currentSessionId: number | null;\n  isSessionsLoading?: boolean;\n  isDocumentsLoading?: boolean;\n  isDocumentsFetching?: boolean;\n  onSelectSession: (sessionId: number) => void;\n  onNewChat: () => void;\n  onUploadDocument: (file: File, title: string) => Promise<void>;\n  onDeleteDocument: (documentId: number) => Promise<void>;\n  onRefreshDocuments: () => void;\n  isUploading?: boolean;\n}\n\nexport function KnowledgeSidebar({\n  sessions,\n  documents,\n  currentSessionId,\n  isSessionsLoading,\n  isDocumentsLoading,\n  isDocumentsFetching,\n  onSelectSession,\n  onNewChat,\n  onUploadDocument,\n  onDeleteDocument,\n  onRefreshDocuments,\n  isUploading,\n}: KnowledgeSidebarProps) {\n  const [activeTab, setActiveTab] = useState<\"chats\" | \"sources\">(\"chats\");\n\n  return (\n    <div className=\"h-full flex flex-col bg-gray-50\">\n      {/* Tabs */}\n      <div className=\"p-3 border-b border-gray-200\">\n        <div className=\"flex rounded-lg p-1\" style={{ backgroundColor: \"#e5e7eb\" }}>\n          <button\n            onClick={() => setActiveTab(\"chats\")}\n            className=\"flex-1 flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors\"\n            style={{\n              backgroundColor: activeTab === \"chats\" ? \"white\" : \"transparent\",\n              color: activeTab === \"chats\" ? \"#111827\" : \"#6b7280\",\n              boxShadow: activeTab === \"chats\" ? \"0 1px 2px rgba(0,0,0,0.05)\" : \"none\",\n            }}\n          >\n            <MessageSquare className=\"h-3.5 w-3.5\" />\n            Chats\n          </button>\n          <button\n            onClick={() => setActiveTab(\"sources\")}\n            className=\"flex-1 flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors\"\n            style={{\n              backgroundColor: activeTab === \"sources\" ? \"white\" : \"transparent\",\n              color: activeTab === \"sources\" ? \"#111827\" : \"#6b7280\",\n              boxShadow: activeTab === \"sources\" ? \"0 1px 2px rgba(0,0,0,0.05)\" : \"none\",\n            }}\n          >\n            <FileText className=\"h-3.5 w-3.5\" />\n            Sources\n          </button>\n        </div>\n      </div>\n\n      {/* Content */}\n      <div className=\"flex-1 overflow-hidden flex flex-col\">\n        {activeTab === \"chats\" ? (\n          <ChatsTab\n            sessions={sessions}\n            currentSessionId={currentSessionId}\n            isLoading={isSessionsLoading}\n            onSelectSession={onSelectSession}\n            onNewChat={onNewChat}\n          />\n        ) : (\n          <SourcesTab\n            documents={documents}\n            isLoading={isDocumentsLoading}\n            isFetching={isDocumentsFetching}\n            onUpload={onUploadDocument}\n            onDelete={onDeleteDocument}\n            onRefresh={onRefreshDocuments}\n            isUploading={isUploading}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction ChatsTab({\n  sessions,\n  currentSessionId,\n  isLoading,\n  onSelectSession,\n  onNewChat,\n}: {\n  sessions: ChatSession[];\n  currentSessionId: number | null;\n  isLoading?: boolean;\n  onSelectSession: (sessionId: number) => void;\n  onNewChat: () => void;\n}) {\n  return (\n    <>\n      <div className=\"p-3\">\n        <Button\n          onClick={onNewChat}\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"w-full gap-2\"\n        >\n          <Plus className=\"h-4 w-4\" />\n          New Chat\n        </Button>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto px-3 pb-3\">\n        {isLoading ? (\n          <div className=\"space-y-2\">\n            {[...Array(4)].map((_, i) => (\n              <Skeleton key={i} className=\"h-12 w-full rounded-lg\" />\n            ))}\n          </div>\n        ) : sessions.length === 0 ? (\n          <div className=\"text-center py-8\">\n            <MessageSquare className=\"mx-auto h-8 w-8 text-gray-300\" />\n            <p className=\"mt-2 text-sm text-gray-500\">No chats yet</p>\n          </div>\n        ) : (\n          <div className=\"space-y-1\">\n            {sessions.map((session) => {\n              const isActive = currentSessionId === session.id;\n              return (\n                <button\n                  key={session.id}\n                  onClick={() => onSelectSession(session.id)}\n                  className=\"w-full text-left px-3 py-2 rounded-lg text-sm transition-colors\"\n                  style={{\n                    backgroundColor: isActive ? \"#ede9fe\" : \"transparent\",\n                    color: isActive ? \"#5b21b6\" : \"#374151\",\n                  }}\n                >\n                  <p className=\"truncate font-medium\">\n                    {ChatHelpers.truncateTitle(session.title)}\n                  </p>\n                  <p className=\"text-xs mt-0.5\" style={{ color: \"#6b7280\" }}>\n                    {ChatHelpers.formatTimestamp(session.updatedAt)}\n                  </p>\n                </button>\n              );\n            })}\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n\nfunction SourcesTab({\n  documents,\n  isLoading,\n  isFetching,\n  onUpload,\n  onDelete,\n  onRefresh,\n  isUploading,\n}: {\n  documents: Document[];\n  isLoading?: boolean;\n  isFetching?: boolean;\n  onUpload: (file: File, title: string) => Promise<void>;\n  onDelete: (documentId: number) => Promise<void>;\n  onRefresh: () => void;\n  isUploading?: boolean;\n}) {\n  return (\n    <>\n      <div className=\"p-3\">\n        <Dialog>\n          <DialogTrigger asChild>\n            <Button variant=\"outline\" size=\"sm\" className=\"w-full gap-2\">\n              <Upload className=\"h-4 w-4\" />\n              Upload\n            </Button>\n          </DialogTrigger>\n          <DialogContent>\n            <DialogHeader>\n              <DialogTitle>Upload Document</DialogTitle>\n            </DialogHeader>\n            <DocumentUpload onUpload={onUpload} isUploading={isUploading} />\n          </DialogContent>\n        </Dialog>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto px-3 pb-3\">\n        <DocumentList\n          documents={documents}\n          isLoading={isLoading}\n          isFetching={isFetching}\n          onDelete={onDelete}\n          onRefresh={onRefresh}\n          compact={true}\n        />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/layout.tsx",
    "content": "import type { Metadata } from \"next\";\n\nexport const metadata: Metadata = {\n  title: \"Knowledge Base\",\n  description: \"Upload documents and chat with AI to find information quickly.\",\n};\n\nexport default function KnowledgeLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/knowledge/page.tsx",
    "content": "import { KnowledgeContent } from \"./components/knowledge-content\";\n\nexport default function KnowledgePage() {\n  return <KnowledgeContent />;\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/layout.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { redirect } from \"next/navigation\";\n\nimport { DashboardLayout } from \"@/components/layout/dashboard-layout\";\nimport { resolveCurrentSubscription } from \"@/lib/polar/current-subscription\";\n\nexport default async function Layout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const subscription = await resolveCurrentSubscription();\n\n  console.info(\"[Polar] Rendering dashboard layout\", {\n    isActive: subscription.isActive,\n    reason: subscription.reason,\n    status: subscription.status,\n    backendAvailable: subscription.backendAvailable,\n  });\n\n  if (!subscription.isAuthenticated) {\n    redirect(\"/auth\");\n  }\n\n  return (\n    <DashboardLayout initialSubscription={subscription}>\n      {children}\n    </DashboardLayout>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\nimport { verifyPayment } from \"@/lib/actions/billing/verify-payment\";\n\ninterface DashboardPageProps {\n  searchParams: Promise<{ checkout_id?: string }>;\n}\n\nexport default async function DashboardPage({ searchParams }: DashboardPageProps) {\n  const params = await searchParams;\n  const checkoutId = params.checkout_id;\n\n  if (checkoutId) {\n    // Call Server Action directly from Server Component\n    const result = await verifyPayment(checkoutId);\n\n    if (result.success) {\n      console.info(\"[Dashboard] Payment verified successfully\", {\n        sessionId: checkoutId,\n        hasActiveSubscription: result.data.has_active_subscription,\n      });\n      redirect(\"/dashboard/settings?view=subscription&payment_verified=true\");\n    } else {\n      console.error(\"[Dashboard] Payment verification failed\", {\n        sessionId: checkoutId,\n        error: result.error,\n      });\n      redirect(`/dashboard/settings?view=subscription&payment_error=true`);\n    }\n  }\n\n  redirect(\"/dashboard/settings\");\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/components/invite-member.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Send, CheckCircle2 } from \"lucide-react\";\nimport { MemberRole } from \"@/lib/models/member.model\";\nimport { useToast } from \"@/hooks/use-toast\";\nimport { rbacRepository } from \"@/lib/api/api/repositories/rbac-repository\";\n\ninterface InviteMemberProps {\n  canInvite: boolean;\n  onInvite: (request: {\n    email: string;\n    name: string;\n    role: MemberRole;\n    sendEmail?: boolean;\n  }) => Promise<void>;\n}\n\ninterface RoleOption {\n  id: MemberRole;\n  name: string;\n  description: string;\n  typicalUsers: string;\n}\n\n// Default roles matching the backend RBAC system\n// Used as fallback if API call fails\nconst DEFAULT_ROLES: RoleOption[] = [\n  {\n    id: \"member\",\n    name: \"Member\",\n    description: \"Basic access - can view and create resources\",\n    typicalUsers: \"Team members, staff\",\n  },\n  {\n    id: \"manager\",\n    name: \"Manager\",\n    description: \"Elevated access - can edit, delete, approve resources and view organization\",\n    typicalUsers: \"Team leads, supervisors, managers\",\n  },\n  {\n    id: \"admin\",\n    name: \"Admin\",\n    description: \"Full system control - all permissions and organization management\",\n    typicalUsers: \"Directors, system administrators\",\n  },\n];\n\nexport function InviteMember({\n  canInvite,\n  onInvite,\n}: InviteMemberProps) {\n  const [email, setEmail] = useState(\"\");\n  const [name, setName] = useState(\"\");\n  const [role, setRole] = useState<MemberRole>(\"member\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [showSuccess, setShowSuccess] = useState(false);\n  const [invitedEmail, setInvitedEmail] = useState(\"\");\n  const [roleOptions, setRoleOptions] = useState<RoleOption[]>([]);\n  const [isLoadingRoles, setIsLoadingRoles] = useState(false);\n  const [rolesError, setRolesError] = useState<string | null>(null);\n  const { toast } = useToast();\n  const [selectPortalContainer, setSelectPortalContainer] = useState<HTMLElement | null>(null);\n\n  useEffect(() => {\n    const container = document.getElementById(\"invite-member-dialog\");\n    setSelectPortalContainer(container);\n  }, []);\n\n  useEffect(() => {\n    let mounted = true;\n    const loadRoles = async () => {\n      setIsLoadingRoles(true);\n      setRolesError(null);\n      try {\n        const roles = await rbacRepository.getRoles();\n        if (!mounted) return;\n\n        const formattedRoles = roles\n          .filter((item) =>\n            [\"member\", \"manager\", \"admin\"].includes(item.id)\n          )\n          .map((item) => ({\n            id: item.id as MemberRole,\n            name: item.name,\n            description: item.description,\n            typicalUsers: item.typicalUsers,\n          }));\n\n        // Use formatted roles if available, otherwise use default fallback\n        const rolesToUse = formattedRoles.length > 0 ? formattedRoles : DEFAULT_ROLES;\n        setRoleOptions(rolesToUse);\n\n        if (rolesToUse.length > 0) {\n          const availableIds = rolesToUse.map((item) => item.id);\n          setRole((prev) =>\n            availableIds.includes(prev)\n              ? prev\n              : availableIds.includes(\"member\")\n                ? \"member\"\n                : rolesToUse[0].id\n          );\n        }\n      } catch (error) {\n        console.error(\"[InviteMember] Failed to load RBAC roles\", error);\n        if (mounted) {\n          // Use default roles on error for graceful degradation\n          setRoleOptions(DEFAULT_ROLES);\n          setRole(\"member\");\n          setRolesError(\n            \"Using default roles. Some features may be limited.\"\n          );\n        }\n      } finally {\n        if (mounted) {\n          setIsLoadingRoles(false);\n        }\n      }\n    };\n\n    loadRoles();\n    return () => {\n      mounted = false;\n    };\n  }, []);\n\n  const handleInvite = async () => {\n    // Validate name\n    if (!name.trim()) {\n      toast({\n        title: \"Name required\",\n        description: \"Please enter the member's name\",\n        variant: \"destructive\",\n      });\n      return;\n    }\n\n    // Validate email\n    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n    if (!emailRegex.test(email)) {\n      toast({\n        title: \"Invalid email\",\n        description: \"Please enter a valid email address\",\n        variant: \"destructive\",\n      });\n      return;\n    }\n\n    setIsLoading(true);\n    setShowSuccess(false);\n    try {\n      // Use the onInvite prop (handles mutation and cache invalidation)\n      await onInvite({\n        email: email.trim(),\n        name: name.trim(),\n        role: role as MemberRole,\n        sendEmail: true,\n      });\n\n      // Success - mutation completed\n      setInvitedEmail(email);\n      setShowSuccess(true);\n      toast({\n        title: \"Invitation sent\",\n        description: `${email} has been invited to join your team`,\n      });\n      setEmail(\"\");\n      setName(\"\");\n      setRole((prev) => {\n        if (roleOptions.some((option) => option.id === \"member\")) {\n          return \"member\";\n        }\n        return roleOptions[0]?.id ?? prev;\n      });\n\n      // Hide success message after 5 seconds\n      setTimeout(() => {\n        setShowSuccess(false);\n      }, 5000);\n    } catch (error: any) {\n      toast({\n        title: \"Error\",\n        description: error.message || \"Failed to send invitation. Please try again.\",\n        variant: \"destructive\",\n      });\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const currentRoleDetails = useMemo(\n    () => roleOptions.find((option) => option.id === role),\n    [roleOptions, role]\n  );\n\n  if (!canInvite) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {showSuccess && (\n        <div className=\"flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 px-4 py-3\">\n          <CheckCircle2 className=\"h-5 w-5 flex-none text-green-600\" />\n          <div className=\"flex-1\">\n            <p className=\"text-sm font-medium text-green-900\">Invitation sent successfully</p>\n            <p className=\"text-xs text-green-700 mt-0.5\">{invitedEmail} will receive an email shortly</p>\n          </div>\n        </div>\n      )}\n\n      {rolesError && (\n        <div className=\"rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900\">\n          {rolesError}\n        </div>\n      )}\n\n      <div className=\"space-y-5\">\n        <div className=\"grid grid-cols-1 gap-5 sm:grid-cols-2\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"name\" className=\"text-sm font-medium text-gray-900\">\n              Full Name\n            </Label>\n            <Input\n              id=\"name\"\n              type=\"text\"\n              placeholder=\"John Doe\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              disabled={isLoading}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"email\" className=\"text-sm font-medium text-gray-900\">\n              Email Address\n            </Label>\n            <Input\n              id=\"email\"\n              type=\"email\"\n              placeholder=\"colleague@example.com\"\n              value={email}\n              onChange={(e) => setEmail(e.target.value)}\n              disabled={isLoading}\n            />\n          </div>\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"role\" className=\"text-sm font-medium text-gray-900\">\n            Role\n          </Label>\n          <Select\n            value={role}\n            onValueChange={(value) => setRole(value as MemberRole)}\n            disabled={isLoading || isLoadingRoles || roleOptions.length === 0}\n          >\n            <SelectTrigger id=\"role\" className=\"w-full justify-between text-left\">\n              <SelectValue placeholder={isLoadingRoles ? \"Loading roles...\" : \"Select a role\"} />\n            </SelectTrigger>\n            <SelectContent container={selectPortalContainer}>\n              {roleOptions.map((option) => (\n                <SelectItem key={option.id} value={option.id}>\n                  <div className=\"space-y-1 text-left\">\n                    <p className=\"font-medium\">{option.name}</p>\n                    <p className=\"text-xs text-muted-foreground\">\n                      {option.description}\n                    </p>\n                    {option.typicalUsers && (\n                      <p className=\"text-xs text-muted-foreground\">\n                        Typical users: {option.typicalUsers}\n                      </p>\n                    )}\n                  </div>\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n          {currentRoleDetails && (\n            <p className=\"text-xs text-muted-foreground\">\n              {currentRoleDetails.description}\n            </p>\n          )}\n        </div>\n\n        <Button\n          onClick={handleInvite}\n          disabled={\n            !email ||\n            !name ||\n            isLoading ||\n            isLoadingRoles ||\n            roleOptions.length === 0\n          }\n          className=\"w-full sm:w-auto\"\n        >\n          {isLoading ? (\n            \"Sending invitation...\"\n          ) : (\n            <>\n              <Send className=\"mr-2 h-4 w-4\" />\n              Send Invitation\n            </>\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/components/member-list.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { MoreVertical, Mail, UserMinus } from \"lucide-react\";\nimport {\n  OrganizationMember,\n  MemberHelpers,\n} from \"@/lib/models/member.model\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\nimport { useToast } from \"@/hooks/use-toast\";\n\ninterface MemberListProps {\n  members: OrganizationMember[];\n  canManage: boolean;\n  currentUserId: string;\n  organizationId: string;\n  isFetching?: boolean;\n  onMemberUpdate?: () => void;\n}\n\nexport function MemberList({\n  members,\n  canManage,\n  currentUserId,\n  organizationId,\n  isFetching = false,\n  onMemberUpdate,\n}: MemberListProps) {\n  const [pendingMemberId, setPendingMemberId] = useState<string | null>(null);\n  const { toast } = useToast();\n\n  const handleRemoveMember = async (member: OrganizationMember) => {\n    const memberName = member.name || member.email;\n    if (!confirm(`Are you sure you want to remove ${memberName} from the organization?\\n\\nThis action cannot be undone.`)) {\n      return;\n    }\n\n    setPendingMemberId(member.id);\n    try {\n      const success = await memberRepository.removeMember(member.id);\n      if (success) {\n        toast({\n          title: \"Member Removed\",\n          description: `${memberName} has been removed from your organization`,\n        });\n        onMemberUpdate?.();\n      } else {\n        throw new Error(\"Failed to remove member\");\n      }\n    } catch (error) {\n      console.error(\"[MemberList] Remove member error:\", error);\n      toast({\n        title: \"Error\",\n        description: \"Failed to remove member. Please try again.\",\n        variant: \"destructive\",\n      });\n    } finally {\n      setPendingMemberId(null);\n    }\n  };\n\n  const handleResendInvite = async (memberId: string) => {\n    setPendingMemberId(memberId);\n    try {\n      const success = await memberRepository.resendInvitation(memberId);\n      if (success) {\n        toast({\n          title: \"Success\",\n          description: \"Invitation resent successfully\",\n        });\n      } else {\n        throw new Error(\"Failed to resend invitation\");\n      }\n    } catch (error) {\n      toast({\n        title: \"Error\",\n        description: \"Failed to resend invitation\",\n        variant: \"destructive\",\n      });\n    } finally {\n      setPendingMemberId(null);\n    }\n  };\n\n  return (\n    <div className=\"overflow-hidden rounded-lg border border-gray-200 bg-white\">\n      <Table>\n        <TableHeader>\n          <TableRow className=\"border-gray-200 bg-gray-50/50\">\n            <TableHead className=\"font-medium text-gray-700\">Member</TableHead>\n            <TableHead className=\"font-medium text-gray-700\">Role</TableHead>\n            <TableHead className=\"font-medium text-gray-700\">Status</TableHead>\n            <TableHead className=\"font-medium text-gray-700\">Joined</TableHead>\n            {canManage && <TableHead className=\"w-[50px]\"></TableHead>}\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {isFetching && members.length === 0 && (\n            <TableRow>\n              <TableCell colSpan={canManage ? 5 : 4} className=\"py-8 text-center\">\n                <div className=\"flex flex-col items-center gap-2\">\n                  <div className=\"h-6 w-6 animate-spin rounded-full border-4 border-gray-200 border-t-primary-500\" />\n                  <p className=\"text-sm text-muted-foreground\">Loading team members...</p>\n                </div>\n              </TableCell>\n            </TableRow>\n          )}\n          {members.map((member) => {\n            const roleConfig = MemberHelpers.getRoleConfig(member.role);\n            const statusConfig = MemberHelpers.getStatusConfig(member.status);\n            const joinedDate = MemberHelpers.formatJoinedDate(member.joinedAt);\n            const isCurrentUser = member.id === currentUserId;\n\n            return (\n              <TableRow key={member.id} className=\"border-gray-100 hover:bg-gray-50/50 transition-colors\">\n                <TableCell className=\"py-4\">\n                  <div className=\"flex items-center gap-3\">\n                    <div>\n                      <p className=\"text-sm font-medium text-gray-900\">\n                        {member.name || member.email}\n                        {isCurrentUser && (\n                          <span className=\"ml-2 text-xs font-normal text-gray-500\">(You)</span>\n                        )}\n                      </p>\n                      {member.name && (\n                        <p className=\"text-xs text-gray-500\">{member.email}</p>\n                      )}\n                    </div>\n                  </div>\n                </TableCell>\n                <TableCell className=\"py-4\">\n                  <span\n                    className={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border ${roleConfig.color}`}\n                  >\n                    {roleConfig.label}\n                  </span>\n                </TableCell>\n                <TableCell className=\"py-4\">\n                  <span className=\"text-sm font-medium text-gray-700\">{statusConfig.label}</span>\n                </TableCell>\n                <TableCell className=\"py-4 text-sm text-gray-600\">\n                  {joinedDate}\n                  {member.invitedBy && (\n                    <div className=\"text-xs text-gray-500 mt-0.5\">by {member.invitedBy}</div>\n                  )}\n                </TableCell>\n                {canManage && (\n                  <TableCell className=\"py-4\">\n                    {!isCurrentUser && (\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"sm\"\n                            disabled={pendingMemberId === member.id || isFetching}\n                            className=\"h-8 w-8 p-0\"\n                          >\n                            <MoreVertical className=\"h-4 w-4 text-gray-500\" />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent align=\"end\" className=\"w-48\">\n                          {member.status === \"pending\" && (\n                            <DropdownMenuItem\n                              disabled={pendingMemberId === member.id}\n                              onClick={() => handleResendInvite(member.id)}\n                              className=\"cursor-pointer\"\n                            >\n                              <Mail className=\"mr-2 h-4 w-4\" />\n                              Resend Invite\n                            </DropdownMenuItem>\n                          )}\n                          <DropdownMenuItem\n                            disabled={pendingMemberId === member.id}\n                            onClick={() => handleRemoveMember(member)}\n                            className=\"cursor-pointer text-red-600 focus:text-red-600\"\n                          >\n                            <UserMinus className=\"mr-2 h-4 w-4\" />\n                            Remove Member\n                          </DropdownMenuItem>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                    )}\n                  </TableCell>\n                )}\n              </TableRow>\n            );\n          })}\n          {!isFetching && members.length === 0 && (\n            <TableRow>\n              <TableCell colSpan={canManage ? 5 : 4} className=\"text-center py-8\">\n                <p className=\"text-sm text-muted-foreground\">No members found</p>\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/components/profile-section.tsx",
    "content": "\"use client\";\n\nimport { UserProfile, MemberHelpers } from \"@/lib/models/member.model\";\n\ninterface ProfileSectionProps {\n  profile: UserProfile;\n}\n\nexport function ProfileSection({ profile }: ProfileSectionProps) {\n  const roleConfig = MemberHelpers.getRoleConfig(profile.role);\n  const displayName =\n    profile.name?.trim() ||\n    (profile.email ? profile.email.split(\"@\")[0] : \"AP Cash member\");\n\n  return (\n    <div className=\"grid gap-6 lg:grid-cols-2\">\n      <section className=\"rounded-2xl border border-gray-200 bg-white p-6 shadow-sm\">\n        <header className=\"space-y-1\">\n          <p className=\"text-xs font-semibold uppercase tracking-[0.2em] text-gray-500\">\n            Account owner\n          </p>\n          <h3 className=\"text-2xl font-semibold text-gray-900\">{displayName}</h3>\n          <p className=\"text-sm text-gray-600\">\n            These details identify you across automations and approvals.\n          </p>\n        </header>\n\n        <dl className=\"mt-8 space-y-5 text-sm\">\n          <div className=\"flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between\">\n            <dt className=\"text-xs font-semibold uppercase tracking-wide text-gray-500\">\n              Email\n            </dt>\n            <dd className=\"text-base font-medium text-gray-900\">{profile.email}</dd>\n          </div>\n\n          <div className=\"flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between\">\n            <dt className=\"text-xs font-semibold uppercase tracking-wide text-gray-500\">\n              Display name\n            </dt>\n            <dd className=\"text-base font-medium text-gray-900\">\n              {displayName}\n            </dd>\n          </div>\n\n          <div className=\"flex flex-col gap-2\">\n            <dt className=\"text-xs font-semibold uppercase tracking-wide text-gray-500\">\n              Access level\n            </dt>\n            <dd>\n              <span\n                className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium ${roleConfig.color}`}\n              >\n                {roleConfig.label}\n              </span>\n              <p className=\"mt-2 text-xs text-gray-500\">{roleConfig.description}</p>\n            </dd>\n          </div>\n        </dl>\n      </section>\n\n      <section className=\"rounded-2xl border border-gray-200 bg-white p-6 shadow-sm\">\n        <header className=\"space-y-1\">\n          <p className=\"text-xs font-semibold uppercase tracking-[0.2em] text-gray-500\">\n            Workspace\n          </p>\n          <h3 className=\"text-xl font-semibold text-gray-900\">\n            {profile.organizationName || \"No workspace connected\"}\n          </h3>\n          <p className=\"text-sm text-gray-600\">\n            Configure branding, invite collaborators, and manage approvals within this workspace.\n          </p>\n        </header>\n\n        <div className=\"mt-8 space-y-4 text-sm\">\n          <div className=\"rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3\">\n            <p className=\"text-xs font-semibold uppercase tracking-wide text-gray-500\">\n              Workspace ID\n            </p>\n            <p className=\"mt-1 font-medium text-gray-900\">\n              {profile.organizationId || \"Not assigned\"}\n            </p>\n            <p className=\"mt-2 text-xs text-gray-500\">\n              You&apos;ll need this ID when connecting AP Cash to external approval tools.\n            </p>\n          </div>\n          <p className=\"text-xs text-gray-500\">\n            Need to switch workspaces or update billing ownership? Reach out to support so we can\n            take care of it for you.\n          </p>\n        </div>\n      </section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/components/profile-tab.tsx",
    "content": "// app/dashboard/settings/components/profile-tab.tsx\n\n\"use client\";\n\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { RefreshCcw } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nimport { ProfileSection } from \"./profile-section\";\nimport { MemberList } from \"./member-list\";\nimport { InviteMember } from \"./invite-member\";\nimport type { OrganizationMember, UserProfile, InviteMemberRequest } from \"@/lib/models/member.model\";\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n\ninterface ProfileTabProps {\n  profile: UserProfile;\n  members: OrganizationMember[];\n  isMembersLoading: boolean;\n  membersError: string | null;\n  canManageMembers: boolean;\n  onInvite: (request: InviteMemberRequest) => Promise<void>;\n  onRefreshMembers: () => void;\n}\n\nexport function ProfileTab({\n  profile,\n  members,\n  isMembersLoading,\n  membersError,\n  canManageMembers,\n  onInvite,\n  onRefreshMembers,\n}: ProfileTabProps) {\n  const { hasPermission } = usePermissions();\n  const canInvite = hasPermission(PERMISSIONS.ORG_MANAGE);\n\n  const organizationId = profile.organizationId;\n  const hasOrganization = Boolean(organizationId);\n  const canInviteMembers = canInvite && hasOrganization;\n  const canViewMembers = canManageMembers && hasOrganization;\n\n  return (\n    <div className={cn(\"grid gap-6\", canManageMembers ? \"lg:grid-cols-[360px,1fr]\" : \"\")}>\n      <Card className=\"self-start\">\n        <CardHeader className=\"border-b border-gray-100 pb-4\">\n          <CardTitle>Profile</CardTitle>\n          <CardDescription>\n            Personal details visible to your teammates.\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"pt-6\">\n          <ProfileSection profile={profile} />\n        </CardContent>\n      </Card>\n\n      {canManageMembers && (\n        <div className=\"space-y-6\">\n          <Card>\n            <CardHeader className=\"border-b border-gray-100 pb-4\">\n              <CardTitle>Invite a teammate</CardTitle>\n              <CardDescription>\n                Send a secure invitation email and assign the right role upfront.\n              </CardDescription>\n            </CardHeader>\n            <CardContent className=\"pt-6\">\n              {canInviteMembers ? (\n                <InviteMember\n                  canInvite={canInviteMembers}\n                  onInvite={onInvite}\n                />\n              ) : (\n                <Alert className=\"border border-amber-200 bg-amber-50\">\n                  <AlertTitle>Organization required</AlertTitle>\n                  <AlertDescription>\n                    We could not determine your organization. Please refresh and try again.\n                  </AlertDescription>\n                </Alert>\n              )}\n            </CardContent>\n          </Card>\n\n          <Card>\n            <CardHeader className=\"flex items-start justify-between gap-3 border-b border-gray-100 pb-4\">\n              <div>\n                <CardTitle>Team members</CardTitle>\n                <CardDescription>\n                  Review account access and manage team roles.\n                </CardDescription>\n              </div>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={onRefreshMembers}\n                disabled={isMembersLoading || !canViewMembers}\n                className=\"mt-1\"\n              >\n                <RefreshCcw className=\"mr-2 h-4 w-4\" />\n                Refresh\n              </Button>\n            </CardHeader>\n            <CardContent className=\"pt-6\">\n              {membersError && (\n                <Alert\n                  variant=\"destructive\"\n                  className=\"mb-4 border border-red-200 bg-red-50\"\n                >\n                  <AlertTitle>Heads up</AlertTitle>\n                  <AlertDescription>{membersError}</AlertDescription>\n                </Alert>\n              )}\n              {isMembersLoading && members.length > 0 && (\n                <div className=\"mb-4 flex items-center gap-2 text-sm text-muted-foreground\">\n                  <span className=\"inline-flex h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primary-500\" />\n                  Refreshing team roster...\n                </div>\n              )}\n              {canViewMembers ? (\n                <MemberList\n                  members={members}\n                  canManage={canManageMembers}\n                  currentUserId={profile.id}\n                  organizationId={organizationId}\n                  isFetching={isMembersLoading}\n                  onMemberUpdate={onRefreshMembers}\n                />\n              ) : (\n                <p className=\"text-sm text-muted-foreground\">\n                  Join or switch into an organization to manage members.\n                </p>\n              )}\n            </CardContent>\n          </Card>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/components/settings-content.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useMemo, useState, useRef } from \"react\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  User,\n  Users,\n  CreditCard,\n  RefreshCcw,\n  ArrowLeft,\n  ChevronRight,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { format } from \"date-fns\";\nimport { toast } from \"sonner\";\n\nimport { ProfileSection } from \"./profile-section\";\nimport { MemberList } from \"./member-list\";\nimport { InviteMember } from \"./invite-member\";\nimport { MemberHelpers } from \"@/lib/models/member.model\";\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport type { SubscriptionGateState } from \"@/lib/polar/current-subscription\";\nimport { SubscriptionTab } from \"./subscription-tab\";\n\n// Query hooks - Component depends ONLY on these hooks\nimport { useProfileQuery } from \"@/lib/hooks/queries/use-profile-query\";\nimport { useMembersQuery } from \"@/lib/hooks/queries/use-members-query\";\nimport { useSubscriptionQuery } from \"@/lib/hooks/queries/use-subscription-query\";\nimport { useInviteMember } from \"@/lib/hooks/mutations/use-invite-member\";\nimport type { InviteMemberRequest } from \"@/lib/models/member.model\";\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\";\n\ninterface SettingsContentProps {\n  // No props required - component fetches its own data\n}\n\ntype SettingsView = \"overview\" | \"profile\" | \"members\" | \"subscription\";\n\ninterface OverviewSection {\n  key: Exclude<SettingsView, \"overview\">;\n  title: string;\n  description: string;\n  value: string;\n  helper: string;\n  icon: LucideIcon;\n  disabled?: boolean;\n}\n\nconst DETAIL_META: Record<Exclude<SettingsView, \"overview\">, { title: string; description: string }> = {\n  profile: {\n    title: \"Account & workspace\",\n    description: \"Update your profile details and workspace metadata.\",\n  },\n  members: {\n    title: \"Team access\",\n    description: \"Invite new teammates, manage existing members, and adjust permissions.\",\n  },\n  subscription: {\n    title: \"Subscription & billing\",\n    description: \"Review your subscription status, usage limits, and cancellation controls.\",\n  },\n};\n\nfunction parseViewParam(raw: string | null): SettingsView | null {\n  if (!raw) return null;\n  const normalized = raw.toLowerCase();\n  if (normalized === \"profile\" || normalized === \"members\" || normalized === \"subscription\") {\n    return normalized as SettingsView;\n  }\n  return null;\n}\n\nfunction getPlanNameFromRecord(record: Record<string, unknown> | null | undefined) {\n  if (!record || typeof record !== \"object\") {\n    return null;\n  }\n\n  const planKeys = [\n    \"plan_name\",\n    \"plan_label\",\n    \"plan_display_name\",\n    \"subscription_name\",\n    \"product_name\",\n    \"name\",\n  ];\n\n  for (const key of planKeys) {\n    const value = record[key];\n    if (typeof value === \"string\") {\n      const trimmed = value.trim();\n      if (trimmed.length > 0) {\n        return trimmed;\n      }\n    }\n  }\n\n  return null;\n}\n\nfunction resolvePlanLabel(state: SubscriptionGateState | null): string {\n  if (!state) {\n    return \"Active plan\";\n  }\n\n  const planNameFromSubscription = getPlanNameFromRecord(\n    state.subscription?.metadata ?? undefined\n  );\n  if (planNameFromSubscription) {\n    return planNameFromSubscription;\n  }\n\n  const planNameFromCustomFields = getPlanNameFromRecord(\n    state.subscription?.customFieldData ?? undefined\n  );\n  if (planNameFromCustomFields) {\n    return planNameFromCustomFields;\n  }\n\n  const planNameFromProduct = getPlanNameFromRecord(\n    state.subscription?.productMetadata ?? undefined\n  );\n  if (planNameFromProduct) {\n    return planNameFromProduct;\n  }\n\n  if (state.subscription?.productName) {\n    return state.subscription.productName;\n  }\n\n  return \"Active plan\";\n}\n\nfunction getSubscriptionQuickStatus(\n  state: SubscriptionGateState | null,\n  isLoading: boolean\n) {\n  if (isLoading && !state) {\n    return {\n      title: \"Loading…\",\n      helper: \"Fetching your Polar subscription.\",\n    };\n  }\n\n  if (!state) {\n    return {\n      title: \"No active plan\",\n      helper: \"Choose a plan below to unlock automations.\",\n    };\n  }\n\n  if (state.reason === \"POLAR_UNCONFIGURED\") {\n    return {\n      title: \"Setup required\",\n      helper: \"Add Polar credentials in the environment to enable billing.\",\n    };\n  }\n\n  if (state.reason === \"BACKEND_UNAVAILABLE\") {\n    return {\n      title: \"Temporarily unavailable\",\n      helper: \"We're still connecting to Polar. Try refreshing shortly.\",\n    };\n  }\n\n  if (!state.isActive || state.reason === \"NO_ACTIVE_SUBSCRIPTION\") {\n    return {\n      title: \"No active plan\",\n      helper: \"Select a plan below to keep automations running.\",\n    };\n  }\n\n  if (state.subscription?.cancelAtPeriodEnd) {\n    const cancellationDate = state.subscription.currentPeriodEnd\n      ? format(new Date(state.subscription.currentPeriodEnd), \"MMM d, yyyy\")\n      : null;\n\n    return {\n      title: \"Cancels soon\",\n      helper: cancellationDate\n        ? `Ends on ${cancellationDate}. Update your plan below to stay active.`\n        : \"Scheduled to cancel at period end.\",\n      };\n  }\n\n  const planLabel = resolvePlanLabel(state);\n  const renewalDate = state.subscription?.currentPeriodEnd\n    ? format(new Date(state.subscription.currentPeriodEnd), \"MMM d, yyyy\")\n    : null;\n\n  return {\n    title: planLabel,\n    helper: renewalDate ? `Renews on ${renewalDate}.` : \"Billing is managed through Polar.\",\n  };\n}\n\nexport function SettingsContent({}: SettingsContentProps = {}) {\n  const {\n    hasPermission,\n    isInitialized: permissionsReady,\n  } = usePermissions();\n  const canManageMembers = hasPermission(PERMISSIONS.ORG_MANAGE);\n  const hasSubscriptionPermission = hasPermission(PERMISSIONS.ORG_MANAGE);\n  const shouldLoadSubscription = permissionsReady && hasSubscriptionPermission;\n\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n  const viewParam = searchParams.get(\"view\");\n  const paymentVerified = searchParams.get(\"payment_verified\");\n  const paymentError = searchParams.get(\"payment_error\");\n\n  const [viewStack, setViewStack] = useState<SettingsView[]>([\"overview\"]);\n  const currentView = viewStack[viewStack.length - 1];\n  const [isInviteModalOpen, setInviteModalOpen] = useState(false);\n\n  // Track if we've shown the payment toast to prevent duplicates\n  const paymentToastShownRef = useRef(false);\n\n  // Handle payment verification feedback\n  useEffect(() => {\n    if (paymentToastShownRef.current) return;\n\n    if (paymentVerified === \"true\") {\n      paymentToastShownRef.current = true;\n      toast.success(\"Subscription activated successfully!\", {\n        description: \"Your payment has been verified and your subscription is now active.\",\n      });\n\n      // Clean up the URL params\n      const params = new URLSearchParams(searchParams.toString());\n      params.delete(\"payment_verified\");\n      const queryString = params.toString();\n      router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false });\n    } else if (paymentError === \"true\") {\n      paymentToastShownRef.current = true;\n      toast.error(\"Payment verification issue\", {\n        description: \"We couldn't verify your payment immediately. Your subscription should activate shortly.\",\n      });\n\n      // Clean up the URL params\n      const params = new URLSearchParams(searchParams.toString());\n      params.delete(\"payment_error\");\n      const queryString = params.toString();\n      router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false });\n    }\n  }, [paymentVerified, paymentError, router, pathname, searchParams]);\n\n  useEffect(() => {\n    if (!permissionsReady) return;\n    const requested = parseViewParam(viewParam);\n    if (!requested) return;\n    if (requested === \"members\" && !canManageMembers) return;\n    if (requested === \"subscription\" && !hasSubscriptionPermission) return;\n\n    setViewStack((stack) => {\n      if (stack[stack.length - 1] === requested) {\n        return stack;\n      }\n      return [\"overview\", requested];\n    });\n  }, [permissionsReady, canManageMembers, hasSubscriptionPermission, viewParam]);\n\n  const pushView = (view: Exclude<SettingsView, \"overview\">) => {\n    setViewStack((stack) => {\n      if (stack[stack.length - 1] === view) {\n        return stack;\n      }\n      return [...stack, view];\n    });\n  };\n\n  const goBack = () => {\n    setViewStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack));\n  };\n\n  useEffect(() => {\n    const desiredParam = currentView === \"overview\" ? null : currentView;\n    if (desiredParam === viewParam) {\n      return;\n    }\n    const params = new URLSearchParams(searchParams.toString());\n    if (desiredParam) {\n      params.set(\"view\", desiredParam);\n    } else {\n      params.delete(\"view\");\n    }\n    const queryString = params.toString();\n    router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false });\n  }, [currentView, viewParam, router, pathname, searchParams]);\n\n  // Use query hooks - data is cached and reused globally\n  const {\n    data: profile,\n    isLoading: isProfileLoading,\n    error: profileError,\n    refetch: refetchProfile,\n  } = useProfileQuery({\n    enabled: permissionsReady,\n  });\n\n  const {\n    data: membersData,\n    isLoading: isMembersLoading,\n    isFetching: isMembersFetching,\n    error: membersError,\n    refetch: refetchMembers,\n  } = useMembersQuery(\n    {\n      organizationId: profile?.organizationId,\n      page: 1,\n      pageSize: 50,\n      enabled: canManageMembers && Boolean(profile?.organizationId),\n    }\n  );\n\n  const {\n    data: subscriptionState,\n    isLoading: isSubscriptionLoading,\n    error: subscriptionError,\n    refetch: refetchSubscription,\n  } = useSubscriptionQuery({\n    enabled: shouldLoadSubscription,\n  });\n\n  // Mutations\n  const inviteMemberMutation = useInviteMember();\n\n  // Memoized values - MUST be before any early returns (React Hooks rules)\n  const members = membersData?.members ?? [];\n  const organizationId = profile?.organizationId ?? \"\";\n  const hasOrganization = Boolean(organizationId);\n  const canInviteMembers = canManageMembers && hasOrganization;\n  const canViewMembers = canManageMembers && hasOrganization;\n\n  useEffect(() => {\n    if (currentView === \"subscription\" && !hasSubscriptionPermission) {\n      setViewStack([\"overview\"]);\n    }\n  }, [currentView, hasSubscriptionPermission]);\n\n  useEffect(() => {\n    if (currentView === \"members\" && (!canManageMembers || !hasOrganization)) {\n      setViewStack([\"overview\"]);\n    }\n  }, [currentView, canManageMembers, hasOrganization]);\n\n  const subscriptionQuick = useMemo(() => {\n    if (!hasSubscriptionPermission) {\n      return null;\n    }\n\n    return getSubscriptionQuickStatus(\n      subscriptionState ?? null,\n      isSubscriptionLoading\n    );\n  }, [hasSubscriptionPermission, subscriptionState, isSubscriptionLoading]);\n\n  const roleConfig = useMemo(\n    () => profile ? MemberHelpers.getRoleConfig(profile.role) : MemberHelpers.getRoleConfig(\"member\"),\n    [profile]\n  );\n\n  const membersErrorMessage = membersError?.message ?? null;\n  const subscriptionErrorMessage = subscriptionError?.message ?? null;\n\n  const overviewSections = useMemo<OverviewSection[]>(() => {\n    if (!profile) {\n      return [];\n    }\n\n    const sections: OverviewSection[] = [];\n\n    const accountLabel =\n      profile.name?.trim().length\n        ? profile.name\n        : profile.email ?? \"Account\";\n    const workspaceLabel =\n      profile.organizationName?.trim().length\n        ? profile.organizationName\n        : \"No workspace assigned\";\n\n    sections.push({\n      key: \"profile\",\n      title: \"Account & workspace\",\n      description: \"Profile identity, workspace label, and contact details.\",\n      value: accountLabel,\n      helper: `${workspaceLabel} • ${roleConfig.label}`,\n      icon: User,\n    });\n\n    if (canManageMembers) {\n      const disabled = !hasOrganization;\n\n      let value = \"Invite teammates\";\n      let helper = \"Bring collaborators into the workflow.\";\n\n      if (disabled) {\n        value = \"No organization\";\n        helper = \"Join or create an organization to manage team access.\";\n      } else if (membersErrorMessage) {\n        value = \"Needs attention\";\n        helper = membersErrorMessage;\n      } else if (isMembersLoading && members.length === 0) {\n        value = \"Loading…\";\n        helper = \"Fetching your team roster.\";\n      } else if (members.length > 0) {\n        value = `${members.length} ${members.length === 1 ? \"member\" : \"members\"}`;\n        helper = \"Manage roles, invitations, and permissions.\";\n      }\n\n      sections.push({\n        key: \"members\",\n        title: \"Team access\",\n        description: \"Invite teammates and fine-tune their permissions.\",\n        value,\n        helper,\n        icon: Users,\n        disabled,\n      });\n    }\n\n    if (hasSubscriptionPermission) {\n      let value = \"Open details\";\n      let helper = \"Review plans, renewals, usage, and invoices.\";\n\n      if (subscriptionErrorMessage) {\n        value = \"Needs attention\";\n        helper = subscriptionErrorMessage;\n      } else if (subscriptionQuick) {\n        value = subscriptionQuick.title;\n        helper = subscriptionQuick.helper;\n      } else if (isSubscriptionLoading) {\n        value = \"Loading…\";\n        helper = \"Fetching your Polar subscription.\";\n      }\n\n      sections.push({\n        key: \"subscription\",\n        title: \"Subscription & billing\",\n        description: \"Manage plan changes, billing history, and usage.\",\n        value,\n        helper,\n        icon: CreditCard,\n      });\n    }\n\n    return sections;\n  }, [\n    profile,\n    roleConfig.label,\n    canManageMembers,\n    hasOrganization,\n    members.length,\n    membersErrorMessage,\n    isMembersLoading,\n    hasSubscriptionPermission,\n    subscriptionQuick,\n    isSubscriptionLoading,\n    subscriptionErrorMessage,\n  ]);\n\n  // Handle invite member\n  const handleInvite = async (request: InviteMemberRequest) => {\n    if (!profile?.organizationId) {\n      return;\n    }\n\n    await inviteMemberMutation.mutateAsync({\n      request,\n      organizationId: profile.organizationId,\n    });\n    setInviteModalOpen(false);\n    // Members list automatically refetches due to invalidation in mutation\n  };\n\n  // Handle refresh members (manual refetch)\n  const handleRefreshMembers = () => {\n    refetchMembers();\n  };\n\n  // Handle refresh subscription\n  const handleRefreshSubscription = async () => {\n    await refetchSubscription();\n  };\n\n  const isOverview = currentView === \"overview\";\n  const activeDetailMeta =\n    currentView === \"overview\"\n      ? null\n      : DETAIL_META[currentView as Exclude<SettingsView, \"overview\">];\n  const activeSectionSummary = overviewSections.find(\n    (section) => section.key === currentView\n  );\n\n  const renderDetailContent = () => {\n    if (!profile) {\n      return null;\n    }\n\n    switch (currentView) {\n      case \"profile\":\n        return (\n          <div className=\"space-y-6\">\n            <ProfileSection profile={profile} />\n          </div>\n        );\n      case \"members\":\n        if (!canManageMembers || !hasOrganization) {\n          return (\n            <Alert className=\"border border-amber-200 bg-amber-50\">\n              <AlertTitle>Organization required</AlertTitle>\n              <AlertDescription>\n                Join or create an organization to manage team members.\n              </AlertDescription>\n            </Alert>\n          );\n        }\n        return (\n          <>\n            <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n              <div className=\"space-y-1\">\n                <h3 className=\"text-xl font-semibold text-gray-900\">Team roster</h3>\n                <p className=\"text-sm text-gray-600\">\n                  Review every teammate in your workspace and keep roles current.\n                </p>\n              </div>\n              <Button\n                onClick={() => setInviteModalOpen(true)}\n                disabled={!canInviteMembers}\n                className=\"w-full bg-gray-900 text-white hover:bg-gray-800 sm:w-auto\"\n              >\n                Add member\n              </Button>\n            </div>\n\n            {membersError && (\n              <Alert\n                variant=\"destructive\"\n                className=\"border border-red-200 bg-red-50\"\n              >\n                <AlertTitle>Error</AlertTitle>\n                <AlertDescription>\n                  {membersError.message || \"Failed to load team members\"}\n                </AlertDescription>\n              </Alert>\n            )}\n\n            {isMembersFetching && members.length > 0 && (\n              <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                <span className=\"inline-flex h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primary-500\" />\n                Refreshing team roster…\n              </div>\n            )}\n\n            {canViewMembers ? (\n              <MemberList\n                members={members}\n                canManage={canManageMembers}\n                currentUserId={profile.id}\n                organizationId={organizationId}\n                isFetching={isMembersFetching}\n                onMemberUpdate={handleRefreshMembers}\n              />\n            ) : (\n              <p className=\"text-sm text-muted-foreground\">\n                Join or switch into an organization to manage members.\n              </p>\n            )}\n\n            <Dialog\n              open={isInviteModalOpen}\n              onOpenChange={(open) => {\n                if (inviteMemberMutation.isPending) {\n                  return;\n                }\n                setInviteModalOpen(open);\n              }}\n            >\n              <DialogContent id=\"invite-member-dialog\" className=\"sm:max-w-lg\">\n                <DialogHeader className=\"space-y-2 text-left\">\n                  <DialogTitle className=\"text-xl font-semibold text-gray-900\">\n                    Add a teammate\n                  </DialogTitle>\n                  <DialogDescription className=\"text-sm text-gray-600\">\n                    Send a secure invitation and assign the right access before they join.\n                  </DialogDescription>\n                </DialogHeader>\n                <div className=\"pt-4\">\n                  <InviteMember\n                    canInvite={canInviteMembers}\n                    onInvite={handleInvite}\n                  />\n                </div>\n              </DialogContent>\n            </Dialog>\n          </>\n        );\n      case \"subscription\":\n        if (!hasSubscriptionPermission) {\n          return (\n            <Alert variant=\"destructive\" className=\"border border-red-200 bg-red-50\">\n              <AlertTitle>Access restricted</AlertTitle>\n              <AlertDescription>\n                You don&apos;t have permission to manage subscription or billing settings.\n              </AlertDescription>\n            </Alert>\n          );\n        }\n\n        return (\n          <SubscriptionTab\n            state={shouldLoadSubscription ? subscriptionState ?? null : null}\n            isLoading={shouldLoadSubscription ? isSubscriptionLoading : false}\n            error={shouldLoadSubscription ? subscriptionErrorMessage : null}\n            onRefresh={handleRefreshSubscription}\n          />\n        );\n      default:\n        return null;\n    }\n  };\n\n  // Loading state\n  const isLoading = isProfileLoading || !permissionsReady;\n\n  if (isLoading) {\n    return (\n      <div className=\"space-y-6\">\n        <Skeleton className=\"h-10 w-64\" />\n        <Skeleton className=\"h-[600px] rounded-xl\" />\n      </div>\n    );\n  }\n\n  // Error state\n  if (profileError || !profile) {\n    return (\n      <div className=\"flex min-h-[400px] flex-col items-center justify-center space-y-4 rounded-xl border border-red-200 bg-red-50 px-6 py-12 text-center\">\n        <p className=\"text-sm font-medium text-red-900\">\n          {profileError?.message || \"Failed to load settings\"}\n        </p>\n        <Button variant=\"outline\" onClick={() => refetchProfile()}>\n          Try again\n        </Button>\n      </div>\n    );\n  }\n\n  if (!isOverview && activeDetailMeta) {\n    const SummaryIcon = activeSectionSummary?.icon ?? null;\n\n    return (\n      <div className=\"space-y-8\">\n        <div>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={goBack}\n            className=\"group inline-flex items-center gap-2 px-0 text-gray-600 hover:text-gray-900\"\n          >\n            <ArrowLeft className=\"h-4 w-4 transition-transform group-hover:-translate-x-0.5\" />\n            Back\n          </Button>\n        </div>\n\n        <div className=\"space-y-2\">\n          <h1 className=\"text-3xl font-semibold text-gray-900\">\n            {activeDetailMeta.title}\n          </h1>\n          <p className=\"text-sm text-gray-600\">\n            {activeDetailMeta.description}\n          </p>\n        </div>\n\n        {activeSectionSummary && SummaryIcon ? (\n          <div className=\"rounded-2xl border border-gray-200 bg-white p-5 shadow-sm\">\n            <div className=\"flex items-start gap-4\">\n              <div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-gray-100\">\n                <SummaryIcon className=\"h-5 w-5 text-gray-600\" />\n              </div>\n              <div>\n                <p className=\"text-xs font-semibold uppercase tracking-[0.2em] text-gray-500\">\n                  {activeSectionSummary.title}\n                </p>\n                <p className=\"mt-2 text-lg font-semibold text-gray-900\">\n                  {activeSectionSummary.value}\n                </p>\n                <p className=\"mt-1 text-sm text-gray-600\">\n                  {activeSectionSummary.helper}\n                </p>\n              </div>\n            </div>\n          </div>\n        ) : null}\n\n        <div className=\"space-y-6\">\n          {renderDetailContent()}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-8\">\n      <div className=\"space-y-2\">\n        <h1 className=\"text-3xl font-semibold text-gray-900\">Workspace settings</h1>\n        <p className=\"text-sm text-gray-600\">\n          Open a section below to review the full details without the clutter.\n        </p>\n      </div>\n\n      <div className=\"overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm\">\n        <ul className=\"divide-y divide-gray-100\">\n          {overviewSections.map((section) => {\n            const SectionIcon = section.icon;\n            const isDisabled = Boolean(section.disabled);\n\n            return (\n              <li key={section.key}>\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    if (!isDisabled) {\n                      pushView(section.key);\n                    }\n                  }}\n                  disabled={isDisabled}\n                  className={`flex w-full items-start justify-between gap-6 px-6 py-5 text-left transition ${\n                    isDisabled\n                      ? \"cursor-not-allowed opacity-60\"\n                      : \"hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-900/10\"\n                  }`}\n                >\n                  <div className=\"flex items-start gap-4\">\n                    <div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-gray-100\">\n                      <SectionIcon className=\"h-5 w-5 text-gray-600\" />\n                    </div>\n                    <div className=\"space-y-1\">\n                      <p className=\"text-sm font-semibold text-gray-900\">\n                        {section.title}\n                      </p>\n                      <p className=\"text-sm text-gray-600\">\n                        {section.description}\n                      </p>\n                    </div>\n                  </div>\n                  <div className=\"flex items-center gap-4\">\n                    <div className=\"text-right\">\n                      <p className=\"text-base font-semibold text-gray-900\">\n                        {section.value}\n                      </p>\n                      <p className=\"mt-1 text-xs text-gray-500\">\n                        {section.helper}\n                      </p>\n                    </div>\n                    <ChevronRight className=\"h-4 w-4 text-gray-400\" aria-hidden=\"true\" />\n                  </div>\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/components/subscription-tab.tsx",
    "content": "\"use client\";\n\nimport { useMemo, useState, useTransition } from \"react\";\nimport { format } from \"date-fns\";\nimport {\n  AlertTriangle,\n  CalendarDays,\n  CheckCircle2,\n  Clock,\n  LifeBuoy,\n  Loader2,\n  RefreshCcw,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { PlansModal } from \"@/components/billing/plans-modal\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport type { SubscriptionGateState } from \"@/lib/polar/current-subscription\";\nimport { getPlanById, getPlanByProductId } from \"@/lib/polar/plans\";\nimport { useProductsQuery } from \"@/lib/hooks/queries/use-products-query\";\nimport { cancelSubscription } from \"@/lib/actions/billing/cancel-subscription\";\n\ninterface SubscriptionTabProps {\n  state: SubscriptionGateState | null;\n  isLoading: boolean;\n  error: string | null;\n  onRefresh: () => Promise<void>;\n}\n\nexport function SubscriptionTab({\n  state,\n  isLoading,\n  error,\n  onRefresh,\n}: SubscriptionTabProps) {\n  const [isPlansOpen, setPlansOpen] = useState(false);\n  const [isPlanChangePending, setPlanChangePending] = useState(false);\n  const [actionError, setActionError] = useState<string | null>(null);\n  const [actionState, setActionState] = useState<\"idle\" | \"cancelling\" | \"resuming\">(\n    \"idle\"\n  );\n  const [isCancelDialogOpen, setCancelDialogOpen] = useState(false);\n  const [cancelInput, setCancelInput] = useState(\"\");\n  const [isPending, startTransition] = useTransition();\n\n  const { data: products } = useProductsQuery();\n\n  const billingConfigured = state?.reason !== \"POLAR_UNCONFIGURED\";\n  const isActive = Boolean(state?.isActive);\n  const showInactive = !isActive || state?.reason === \"NO_ACTIVE_SUBSCRIPTION\";\n  const canInteract = billingConfigured && !isPlanChangePending && actionState === \"idle\";\n\n  const plan = useMemo(() => {\n    if (!state || !products) return null;\n    if (state.planId) {\n      const byId = getPlanById(products, state.planId);\n      if (byId) return byId;\n    }\n    return getPlanByProductId(products, state.subscription?.productId ?? null);\n  }, [state, products]);\n\n  const usage = state?.usage;\n  const includedInvoices = usage?.included ?? plan?.includedInvoices ?? 0;\n  const usedInvoices = usage?.used ?? 0;\n  const usagePercent =\n    includedInvoices > 0\n      ? Math.min(100, Math.round((usedInvoices / includedInvoices) * 100))\n      : 0;\n\n  const nextBillingDate = state?.subscription?.currentPeriodEnd\n    ? new Date(state.subscription.currentPeriodEnd)\n    : null;\n  const trialEndDate = state?.subscription?.trialEnd\n    ? new Date(state.subscription.trialEnd)\n    : null;\n  const cancelAtPeriodEnd = state?.subscription?.cancelAtPeriodEnd ?? false;\n\n  const statusDisplay = getStatusDisplay(state, cancelAtPeriodEnd);\n  const planPrice =\n    plan?.price != null\n      ? new Intl.NumberFormat(undefined, {\n          style: \"currency\",\n          currency: \"USD\",\n        }).format(plan.price)\n      : null;\n\n  if (isLoading && !state) {\n    return <SubscriptionSkeleton />;\n  }\n\n  const contactHref =\n    process.env.NEXT_PUBLIC_CONTACT_EMAIL ||\n    process.env.NOTIFICATION_EMAIL ||\n    process.env.NOTIFICATION_EMAIL ||\n    \"mailto:info@yourdomain.com\";\n\n  if (!billingConfigured) {\n    return (\n      <Card className=\"border-amber-200 bg-amber-50/60\">\n        <CardHeader className=\"flex items-start gap-3\">\n          <AlertTriangle className=\"mt-1 h-5 w-5 text-amber-500\" />\n          <div className=\"space-y-1\">\n            <CardTitle className=\"text-lg text-amber-900\">\n              Polar configuration required\n            </CardTitle>\n            <CardDescription className=\"text-sm text-amber-800\">\n              Add <code>POLAR_ACCESS_TOKEN</code> and <code>POLAR_WEBHOOK_SECRET</code>{\" \"}\n              to your environment to enable subscription management.\n            </CardDescription>\n          </div>\n        </CardHeader>\n        <CardContent className=\"flex flex-wrap gap-3\">\n          <Button\n            variant=\"outline\"\n            onClick={() => {\n              void onRefresh();\n            }}\n          >\n            <RefreshCcw className=\"mr-2 h-4 w-4\" />\n            Check again\n          </Button>\n          <Button variant=\"ghost\" asChild className=\"text-amber-800 hover:text-amber-900\">\n            <a href={contactHref}>Talk to support</a>\n          </Button>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  const overlayActive = isPlanChangePending || actionState !== \"idle\";\n\n  const summarySection = showInactive ? (\n    <section className=\"rounded-3xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm\">\n      <div className=\"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100\">\n        <AlertTriangle className=\"h-6 w-6 text-gray-500\" />\n      </div>\n      <h3 className=\"mt-6 text-2xl font-semibold text-gray-900\">No active plan</h3>\n      <p className=\"mt-2 text-sm text-gray-600\">\n        Choose a plan to unlock automations. You&apos;ll be redirected to a secure\n        Polar checkout page to confirm your subscription.\n      </p>\n      <div className=\"mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row\">\n        <Button\n          onClick={() => setPlansOpen(true)}\n          disabled={!canInteract}\n          className=\"bg-gray-900 text-white hover:bg-gray-800\"\n        >\n          Browse plans\n        </Button>\n        <Button variant=\"outline\" asChild>\n          <a href={contactHref} className=\"text-sm\">\n            Talk to sales\n          </a>\n        </Button>\n      </div>\n    </section>\n  ) : (\n    <section className=\"rounded-3xl border border-gray-200 bg-white p-8 shadow-sm\">\n      <div className=\"flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between\">\n        <div className=\"space-y-2\">\n          <div className=\"inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-gray-600\">\n            <CalendarDays className=\"h-3.5 w-3.5\" />\n            Current plan\n          </div>\n          <h3 className=\"text-3xl font-semibold text-gray-900\">\n            {plan?.name ?? \"Custom plan\"}\n          </h3>\n          <p className=\"text-sm text-gray-600\">\n            {planPrice ? `${planPrice} • billed monthly` : \"Billed via Polar\"}\n          </p>\n        </div>\n        <Badge className={statusDisplay.className}>{statusDisplay.label}</Badge>\n      </div>\n\n      <div className=\"mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n        {nextBillingDate && (\n          <SummaryMetric label=\"Renews on\" value={format(nextBillingDate, \"MMM d, yyyy\")} />\n        )}\n        {trialEndDate && (\n          <SummaryMetric label=\"Trial ends\" value={format(trialEndDate, \"MMM d, yyyy\")} />\n        )}\n        {plan?.includedInvoices != null && (\n          <SummaryMetric\n            label=\"Invoices per month\"\n            value={plan.includedInvoices.toLocaleString()}\n          />\n        )}\n        {plan?.includedSeats != null && (\n          <SummaryMetric\n            label=\"Seats included\"\n            value={plan.includedSeats.toLocaleString()}\n          />\n        )}\n      </div>\n\n      <div className=\"mt-6 space-y-3\">\n        {cancelAtPeriodEnd && nextBillingDate ? (\n          <Alert className=\"border border-amber-200 bg-amber-50\">\n            <AlertTitle className=\"text-sm font-semibold text-amber-900\">\n              Scheduled to end\n            </AlertTitle>\n            <AlertDescription className=\"text-sm text-amber-800\">\n              Your subscription will end on{\" \"}\n              <span className=\"font-semibold\">\n                {format(nextBillingDate, \"MMM d, yyyy\")}\n              </span>\n              . Resume before then to keep automations running.\n            </AlertDescription>\n          </Alert>\n        ) : (\n          <p className=\"text-sm text-gray-600\">\n            To switch plans, cancel your current subscription. Once it ends, choose a new plan\n            and subscribe again from this page.\n          </p>\n        )}\n\n        {plan?.benefits?.length ? (\n          <div className=\"rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-5\">\n            <p className=\"text-xs font-semibold uppercase tracking-[0.2em] text-gray-500\">\n              Plan benefits\n            </p>\n            <ul className=\"mt-3 space-y-2\">\n              {plan.benefits.map((benefit) => (\n                <li key={benefit} className=\"flex items-start gap-2 text-sm text-gray-600\">\n                  <CheckCircle2 className=\"mt-0.5 h-4 w-4 text-gray-500\" />\n                  <span>{benefit}</span>\n                </li>\n              ))}\n            </ul>\n          </div>\n        ) : null}\n\n        <div className=\"flex flex-wrap items-center gap-3\">\n          {cancelAtPeriodEnd ? (\n            <Button\n              variant=\"outline\"\n              onClick={() => handleUpdateCancellation(false)}\n              disabled={actionState !== \"idle\"}\n            >\n              Resume subscription\n            </Button>\n          ) : (\n            <Button\n              variant=\"outline\"\n              className=\"border-red-200 text-red-600 hover:bg-red-50\"\n              onClick={() => setCancelDialogOpen(true)}\n              disabled={actionState !== \"idle\"}\n            >\n              Schedule cancellation\n            </Button>\n          )}\n          <Button\n            variant=\"outline\"\n            onClick={() => {\n              setPlanChangePending(true);\n              void onRefresh().finally(() => setPlanChangePending(false));\n            }}\n            disabled={!canInteract}\n          >\n            <RefreshCcw className=\"mr-2 h-4 w-4\" />\n            Refresh status\n          </Button>\n          <Button variant=\"ghost\" asChild>\n            <a href={contactHref} className=\"text-sm text-gray-600 hover:text-gray-900\">\n              <LifeBuoy className=\"mr-2 h-4 w-4\" />\n              Contact support\n            </a>\n          </Button>\n        </div>\n      </div>\n    </section>\n  );\n\n  const usageSection =\n    !showInactive && includedInvoices > 0 ? (\n      <section className=\"rounded-3xl border border-gray-200 bg-white p-8 shadow-sm\">\n        <div className=\"flex items-start justify-between gap-4\">\n          <div className=\"space-y-1\">\n            <h4 className=\"text-lg font-semibold text-gray-900\">Invoice usage</h4>\n            <p className=\"text-sm text-gray-600\">\n              {nextBillingDate\n                ? `Usage resets on ${format(nextBillingDate, \"MMM d, yyyy\")}.`\n                : \"Track how many invoices you’ve processed this period.\"}\n            </p>\n          </div>\n          <Badge className=\"bg-gray-100 text-gray-700\">\n            {usagePercent}% of {includedInvoices.toLocaleString()}\n          </Badge>\n        </div>\n        <div className=\"mt-6 space-y-3\">\n          <Progress value={usagePercent} className=\"h-2\" />\n          <div className=\"flex flex-wrap items-center gap-4 text-sm text-gray-600\">\n            <span>\n              <span className=\"font-semibold text-gray-900\">\n                {usedInvoices.toLocaleString()}\n              </span>{\" \"}\n              invoices processed\n            </span>\n            <span className=\"flex items-center gap-2 text-xs text-gray-500\">\n              <Clock className=\"h-4 w-4\" />\n              {remainingInvoicesText(includedInvoices, usedInvoices)}\n            </span>\n          </div>\n        </div>\n      </section>\n    ) : null;\n\n  return (\n    <>\n      <div className=\"relative\">\n        {overlayActive && (\n          <div className=\"absolute inset-0 z-20 rounded-3xl bg-white/70 backdrop-blur-sm\">\n            <div className=\"flex h-full flex-col items-center justify-center gap-3 text-sm font-medium text-gray-700\">\n              <Loader2 className=\"h-6 w-6 animate-spin\" />\n              Processing request…\n            </div>\n          </div>\n        )}\n\n        <div className=\"space-y-8\">\n          {error ? (\n            <Alert variant=\"destructive\">\n              <AlertTitle>Unable to load subscription</AlertTitle>\n              <AlertDescription>{error}</AlertDescription>\n            </Alert>\n          ) : null}\n\n          {actionError ? (\n            <Alert variant=\"destructive\">\n              <AlertTitle>Action failed</AlertTitle>\n              <AlertDescription>{actionError}</AlertDescription>\n            </Alert>\n          ) : null}\n\n          {summarySection}\n          {usageSection}\n        </div>\n      </div>\n\n      <PlansModal\n        open={isPlansOpen}\n        onOpenChange={(open) => {\n          if (!open) {\n            setPlanChangePending(false);\n          }\n          setPlansOpen(open);\n        }}\n        subscriptionState={state}\n        onPlanChangePending={(pending) => setPlanChangePending(pending)}\n      />\n\n      <Dialog\n        open={isCancelDialogOpen}\n        onOpenChange={(open) => {\n          if (actionState === \"cancelling\") return;\n          if (!open) {\n            setCancelInput(\"\");\n          }\n          setCancelDialogOpen(open);\n        }}\n      >\n        <DialogContent className=\"max-w-lg\">\n          <DialogHeader className=\"text-left\">\n            <DialogTitle className=\"text-xl font-semibold text-gray-900\">\n              Confirm cancellation\n            </DialogTitle>\n            <DialogDescription className=\"text-sm text-gray-600\">\n              Type <span className=\"font-semibold text-gray-900\">CANCEL</span> to end your\n              subscription at the close of your current billing period. You&apos;ll keep\n              access until{\" \"}\n              {nextBillingDate ? format(nextBillingDate, \"MMM d, yyyy\") : \"the end of the term\"}.\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <Input\n              value={cancelInput}\n              onChange={(event) => setCancelInput(event.target.value)}\n              placeholder=\"Type CANCEL to confirm\"\n              className=\"uppercase tracking-[0.3em]\"\n              autoFocus\n            />\n            <Alert className=\"border border-amber-200 bg-amber-50\">\n              <AlertTitle className=\"text-sm font-semibold text-amber-900\">\n                Heads up\n              </AlertTitle>\n              <AlertDescription className=\"text-sm text-amber-800\">\n                Cancellation takes effect after the current term. You can resume the\n                subscription before that date to keep your automation running.\n              </AlertDescription>\n            </Alert>\n          </div>\n          <DialogFooter className=\"flex flex-col gap-2 sm:flex-row sm:justify-between sm:gap-3\">\n            <Button\n              variant=\"outline\"\n              onClick={() => {\n                setCancelInput(\"\");\n                setCancelDialogOpen(false);\n              }}\n              disabled={actionState === \"cancelling\"}\n            >\n              Keep subscription\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={() => handleUpdateCancellation(true)}\n              disabled={cancelInput.trim().toUpperCase() !== \"CANCEL\" || actionState === \"cancelling\"}\n            >\n              {actionState === \"cancelling\" ? (\n                <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n              ) : null}\n              Confirm cancellation\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n\n  async function handleUpdateCancellation(cancel: boolean) {\n    if (!state?.subscription || actionState !== \"idle\") {\n      return;\n    }\n\n    setActionError(null);\n    setActionState(cancel ? \"cancelling\" : \"resuming\");\n\n    startTransition(async () => {\n      try {\n        const result = await cancelSubscription({\n          cancelAtPeriodEnd: cancel,\n        });\n\n        if (!result.success) {\n          throw new Error(result.error);\n        }\n\n        if (cancel) {\n          toast.success(\"Cancellation scheduled successfully.\");\n          setCancelInput(\"\");\n          setCancelDialogOpen(false);\n        } else {\n          toast.success(\"Subscription resumed — you will remain active.\");\n        }\n\n        await onRefresh();\n      } catch (updateError) {\n        console.error(\"[Settings] Failed to update subscription cancellation\", updateError);\n        const message =\n          updateError instanceof Error\n            ? updateError.message\n            : \"We could not update your subscription. Please try again.\";\n        setActionError(message);\n        toast.error(message);\n      } finally {\n        setActionState(\"idle\");\n      }\n    });\n  }\n}\n\nfunction SummaryMetric({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3 text-left\">\n      <p className=\"text-xs font-semibold uppercase tracking-[0.2em] text-gray-500\">\n        {label}\n      </p>\n      <p className=\"mt-1 text-sm font-semibold text-gray-900\">{value}</p>\n    </div>\n  );\n}\n\nfunction remainingInvoicesText(limit: number, used: number) {\n  const remaining = Math.max(limit - used, 0);\n  const suffix = remaining === 1 ? \"invoice remaining\" : \"invoices remaining\";\n  return `${remaining.toLocaleString()} ${suffix}`;\n}\n\nfunction SubscriptionSkeleton() {\n  return (\n    <Card className=\"border-gray-200\">\n      <CardHeader>\n        <CardTitle className=\"text-xl\">Subscription overview</CardTitle>\n        <CardDescription className=\"text-sm text-gray-600\">\n          Loading your plan details…\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <Skeleton className=\"h-12 w-44 rounded-2xl\" />\n        <Skeleton className=\"h-32 w-full rounded-3xl\" />\n        <Skeleton className=\"h-20 w-full rounded-3xl\" />\n      </CardContent>\n    </Card>\n  );\n}\n\nfunction getStatusDisplay(\n  state: SubscriptionGateState | null | undefined,\n  cancelAtPeriodEnd: boolean\n) {\n  const status = state?.status ?? null;\n  if (!state?.isActive) {\n    return {\n      label: status ? titleCase(status) : \"Inactive\",\n      className: \"bg-gray-200 text-gray-700\",\n    };\n  }\n\n  if (cancelAtPeriodEnd) {\n    return {\n      label: \"Cancels soon\",\n      className: \"bg-amber-100 text-amber-700\",\n    };\n  }\n\n  switch (status) {\n    case \"trialing\":\n      return {\n        label: \"Trialing\",\n        className: \"bg-blue-100 text-blue-700\",\n      };\n    case \"past_due\":\n      return {\n        label: \"Past due\",\n        className: \"bg-amber-100 text-amber-700\",\n      };\n    case \"grace\":\n      return {\n        label: \"Grace period\",\n        className: \"bg-amber-100 text-amber-700\",\n      };\n    case \"active\":\n    default:\n      return {\n        label: status ? titleCase(status) : \"Active\",\n        className: \"bg-emerald-100 text-emerald-700\",\n      };\n  }\n}\n\nfunction titleCase(value: string) {\n  return value\n    .split(\"_\")\n    .map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))\n    .join(\" \");\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/layout.tsx",
    "content": "import type { Metadata } from \"next\";\n\nexport const metadata: Metadata = {\n  title: \"Settings | AP Cash\",\n  description: \"Manage your profile and organization settings\",\n};\n\nexport default function SettingsLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"mx-auto w-full max-w-6xl px-4 pb-12 pt-4 sm:px-6 lg:px-8\">\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/dashboard/settings/page.tsx",
    "content": "import { SettingsContent } from \"./components/settings-content\";\n\nexport default function SettingsPage() {\n  return <SettingsContent />;\n}\n"
  },
  {
    "path": "next_b2b_starter/app/globals.css",
    "content": "\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 240 10% 3.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 240 10% 3.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 240 10% 3.9%;\n    --primary: 240 9% 1%;\n    --primary-foreground: 0 0% 98%;\n    --secondary: 240 4.8% 95.9%;\n    --secondary-foreground: 240 5.9% 10%;\n    --muted: 240 4.8% 95.9%;\n    --muted-foreground: 240 3.8% 46.1%;\n    --accent: 240 4.8% 95.9%;\n    --accent-foreground: 240 5.9% 10%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 240 5.9% 90%;\n    --input: 240 5.9% 90%;\n    --ring: 240 10% 3.9%;\n    --radius: 0.5rem;\n    --chart-1: 12 76% 61%;\n    --chart-2: 173 58% 39%;\n    --chart-3: 197 37% 24%;\n    --chart-4: 43 74% 66%;\n    --chart-5: 27 87% 67%;\n  }\n\n  .dark {\n    --background: 240 10% 3.9%;\n    --foreground: 0 0% 98%;\n    --card: 240 10% 3.9%;\n    --card-foreground: 0 0% 98%;\n    --popover: 240 10% 3.9%;\n    --popover-foreground: 0 0% 98%;\n    --primary: 0 0% 98%;\n    --primary-foreground: 240 5.9% 10%;\n    --secondary: 240 3.7% 15.9%;\n    --secondary-foreground: 0 0% 98%;\n    --muted: 240 3.7% 15.9%;\n    --muted-foreground: 240 5% 64.9%;\n    --accent: 240 3.7% 15.9%;\n    --accent-foreground: 0 0% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 240 3.7% 15.9%;\n    --input: 240 3.7% 15.9%;\n    --ring: 240 4.9% 83.9%;\n    --chart-1: 220 70% 50%;\n    --chart-2: 160 60% 45%;\n    --chart-3: 30 80% 55%;\n    --chart-4: 280 65% 60%;\n    --chart-5: 340 75% 55%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/app/head.tsx",
    "content": "export default function Head() {\n  return (\n    <>\n      <link rel=\"preconnect\" href=\"https://i.ytimg.com\" crossOrigin=\"\" />\n      <link rel=\"dns-prefetch\" href=\"//i.ytimg.com\" />\n    </>\n  );\n}\n\n"
  },
  {
    "path": "next_b2b_starter/app/layout.tsx",
    "content": "import \"./globals.css\";\nimport type { Metadata, Viewport } from \"next\";\nimport { Toaster } from \"sonner\";\nimport { AuthProvider } from \"@/lib/contexts/auth-context\";\nimport { StytchProvider } from \"@/components/auth/stytch-provider\";\nimport { authBootstrap } from \"@/lib/auth/bootstrap\";\nimport { buildStytchClientConfig } from \"@/lib/auth/stytch-server\";\nimport { QueryProvider } from \"@/lib/providers/query-provider\";\nimport { JsonLd } from \"@/components/seo/jsonld\";\nimport { Inter } from \"next/font/google\";\nimport Script from \"next/script\";\n\nconst inter = Inter({\n  subsets: [\"latin\"],\n  display: \"swap\",\n  variable: \"--font-sans\",\n});\n\nexport const viewport: Viewport = {\n  width: \"device-width\",\n  initialScale: 1,\n  maximumScale: 5,\n  themeColor: \"#000000\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://yourdomain.com\"),\n  icons: {\n    icon: \"/icon.png\",\n  },\n  title: {\n    default: \"Your App | Next.js Starter with Auth & Billing\",\n    template: \"%s | Your App\",\n  },\n  description:\n    \"A modern Next.js starter template with authentication, billing, and team management built in. Perfect for launching your SaaS application quickly.\",\n  keywords: [\n    \"nextjs starter\",\n    \"saas starter\",\n    \"authentication\",\n    \"billing integration\",\n    \"team management\",\n    \"nextjs template\",\n    \"react starter\",\n    \"typescript starter\",\n    \"tailwind starter\",\n  ],\n  authors: [{ name: \"Your Team\" }],\n  creator: \"Your App\",\n  publisher: \"Your App\",\n  formatDetection: {\n    email: false,\n    address: false,\n    telephone: false,\n  },\n  alternates: {\n    canonical: \"/\",\n    languages: {\n      \"en-US\": \"/\",\n    },\n  },\n  openGraph: {\n    type: \"website\",\n    url: \"https://yourdomain.com/\",\n    siteName: \"Your App\",\n    title: \"Your App | Next.js Starter with Auth & Billing\",\n    description:\n      \"A modern Next.js starter template with authentication, billing, and team management built in. Perfect for launching your SaaS application quickly.\",\n    locale: \"en_US\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: \"Your App | Next.js Starter with Auth & Billing\",\n    description:\n      \"A modern Next.js starter template with authentication, billing, and team management built in. Perfect for launching your SaaS application quickly.\",\n  },\n  robots: {\n    index: true,\n    follow: true,\n    nocache: false,\n    googleBot: {\n      index: true,\n      follow: true,\n      \"max-image-preview\": \"large\",\n      \"max-snippet\": -1,\n      \"max-video-preview\": -1,\n    },\n  },\n  category: \"Business\",\n};\n\nexport default async function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const bootstrap = await authBootstrap();\n  const stytchConfig = buildStytchClientConfig();\n\n  return (\n    <html lang=\"en\" className={inter.variable} suppressHydrationWarning>\n      <head>\n        <link rel=\"dns-prefetch\" href=\"https://fonts.googleapis.com\" />\n        <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n        <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossOrigin=\"anonymous\" />\n        <link rel=\"dns-prefetch\" href=\"https://www.youtube.com\" />\n        <link rel=\"preconnect\" href=\"https://www.youtube.com\" />\n        <link rel=\"dns-prefetch\" href=\"https://img.youtube.com\" />\n        <link rel=\"preconnect\" href=\"https://img.youtube.com\" />\n      </head>\n      <body className=\"antialiased font-sans\" suppressHydrationWarning>\n        {/* Global JSON-LD: Organization + WebSite */}\n        <JsonLd\n          id=\"org-jsonld\"\n          data={{\n            \"@context\": \"https://schema.org\",\n            \"@type\": \"Organization\",\n            name: \"Your App\",\n            url: \"https://yourdomain.com\",\n            logo: \"https://yourdomain.com/icon.png\",\n            description: \"A modern Next.js starter template with authentication, billing, and team management built in.\",\n          }}\n        />\n        <JsonLd\n          id=\"website-jsonld\"\n          data={{\n            \"@context\": \"https://schema.org\",\n            \"@type\": \"WebSite\",\n            name: \"Your App\",\n            url: \"https://yourdomain.com\",\n            inLanguage: \"en\",\n          }}\n        />\n        <StytchProvider config={stytchConfig}>\n          <AuthProvider\n            initialProfile={bootstrap.profile}\n            initialRoles={bootstrap.roles}\n            initialPermissions={bootstrap.permissions}\n            shouldClearCache={bootstrap.shouldClearCache}\n          >\n            <QueryProvider>\n              {children}\n              <Toaster position=\"top-right\" richColors />\n            </QueryProvider>\n          </AuthProvider>\n        </StytchProvider>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/not-found.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\n\nexport default function NotFound() {\n  const router = useRouter();\n  const { profile, isInitialized } = usePermissions();\n\n  useEffect(() => {\n    if (!isInitialized) return;\n\n    // Redirect authenticated users to dashboard, others to landing page\n    const redirectTo = profile ? \"/dashboard\" : \"/\";\n    router.replace(redirectTo);\n  }, [profile, isInitialized, router]);\n\n  // Show minimal loading state while redirecting\n  return (\n    <div className=\"flex min-h-screen items-center justify-center\">\n      <div className=\"h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-[#0FA8A0]\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/page.tsx",
    "content": "import Link from \"next/link\";\nimport { Button } from \"@/components/ui/button\";\nimport { ArrowRight } from \"lucide-react\";\n\nexport default function HomePage() {\n  return (\n    <div className=\"min-h-screen bg-gradient-to-b from-white via-slate-50 to-white\">\n      <main className=\"container mx-auto px-6 py-16 sm:px-10 sm:py-24\">\n        <div className=\"mx-auto flex max-w-4xl flex-col items-center justify-center space-y-12 text-center\">\n          {/* Hero Section */}\n          <div className=\"space-y-8\">\n            <div className=\"space-y-6\">\n              <h1 className=\"text-balance text-5xl font-bold tracking-tight text-slate-900 sm:text-6xl lg:text-7xl\">\n                Welcome to Your App\n              </h1>\n              <p className=\"mx-auto max-w-2xl text-xl text-slate-600 sm:text-2xl\">\n                A modern Next.js starter with authentication, billing, and team management built in.\n              </p>\n            </div>\n\n            {/* CTA Button */}\n            <div className=\"flex flex-col items-center gap-4 sm:flex-row sm:justify-center\">\n              <Link href=\"/signup\">\n                <Button\n                  size=\"lg\"\n                  className=\"h-14 rounded-full bg-slate-900 px-8 text-base font-semibold text-white shadow-lg hover:bg-slate-800\"\n                >\n                  Get Started\n                  <ArrowRight className=\"ml-2 h-5 w-5\" />\n                </Button>\n              </Link>\n            </div>\n          </div>\n\n          {/* Features Grid */}\n          <div className=\"grid w-full gap-6 pt-12 md:grid-cols-3\">\n            <div className=\"rounded-2xl border border-slate-200 bg-white p-6 shadow-sm\">\n              <div className=\"mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-100\">\n                <svg\n                  className=\"h-6 w-6 text-slate-600\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z\"\n                  />\n                </svg>\n              </div>\n              <h3 className=\"mb-2 text-lg font-semibold text-slate-900\">\n                Secure Authentication\n              </h3>\n              <p className=\"text-sm text-slate-600\">\n                Built-in auth with magic link login powered by Stytch.\n              </p>\n            </div>\n\n            <div className=\"rounded-2xl border border-slate-200 bg-white p-6 shadow-sm\">\n              <div className=\"mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-100\">\n                <svg\n                  className=\"h-6 w-6 text-slate-600\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z\"\n                  />\n                </svg>\n              </div>\n              <h3 className=\"mb-2 text-lg font-semibold text-slate-900\">\n                Billing Integration\n              </h3>\n              <p className=\"text-sm text-slate-600\">\n                Subscription management and payments via Polar.\n              </p>\n            </div>\n\n            <div className=\"rounded-2xl border border-slate-200 bg-white p-6 shadow-sm\">\n              <div className=\"mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-100\">\n                <svg\n                  className=\"h-6 w-6 text-slate-600\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z\"\n                  />\n                </svg>\n              </div>\n              <h3 className=\"mb-2 text-lg font-semibold text-slate-900\">\n                Team Management\n              </h3>\n              <p className=\"text-sm text-slate-600\">\n                Invite members, manage roles, and collaborate.\n              </p>\n            </div>\n          </div>\n        </div>\n      </main>\n\n      {/* Footer */}\n      <footer className=\"border-t border-slate-200 py-8\">\n        <div className=\"container mx-auto px-6 text-center\">\n          <p className=\"text-sm text-slate-600\">\n            Built with Next.js, TypeScript, and Tailwind CSS\n          </p>\n        </div>\n      </footer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/robots.ts",
    "content": "import type { MetadataRoute } from \"next\";\n\nexport default function robots(): MetadataRoute.Robots {\n  return {\n    rules: [\n      {\n        userAgent: \"*\",\n        allow: \"/\",\n        disallow: [\n          \"/api/\",\n          \"/dashboard\",\n          \"/dashboard/*\",\n          \"/approvals\",\n          \"/approvals/*\",\n        ],\n      },\n    ],\n    sitemap: \"https://yourdomain.com/sitemap.xml\",\n    host: \"https://yourdomain.com\",\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/app/signup/page.tsx",
    "content": "\"use client\";\n\nimport { useSignupFlow } from \"@/hooks/use-signup-flow\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { ArrowRight, Home, Inbox } from \"lucide-react\";\nimport Link from \"next/link\";\n\nexport default function SignupPage() {\n  const {\n    step,\n    owner,\n    organization,\n    isLoading,\n    error,\n    emailSent,\n    canContinueAccount,\n    canContinueOrganization,\n    goNext,\n    goBack,\n    sendMagicLink,\n    updateOwner,\n    updateOrganization,\n  } = useSignupFlow();\n\n  // Success view after email sent\n  if (emailSent) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-gray-50 px-6\">\n        <div className=\"w-full max-w-md text-center space-y-6\">\n          <div className=\"mx-auto h-14 w-14 bg-primary-50 rounded-full flex items-center justify-center\">\n            <Inbox className=\"h-7 w-7 text-primary-600\" />\n          </div>\n          <h1 className=\"text-2xl font-semibold text-gray-900\">Check your email</h1>\n          <p className=\"text-gray-600\">\n            We sent a verification link to <strong>{owner.email}</strong>.\n            Click the link to complete your signup.\n          </p>\n          <Link href=\"/auth\">\n            <Button variant=\"outline\">Back to Sign In</Button>\n          </Link>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-gray-50 px-6\">\n      <div className=\"w-full max-w-md\">\n        <div className=\"bg-white p-8 rounded-2xl shadow-lg border border-gray-200\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <div>\n              <h1 className=\"text-2xl font-semibold text-gray-900\">\n                Create your account\n              </h1>\n              <p className=\"text-sm text-gray-600 mt-1\">\n                Get started with Your App\n              </p>\n            </div>\n            <Link href=\"/\" className=\"flex items-center gap-1.5 text-xs text-gray-500 hover:text-primary-600 transition-colors\">\n              <Home className=\"h-3.5 w-3.5\" />\n              <span>Home</span>\n            </Link>\n          </div>\n\n          {error && (\n            <div className=\"mb-4 p-3 bg-red-50 border border-red-200 rounded-md\">\n              <p className=\"text-sm text-red-800\">{error}</p>\n            </div>\n          )}\n\n          {/* Step 1: Account */}\n          {step === \"account\" && (\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"block text-sm font-medium text-gray-700 mb-1\">\n                  Full Name\n                </label>\n                <Input\n                  type=\"text\"\n                  placeholder=\"John Doe\"\n                  value={owner.fullName}\n                  onChange={(e) => updateOwner({ fullName: e.target.value })}\n                  disabled={isLoading}\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm font-medium text-gray-700 mb-1\">\n                  Email\n                </label>\n                <Input\n                  type=\"email\"\n                  placeholder=\"you@company.com\"\n                  value={owner.email}\n                  onChange={(e) => updateOwner({ email: e.target.value })}\n                  disabled={isLoading}\n                />\n              </div>\n              <Button\n                onClick={goNext}\n                disabled={!canContinueAccount || isLoading}\n                className=\"w-full\"\n              >\n                Continue <ArrowRight className=\"ml-2 h-4 w-4\" />\n              </Button>\n            </div>\n          )}\n\n          {/* Step 2: Organization */}\n          {step === \"organization\" && (\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"block text-sm font-medium text-gray-700 mb-1\">\n                  Organization Name\n                </label>\n                <Input\n                  type=\"text\"\n                  placeholder=\"Acme Inc\"\n                  value={organization.displayName}\n                  onChange={(e) => updateOrganization({ displayName: e.target.value })}\n                  disabled={isLoading}\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm font-medium text-gray-700 mb-1\">\n                  Industry\n                </label>\n                <select\n                  className=\"w-full px-3 py-2 border border-gray-300 rounded-md\"\n                  value={organization.industry}\n                  onChange={(e) => updateOrganization({ industry: e.target.value })}\n                  disabled={isLoading}\n                >\n                  <option value=\"Technology\">Technology</option>\n                  <option value=\"Finance\">Finance</option>\n                  <option value=\"Healthcare\">Healthcare</option>\n                  <option value=\"Retail\">Retail</option>\n                  <option value=\"Other\">Other</option>\n                </select>\n              </div>\n              <div className=\"flex gap-3\">\n                <Button\n                  variant=\"outline\"\n                  onClick={goBack}\n                  disabled={isLoading}\n                  className=\"flex-1\"\n                >\n                  Back\n                </Button>\n                <Button\n                  onClick={sendMagicLink}\n                  disabled={!canContinueOrganization || isLoading}\n                  className=\"flex-1\"\n                >\n                  {isLoading ? \"Creating...\" : \"Create Account\"}\n                </Button>\n              </div>\n            </div>\n          )}\n\n          {!emailSent && (\n            <p className=\"mt-6 text-center text-sm text-gray-600\">\n              Already have an account?{\" \"}\n              <Link href=\"/auth\" className=\"text-primary-600 hover:underline font-medium\">\n                Sign in\n              </Link>\n            </p>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/app/sitemap.ts",
    "content": "import type { MetadataRoute } from 'next';\n\nexport default function sitemap(): MetadataRoute.Sitemap {\n  const base = 'https://yourdomain.com';\n  const now = new Date();\n\n  const pages: MetadataRoute.Sitemap = [\n    {\n      url: `${base}/`,\n      lastModified: now,\n      changeFrequency: 'daily',\n      priority: 1,\n    },\n    {\n      url: `${base}/about`,\n      lastModified: now,\n      changeFrequency: 'monthly',\n      priority: 0.7,\n    },\n    {\n      url: `${base}/faq`,\n      lastModified: now,\n      changeFrequency: 'weekly',\n      priority: 0.8,\n    },\n    {\n      url: `${base}/security`,\n      lastModified: now,\n      changeFrequency: 'monthly',\n      priority: 0.6,\n    },\n    {\n      url: `${base}/privacy`,\n      lastModified: now,\n      changeFrequency: 'monthly',\n      priority: 0.4,\n    },\n    {\n      url: `${base}/terms`,\n      lastModified: now,\n      changeFrequency: 'monthly',\n      priority: 0.4,\n    },\n  ];\n\n  return pages;\n}\n"
  },
  {
    "path": "next_b2b_starter/components/auth/can.tsx",
    "content": "/**\n * Can Component - Permission-based conditional rendering\n * Wraps children and only renders them if the user has required permissions\n */\n\n'use client';\n\nimport { ReactNode } from 'react';\nimport { usePermissions } from '@/lib/hooks/use-permissions';\n\nexport interface CanProps {\n  /**\n   * Single permission required to render children\n   * @example \"invoice:create\"\n   */\n  permission?: string;\n\n  /**\n   * Multiple permissions - user must have ANY (OR logic)\n   * @example [\"invoice:view\", \"invoice:create\"]\n   */\n  anyPermission?: string[];\n\n  /**\n   * Multiple permissions - user must have ALL (AND logic)\n   * @example [\"invoice:view\", \"approval:approve\"]\n   */\n  allPermissions?: string[];\n\n  /**\n   * Single role required to render children\n   * @example \"admin\"\n   */\n  role?: string;\n\n  /**\n   * Multiple roles - user must have ANY (OR logic)\n   * @example [\"admin\", \"manager\"]\n   */\n  anyRole?: string[];\n\n  /**\n   * Multiple roles - user must have ALL (AND logic)\n   * @example [\"admin\", \"member\"]\n   */\n  allRoles?: string[];\n\n  /**\n   * Render this when user doesn't have permission\n   */\n  fallback?: ReactNode;\n\n  /**\n   * Children to render when permission check passes\n   */\n  children: ReactNode;\n}\n\n/**\n * Permission wrapper component\n *\n * @example\n * // Single permission\n * <Can permission=\"invoice:create\">\n *   <CreateButton />\n * </Can>\n *\n * @example\n * // Any of multiple permissions\n * <Can anyPermission={['invoice:view', 'invoice:*']}>\n *   <InvoiceList />\n * </Can>\n *\n * @example\n * // All permissions required\n * <Can allPermissions={['invoice:view', 'approval:approve']}>\n *   <ApprovalPanel />\n * </Can>\n *\n * @example\n * // With fallback\n * <Can permission=\"invoice:create\" fallback={<AccessDenied />}>\n *   <CreateButton />\n * </Can>\n *\n * @example\n * // Role-based\n * <Can role=\"admin\">\n *   <AdminPanel />\n * </Can>\n */\nexport function Can({\n  permission,\n  anyPermission,\n  allPermissions,\n  role,\n  anyRole,\n  allRoles,\n  fallback = null,\n  children,\n}: CanProps) {\n  const {\n    hasPermission: checkPermission,\n    hasAnyPermission: checkAnyPermission,\n    hasAllPermissions: checkAllPermissions,\n    hasRole: checkRole,\n    hasAnyRole: checkAnyRole,\n    hasAllRoles: checkAllRoles,\n  } = usePermissions();\n\n  // Check permission-based conditions\n  if (permission && !checkPermission(permission)) {\n    return <>{fallback}</>;\n  }\n\n  if (anyPermission && !checkAnyPermission(anyPermission)) {\n    return <>{fallback}</>;\n  }\n\n  if (allPermissions && !checkAllPermissions(allPermissions)) {\n    return <>{fallback}</>;\n  }\n\n  // Check role-based conditions\n  if (role && !checkRole(role)) {\n    return <>{fallback}</>;\n  }\n\n  if (anyRole && !checkAnyRole(anyRole)) {\n    return <>{fallback}</>;\n  }\n\n  if (allRoles && !checkAllRoles(allRoles)) {\n    return <>{fallback}</>;\n  }\n\n  // All checks passed, render children\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "next_b2b_starter/components/auth/permission-gate.tsx",
    "content": "/**\n * PermissionGate Component - Page-level permission guard\n * Protects entire pages or sections with permission checks\n */\n\n'use client';\n\nimport { ReactNode } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { usePermissions } from '@/lib/hooks/use-permissions';\nimport { AlertCircle } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\n\nexport interface PermissionGateProps {\n  /**\n   * Permissions required - user must have ALL (AND logic)\n   * @example [\"invoice:view\"]\n   */\n  required?: string[];\n\n  /**\n   * Permissions required - user must have ANY (OR logic)\n   * @example [\"invoice:view\", \"invoice:*\"]\n   */\n  anyRequired?: string[];\n\n  /**\n   * Roles required - user must have ALL (AND logic)\n   * @example [\"admin\"]\n   */\n  requiredRoles?: string[];\n\n  /**\n   * Roles required - user must have ANY (OR logic)\n   * @example [\"admin\", \"manager\"]\n   */\n  anyRequiredRole?: string[];\n\n  /**\n   * Custom fallback component when permission check fails\n   */\n  fallback?: ReactNode;\n\n  /**\n   * Show default access denied message\n   * @default true\n   */\n  showAccessDenied?: boolean;\n\n  /**\n   * Redirect to this path when permission check fails\n   * @example \"/dashboard\"\n   */\n  redirectTo?: string;\n\n  /**\n   * Children to render when permission check passes\n   */\n  children: ReactNode;\n}\n\n/**\n * Default access denied message\n */\nfunction AccessDenied({ onGoBack }: { onGoBack: () => void }) {\n  return (\n    <div className=\"flex min-h-[400px] items-center justify-center p-8\">\n      <div className=\"w-full max-w-md\">\n        <Alert variant=\"destructive\">\n          <AlertCircle className=\"h-4 w-4\" />\n          <AlertTitle>Access Denied</AlertTitle>\n          <AlertDescription className=\"mt-2\">\n            You don't have permission to access this page. Please contact your\n            administrator if you believe this is a mistake.\n          </AlertDescription>\n        </Alert>\n        <div className=\"mt-6 flex justify-center\">\n          <Button onClick={onGoBack} variant=\"outline\">\n            Go Back\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Page-level permission guard\n *\n * @example\n * // Require single permission\n * <PermissionGate required={['invoice:view']}>\n *   <InvoicePage />\n * </PermissionGate>\n *\n * @example\n * // Require any of multiple permissions\n * <PermissionGate anyRequired={['invoice:view', 'invoice:*']}>\n *   <InvoicePage />\n * </PermissionGate>\n *\n * @example\n * // Require multiple permissions (all must be present)\n * <PermissionGate required={['invoice:view', 'approval:approve']}>\n *   <ComplexPage />\n * </PermissionGate>\n *\n * @example\n * // With custom fallback\n * <PermissionGate\n *   required={['invoice:view']}\n *   fallback={<CustomAccessDenied />}\n * >\n *   <InvoicePage />\n * </PermissionGate>\n *\n * @example\n * // Redirect to another page\n * <PermissionGate\n *   required={['invoice:view']}\n *   redirectTo=\"/dashboard\"\n * >\n *   <InvoicePage />\n * </PermissionGate>\n *\n * @example\n * // Role-based protection\n * <PermissionGate anyRequiredRole={['admin', 'manager']}>\n *   <AdminPanel />\n * </PermissionGate>\n */\nexport function PermissionGate({\n  required,\n  anyRequired,\n  requiredRoles,\n  anyRequiredRole,\n  fallback,\n  showAccessDenied = true,\n  redirectTo,\n  children,\n}: PermissionGateProps) {\n  const router = useRouter();\n  const {\n    hasAllPermissions,\n    hasAnyPermission,\n    hasAllRoles,\n    hasAnyRole,\n    isInitialized,\n  } = usePermissions();\n\n  // Wait for initialization\n  if (!isInitialized) {\n    return (\n      <div className=\"flex min-h-[400px] items-center justify-center\">\n        <div className=\"h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-gray-900\" />\n      </div>\n    );\n  }\n\n  // Check permissions\n  let hasAccess = true;\n\n  if (required && !hasAllPermissions(required)) {\n    hasAccess = false;\n  }\n\n  if (anyRequired && !hasAnyPermission(anyRequired)) {\n    hasAccess = false;\n  }\n\n  if (requiredRoles && !hasAllRoles(requiredRoles)) {\n    hasAccess = false;\n  }\n\n  if (anyRequiredRole && !hasAnyRole(anyRequiredRole)) {\n    hasAccess = false;\n  }\n\n  // Access granted\n  if (hasAccess) {\n    return <>{children}</>;\n  }\n\n  // Access denied - handle redirect\n  if (redirectTo) {\n    router.push(redirectTo);\n    return null;\n  }\n\n  // Access denied - show custom fallback\n  if (fallback) {\n    return <>{fallback}</>;\n  }\n\n  // Access denied - show default message\n  if (showAccessDenied) {\n    return <AccessDenied onGoBack={() => router.back()} />;\n  }\n\n  // Don't show anything\n  return null;\n}\n"
  },
  {
    "path": "next_b2b_starter/components/auth/stytch-provider.tsx",
    "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { StytchB2BProvider } from \"@stytch/nextjs/b2b\";\nimport { createStytchB2BUIClient } from \"@stytch/nextjs/b2b/ui\";\nimport {\n  SESSION_COOKIE_NAME,\n  SESSION_JWT_COOKIE_NAME,\n} from \"@/lib/auth/constants\";\nimport { StytchConfigProvider } from \"@/lib/contexts/stytch-config-context\";\nimport type { StytchClientConfig } from \"@/lib/auth/config-types\";\n\ninterface Props {\n  children: React.ReactNode;\n  config: StytchClientConfig;\n}\n\nconst COOKIE_OPTIONS = {\n  opaqueTokenCookieName: SESSION_COOKIE_NAME,\n  jwtCookieName: SESSION_JWT_COOKIE_NAME,\n  path: \"/\",\n  availableToSubdomains: false,\n  domain: \"\",\n} as const;\n\nexport function StytchProvider({ children, config }: Props) {\n  const stytchClient = useMemo(() => {\n    if (!config.publicToken) {\n      throw new Error(\n        \"NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN is required to initialize Stytch.\"\n      );\n    }\n\n    return createStytchB2BUIClient(config.publicToken, {\n      cookieOptions: COOKIE_OPTIONS,\n    });\n  }, [config.publicToken]);\n\n  return (\n    <StytchB2BProvider stytch={stytchClient}>\n      <StytchConfigProvider config={config}>\n        {children}\n      </StytchConfigProvider>\n    </StytchB2BProvider>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/components/billing/plans-modal.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useMemo, useState, useTransition } from \"react\";\n\nimport type { SubscriptionGateState } from \"@/lib/polar/current-subscription\";\nimport { type PolarPlan } from \"@/lib/polar/plans\";\nimport { useProductsQuery } from \"@/lib/hooks/queries/use-products-query\";\nimport { createCheckout } from \"@/lib/actions/billing/create-checkout\";\n\ninterface PlansModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  subscriptionState?: SubscriptionGateState | null;\n  onPlanChangePending?: (pending: boolean) => void;\n}\n\nexport function PlansModal({\n  open,\n  onOpenChange,\n  subscriptionState,\n  onPlanChangePending,\n}: PlansModalProps) {\n  const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);\n  const [checkoutError, setCheckoutError] = useState<string | null>(null);\n  const [isPending, startTransition] = useTransition();\n  const { data: products, isLoading, error } = useProductsQuery();\n\n  // Define current plan data BEFORE useEffects that depend on it\n  const currentProductId = subscriptionState?.subscription?.productId ?? null;\n\n  useEffect(() => {\n    if (!open) {\n      setSelectedPlanId(null);\n      document.body.style.removeProperty(\"overflow\");\n      return;\n    }\n\n    document.body.style.setProperty(\"overflow\", \"hidden\");\n  }, [open]);\n\n  useEffect(() => {\n    return () => {\n      onPlanChangePending?.(false);\n    };\n  }, [onPlanChangePending]);\n\n  useEffect(() => {\n    return () => {\n      document.body.style.removeProperty(\"overflow\");\n    };\n  }, []);\n\n  const plans = useMemo(() => {\n    if (!products) return [];\n\n    return products.map((plan) => {\n      const isCurrent =\n        Boolean(subscriptionState?.isActive) &&\n        currentProductId === plan.productId;\n\n      return {\n        ...plan,\n        isCurrent,\n      };\n    });\n  }, [currentProductId, subscriptionState?.isActive, products]);\n\n  if (!open) {\n    return null;\n  }\n\n  const handleSelectPlan = (plan: PolarPlan) => {\n    if (subscriptionState?.isActive) {\n      window.alert(\"Please cancel your current subscription before selecting a new plan.\");\n      return;\n    }\n\n    onPlanChangePending?.(true);\n    setSelectedPlanId(plan.id);\n    setCheckoutError(null);\n\n    startTransition(async () => {\n      try {\n        const result = await createCheckout({ planId: plan.id });\n\n        // If result is returned (error case), handle it\n        if (!result.success) {\n          setCheckoutError(result.error);\n          setSelectedPlanId(null);\n          onPlanChangePending?.(false);\n        }\n        // If successful, createCheckout will redirect to Polar - no need to handle here\n      } catch (error) {\n        // Redirect errors are expected - Next.js throws these for redirects\n        if (error instanceof Error && error.message === \"NEXT_REDIRECT\") {\n          return;\n        }\n        // Handle unexpected errors\n        console.error(\"[PlansModal] Checkout error:\", error);\n        setCheckoutError(\"An unexpected error occurred. Please try again.\");\n        setSelectedPlanId(null);\n        onPlanChangePending?.(false);\n      }\n    });\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-10 backdrop-blur-sm\">\n      <div className=\"relative w-full max-w-4xl rounded-3xl bg-white p-8 shadow-2xl ring-1 ring-gray-200\">\n        <button\n          type=\"button\"\n          onClick={() => onOpenChange(false)}\n          className=\"absolute right-5 top-5 inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-200\"\n          aria-label=\"Close plans modal\"\n        >\n          <span className=\"text-xl leading-none\">&times;</span>\n        </button>\n        <header className=\"mb-8 space-y-2 pr-12\">\n          <p className=\"text-sm font-semibold uppercase tracking-[0.2em] text-gray-500\">\n            Choose your plan\n          </p>\n          <h2 className=\"text-3xl font-semibold text-gray-900\">Scale approvals without hitting limits</h2>\n          <p className=\"max-w-2xl text-sm text-gray-600\">\n            Plans are billed monthly through Polar. Seats and invoice quotas update immediately after checkout completes.\n          </p>\n        </header>\n\n        {isLoading && (\n          <div className=\"flex min-h-[400px] items-center justify-center\">\n            <div className=\"text-center\">\n              <div className=\"mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-gray-900\" />\n              <p className=\"text-sm text-gray-600\">Loading plans...</p>\n            </div>\n          </div>\n        )}\n\n        {error && (\n          <div className=\"flex min-h-[400px] items-center justify-center\">\n            <div className=\"text-center\">\n              <p className=\"text-sm text-red-600\">Failed to load plans. Please try again.</p>\n              <button\n                type=\"button\"\n                onClick={() => window.location.reload()}\n                className=\"mt-4 rounded-full bg-gray-900 px-4 py-2 text-sm font-semibold text-white hover:bg-gray-800\"\n              >\n                Retry\n              </button>\n            </div>\n          </div>\n        )}\n\n        {checkoutError && (\n          <div className=\"mb-6 rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-600\">\n            <p className=\"font-semibold\">Checkout Error</p>\n            <p className=\"mt-1\">{checkoutError}</p>\n          </div>\n        )}\n\n        {!isLoading && !error && plans.length === 0 && (\n          <div className=\"flex min-h-[400px] items-center justify-center\">\n            <p className=\"text-sm text-gray-600\">No plans available.</p>\n          </div>\n        )}\n\n        {!isLoading && !error && plans.length > 0 && (\n          <div className=\"grid gap-4 lg:grid-cols-2\">\n            {plans.map((plan) => (\n              <PlanCard\n                key={plan.id}\n                plan={plan}\n                disabled={Boolean(selectedPlanId) || isPending}\n                isSelected={selectedPlanId === plan.id}\n                isCurrent={plan.isCurrent}\n                onSelect={() => handleSelectPlan(plan)}\n              />\n            ))}\n          </div>\n        )}\n\n        {selectedPlanId && (\n          <div className=\"mt-8 flex items-center justify-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-5 py-4 text-sm text-gray-600\">\n            <span className=\"inline-flex h-3.5 w-3.5 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900\" />\n            Redirecting to secure Polar checkout…\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\ninterface PlanCardProps {\n  plan: PolarPlan & { isCurrent: boolean };\n  disabled: boolean;\n  isSelected: boolean;\n  isCurrent: boolean;\n  onSelect: () => void;\n}\n\nfunction PlanCard({ plan, disabled, isSelected, isCurrent, onSelect }: PlanCardProps) {\n  const actionLabel = isCurrent\n    ? \"Current plan\"\n    : plan.price === 0\n      ? \"Select plan\"\n      : `Select ${plan.name}`;\n\n  return (\n    <article\n      className={`relative flex h-full flex-col justify-between rounded-3xl border p-6 transition ${\n        isSelected\n          ? \"border-gray-900 ring-2 ring-gray-900\"\n          : isCurrent\n            ? \"border-emerald-300 ring-1 ring-emerald-200\"\n            : \"border-gray-200 hover:border-gray-300\"\n      }`}\n    >\n      {isCurrent ? (\n        <span className=\"absolute right-6 top-6 rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white\">\n          Current Plan\n        </span>\n      ) : plan.badge && (\n        <span className=\"absolute right-6 top-6 rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white\">\n          {plan.badge}\n        </span>\n      )}\n\n      <div className=\"space-y-4\">\n        <div>\n          <h3 className=\"text-xl font-semibold text-gray-900\">{plan.name}</h3>\n          <p className=\"mt-1 text-sm text-gray-500\">{plan.description}</p>\n        </div>\n\n        <div>\n          <span className=\"text-3xl font-semibold text-gray-900\">{formatCurrency(plan.price)}</span>\n          <span className=\"ml-1 text-sm text-gray-500\">/month</span>\n        </div>\n\n        <ul className=\"space-y-2 text-sm text-gray-600\">\n          {plan.includedSeats !== null && (\n            <li>\n              <strong className=\"font-medium text-gray-900\">{plan.includedSeats}</strong> seats included\n            </li>\n          )}\n          {plan.includedInvoices !== null && (\n            <li>\n              <strong className=\"font-medium text-gray-900\">{plan.includedInvoices.toLocaleString()}</strong> invoices\n              per month\n            </li>\n          )}\n          {plan.benefits.map((benefit) => (\n            <li key={benefit}>{benefit}</li>\n          ))}\n        </ul>\n      </div>\n\n      <button\n        type=\"button\"\n        onClick={onSelect}\n        disabled={disabled || isCurrent}\n        className={`mt-6 inline-flex w-full items-center justify-center rounded-full px-5 py-2 text-sm font-semibold transition ${\n          isCurrent\n            ? \"cursor-not-allowed border border-emerald-100 bg-emerald-50 text-emerald-600\"\n            : \"bg-gray-900 text-white shadow hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500\"\n        }`}\n      >\n        {isCurrent ? \"Current plan\" : isSelected ? \"Processing…\" : actionLabel}\n      </button>\n    </article>\n  );\n}\n\nfunction formatCurrency(amount: number) {\n  return new Intl.NumberFormat(undefined, {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(amount);\n}\n"
  },
  {
    "path": "next_b2b_starter/components/billing/subscription-alerts.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport {\n  AlertTriangle,\n  ShieldAlert,\n  Info,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type SubscriptionAlertVariant = \"info\" | \"warning\" | \"critical\";\n\nexport interface SubscriptionAlertAction {\n  label: string;\n  href?: string;\n  onClick?: () => void;\n  priority?: \"primary\" | \"secondary\";\n}\n\nexport interface SubscriptionAlertDescriptor {\n  id: string;\n  variant: SubscriptionAlertVariant;\n  title: string;\n  description: string;\n  action?: SubscriptionAlertAction;\n  actions?: SubscriptionAlertAction[];\n}\n\ninterface SubscriptionAlertsProps {\n  alerts: SubscriptionAlertDescriptor[];\n  className?: string;\n}\n\nconst variantStyles: Record<\n  SubscriptionAlertVariant,\n  { container: string; icon: typeof Info }\n> = {\n  info: {\n    container:\n      \"border border-blue-200 bg-blue-50/70 text-blue-900\",\n    icon: Info,\n  },\n  warning: {\n    container:\n      \"border border-amber-200 bg-amber-50/70 text-amber-900\",\n    icon: AlertTriangle,\n  },\n  critical: {\n    container:\n      \"border border-red-200 bg-red-50/70 text-red-900\",\n    icon: ShieldAlert,\n  },\n};\n\nexport function SubscriptionAlerts({ alerts, className }: SubscriptionAlertsProps) {\n  if (!alerts.length) {\n    return null;\n  }\n\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      {alerts.map((alert) => {\n        const { container, icon: Icon } = variantStyles[alert.variant];\n        const actions =\n          alert.actions ??\n          (alert.action ? [alert.action] : []);\n\n        return (\n          <div\n            key={alert.id}\n            className={cn(\n              \"flex gap-3 rounded-2xl px-4 py-3 shadow-sm transition\",\n              container\n            )}\n          >\n            <Icon className=\"mt-1 h-5 w-5 flex-none\" />\n            <div className=\"flex-1 space-y-2\">\n              <p className=\"text-sm font-semibold leading-tight\">\n                {alert.title}\n              </p>\n              <p className=\"text-sm leading-relaxed text-gray-700\">\n                {alert.description}\n              </p>\n              {actions.length ? (\n                <div className=\"flex flex-wrap gap-2\">\n                  {actions.map((action) => {\n                    const baseClasses = cn(\n                      \"inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2\",\n                      action.priority === \"primary\"\n                        ? \"bg-gray-900 text-white hover:bg-gray-800 focus-visible:outline-gray-900\"\n                        : \"border border-gray-300 bg-white text-gray-900 hover:bg-gray-100 focus-visible:outline-gray-300\"\n                    );\n\n                    if (action.onClick) {\n                      return (\n                        <button\n                          key={`${alert.id}-${action.label}`}\n                          type=\"button\"\n                          className={baseClasses}\n                          onClick={action.onClick}\n                        >\n                          {action.label}\n                        </button>\n                      );\n                    }\n\n                    if (action.href) {\n                      return (\n                        <Link\n                          key={`${alert.id}-${action.label}`}\n                          href={action.href}\n                          className={baseClasses}\n                        >\n                          {action.label}\n                        </Link>\n                      );\n                    }\n\n                    return null;\n                  })}\n                </div>\n              ) : null}\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/components/billing/subscription-paywall.tsx",
    "content": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { useRouter } from \"next/navigation\";\n\nimport type { SubscriptionGateState } from \"@/lib/polar/current-subscription\";\nimport { PlansModal } from \"@/components/billing/plans-modal\";\nimport { getPlanById, getPlanByProductId, type PolarPlan } from \"@/lib/polar/plans\";\nimport { useSubscriptionQuery } from \"@/lib/hooks/queries/use-subscription-query\";\nimport { useProductsQuery } from \"@/lib/hooks/queries/use-products-query\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n\ninterface SubscriptionPaywallProps {\n  // No props required - component fetches its own data\n}\n\nexport function SubscriptionPaywall({}: SubscriptionPaywallProps = {}) {\n  const router = useRouter();\n  const {\n    hasPermission,\n    isInitialized: permissionsReady,\n  } = usePermissions();\n  const hasSubscriptionPermission = hasPermission(PERMISSIONS.ORG_MANAGE);\n  const shouldLoadSubscription = permissionsReady && hasSubscriptionPermission;\n\n  const [isPlansOpen, setPlansOpen] = useState(false);\n\n  const {\n    data: state,\n    isLoading,\n    error: subscriptionError,\n  } = useSubscriptionQuery({\n    enabled: shouldLoadSubscription,\n  });\n  const { data: products, error: productsError } = useProductsQuery({\n    enabled: shouldLoadSubscription,\n  });\n\n  // Redirect to dashboard if subscription becomes active\n  useEffect(() => {\n    if (state?.isActive) {\n      router.replace(\"/dashboard\");\n    }\n  }, [state?.isActive, router]);\n\n  if (permissionsReady && !hasSubscriptionPermission) {\n    return (\n      <main className=\"flex min-h-screen items-center justify-center bg-gray-50 px-6 py-12\">\n        <div className=\"w-full max-w-lg space-y-4 text-center\">\n          <h1 className=\"text-2xl font-semibold text-gray-900\">\n            Subscription access restricted\n          </h1>\n          <p className=\"text-sm text-gray-600\">\n            You don&apos;t have permission to manage subscription or billing settings. Contact your workspace administrator if you believe this is an error.\n          </p>\n        </div>\n      </main>\n    );\n  }\n\n  const loadError = subscriptionError ?? productsError;\n\n  if (shouldLoadSubscription && loadError) {\n    return (\n      <main className=\"flex min-h-screen items-center justify-center bg-gray-50 px-6 py-12\">\n        <div className=\"w-full max-w-lg space-y-4 text-center\">\n          <h1 className=\"text-2xl font-semibold text-gray-900\">\n            We couldn&apos;t load your subscription\n          </h1>\n          <p className=\"text-sm text-gray-600\">\n            {loadError.message || \"Please refresh the page or reach out to support.\"}\n          </p>\n        </div>\n      </main>\n    );\n  }\n\n  if (!shouldLoadSubscription || isLoading || !state) {\n    return (\n      <main className=\"flex min-h-screen items-center justify-center bg-gray-50 px-6 py-12\">\n        <div className=\"w-full max-w-4xl space-y-6\">\n          <Skeleton className=\"h-96 w-full rounded-3xl\" />\n        </div>\n      </main>\n    );\n  }\n\n  // If subscription is active but haven't redirected yet, show loading\n  if (state.isActive) {\n    return (\n      <main className=\"flex min-h-screen items-center justify-center bg-gray-50 px-6 py-12\">\n        <div className=\"flex flex-col items-center gap-3\">\n          <div className=\"h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-gray-900\" />\n          <p className=\"text-sm text-gray-600\">Redirecting to dashboard...</p>\n        </div>\n      </main>\n    );\n  }\n\n  const usage = state.usage;\n\n  const included = usage?.included ?? 1000; // Default fallback\n  const used = usage?.used ?? 0;\n  const remaining = usage?.remaining ?? included;\n  const plan = resolvePlanFromState(state, products ?? []);\n  const planPrice = plan?.price ?? null;\n  const formattedPrice = planPrice != null ? formatUsd(planPrice) : null;\n  const interval = plan?.interval === \"month\" ? \"per month\" : \"per billing period\";\n\n  const contactHref =\n    process.env.NEXT_PUBLIC_CONTACT_EMAIL ||\n    process.env.NOTIFICATION_EMAIL ||\n    \"mailto:info@yourdomain.com\";\n\n  console.info(\"[Polar] Rendering SubscriptionPaywall\", {\n    isActive: state.isActive,\n    reason: state.reason,\n    status: state.status,\n    included,\n    used,\n    remaining,\n    plan: plan?.id ?? \"unknown\",\n    planPrice,\n  });\n\n  const featureList = buildFeatureList({\n    includedInvoices: included,\n    plan,\n  });\n\n  return (\n    <main className=\"flex min-h-screen items-center justify-center bg-gray-50 px-6 py-12\">\n      <div className=\"w-full max-w-4xl\">\n        <div className=\"grid gap-6 lg:grid-cols-[2fr,1fr]\">\n          <section className=\"flex flex-col justify-between rounded-3xl bg-white p-10 shadow-lg ring-1 ring-gray-200\">\n            <div>\n              <span className=\"inline-flex items-center rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white\">\n                {plan?.name ?? \"Pro Plan\"}\n              </span>\n              <h1 className=\"mt-6 text-3xl font-semibold text-gray-900 sm:text-4xl\">\n                Unlock the full AP automation experience\n              </h1>\n              <p className=\"mt-4 text-base text-gray-600\">\n                Process invoices faster, eliminate duplicates, and stay ahead of the month-end crunch.\n                Subscribe now to regain access to the dashboard.\n              </p>\n\n              <div className=\"mt-8 grid gap-4 sm:grid-cols-2\">\n                {featureList.map((feature) => (\n                  <Feature\n                    key={feature.title}\n                    title={feature.title}\n                    description={feature.description}\n                  />\n                ))}\n              </div>\n            </div>\n\n            <div className=\"mt-10 flex flex-col gap-3 sm:flex-row sm:items-center\">\n              <button\n                type=\"button\"\n                onClick={() => setPlansOpen(true)}\n                className=\"inline-flex items-center justify-center rounded-md bg-gray-900 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900\"\n              >\n                View pricing plans\n              </button>\n              <a\n                href={contactHref}\n                className=\"inline-flex items-center justify-center rounded-md border border-gray-200 px-6 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-200\"\n              >\n                Talk to sales\n              </a>\n            </div>\n          </section>\n\n          <aside className=\"rounded-3xl bg-gray-900 p-8 text-white shadow-lg ring-1 ring-gray-900/10\">\n            <p className=\"text-sm uppercase tracking-[0.16em] text-gray-400\">\n              Plan snapshot\n            </p>\n            <p className=\"mt-4 text-4xl font-semibold\">\n              {formattedPrice ?? \"Contact sales\"}\n            </p>\n            <p className=\"text-sm text-gray-400\">\n              {formattedPrice ? `${interval} · cancel anytime` : \"We’ll help you activate the right plan.\"}\n            </p>\n\n            <dl className=\"mt-8 space-y-4\">\n              <UsageItem label=\"Invoices remaining\" value={remaining} total={included} />\n              <UsageItem label=\"Invoices used this period\" value={used} total={included} />\n              {state.subscription?.trialEnd && (\n                <div className=\"rounded-lg border border-white/10 bg-white/5 p-4\">\n                  <dt className=\"text-sm text-gray-300\">Trial ends</dt>\n                  <dd className=\"mt-1 text-lg font-semibold text-white\">\n                    {new Date(state.subscription.trialEnd).toLocaleDateString()}\n                  </dd>\n                </div>\n              )}\n            </dl>\n\n            {state.reason === \"NO_ACTIVE_SUBSCRIPTION\" && (\n              <p className=\"mt-8 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100\">\n                Your payment method may have expired or the subscription was canceled. Restart your\n                plan to continue where you left off.\n              </p>\n            )}\n\n            {state.reason === \"CUSTOMER_NOT_FOUND\" && (\n              <p className=\"mt-8 rounded-lg border border-blue-500/20 bg-blue-500/10 px-4 py-3 text-sm text-blue-100\">\n                We couldn&apos;t match your organization to an active Polar customer. Subscribe using\n                the same email you used to sign in, and we&apos;ll link everything automatically.\n              </p>\n            )}\n\n            {state.reason === \"UNKNOWN_ERROR\" && (\n              <p className=\"mt-8 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-100\">\n                We couldn&apos;t verify your subscription at the moment. Please retry or contact support.\n              </p>\n            )}\n          </aside>\n        </div>\n      </div>\n      <PlansModal\n        open={isPlansOpen}\n        onOpenChange={setPlansOpen}\n        subscriptionState={state}\n      />\n    </main>\n  );\n}\n\ninterface FeatureProps {\n  title: string;\n  description: string;\n}\n\nfunction Feature({ title, description }: FeatureProps) {\n  return (\n    <div className=\"rounded-2xl border border-gray-200 p-4\">\n      <h3 className=\"text-sm font-semibold text-gray-900\">{title}</h3>\n      <p className=\"mt-1 text-sm text-gray-500\">{description}</p>\n    </div>\n  );\n}\n\ninterface UsageItemProps {\n  label: string;\n  value: number;\n  total: number;\n}\n\nfunction UsageItem({ label, value, total }: UsageItemProps) {\n  const percentage = Math.min(Math.round((value / Math.max(total, 1)) * 100), 100);\n  return (\n    <div>\n      <dt className=\"text-sm text-gray-400\">{label}</dt>\n      <dd className=\"mt-1 flex items-center justify-between text-lg font-semibold text-white\">\n        <span>{value.toLocaleString()}</span>\n        <span className=\"text-sm font-medium text-gray-400\">of {total.toLocaleString()}</span>\n      </dd>\n      <div className=\"mt-2 h-2 rounded-full bg-gray-800\">\n        <div\n          className=\"h-full rounded-full bg-gradient-to-r from-violet-400 to-indigo-400\"\n          style={{ width: `${percentage}%` }}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction resolvePlanFromState(state: SubscriptionGateState, products: PolarPlan[]): PolarPlan | null {\n  if (state.planId) {\n    const byId = getPlanById(products, state.planId);\n    if (byId) {\n      return byId;\n    }\n  }\n\n  if (state.subscription?.productId) {\n    const bySubscriptionProduct = getPlanByProductId(products, state.subscription.productId);\n    if (bySubscriptionProduct) {\n      return bySubscriptionProduct;\n    }\n  }\n\n  if (state.productId) {\n    const byProduct = getPlanByProductId(products, state.productId);\n    if (byProduct) {\n      return byProduct;\n    }\n  }\n\n  return null;\n}\n\nfunction buildFeatureList({\n  includedInvoices,\n  plan,\n}: {\n  includedInvoices: number;\n  plan: PolarPlan | null;\n}) {\n  const features: Array<{ title: string; description: string }> = [\n    {\n      title: `${includedInvoices.toLocaleString()} invoices / month included`,\n      description: \"Metered usage with overage protection. Track consumption in real time.\",\n    },\n  ];\n\n  if (plan?.price != null) {\n    features.push({\n      title: `${formatUsd(plan.price)} flat subscription`,\n      description: \"Predictable billing aligned with your finance team’s needs.\",\n    });\n  }\n\n  if (plan?.benefits?.length) {\n    for (const benefit of plan.benefits) {\n      features.push({\n        title: benefit,\n        description: \"Included with your current plan.\",\n      });\n    }\n  } else {\n    features.push(\n      {\n        title: \"Approvals & anomaly detection\",\n        description: \"Keep approvers accountable and surface risk before it hits ERP.\",\n      },\n      {\n        title: \"Export-ready payments\",\n        description: \"Generate payment files that drop straight into NetSuite and SAP.\",\n      }\n    );\n  }\n\n  return features;\n}\n\nfunction formatUsd(amount: number) {\n  return new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n    maximumFractionDigits: amount % 1 === 0 ? 0 : 2,\n  }).format(amount);\n}\n"
  },
  {
    "path": "next_b2b_starter/components/common/obfuscated-email.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\ntype Props = {\n  user: string; // e.g. \"info\"\n  domain: string; // e.g. \"yourdomain.com\"\n  className?: string;\n  children?: React.ReactNode; // link text, defaults to \"Email us\"\n};\n\nexport function ObfuscatedEmail({ user, domain, className, children }: Props) {\n  const ref = useRef<HTMLAnchorElement>(null);\n\n  useEffect(() => {\n    const a = ref.current;\n    if (!a) return;\n    const address = `${user}@${domain}`;\n    a.href = `mailto:${address}`;\n    if (!a.textContent || a.textContent.trim().length === 0) {\n      a.textContent = address;\n    }\n  }, [user, domain]);\n\n  return (\n    <a ref={ref} className={className} rel=\"nofollow noopener noreferrer\">\n      {children ?? \"Email us\"}\n    </a>\n  );\n}\n\n"
  },
  {
    "path": "next_b2b_starter/components/layout/dashboard-layout.tsx",
    "content": "// components/layout/dashboard-layout.tsx\n\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState, createContext, useContext } from \"react\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\n\nimport { Sidebar } from \"./sidebar\";\nimport { Header } from \"./header\";\nimport { PlansModal } from \"@/components/billing/plans-modal\";\nimport {\n  SubscriptionAlerts,\n  type SubscriptionAlertDescriptor,\n  type SubscriptionAlertAction,\n} from \"@/components/billing/subscription-alerts\";\nimport { cn } from \"@/lib/utils\";\nimport { useSidebarStore } from \"@/lib/stores/sidebar-store\";\nimport type { ServerPermissions } from \"@/lib/auth/server-permissions\";\nimport { useAuthContext } from \"@/lib/contexts/auth-context\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\nimport { useSubscriptionQuery } from \"@/lib/hooks/queries/use-subscription-query\";\nimport { queryKeys } from \"@/lib/hooks/queries/query-keys\";\nimport type { SubscriptionGateState } from \"@/lib/polar/current-subscription\";\nimport { useIsPlansModalOpen, useUIStore } from \"@/stores/ui-store\";\n\ninterface DashboardLayoutProps {\n  children: React.ReactNode;\n  initialSubscription: SubscriptionGateState;\n}\n\nexport function DashboardLayout({\n  children,\n  initialSubscription,\n}: DashboardLayoutProps) {\n  const isSidebarCollapsed = useSidebarStore((state) => state.isCollapsed);\n  const setAutoCollapsed = useSidebarStore((state) => state.setAutoCollapsed);\n  const auth = useAuthContext();\n  const queryClient = useQueryClient();\n  const isPlansModalOpen = useIsPlansModalOpen();\n  const setPlansModalOpen = useUIStore((state) => state.setPlansModalOpen);\n  const openPlansModal = useCallback(() => setPlansModalOpen(true), [setPlansModalOpen]);\n  const handlePlansModalOpenChange = useCallback(\n    (open: boolean) => setPlansModalOpen(open),\n    [setPlansModalOpen]\n  );\n\n  useEffect(() => {\n    queryClient.setQueryData(\n      queryKeys.subscription.status(),\n      initialSubscription\n    );\n  }, [initialSubscription, queryClient]);\n\n  // Show toast notification when server is unreachable\n  useEffect(() => {\n    if (!initialSubscription.backendAvailable) {\n      toast.error(\"Server Unreachable\", {\n        description: \"Unable to connect to the server. Some features may be unavailable.\",\n        duration: 5000,\n      });\n    }\n  }, [initialSubscription.backendAvailable]);\n\n  const permissions: ServerPermissions | null = useMemo(() => {\n    if (!auth) {\n      return null;\n    }\n\n    const profile = auth.profile;\n    const roles = auth.roles;\n    const granted = auth.permissions;\n\n    return {\n      profile,\n      roles,\n      permissions: granted,\n      canViewInvoices: granted.includes(PERMISSIONS.INVOICE_VIEW),\n      canCreateInvoices: granted.includes(PERMISSIONS.INVOICE_CREATE),\n      canUploadInvoices: granted.includes(PERMISSIONS.INVOICE_UPLOAD),\n      canDeleteInvoices: granted.includes(PERMISSIONS.INVOICE_DELETE),\n      canViewApprovals: granted.includes(PERMISSIONS.APPROVALS_VIEW),\n      canApproveInvoices: granted.includes(PERMISSIONS.APPROVALS_APPROVE),\n      canViewDuplicates: granted.includes(PERMISSIONS.DUPLICATES_VIEW),\n      canResolveDuplicates: granted.includes(PERMISSIONS.DUPLICATES_RESOLVE),\n      canSchedulePayments: granted.includes(PERMISSIONS.PAYMENT_OPTIMIZATION_SCHEDULE),\n      canExportPayments: granted.includes(PERMISSIONS.PAYMENT_OPTIMIZATION_EXPORT),\n      canExecutePayments: granted.includes(PERMISSIONS.PAYMENT_OPTIMIZATION_EXECUTE),\n      canViewAudit: granted.includes(PERMISSIONS.AUDIT_VIEW),\n      canViewOrganization: granted.includes(PERMISSIONS.ORG_VIEW),\n      canManageOrganization: granted.includes(PERMISSIONS.ORG_MANAGE),\n      canManageSubscriptions: granted.includes(PERMISSIONS.ORG_MANAGE),\n      backendAvailable: true,\n      backendError: null,\n    };\n  }, [auth]);\n\n  const shouldLoadSubscription = Boolean(\n    permissions?.canManageSubscriptions\n  );\n\n  const { data: subscriptionData } = useSubscriptionQuery({\n    enabled: shouldLoadSubscription,\n    initialData: initialSubscription,\n  });\n\n  const subscriptionState =\n    subscriptionData ?? initialSubscription ?? null;\n\n  const {\n    alerts,\n  } = useMemo(\n    () => deriveSubscriptionUiState(subscriptionState, openPlansModal),\n    [subscriptionState, openPlansModal]\n  );\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(max-width: 1279px)\");\n\n    const applyMatch = (matches: boolean) => {\n      setAutoCollapsed(matches);\n    };\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      applyMatch(event.matches);\n    };\n\n    applyMatch(mediaQuery.matches);\n\n    if (typeof mediaQuery.addEventListener === \"function\") {\n      mediaQuery.addEventListener(\"change\", handleChange);\n      return () => mediaQuery.removeEventListener(\"change\", handleChange);\n    }\n\n    const legacyHandler = () => applyMatch(mediaQuery.matches);\n    mediaQuery.addListener(legacyHandler);\n    return () => mediaQuery.removeListener(legacyHandler);\n  }, [setAutoCollapsed]);\n\n  const isReady = Boolean(auth?.isInitialized && permissions);\n\n  if (!isReady || !permissions) {\n    return (\n      <div className=\"min-h-screen bg-white flex items-center justify-center\">\n        <div className=\"text-center\">\n          <div className=\"h-8 w-8 border-4 border-gray-200 border-t-gray-900 rounded-full animate-spin mx-auto mb-4\" />\n          <p className=\"text-gray-600\">Loading...</p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n      <div className=\"min-h-screen bg-white\">\n        <Sidebar\n          permissions={permissions}\n        />\n        <Header />\n\n        <main\n          className={cn(\n            \"p-6 transition-[padding] duration-200\",\n            isSidebarCollapsed ? \"lg:pl-24\" : \"lg:pl-64\"\n          )}\n        >\n          <div className=\"mx-auto max-w-7xl space-y-6\">\n            <SubscriptionAlerts alerts={alerts} />\n            {children}\n          </div>\n        </main>\n\n        <PlansModal\n          open={isPlansModalOpen}\n          onOpenChange={handlePlansModalOpenChange}\n          subscriptionState={subscriptionState}\n        />\n      </div>\n  );\n}\n\nfunction deriveSubscriptionUiState(\n  state: SubscriptionGateState | null | undefined,\n  openPlansModal: () => void\n): {\n  alerts: SubscriptionAlertDescriptor[];\n} {\n  if (!state) {\n    return {\n      alerts: [],\n    };\n  }\n\n  const alerts: SubscriptionAlertDescriptor[] = [];\n  const settingsHref = \"/dashboard/settings?view=subscription\";\n  const reason = state.reason ?? null;\n\n  const pushAlert = (alert: SubscriptionAlertDescriptor) => {\n    alerts.push(alert);\n  };\n\n  if (!state.isActive) {\n    if (reason === \"INSUFFICIENT_PERMISSIONS\") {\n      alerts.push({\n        id: \"subscription-permissions\",\n        variant: \"info\",\n        title: \"Limited billing visibility\",\n        description:\n          \"You don't have permission to view subscription details.\",\n      });\n      return {\n        alerts,\n      };\n    }\n\n    if (reason === \"POLAR_UNCONFIGURED\") {\n      alerts.push({\n        id: \"subscription-unconfigured\",\n        variant: \"info\",\n        title: \"Billing configuration required\",\n        description:\n          \"We couldn't verify your subscription because Polar credentials are missing. Add them in settings to enable monitoring.\",\n        actions: [\n          {\n            label: \"Open billing settings\",\n            href: settingsHref,\n            priority: \"secondary\",\n          },\n        ],\n      });\n      return {\n        alerts,\n      };\n    }\n\n    pushAlert({\n      id: \"subscription-inactive\",\n      variant: \"critical\",\n      title: \"Subscription inactive\",\n      description: getInactiveDescription(state),\n      actions: [\n        {\n          label: \"Subscribe now\",\n          onClick: openPlansModal,\n          priority: \"primary\",\n        },\n        {\n          label: \"Manage subscription\",\n          href: settingsHref,\n          priority: \"secondary\",\n        },\n      ],\n    });\n  }\n\n  const usage = state.usage;\n  if (usage && usage.included > 0) {\n    const { included, used, remaining } = usage;\n    const usageRatio = included > 0 ? used / included : 0;\n\n    if (remaining <= 0) {\n      pushAlert({\n        id: \"subscription-usage-max\",\n        variant: \"critical\",\n        title: \"Usage limit reached\",\n        description: `You've used ${formatNumber(\n          used\n        )} of ${formatNumber(\n          included\n        )} units this billing period. Upgrade or extend your plan to continue.`,\n        actions: [\n          {\n            label: \"Upgrade plan\",\n            onClick: openPlansModal,\n            priority: \"primary\",\n          },\n          {\n            label: \"Manage billing\",\n            href: settingsHref,\n            priority: \"secondary\",\n          },\n        ],\n      });\n    } else {\n      const thresholdPercent = 0.85;\n      const thresholdRemaining = Math.max(5, Math.ceil(included * 0.05));\n      if (\n        usageRatio >= thresholdPercent ||\n        remaining <= thresholdRemaining\n      ) {\n        alerts.push({\n          id: \"subscription-usage-warning\",\n          variant: \"warning\",\n          title: \"You're nearing your usage limit\",\n          description: `Only ${formatNumber(\n            remaining\n          )} unit${remaining === 1 ? \"\" : \"s\"} remain in this billing period.`,\n          actions: [\n            {\n              label: \"Review plans\",\n              onClick: openPlansModal,\n              priority: \"primary\",\n            },\n            {\n              label: \"Track usage\",\n              href: settingsHref,\n              priority: \"secondary\",\n            },\n          ],\n        });\n      }\n    }\n  }\n\n  const subscription = state.subscription;\n  if (subscription?.cancelAtPeriodEnd) {\n    const cancelDate = subscription.currentPeriodEnd\n      ? formatDateString(subscription.currentPeriodEnd)\n      : \"the end of this billing period\";\n    alerts.push({\n      id: \"subscription-cancelled\",\n      variant: \"warning\",\n      title: \"Subscription scheduled to cancel\",\n      description: `Your current plan will end on ${cancelDate}. Resume the subscription to maintain uninterrupted access.`,\n      actions: [\n        {\n          label: \"Resume subscription\",\n          href: settingsHref,\n          priority: \"primary\",\n        },\n      ],\n    });\n  }\n\n  const trialEnd = subscription?.trialEnd;\n  if (trialEnd) {\n    const daysLeft = daysUntil(trialEnd);\n    if (daysLeft !== null && daysLeft <= 7) {\n      alerts.push({\n        id: \"subscription-trial-ending\",\n        variant: daysLeft <= 2 ? \"critical\" : \"warning\",\n        title: \"Trial ending soon\",\n        description: `Your trial ends on ${formatDateString(\n          trialEnd\n        )}. Add a payment method to stay active.`,\n        actions: [\n          {\n            label: \"Secure your plan\",\n            onClick: openPlansModal,\n            priority: \"primary\",\n          },\n          {\n            label: \"Update billing details\",\n            href: settingsHref,\n            priority: \"secondary\",\n          },\n        ],\n      });\n    }\n  }\n\n  if (reason === \"UNKNOWN_ERROR\") {\n    alerts.push({\n      id: \"subscription-unknown-error\",\n      variant: \"info\",\n      title: \"Subscription status unavailable\",\n      description:\n        \"We couldn't refresh your subscription details. We'll retry automatically, or you can refresh the page.\",\n    });\n  }\n\n  return {\n    alerts,\n  };\n}\n\nfunction getInactiveDescription(state: SubscriptionGateState): string {\n  const reason = state.reason ?? null;\n  const status = state.status ?? null;\n\n  switch (reason) {\n    case \"CUSTOMER_NOT_FOUND\":\n      return \"We couldn't match your workspace to an active billing account. Start a subscription to unlock premium features.\";\n    case \"NO_ACTIVE_SUBSCRIPTION\":\n      if (status === \"past_due\") {\n        return \"We couldn't process your latest payment. Update billing details to resume service.\";\n      }\n      return \"Your subscription has ended. Restart your plan to continue using the service.\";\n    case \"PROFILE_UNAVAILABLE\":\n      return \"We couldn't load your profile to verify billing status. Please refresh the page or contact support.\";\n    case \"UNKNOWN_ERROR\":\n      return \"We couldn't verify your subscription. Try again shortly or contact support if this persists.\";\n    default:\n      return \"We could not confirm an active subscription for this workspace. Update your plan to continue.\";\n  }\n}\n\nfunction formatNumber(value: number): string {\n  return new Intl.NumberFormat().format(value);\n}\n\nfunction formatDateString(value: string): string {\n  const date = new Date(value);\n  if (Number.isNaN(date.getTime())) {\n    return value;\n  }\n\n  return date.toLocaleDateString(undefined, {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n  });\n}\n\nfunction daysUntil(value: string): number | null {\n  const date = new Date(value);\n  if (Number.isNaN(date.getTime())) {\n    return null;\n  }\n\n  const diff = date.getTime() - Date.now();\n  return Math.ceil(diff / (1000 * 60 * 60 * 24));\n}\n"
  },
  {
    "path": "next_b2b_starter/components/layout/header.tsx",
    "content": "// components/layout/header.tsx\n\"use client\";\n\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\nimport {\n  ChevronRight,\n  LifeBuoy,\n  PanelLeftClose,\n  PanelLeftOpen,\n  Settings,\n} from \"lucide-react\";\nimport { usePathname } from \"next/navigation\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useSidebarStore } from \"@/lib/stores/sidebar-store\";\nimport { UserMenu } from \"./user-menu\";\n\nexport function Header() {\n  const isSidebarCollapsed = useSidebarStore((state) => state.isCollapsed);\n  const toggleSidebar = useSidebarStore((state) => state.toggle);\n  const isAutoCollapsed = useSidebarStore((state) => state.isAutoCollapsed);\n  const pathname = usePathname();\n\n  const breadcrumbItems = useMemo(() => {\n    const segments = pathname.split(\"/\").filter(Boolean);\n\n    // Add Dashboard as first item if not already on dashboard\n    const items: Array<{ label: string; href: string; isLast: boolean }> = [];\n\n    if (segments.length > 0 && segments[0] !== 'dashboard') {\n      items.push({\n        label: 'Dashboard',\n        href: '/dashboard',\n        isLast: false,\n      });\n    }\n\n    segments.forEach((segment, index) => {\n      const href = `/${segments.slice(0, index + 1).join(\"/\")}`;\n\n      const label = segment\n        .replace(/-/g, \" \")\n        .replace(/\\b\\w/g, (char) => char.toUpperCase());\n\n      items.push({\n        label: /^\\d+$/.test(segment) ? `Invoice ${segment}` : label,\n        href,\n        isLast: index === segments.length - 1,\n      });\n    });\n\n    return items;\n  }, [pathname]);\n\n  const pageTitle = breadcrumbItems[breadcrumbItems.length - 1]?.label ?? \"Overview\";\n\n  return (\n    <header className=\"sticky top-0 z-40 border-b border-gray-200 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/80\">\n      <div className=\"flex\">\n        <div\n          className={cn(\n            \"hidden border-r border-gray-200 transition-[width] duration-200 lg:block\",\n            isSidebarCollapsed ? \"w-20\" : \"w-64\"\n          )}\n        />\n\n        <div className=\"flex-1 px-6 py-4\">\n          <div className=\"flex flex-col gap-4\">\n            <div className=\"flex items-start justify-between gap-4\">\n              <div className=\"flex items-center gap-3\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  onClick={toggleSidebar}\n                  className=\"hidden h-9 w-9 lg:inline-flex\"\n                  aria-label={isSidebarCollapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n                  disabled={isAutoCollapsed}\n                >\n                  {isSidebarCollapsed ? (\n                    <PanelLeftOpen className=\"h-4 w-4\" />\n                  ) : (\n                    <PanelLeftClose className=\"h-4 w-4\" />\n                  )}\n                </Button>\n\n                <span className=\"hidden h-8 w-px bg-gray-200 lg:block\" aria-hidden=\"true\" />\n\n                <div>\n                  <h1 className=\"text-lg font-semibold text-gray-900\">{pageTitle}</h1>\n                  <nav className=\"mt-1 flex flex-wrap items-center gap-1 text-sm text-gray-500\">\n                    {breadcrumbItems.map((item, index) => (\n                      <span key={item.href} className=\"flex items-center gap-1\">\n                        {item.isLast ? (\n                          <span className=\"font-medium text-gray-700\">{item.label}</span>\n                        ) : (\n                          <Link\n                            href={item.href}\n                            className=\"transition-colors hover:text-gray-900\"\n                          >\n                            {item.label}\n                          </Link>\n                        )}\n                        {index < breadcrumbItems.length - 1 && (\n                          <ChevronRight className=\"h-3.5 w-3.5 text-gray-300\" />\n                        )}\n                      </span>\n                    ))}\n                  </nav>\n                </div>\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                <Button variant=\"ghost\" size=\"icon\" className=\"h-9 w-9\">\n                  <LifeBuoy className=\"h-4 w-4\" />\n                  <span className=\"sr-only\">Support</span>\n                </Button>\n                <Button variant=\"ghost\" size=\"icon\" className=\"h-9 w-9\">\n                  <Settings className=\"h-4 w-4\" />\n                  <span className=\"sr-only\">Preferences</span>\n                </Button>\n                <UserMenu />\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/components/layout/sidebar.tsx",
    "content": "// components/layout/sidebar.tsx\n\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport Link from \"next/link\";\nimport Image from \"next/image\";\nimport { usePathname } from \"next/navigation\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  LayoutDashboard,\n  Settings,\n  Menu,\n  X,\n  BookOpen,\n} from \"lucide-react\";\nimport { useSidebarStore } from \"@/lib/stores/sidebar-store\";\nimport type { ServerPermissions } from \"@/lib/auth/server-permissions\";\nimport type { LucideIcon } from \"lucide-react\";\n\ninterface NavigationItem {\n  name: string;\n  href: string;\n  icon: LucideIcon;\n  permission?: string;\n  anyPermissions?: string[];\n}\n\nconst mainNavigation: NavigationItem[] = [\n  {\n    name: \"Dashboard\",\n    href: \"/dashboard\",\n    icon: LayoutDashboard,\n    // No permission required - everyone can see dashboard\n  },\n  {\n    name: \"Knowledge Base\",\n    href: \"/dashboard/knowledge\",\n    icon: BookOpen,\n    // No permission required - everyone can access knowledge base\n  },\n];\n\nconst accountNavigation = [{ name: \"Settings\", href: \"/dashboard/settings\", icon: Settings }];\n\ninterface SidebarProps {\n  permissions: ServerPermissions;\n}\n\nexport function Sidebar({\n  permissions,\n}: SidebarProps) {\n  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n  const pathname = usePathname();\n  const isCollapsed = useSidebarStore((state) => state.isCollapsed);\n\n  const closeMobileMenu = () => setIsMobileMenuOpen(false);\n\n  // Filter navigation items based on permissions\n  const visibleNavigation = useMemo(() => {\n    return mainNavigation.filter((item) => {\n      // If no permission required, always show\n      if (!item.permission && !(\"anyPermissions\" in item)) return true;\n\n      // Check if item has multiple permissions (anyPermissions)\n      if (\"anyPermissions\" in item && item.anyPermissions) {\n        // User must have at least one of the specified permissions\n        return item.anyPermissions.some((perm) =>\n          permissions.permissions.includes(perm as any)\n        );\n      }\n\n      // Check single permission\n      if (item.permission) {\n        return permissions.permissions.includes(item.permission as any);\n      }\n\n      return true;\n    });\n  }, [permissions.permissions]);\n\n  return (\n    <>\n      {/* Mobile menu button */}\n      <div className=\"fixed left-4 top-4 z-50 lg:hidden\">\n        <Button\n          variant=\"outline\"\n          size=\"icon\"\n          onClick={() => setIsMobileMenuOpen((open) => !open)}\n          className=\"bg-white\"\n          aria-label={isMobileMenuOpen ? \"Close sidebar\" : \"Open sidebar\"}\n        >\n          {isMobileMenuOpen ? (\n            <X className=\"h-4 w-4\" />\n          ) : (\n            <Menu className=\"h-4 w-4\" />\n          )}\n        </Button>\n      </div>\n\n      {/* Mobile sidebar overlay */}\n      {isMobileMenuOpen && (\n        <div\n          className=\"fixed inset-0 z-40 bg-black/50 lg:hidden\"\n          onClick={closeMobileMenu}\n        />\n      )}\n\n      {/* Sidebar */}\n      <div\n        className={cn(\n          \"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r border-gray-200 bg-white transition-[transform,width] duration-200 ease-in-out lg:translate-x-0\",\n          isMobileMenuOpen\n            ? \"translate-x-0\"\n            : \"-translate-x-full lg:translate-x-0\",\n          isCollapsed && \"lg:w-20\",\n          \"overflow-hidden\"\n        )}\n      >\n        {/* Logo / brand */}\n        <div className=\"flex items-center gap-2 border-b border-gray-100 px-5 py-5\">\n          <div className=\"flex h-11 w-11 flex-none items-center justify-center\">\n            <Image\n              src=\"/icon.png\"\n              alt=\"App icon\"\n              width={32}\n              height={32}\n              className=\"h-8 w-8 object-contain\"\n            />\n          </div>\n          <div\n            className={cn(\n              \"max-w-[200px] overflow-hidden transition-[max-width,opacity] duration-200 ease-linear\",\n              isCollapsed ? \"lg:max-w-0 lg:opacity-0\" : \"lg:opacity-100\"\n            )}\n          >\n            <div className=\"text-lg font-semibold tracking-tight text-gray-900\">\n              Your App\n            </div>\n          </div>\n        </div>\n\n        {/* Navigation */}\n        <nav className=\"flex-1 overflow-y-auto p-6\">\n          <div className=\"space-y-1\">\n            {visibleNavigation.map((item) => {\n              const isActive =\n                item.href.startsWith(\"/dashboard/\") && item.href !== \"/dashboard\"\n                  ? pathname.startsWith(item.href)\n                  : pathname === item.href;\n\n              return (\n                <Link\n                  key={item.name}\n                  href={item.href}\n                  onClick={closeMobileMenu}\n                  title={isCollapsed ? item.name : undefined}\n                  className={cn(\n                    \"relative flex items-center overflow-hidden rounded-md px-3 py-2.5 text-sm font-medium transition-colors\",\n                    isCollapsed && \"lg:justify-center lg:px-5\",\n                    isActive\n                      ? \"bg-gray-900 text-white\"\n                      : \"text-gray-600 hover:bg-gray-100 hover:text-gray-900\"\n                  )}\n                  aria-label={item.name}\n                >\n                  <item.icon\n                    className={cn(\n                      \"h-4 w-4 flex-none\",\n                      isActive ? \"text-white\" : \"text-gray-400\"\n                    )}\n                  />\n                  <span\n                    className={cn(\n                      \"ml-3 whitespace-nowrap transition-[margin,max-width,opacity] duration-200 ease-linear\",\n                      isCollapsed\n                        ? \"lg:ml-0 lg:max-w-0 lg:opacity-0\"\n                        : \"lg:max-w-[160px] lg:opacity-100\"\n                    )}\n                  >\n                    {item.name}\n                  </span>\n                </Link>\n              );\n            })}\n          </div>\n        </nav>\n\n        {/* Account section */}\n        <div\n          className={cn(\n            \"border-t border-gray-100 px-5 py-4 transition-[padding] duration-200\",\n            isCollapsed && \"lg:px-3\"\n          )}\n        >\n          <div className=\"space-y-2.5\">\n            {accountNavigation.map((item) => {\n              const isActive = pathname === item.href;\n\n              return (\n                <Link\n                  key={item.name}\n                  href={item.href}\n                  onClick={closeMobileMenu}\n                  title={isCollapsed ? item.name : undefined}\n                  className={cn(\n                    \"flex h-10 items-center gap-2 rounded-md border border-transparent px-3 text-sm font-medium transition-colors\",\n                    isCollapsed && \"lg:h-9 lg:justify-center lg:px-0\",\n                    isActive\n                      ? \"border-gray-900 bg-gray-900 text-white shadow-sm\"\n                      : \"text-gray-600 hover:border-gray-200 hover:bg-gray-100 hover:text-gray-900\"\n                  )}\n                >\n                  <item.icon\n                    className={cn(\n                      \"h-4 w-4 flex-none text-gray-500\",\n                      isActive && \"text-white\"\n                    )}\n                  />\n                  <span\n                    className={cn(\n                      \"flex-1 truncate transition-[max-width,opacity] duration-200\",\n                      isCollapsed\n                        ? \"lg:max-w-0 lg:opacity-0\"\n                        : \"lg:max-w-[120px] lg:opacity-100\"\n                    )}\n                  >\n                    {item.name}\n                  </span>\n                </Link>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/components/layout/user-menu.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useMemo, useCallback, useTransition } from \"react\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { buildLoginUrl } from \"@/lib/auth/stytch-client\";\nimport { useStytchConfig } from \"@/lib/contexts/stytch-config-context\";\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\nimport { useAuthContext } from \"@/lib/contexts/auth-context\";\nimport { resetCachedToken } from \"@/lib/api/api/client/api-client\";\nimport { logout } from \"@/lib/actions/auth/logout\";\n\nfunction getInitials(name?: string) {\n  if (!name) return \"?\";\n  const parts = name.trim().split(/\\s+/);\n  const first = parts[0]?.[0] || \"\";\n  const last = parts.length > 1 ? parts[parts.length - 1][0] : \"\";\n  return (first + last).toUpperCase();\n}\n\nexport function UserMenu() {\n  const { profile, isInitialized } = usePermissions();\n  const authContext = useAuthContext();\n  const queryClient = useQueryClient();\n  const stytchConfig = useStytchConfig();\n  const [isPending, startTransition] = useTransition();\n\n  const loginHref = useMemo(() => {\n    return buildLoginUrl(stytchConfig);\n  }, [stytchConfig]);\n\n  const handleLogout = useCallback(() => {\n    startTransition(async () => {\n      // Clear all client-side state\n      authContext?.clearAuthState();\n      queryClient.clear();\n      resetCachedToken();\n\n      // Call Server Action (will redirect to home page)\n      await logout(\"/\");\n    });\n  }, [authContext, queryClient]);\n\n  if (!isInitialized) {\n    return (\n      <div className=\"h-9 w-24 animate-pulse rounded-md bg-gray-100\" aria-label=\"Loading user\" />\n    );\n  }\n\n  if (!profile) {\n    return (\n      <Button asChild variant=\"default\" className=\"h-9\">\n        <a href={loginHref}>Log in</a>\n      </Button>\n    );\n  }\n\n  const display = profile.name || profile.email || \"Account\";\n  const initials = getInitials(profile.name || profile.email);\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"outline\" className=\"h-9 gap-2\">\n          <span className=\"flex h-6 w-6 items-center justify-center rounded-full bg-gray-900 text-xs font-semibold text-white\">\n            {initials}\n          </span>\n          <span className=\"hidden max-w-[160px] truncate text-sm md:inline\">{display}</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-52\">\n        <DropdownMenuItem asChild>\n          <Link href=\"/dashboard\">Dashboard</Link>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleLogout} disabled={isPending}>\n          {isPending ? \"Logging out...\" : \"Log out\"}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/components/seo/jsonld.tsx",
    "content": "import Script from \"next/script\";\n\ntype JsonLdProps = {\n  data: Record<string, unknown> | Record<string, unknown>[];\n  id?: string;\n};\n\nexport function JsonLd({ data, id }: JsonLdProps) {\n  const json = Array.isArray(data) ? data : [data];\n  const content =\n    json.length === 1 ? JSON.stringify(json[0]) : JSON.stringify(json);\n  return (\n    <Script\n      id={id}\n      type=\"application/ld+json\"\n      strategy=\"afterInteractive\"\n      dangerouslySetInnerHTML={{ __html: content }}\n    />\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/components/ui/accordion.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Accordion = AccordionPrimitive.Root\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n))\nAccordionItem.displayName = \"AccordionItem\"\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        \"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n))\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n  </AccordionPrimitive.Content>\n))\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/alert.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n))\nAlert.displayName = \"Alert\"\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nAlertTitle.displayName = \"AlertTitle\"\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n))\nAlertDescription.displayName = \"AlertDescription\"\n\nexport { Alert, AlertTitle, AlertDescription }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/avatar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Avatar = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full\",\n      className\n    )}\n    {...props}\n  />\n))\nAvatar.displayName = AvatarPrimitive.Root.displayName\n\nconst AvatarImage = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Image>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Image\n    ref={ref}\n    className={cn(\"aspect-square h-full w-full\", className)}\n    {...props}\n  />\n))\nAvatarImage.displayName = AvatarPrimitive.Image.displayName\n\nconst AvatarFallback = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Fallback>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Fallback\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full items-center justify-center rounded-full bg-muted\",\n      className\n    )}\n    {...props}\n  />\n))\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName\n\nexport { Avatar, AvatarImage, AvatarFallback }"
  },
  {
    "path": "next_b2b_starter/components/ui/badge.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n  \"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80\",\n        outline: \"text-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  )\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90\",\n        outline:\n          \"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-md px-8\",\n        icon: \"h-9 w-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\"\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/calendar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronDownIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n} from \"lucide-react\"\nimport { DayButton, DayPicker, getDefaultClassNames } from \"react-day-picker\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button, buttonVariants } from \"@/components/ui/button\"\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  captionLayout = \"label\",\n  buttonVariant = \"ghost\",\n  formatters,\n  components,\n  ...props\n}: React.ComponentProps<typeof DayPicker> & {\n  buttonVariant?: React.ComponentProps<typeof Button>[\"variant\"]\n}) {\n  const defaultClassNames = getDefaultClassNames()\n\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn(\n        \"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent\",\n        String.raw`rtl:**:[.rdp-button\\_next>svg]:rotate-180`,\n        String.raw`rtl:**:[.rdp-button\\_previous>svg]:rotate-180`,\n        className\n      )}\n      captionLayout={captionLayout}\n      formatters={{\n        formatMonthDropdown: (date) =>\n          date.toLocaleString(\"default\", { month: \"short\" }),\n        ...formatters,\n      }}\n      classNames={{\n        root: cn(\"w-fit\", defaultClassNames.root),\n        months: cn(\n          \"relative flex flex-col gap-4 md:flex-row\",\n          defaultClassNames.months\n        ),\n        month: cn(\"flex w-full flex-col gap-4\", defaultClassNames.month),\n        nav: cn(\n          \"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1\",\n          defaultClassNames.nav\n        ),\n        button_previous: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50\",\n          defaultClassNames.button_previous\n        ),\n        button_next: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50\",\n          defaultClassNames.button_next\n        ),\n        month_caption: cn(\n          \"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]\",\n          defaultClassNames.month_caption\n        ),\n        dropdowns: cn(\n          \"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium\",\n          defaultClassNames.dropdowns\n        ),\n        dropdown_root: cn(\n          \"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border\",\n          defaultClassNames.dropdown_root\n        ),\n        dropdown: cn(\n          \"bg-popover absolute inset-0 opacity-0\",\n          defaultClassNames.dropdown\n        ),\n        caption_label: cn(\n          \"select-none font-medium\",\n          captionLayout === \"label\"\n            ? \"text-sm\"\n            : \"[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5\",\n          defaultClassNames.caption_label\n        ),\n        table: \"w-full border-collapse\",\n        weekdays: cn(\"flex\", defaultClassNames.weekdays),\n        weekday: cn(\n          \"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal\",\n          defaultClassNames.weekday\n        ),\n        week: cn(\"mt-2 flex w-full\", defaultClassNames.week),\n        week_number_header: cn(\n          \"w-[--cell-size] select-none\",\n          defaultClassNames.week_number_header\n        ),\n        week_number: cn(\n          \"text-muted-foreground select-none text-[0.8rem]\",\n          defaultClassNames.week_number\n        ),\n        day: cn(\n          \"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md\",\n          defaultClassNames.day\n        ),\n        range_start: cn(\n          \"bg-accent rounded-l-md\",\n          defaultClassNames.range_start\n        ),\n        range_middle: cn(\"rounded-none\", defaultClassNames.range_middle),\n        range_end: cn(\"bg-accent rounded-r-md\", defaultClassNames.range_end),\n        today: cn(\n          \"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none\",\n          defaultClassNames.today\n        ),\n        outside: cn(\n          \"text-muted-foreground aria-selected:text-muted-foreground\",\n          defaultClassNames.outside\n        ),\n        disabled: cn(\n          \"text-muted-foreground opacity-50\",\n          defaultClassNames.disabled\n        ),\n        hidden: cn(\"invisible\", defaultClassNames.hidden),\n        ...classNames,\n      }}\n      components={{\n        Root: ({ className, rootRef, ...props }) => {\n          return (\n            <div\n              data-slot=\"calendar\"\n              ref={rootRef}\n              className={cn(className)}\n              {...props}\n            />\n          )\n        },\n        Chevron: ({ className, orientation, ...props }) => {\n          if (orientation === \"left\") {\n            return (\n              <ChevronLeftIcon className={cn(\"size-4\", className)} {...props} />\n            )\n          }\n\n          if (orientation === \"right\") {\n            return (\n              <ChevronRightIcon\n                className={cn(\"size-4\", className)}\n                {...props}\n              />\n            )\n          }\n\n          return (\n            <ChevronDownIcon className={cn(\"size-4\", className)} {...props} />\n          )\n        },\n        DayButton: CalendarDayButton,\n        WeekNumber: ({ children, ...props }) => {\n          return (\n            <td {...props}>\n              <div className=\"flex size-[--cell-size] items-center justify-center text-center\">\n                {children}\n              </div>\n            </td>\n          )\n        },\n        ...components,\n      }}\n      {...props}\n    />\n  )\n}\n\nfunction CalendarDayButton({\n  className,\n  day,\n  modifiers,\n  ...props\n}: React.ComponentProps<typeof DayButton>) {\n  const defaultClassNames = getDefaultClassNames()\n\n  const ref = React.useRef<HTMLButtonElement>(null)\n  React.useEffect(() => {\n    if (modifiers.focused) ref.current?.focus()\n  }, [modifiers.focused])\n\n  return (\n    <Button\n      ref={ref}\n      variant=\"ghost\"\n      size=\"icon\"\n      data-day={day.date.toLocaleDateString()}\n      data-selected-single={\n        modifiers.selected &&\n        !modifiers.range_start &&\n        !modifiers.range_end &&\n        !modifiers.range_middle\n      }\n      data-range-start={modifiers.range_start}\n      data-range-end={modifiers.range_end}\n      data-range-middle={modifiers.range_middle}\n      className={cn(\n        \"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70\",\n        defaultClassNames.day,\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Calendar, CalendarDayButton }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"rounded-xl border bg-card text-card-foreground shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nCard.displayName = \"Card\"\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex flex-col space-y-1.5 p-6\", className)}\n    {...props}\n  />\n))\nCardHeader.displayName = \"CardHeader\"\n\nconst CardTitle = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"font-semibold leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nCardTitle.displayName = \"CardTitle\"\n\nconst CardDescription = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nCardDescription.displayName = \"CardDescription\"\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n))\nCardContent.displayName = \"CardContent\"\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex items-center p-6 pt-0\", className)}\n    {...props}\n  />\n))\nCardFooter.displayName = \"CardFooter\"\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/checkbox.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { Check } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Checkbox = React.forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground\",\n      className\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator\n      className={cn(\"flex items-center justify-center text-current\")}\n    >\n      <Check className=\"h-4 w-4\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n))\nCheckbox.displayName = CheckboxPrimitive.Root.displayName\n\nexport { Checkbox }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/date-picker.tsx",
    "content": "// components/ui/date-picker.tsx\n\"use client\";\n\nimport * as React from \"react\";\nimport { format } from \"date-fns\";\nimport { CalendarIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\n\ninterface DatePickerProps {\n  date?: Date;\n  onDateChange?: (date: Date | undefined) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function DatePicker({\n  date,\n  onDateChange,\n  placeholder = \"Pick a date\",\n  disabled = false,\n  className,\n}: DatePickerProps) {\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          className={cn(\n            \"w-full justify-start text-left font-normal\",\n            !date && \"text-muted-foreground\",\n            className\n          )}\n          disabled={disabled}\n        >\n          <CalendarIcon className=\"mr-2 h-4 w-4\" />\n          {date ? format(date, \"PPP\") : <span>{placeholder}</span>}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-0\" align=\"start\">\n        <Calendar\n          mode=\"single\"\n          selected={date}\n          onSelect={onDateChange}\n          initialFocus\n        />\n      </PopoverContent>\n    </Popover>\n  );\n}"
  },
  {
    "path": "next_b2b_starter/components/ui/dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = DialogPrimitive.Portal\n\nconst DialogClose = DialogPrimitive.Close\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      \"fixed inset-0 z-[200] bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName\n\ninterface DialogContentProps\n  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {\n  hideCloseButton?: boolean;\n}\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  DialogContentProps\n>(({ className, children, hideCloseButton = false, ...props }, ref) => (\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-[210] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {hideCloseButton ? null : (\n        <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      )}\n    </DialogPrimitive.Content>\n  </DialogPortal>\n))\nDialogContent.displayName = DialogPrimitive.Content.displayName\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-1.5 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogHeader.displayName = \"DialogHeader\"\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogFooter.displayName = \"DialogFooter\"\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogTitle.displayName = DialogPrimitive.Title.displayName\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nDialogDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n  Dialog,\n  DialogPortal,\n  DialogOverlay,\n  DialogTrigger,\n  DialogClose,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n}\n"
  },
  {
    "path": "next_b2b_starter/components/ui/dropdown-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DropdownMenu = DropdownMenuPrimitive.Root\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto\" />\n  </DropdownMenuPrimitive.SubTrigger>\n))\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]\",\n        className\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n))\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n))\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-2 w-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n))\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-sm font-semibold\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n))\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)}\n      {...props}\n    />\n  )\n}\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\"\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n}\n"
  },
  {
    "path": "next_b2b_starter/components/ui/form.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport {\n  Controller,\n  FormProvider,\n  useFormContext,\n  type ControllerProps,\n  type FieldPath,\n  type FieldValues,\n} from \"react-hook-form\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n  name: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue\n)\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  )\n}\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext)\n  const itemContext = React.useContext(FormItemContext)\n  const { getFieldState, formState } = useFormContext()\n\n  const fieldState = getFieldState(fieldContext.name, formState)\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\")\n  }\n\n  const { id } = itemContext\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  }\n}\n\ntype FormItemContextValue = {\n  id: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue\n)\n\nconst FormItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const id = React.useId()\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n    </FormItemContext.Provider>\n  )\n})\nFormItem.displayName = \"FormItem\"\n\nconst FormLabel = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  const { error, formItemId } = useFormField()\n\n  return (\n    <Label\n      ref={ref}\n      className={cn(error && \"text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  )\n})\nFormLabel.displayName = \"FormLabel\"\n\nconst FormControl = React.forwardRef<\n  React.ElementRef<typeof Slot>,\n  React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  )\n})\nFormControl.displayName = \"FormControl\"\n\nconst FormDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n  const { formDescriptionId } = useFormField()\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn(\"text-[0.8rem] text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n})\nFormDescription.displayName = \"FormDescription\"\n\nconst FormMessage = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n  const { error, formMessageId } = useFormField()\n  const body = error ? String(error?.message ?? \"\") : children\n\n  if (!body) {\n    return null\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn(\"text-[0.8rem] font-medium text-destructive\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  )\n})\nFormMessage.displayName = \"FormMessage\"\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n}\n"
  },
  {
    "path": "next_b2b_starter/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nInput.displayName = \"Input\"\n\nexport { Input }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/label.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst labelVariants = cva(\n  \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n)\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n))\nLabel.displayName = LabelPrimitive.Root.displayName\n\nexport { Label }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverAnchor = PopoverPrimitive.Anchor\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]\",\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/progress.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Progress = React.forwardRef<\n  React.ElementRef<typeof ProgressPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>\n>(({ className, value, ...props }, ref) => (\n  <ProgressPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"relative h-2 w-full overflow-hidden rounded-full bg-primary/20\",\n      className\n    )}\n    {...props}\n  >\n    <ProgressPrimitive.Indicator\n      className=\"h-full w-full flex-1 bg-primary transition-all\"\n      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n    />\n  </ProgressPrimitive.Root>\n))\nProgress.displayName = ProgressPrimitive.Root.displayName\n\nexport { Progress }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/scroll-area.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ScrollArea = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn(\"relative overflow-hidden\", className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n    <ScrollBar />\n    <ScrollAreaPrimitive.Corner />\n  </ScrollAreaPrimitive.Root>\n))\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName\n\nconst ScrollBar = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = \"vertical\", ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      \"flex touch-none select-none transition-colors\",\n      orientation === \"vertical\" &&\n        \"h-full w-2.5 border-l border-l-transparent p-[1px]\",\n      orientation === \"horizontal\" &&\n        \"h-2.5 flex-col border-t border-t-transparent p-[1px]\",\n      className\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n))\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/select.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, ChevronDown, ChevronUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n))\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className\n    )}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n))\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className\n    )}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n))\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName\n\ninterface SelectContentProps\n  extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> {\n  container?: HTMLElement | null;\n}\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  SelectContentProps\n>(({ className, children, position = \"popper\", container, ...props }, ref) => {\n  const portalProps = container ? { container } : undefined;\n\n  return (\n    <SelectPrimitive.Portal {...portalProps}>\n      <SelectPrimitive.Content\n        ref={ref}\n        className={cn(\n          \"relative z-[260] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className\n        )}\n        position={position}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"min-h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\"\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n})\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 text-sm font-semibold\", className)}\n    {...props}\n  />\n))\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText asChild>\n      <span className=\"w-full text-left\">{children}</span>\n    </SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n))\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n))\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton,\n}\n"
  },
  {
    "path": "next_b2b_starter/components/ui/separator.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Separator = React.forwardRef<\n  React.ElementRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n  (\n    { className, orientation = \"horizontal\", decorative = true, ...props },\n    ref\n  ) => (\n    <SeparatorPrimitive.Root\n      ref={ref}\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border\",\n        orientation === \"horizontal\" ? \"h-[1px] w-full\" : \"h-full w-[1px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n)\nSeparator.displayName = SeparatorPrimitive.Root.displayName\n\nexport { Separator }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/sheet-header.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { X } from \"lucide-react\";\n\ninterface SheetHeaderProps {\n  title: string;\n  onClose: () => void;\n}\n\nexport function SheetHeader({ title, onClose }: SheetHeaderProps) {\n  return (\n    <div>\n      <div className=\"flex justify-between items-center px-6 py-4\">\n        <h2 className=\"text-xl font-semibold text-gray-900\">{title}</h2>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onClose}\n          className=\"h-8 w-8 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100\"\n        >\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </Button>\n      </div>\n      <Separator />\n    </div>\n  );\n}"
  },
  {
    "path": "next_b2b_starter/components/ui/sheet.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Sheet = DialogPrimitive.Root;\n\nconst SheetTrigger = DialogPrimitive.Trigger;\n\nconst SheetClose = DialogPrimitive.Close;\n\nconst SheetPortal = DialogPrimitive.Portal;\n\nconst SheetOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n));\nSheetOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst sheetVariants = cva(\n  \"fixed z-50 gap-4 bg-white p-0 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300\",\n  {\n    variants: {\n      side: {\n        top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n        bottom:\n          \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n        left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n        right:\n          \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n      },\n    },\n    defaultVariants: {\n      side: \"right\",\n    },\n  }\n);\n\ninterface SheetContentProps\n  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,\n    VariantProps<typeof sheetVariants> {}\n\nconst SheetContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  SheetContentProps & { hideCloseButton?: boolean }\n>(({ side = \"right\", className, children, hideCloseButton = false, ...props }, ref) => (\n  <SheetPortal>\n    <SheetOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(sheetVariants({ side }), className)}\n      {...props}\n    >\n      {children}\n      {!hideCloseButton && (\n        <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary z-10\">\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      )}\n    </DialogPrimitive.Content>\n  </SheetPortal>\n));\nSheetContent.displayName = DialogPrimitive.Content.displayName;\n\nconst SheetHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n);\nSheetHeader.displayName = \"SheetHeader\";\n\nconst SheetFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n);\nSheetFooter.displayName = \"SheetFooter\";\n\nconst SheetTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold text-foreground\", className)}\n    {...props}\n  />\n));\nSheetTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst SheetDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nSheetDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n  Sheet,\n  SheetPortal,\n  SheetOverlay,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n};\n"
  },
  {
    "path": "next_b2b_starter/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn(\"animate-pulse rounded-md bg-primary/10\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/slider.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SliderPrimitive from \"@radix-ui/react-slider\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Slider = React.forwardRef<\n  React.ElementRef<typeof SliderPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>\n>(({ className, defaultValue, value, min = 0, max = 100, ...props }, ref) => {\n  const normalizedValues = React.useMemo(() => {\n    if (Array.isArray(value) && value.length > 0) return value;\n    if (Array.isArray(defaultValue) && defaultValue.length > 0)\n      return defaultValue;\n    return [min, max];\n  }, [defaultValue, value, min, max]);\n\n  return (\n    <SliderPrimitive.Root\n      ref={ref}\n      data-slot=\"slider\"\n      defaultValue={defaultValue}\n      value={value}\n      min={min}\n      max={max}\n      className={cn(\n        \"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col\",\n        className\n      )}\n      {...props}\n    >\n      <SliderPrimitive.Track\n        data-slot=\"slider-track\"\n        className={cn(\n          \"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5\"\n        )}\n      >\n        <SliderPrimitive.Range\n          data-slot=\"slider-range\"\n          className={cn(\n            \"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full\"\n          )}\n        />\n      </SliderPrimitive.Track>\n      {Array.from({ length: normalizedValues.length }, (_, index) => (\n        <SliderPrimitive.Thumb\n          data-slot=\"slider-thumb\"\n          key={index}\n          className=\"border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50\"\n        />\n      ))}\n    </SliderPrimitive.Root>\n  );\n})\nSlider.displayName = SliderPrimitive.Root.displayName\n\nexport { Slider }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/switch.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    className={cn(\n      \"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        \"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0\"\n      )}\n    />\n  </SwitchPrimitives.Root>\n))\nSwitch.displayName = SwitchPrimitives.Root.displayName\n\nexport { Switch }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/table.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <div className=\"relative w-full overflow-x-auto\">\n    <table\n      ref={ref}\n      className={cn(\"w-full caption-bottom text-sm min-w-[650px] table-fixed\", className)}\n      {...props}\n    />\n  </div>\n))\nTable.displayName = \"Table\"\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn(\"[&_tr]:border-b [&_tr]:border-gray-200\", className)} {...props} />\n))\nTableHeader.displayName = \"TableHeader\"\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn(\"[&_tr:last-child]:border-0\", className)}\n    {...props}\n  />\n))\nTableBody.displayName = \"TableBody\"\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\n      \"border-t border-gray-200 font-medium [&>tr]:last:border-b-0\",\n      className\n    )}\n    {...props}\n  />\n))\nTableFooter.displayName = \"TableFooter\"\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      \"border-b border-gray-100 transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50\",\n      className\n    )}\n    {...props}\n  />\n))\nTableRow.displayName = \"TableRow\"\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"h-12 px-4 text-left align-middle font-semibold text-gray-700 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableHead.displayName = \"TableHead\"\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\n      \"px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableCell.displayName = \"TableCell\"\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nTableCaption.displayName = \"TableCaption\"\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}\n"
  },
  {
    "path": "next_b2b_starter/components/ui/tabs.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Tabs = TabsPrimitive.Root\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsContent.displayName = TabsPrimitive.Content.displayName\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "next_b2b_starter/components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Textarea = React.forwardRef<\n  HTMLTextAreaElement,\n  React.ComponentProps<\"textarea\">\n>(({ className, ...props }, ref) => {\n  return (\n    <textarea\n      className={cn(\n        \"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      ref={ref}\n      {...props}\n    />\n  )\n})\nTextarea.displayName = \"Textarea\"\n\nexport { Textarea }\n"
  },
  {
    "path": "next_b2b_starter/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.ts\",\n    \"css\": \"app/globals.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "next_b2b_starter/docs/01-getting-started.md",
    "content": "# Getting Started\n \n ## 1. Quick Setup\n \n 1. **Clone & Install**\n    ```bash\n    git clone <repository-url>\n    cd next_b2b_starter\n    ./setup.sh\n    ```\n    *(This script sets up Docker, database migrations, and .env files)*\n \n 2. **Run Backend**\n    ```bash\n    cd go-b2b-starter\n    make dev\n    ```\n \n 3. **Run Frontend**\n    ```bash\n    cd next_b2b_starter\n    pnpm dev\n    ```\n \n Visit `http://localhost:3000`.\n \n ## 2. Project Structure\n \n - **`app/`**: Next.js App Router (Pages & APIs).\n - **`components/`**: Shared UI components.\n - **`lib/`**: Business logic, API clients, and hooks.\n - **`middleware.ts`**: Auth protection.\n \n ## 3. Common Issues\n \n - **Environment Variables**: Ensure `app.env` is loaded by the backend.\n - **Ports**: Backend runs on `8080`, Frontend on `3000`.\n \n ## Next Steps\n \n 👉 **Learn about**: [Authentication](./02-authentication.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/02-authentication.md",
    "content": "# Authentication\n\nThis guide explains how authentication works and how to check if a user is logged in.\n\n## How Authentication Works\n\nThe app uses **Stytch B2B** for authentication with magic link emails. No passwords required.\n\n### Authentication Flow\n\n```mermaid\ngraph TD\n    A[User visits /dashboard] --> B{Has session cookie?}\n    B -->|No| C[Redirect to /auth]\n    B -->|Yes| D{JWT token valid?}\n    D -->|No| E[Call /api/auth/session/refresh]\n    D -->|Yes| F[Render /dashboard]\n    E --> G{Refresh successful?}\n    G -->|Yes| F\n    G -->|No| C\n\n    C --> H[User enters email]\n    H --> I[Send magic link]\n    I --> J[User clicks link in email]\n    J --> K[Redirect to /authenticate]\n    K --> L[Exchange token for session]\n    L --> M[Set session cookies]\n    M --> F\n```\n\n##  Token System\n\nThe app uses **two cookies** for authentication:\n\n1. **Session Token** (`stytch_session`)\n   - HttpOnly cookie (JavaScript cannot read it)\n   - Sent to Stytch backend to get JWT\n   - Used for token refresh\n\n2. **JWT Token** (`stytch_session_jwt`)\n   - Readable by JavaScript\n   - Attached to API requests as `Authorization: Bearer <token>`\n   - Contains user info and expiration\n\n### Token Lifecycle\n\n- **Duration**: 8 hours (480 minutes)\n- **Auto-Refresh**: Happens automatically when expired\n- **Grace Period**: 60 seconds (handles clock skew)\n\n## Checking if User is Authenticated\n\n### In Server Components\n\nUse `getMemberSession()` to check authentication.\n\n**File**: `lib/auth/stytch/server.ts`\n\n```typescript\nimport { getMemberSession } from '@/lib/auth/stytch/server';\n\nexport default async function DashboardPage() {\n  const session = await getMemberSession();\n\n  // Not authenticated\n  if (!session) {\n    return <div>Please log in</div>;\n  }\n\n  // Authenticated\n  return <div>Welcome, {session.member.email}</div>;\n}\n```\n\n### Requiring Authentication\n\nUse `requireMemberSession()` to enforce authentication. It automatically redirects if not logged in.\n\n```typescript\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\n\nexport default async function ProtectedPage() {\n  // Will redirect to /auth if not logged in\n  const session = await requireMemberSession();\n\n  return <div>Protected content for {session.member.email}</div>;\n}\n```\n\n### In Client Components\n\nUse `useStytchMember()` hook from Stytch SDK.\n\n```typescript\n'use client';\nimport { useStytchMember } from '@stytch/nextjs/b2b';\n\nexport function UserProfile() {\n  const { member, isInitialized } = useStytchMember();\n\n  if (!isInitialized) {\n    return <div>Loading...</div>;\n  }\n\n  if (!member) {\n    return <div>Not logged in</div>;\n  }\n\n  return <div>Hello, {member.email_address}</div>;\n}\n```\n\n## Protected vs Public Routes\n\n### Protected Routes\n\nThese routes require authentication. Defined in `middleware.ts`:\n\n- `/dashboard`\n- `/dashboard/settings`\n- Any route starting with `/dashboard/`\n\nIf you visit a protected route without being logged in, you'll be redirected to `/auth`.\n\n### Public Routes\n\nThese routes don't require authentication:\n\n- `/` (homepage)\n- `/auth` (login page)\n- `/authenticate` (magic link callback)\n- `/signup` (signup page)\n- `/api/auth/*` (auth API routes)\n\n## Login Flow\n\n```mermaid\nsequenceDiagram\n    participant User\n    participant Browser\n    participant Frontend\n    participant Stytch\n\n    User->>Browser: Click \"Sign In\"\n    Browser->>Frontend: Navigate to /auth\n    Frontend-->>Browser: Show email form\n    User->>Frontend: Enter email\n    Frontend->>Stytch: Send magic link request\n    Stytch-->>User: Email with magic link\n    User->>Stytch: Click link in email\n    Stytch->>Frontend: Redirect to /authenticate?token=xxx\n    Frontend->>Stytch: Exchange token for session\n    Stytch-->>Frontend: Return session tokens\n    Frontend->>Browser: Set cookies (session + JWT)\n    Frontend->>Browser: Redirect to /dashboard\n```\n\n## Logout Flow\n\nTo log out a user, redirect them to the logout API:\n\n```typescript\n// In a client component\n'use client';\n\nexport function LogoutButton() {\n  const handleLogout = () => {\n    window.location.href = '/api/auth/logout';\n  };\n\n  return <button onClick={handleLogout}>Log Out</button>;\n}\n```\n\nThe logout API will:\n1. Clear session cookies\n2. Redirect to login page\n\n## Token Refresh\n\nWhen a JWT expires, the app automatically refreshes it.\n\n### Token Refresh Flow\n\n```mermaid\ngraph LR\n    A[API Request] --> B{Token expired?}\n    B -->|No| C[Make request]\n    B -->|Yes| D[Call /api/auth/session/refresh]\n    D --> E{Has valid session?}\n    E -->|Yes| F[Get new JWT]\n    E -->|No| G[Logout user]\n    F --> C\n    G --> H[Redirect to /auth]\n```\n\nThis happens automatically in `lib/api/api/client/token-manager.ts`.\n\n### Retry Logic\n\nIf token refresh fails:\n- **Retry** 3 times\n- **Wait** 1s, then 2s, then 4s (exponential backoff)\n- **Logout** if all retries fail\n\n## Security Features\n\n### Cookie Security\n\n- **HttpOnly**: Session token cannot be read by JavaScript\n- **Secure**: Cookies only sent over HTTPS (in production)\n- **SameSite**: Lax (protects against CSRF)\n- **Path**: `/` (available to all routes)\n\n### Token Storage\n\n- **Session token**: Server-side only (secure)\n- **JWT token**: Client + server (needed for API calls)\n- **In-memory cache**: Browser caches JWT to reduce cookie reads\n\n## Common Patterns\n\n### Check Auth in API Route\n\n```typescript\n// app/api/data/route.ts\nimport { getMemberSession } from '@/lib/auth/stytch/server';\n\nexport async function GET() {\n  const session = await getMemberSession();\n\n  if (!session) {\n    return NextResponse.json(\n      { error: 'Unauthorized' },\n      { status: 401 }\n    );\n  }\n\n  // Authenticated logic\n  return NextResponse.json({ data: 'secret data' });\n}\n```\n\n### Conditional Rendering Based on Auth\n\n```typescript\n'use client';\n\nexport function ConditionalContent() {\n  const { member } = useStytchMember();\n\n  return (\n    <div>\n      {member ? (\n        <div>Logged in as {member.email_address}</div>\n      ) : (\n        <a href=\"/auth\">Please log in</a>\n      )}\n    </div>\n  );\n}\n```\n\n## Key Files\n\n- **`middleware.ts`** - Route protection logic\n- **`lib/auth/stytch/server.ts`** - Server-side auth helpers\n- **`lib/api/api/client/token-manager.ts`** - Token management\n- **`lib/auth/constants.ts`** - Auth configuration\n- **`app/api/auth/session/refresh/route.ts`** - Token refresh endpoint\n- **`app/api/auth/logout/route.ts`** - Logout endpoint\n- **`app/auth/page.tsx`** - Login page\n- **`app/authenticate/page.tsx`** - Magic link callback\n\n## Next Steps\n\n👉 **Learn about**: [Permissions & Roles](./03-permissions-and-roles.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/03-permissions-and-roles.md",
    "content": "# Permissions & Roles\n\nThe app uses **Role-Based Access Control (RBAC)**.\n\n- **Frontend**: Uses server-computed permissions.\n- **Backend**: The source of truth (`go-b2b-starter/src/pkg/auth/rbac.go`).\n\n## Checking Permissions\n\n### In React Components\n\n**File**: `lib/hooks/use-permissions.ts`\n\n```typescript\nimport { usePermissions } from '@/lib/hooks/use-permissions';\n\nexport function EditButton() {\n  const { hasPermission, hasRole } = usePermissions();\n\n  // Check Permission\n  if (hasPermission('resource:edit')) {\n    return <button>Edit</button>;\n  }\n  \n  // Check Role\n  if (hasRole('admin')) {\n    return <AdminBadge />;\n  }\n  \n  return null;\n}\n```\n\n### In API Routes\n\n**File**: `lib/auth/server-permissions.ts`\n\n```typescript\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\n\nexport async function POST(req) {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n  \n  if (!permissions.canCreateResources) {\n    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });\n  }\n}\n```\n\n## Available Roles\n\nDefined in `go-b2b-starter/src/pkg/auth/roles.go`.\n\n- **Admin**: Full access (`*`).\n- **Manager**: Can view, create, edit, delete, approve.\n- **Member**: Can view, create.\n\n## Permissions Format\n\n- `resource:view`\n- `resource:create`\n- `resource:edit`\n- `resource:delete`\n- `resource:approve`\n- `org:manage`\n\n## Next Steps\n\n👉 **Learn about**: [Payments & Billing](./04-payments-and-billing.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/04-payments-and-billing.md",
    "content": "# Payments & Billing\n\nThe starter supports Stripe or Polar. Subscription status is managed via Stytch Custom Claims.\n\n## Subscription Status\n\n- **`active` / `trialing`**: User has access.\n- **`past_due` / `canceled` / `unpaid`**: Access restricted.\n\n## Checking Payment Status\n\nUse `hasActiveSubscription()` helper function.\n\n**File**: `lib/auth/subscription.ts`\n\n### Server-Side Check\n\n```typescript\nimport { hasActiveSubscription } from '@/lib/auth/subscription';\n\nexport default async function Page() {\n  const session = await requireMemberSession();\n  if (!hasActiveSubscription(session)) {\n    return <div>Upgrade to access</div>;\n  }\n  return <PremiumContent />;\n}\n```\n\n### Client-Side Check\n\n```typescript\n'use client';\nimport { hasActiveSubscription } from '@/lib/auth/subscription';\n\nexport function Feature() {\n  const { session, member } = useStytchMemberSession();\n  if (!hasActiveSubscription(session, member)) {\n    return <button>Subscribe</button>;\n  }\n  return <FeatureContent />;\n}\n```\n\n## Payment Flow Architecture\n\n```mermaid\nsequenceDiagram\n    participant User\n    participant Frontend\n    participant API\n    participant Stripe/Polar\n\n    User->>Frontend: Click Subscribe\n    Frontend->>API: POST /api/billing/checkout\n    API->>Stripe/Polar: Create Session\n    Stripe/Polar-->>User: Redirect to Checkout\n    User->>Stripe/Polar: Pay\n    Stripe/Polar->>API: Webhook (async)\n    API->>Stytch: Update Custom Claims\n```\n\n## Paywalls\n\nWe include a pre-built Paywall component.\n\n**File**: `components/billing/subscription-paywall.tsx`\n\n```typescript\nimport { SubscriptionPaywall } from '@/components/billing/subscription-paywall';\n\nexport default function Page() {\n  return (\n    <SubscriptionPaywall>\n      <ProtectedContent />\n    </SubscriptionPaywall>\n  );\n}\n```\n\n## API Route Protection\n\nAlways verify logic in your API routes, returning `402 Payment Required` if needed.\n\n```typescript\nif (!hasActiveSubscription(session)) {\n  return NextResponse.json({ error: 'Upgrade required' }, { status: 402 });\n}\n```\n\n## Webhooks\n\nWebhooks handle status updates asynchronously.\n\n**File**: `app/api/billing/webhook/route.ts`\n\n## Next Steps\n\n👉 **Learn about**: [Making API Requests](./05-making-api-requests.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/05-making-api-requests.md",
    "content": "# Making API Requests\n\nThis guide explains how to make API requests using the API client and repository pattern.\n\n## API Client Overview\n\nThe app uses a centralized API client that handles:\n- Automatic authentication (adds Bearer token)\n- Token refresh on expiration\n- Error handling\n- Next.js 16 caching options\n\n**File**: `lib/api/api/client/api-client.ts`\n\n## Basic Usage\n\n### Import the API Client\n\n```typescript\nimport { apiClient } from '@/lib/api/api/client/api-client';\n```\n\n### Make a GET Request\n\n```typescript\nconst data = await apiClient.get<ResponseType>('/endpoint');\n```\n\n### Make a POST Request\n\n```typescript\nconst result = await apiClient.post<ResponseType>('/endpoint', {\n  name: 'value',\n  count: 42\n});\n```\n\n### Make a PUT Request\n\n```typescript\nconst updated = await apiClient.put<ResponseType>('/endpoint/123', {\n  name: 'new value'\n});\n```\n\n### Make a DELETE Request\n\n```typescript\nawait apiClient.delete('/endpoint/123');\n```\n\n## API Request Flow\n\n```mermaid\ngraph LR\n    A[Component] --> B[apiClient.get]\n    B --> C{Has token?}\n    C -->|No| D[token-manager]\n    C -->|Yes| E[Add Authorization header]\n    D --> E\n    E --> F[fetch with token]\n    F --> G{200 OK?}\n    G -->|Yes| H[Return data]\n    G -->|401| I[Refresh token]\n    I --> J{Refresh OK?}\n    J -->|Yes| F\n    J -->|No| K[Logout user]\n```\n\n## Handling 401 Errors\n\nWhen a request returns 401 (unauthorized), the API client automatically:\n\n1. Attempts to refresh the token\n2. Retries the request with new token\n3. If refresh fails, logs out the user\n\n```mermaid\ngraph TD\n    A[API returns 401] --> B{Token valid?}\n    B -->|Expired| C[Call refresh endpoint]\n    B -->|Invalid| D[Call refresh endpoint]\n    C --> E{Refresh success?}\n    D --> E\n    E -->|Yes| F[Retry original request]\n    E -->|No| G[Clear cookies]\n    F --> H{Request success?}\n    H -->|Yes| I[Return data]\n    H -->|401 again| G\n    G --> J[Redirect to /auth]\n```\n\nThis happens automatically - you don't need to handle it.\n\n## Error Handling\n\nThe API client throws `ApiError` for HTTP errors.\n\n```typescript\nimport { apiClient, ApiError } from '@/lib/api/api/client/api-client';\n\ntry {\n  const data = await apiClient.post('/endpoint', payload);\n} catch (error) {\n  if (error instanceof ApiError) {\n    console.error('Status:', error.status);    // 404, 500, etc.\n    console.error('Code:', error.code);        // \"HTTP_404\", \"SESSION_EXPIRED\"\n    console.error('Message:', error.message);  // Error message\n    console.error('Details:', error.details);  // Extra error data\n  }\n}\n```\n\n## Next.js 16 Caching Options\n\nThe API client supports Next.js 16 fetch options.\n\n### No Cache (Default)\n\n```typescript\nconst data = await apiClient.get('/endpoint', {\n  cache: 'no-store'  // Don't cache (default for authenticated requests)\n});\n```\n\n### Force Cache\n\n```typescript\nconst data = await apiClient.get('/public-data', {\n  cache: 'force-cache'  // Cache indefinitely\n});\n```\n\n### Revalidate After Time\n\n```typescript\nconst data = await apiClient.get('/products', {\n  next: {\n    revalidate: 3600  // Refresh every hour (3600 seconds)\n  }\n});\n```\n\n### Tag-Based Revalidation\n\n```typescript\nconst data = await apiClient.get('/invoices', {\n  next: {\n    tags: ['invoices', 'financial-data']\n  }\n});\n\n// Later, revalidate all 'invoices' requests\nimport { revalidateTag } from 'next/cache';\nrevalidateTag('invoices');\n```\n\n## Repository Pattern\n\n**Don't call** the API client directly in components. Use **repositories** instead.\n\n### What is a Repository?\n\nA repository is a class that wraps API calls for a specific resource.\n\n**Benefits:**\n- Centralized API logic\n- Type-safe responses\n- Easy to mock for testing\n- Consistent error handling\n\n### Using a Repository\n\n```typescript\nimport { profileRepository } from '@/lib/api/api/repositories/profile-repository';\n\n// Server component\nexport default async function ProfilePage() {\n  const session = await requireMemberSession();\n\n  // Use repository\n  const profile = await profileRepository.getProfile(session.session_jwt);\n\n  return <div>{profile.name}</div>;\n}\n```\n\n### Repository Structure\n\n**File**: `lib/api/api/repositories/profile-repository.ts`\n\n```typescript\nclass ProfileRepository {\n  async getProfile(sessionToken?: string) {\n    const options = sessionToken\n      ? { headers: { Authorization: `Bearer ${sessionToken}` } }\n      : undefined;\n\n    return apiClient.get<ProfileResponseDto>('/auth/profile/me', options);\n  }\n}\n\nexport const profileRepository = new ProfileRepository();\n```\n\n## Available Repositories\n\n**File**: `lib/api/api/repositories/`\n\n- **`profile-repository.ts`** - User profile\n- **`member-repository.ts`** - Team members\n- **`document-repository.ts`** - Documents\n- **`rbac-repository.ts`** - Roles and permissions\n- **`signup-repository.ts`** - Organization signup\n- **`cognitive-repository.ts`** - AI chat\n\n## Skip Authentication\n\nFor public endpoints that don't require authentication:\n\n```typescript\nconst data = await apiClient.get('/public-data', {\n  skipAuth: true\n});\n```\n\nThis skips adding the `Authorization` header.\n\n## Custom Headers\n\nAdd custom headers to any request:\n\n```typescript\nconst data = await apiClient.post('/endpoint', payload, {\n  headers: {\n    'X-Custom-Header': 'value'\n  }\n});\n```\n\n## File Uploads\n\nThe API client supports `FormData` for file uploads.\n\n```typescript\nconst formData = new FormData();\nformData.append('file', fileBlob);\nformData.append('name', 'document.pdf');\n\nconst result = await apiClient.post('/upload', formData);\n```\n\nThe client automatically:\n- Detects FormData\n- Sets correct `Content-Type` header\n- Sends as multipart/form-data\n\n## Common Patterns\n\n### Fetching Data in Server Component\n\n```typescript\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\nimport { invoiceRepository } from '@/lib/api/api/repositories/invoice-repository';\n\nexport default async function InvoicesPage() {\n  const session = await requireMemberSession();\n\n  // Fetch data using repository\n  const invoices = await invoiceRepository.list(session.session_jwt);\n\n  return <InvoiceList invoices={invoices} />;\n}\n```\n\n### Fetching Data in Client Component\n\nUse React Query hooks (covered in [Using Hooks](./08-using-hooks.md)):\n\n```typescript\n'use client';\nimport { useInvoicesQuery } from '@/lib/hooks/queries/use-invoices-query';\n\nexport function InvoiceList() {\n  const { data: invoices, isLoading } = useInvoicesQuery();\n\n  if (isLoading) return <div>Loading...</div>;\n\n  return <div>{invoices.map(inv => ...)}</div>;\n}\n```\n\n### Conditional Requests\n\n```typescript\nexport default async function DataPage({ shouldFetchData }) {\n  if (!shouldFetchData) {\n    return <div>No data</div>;\n  }\n\n  const session = await requireMemberSession();\n  const data = await dataRepository.fetch(session.session_jwt);\n\n  return <DataView data={data} />;\n}\n```\n\n### Parallel Requests\n\nMake multiple requests in parallel:\n\n```typescript\nexport default async function DashboardPage() {\n  const session = await requireMemberSession();\n\n  // Fetch in parallel\n  const [profile, invoices, stats] = await Promise.all([\n    profileRepository.getProfile(session.session_jwt),\n    invoiceRepository.list(session.session_jwt),\n    statsRepository.get(session.session_jwt),\n  ]);\n\n  return <Dashboard profile={profile} invoices={invoices} stats={stats} />;\n}\n```\n\n## Testing with Mocks\n\nMock the API client for testing:\n\n```typescript\nimport { apiClient } from '@/lib/api/api/client/api-client';\n\n// Mock implementation\njest.spyOn(apiClient, 'get').mockResolvedValue({\n  id: 1,\n  name: 'Test'\n});\n\n// Test your component\nconst result = await myFunction();\nexpect(result.name).toBe('Test');\n```\n\n## Key Files\n\n- **`lib/api/api/client/api-client.ts`** - Main API client\n- **`lib/api/api/client/token-manager.ts`** - Token management\n- **`lib/api/api/repositories/`** - All repositories\n- **`lib/api/api/dto/`** - TypeScript types for API responses\n\n## Best Practices\n\n1. **Always use repositories** - Don't call apiClient directly from components\n2. **Handle errors** - Wrap calls in try/catch\n3. **Use TypeScript types** - Define response types\n4. **Cache wisely** - Use `next.revalidate` for data that changes\n5. **Skip auth sparingly** - Only for truly public endpoints\n6. **Test with mocks** - Mock repositories, not the API client\n\n## Next Steps\n\n👉 **Learn about**: [Creating Pages](./06-creating-pages.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/06-creating-pages.md",
    "content": "# Creating Pages\n\nThis guide shows how to create new pages and views in the app.\n\n## Page Types\n\nNext.js 16 uses the App Router with two component types:\n\n- **Server Components** - Run on server, can fetch data directly\n- **Client Components** - Run in browser, interactive\n\n## Creating a Server Component Page\n \n Use generic \"page.tsx\" files within your route directory.\n \n **Check `app/dashboard/page.tsx` for a live example.**\n \n ```typescript\n // app/your-route/page.tsx\n import { requireMemberSession } from '@/lib/auth/stytch/server';\n \n export default async function Page() {\n   const session = await requireMemberSession(); // 1. Authenticate\n   // 2. Fetch data directly\n   // 3. Render\n   return <div>My Protected Page</div>;\n }\n ```\n \n ## Server Component Rendering Flow\n \n ```mermaid\n graph TD\n     A[Request /route] --> B[middleware.ts checks auth]\n     B -->|Authenticated| C[page.tsx runs on server]\n     C --> D[Fetch data]\n     D --> E[Render HTML]\n ```\n \n ## Creating a Client Component Page\n \n Use `'use client'` at the top of the file for interactivity.\n \n **Check `components/billing/subscription-paywall.tsx` for a client component example.**\n \n ```typescript\n // app/interactive/page.tsx\n 'use client';\n \n import { usePermissions } from '@/lib/hooks/use-permissions';\n \n export default function InteractivePage() {\n   const { hasPermission } = usePermissions();\n   \n   if (!hasPermission('resource:view')) return <div>Denied</div>;\n \n   return <button onClick={() => alert('Interactive!')}>Click Me</button>;\n }\n ```\n \n ## Layouts\n \n Layouts persist across route changes and are perfect for navigation.\n \n **See `app/dashboard/layout.tsx`**\n \n ```typescript\n // app/dashboard/layout.tsx\n export default function DashboardLayout({ children }) {\n   return (\n     <div className=\"flex h-screen\">\n       <Sidebar />\n       <main className=\"flex-1\">{children}</main>\n     </div>\n   );\n }\n ```\n \n ## Protected Pages\n \n ### Middleware Protection\n Adds routes to `PROTECTED_ROUTES` in `middleware.ts`.\n \n ```typescript\n // middleware.ts\n const PROTECTED_ROUTES = ['/dashboard', '/settings'];\n ```\n \n ## Dynamic Routes\n \n Create folders with brackets like `[id]` to capture parameters.\n \n **Example**: `app/invoices/[id]/page.tsx`\n \n ```typescript\n export default function Page({ params }: { params: { id: string } }) {\n   return <h1>Invoice {params.id}</h1>;\n }\n ```\n \n ## Common Patterns\n \n - **Loading**: Create `loading.tsx` for automatic skeletons.\n - **Errors**: Create `error.tsx` for error boundaries.\n - **Data Passing**: Fetch in Server Component -> Pass props to Client Component.\n \n ## Next Steps\n \n 👉 **Learn about**: [Creating APIs](./07-creating-apis.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/07-creating-apis.md",
    "content": "# Creating APIs\n\nThis guide shows how to create new API endpoints.\n\n## API Route Structure\n\nAPI routes live in `app/api/` and use file-based routing.\n\n### File Structure\n\n```\napp/api/\n├── vendors/\n│   ├── route.ts           # GET /api/vendors, POST /api/vendors\n│   └── [id]/\n│       └── route.ts       # GET /api/vendors/123, DELETE /api/vendors/123\n├── auth/\n│   ├── logout/route.ts    # POST /api/auth/logout\n│   └── magic-link/route.ts\n```\n\n## Creating a Basic API Route\n\n### 1. Create the File\n\n```typescript\n// app/api/vendors/route.ts\nimport { NextResponse } from 'next/server';\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\n\nexport async function GET(request: Request) {\n  // Check authentication\n  const session = await getMemberSession();\n  if (!session) {\n    return NextResponse.json(\n      { error: 'Unauthorized' },\n      { status: 401 }\n    );\n  }\n\n  // Fetch data\n  const vendors = await vendorRepository.list(session.session_jwt);\n\n  // Return response\n  return NextResponse.json(vendors);\n}\n```\n\n### 2. HTTP Methods\n\nExport functions for each HTTP method:\n\n```typescript\n// GET /api/vendors\nexport async function GET(request: Request) {\n  // Handle GET request\n}\n\n// POST /api/vendors\nexport async function POST(request: Request) {\n  // Handle POST request\n}\n\n// PUT /api/vendors\nexport async function PUT(request: Request) {\n  // Handle PUT request\n}\n\n// DELETE /api/vendors\nexport async function DELETE(request: Request) {\n  // Handle DELETE request\n}\n```\n\n### 3. Access the API\n\nCall from frontend:\n\n```typescript\nconst response = await fetch('/api/vendors');\nconst vendors = await response.json();\n```\n\n## API Request Flow\n\n```mermaid\ngraph TD\n    A[Client makes request] --> B[middleware.ts]\n    B -->|Public route| C[route.ts handler]\n    B -->|Protected route| D{Has session?}\n    D -->|No| E[Return 401]\n    D -->|Yes| C\n    C --> F[getMemberSession]\n    F --> G[Check permissions]\n    G --> H{Has permission?}\n    H -->|No| I[Return 403]\n    H -->|Yes| J[Process request]\n    J --> K[Return NextResponse]\n```\n\n## Authentication in API Routes\n\n### Check if Authenticated\n\n```typescript\nimport { getMemberSession } from '@/lib/auth/stytch/server';\n\nexport async function GET() {\n  const session = await getMemberSession();\n\n  if (!session) {\n    return NextResponse.json(\n      { error: 'Not authenticated' },\n      { status: 401 }\n    );\n  }\n\n  // Authenticated logic\n}\n```\n\n### Require Authentication\n\n```typescript\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\n\nexport async function GET() {\n  // Throws error if not authenticated\n  const session = await requireMemberSession();\n\n  // This code only runs if authenticated\n  return NextResponse.json({ success: true });\n}\n```\n\n## Permission Checks in APIs\n\n```typescript\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\n\nexport async function POST(request: Request) {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canCreateVendors) {\n    return NextResponse.json(\n      { error: 'Permission denied' },\n      { status: 403 }\n    );\n  }\n\n  // Create vendor logic\n}\n```\n\n## Reading Request Body\n\n```typescript\nexport async function POST(request: Request) {\n  // Parse JSON body\n  const body = await request.json();\n\n  const { name, email } = body;\n\n  // Validate\n  if (!name || !email) {\n    return NextResponse.json(\n      { error: 'Missing required fields' },\n      { status: 400 }\n    );\n  }\n\n  // Process request\n}\n```\n\n## Reading Query Parameters\n\n```typescript\nexport async function GET(request: Request) {\n  const { searchParams } = new URL(request.url);\n\n  const page = searchParams.get('page') || '1';\n  const limit = searchParams.get('limit') || '10';\n\n  // Use parameters\n  const results = await repository.list({\n    page: parseInt(page),\n    limit: parseInt(limit)\n  });\n\n  return NextResponse.json(results);\n}\n```\n\n## Dynamic Routes\n\n### Create Dynamic Route\n\n```typescript\n// app/api/vendors/[id]/route.ts\n\nexport async function GET(\n  request: Request,\n  { params }: { params: { id: string } }\n) {\n  const { id } = params;\n\n  const vendor = await vendorRepository.get(id);\n\n  if (!vendor) {\n    return NextResponse.json(\n      { error: 'Not found' },\n      { status: 404 }\n    );\n  }\n\n  return NextResponse.json(vendor);\n}\n```\n\n### Access Dynamic Route\n\n```typescript\n// GET /api/vendors/123\nconst response = await fetch('/api/vendors/123');\n```\n\n## File Uploads\n\nHandle `multipart/form-data` for file uploads:\n\n```typescript\nexport async function POST(request: Request) {\n  const formData = await request.formData();\n\n  const file = formData.get('file') as File;\n  const name = formData.get('name') as string;\n\n  if (!file) {\n    return NextResponse.json(\n      { error: 'No file provided' },\n      { status: 400 }\n    );\n  }\n\n  // Process file\n  const buffer = await file.arrayBuffer();\n\n  // Save or upload file\n  await saveFile(buffer, file.name);\n\n  return NextResponse.json({ success: true });\n}\n```\n\n## Error Handling\n\n```typescript\nexport async function POST(request: Request) {\n  try {\n    const body = await request.json();\n\n    // Process request\n    const result = await processData(body);\n\n    return NextResponse.json(result);\n  } catch (error) {\n    console.error('API Error:', error);\n\n    return NextResponse.json(\n      { error: 'Internal server error' },\n      { status: 500 }\n    );\n  }\n}\n```\n\n## Response Formats\n\n### Success Response\n\n```typescript\nreturn NextResponse.json({\n  success: true,\n  data: { id: 1, name: 'Vendor' }\n});\n```\n\n### Error Response\n\n```typescript\nreturn NextResponse.json(\n  {\n    error: 'Validation failed',\n    details: { name: 'Required' }\n  },\n  { status: 400 }\n);\n```\n\n### Status Codes\n\n- `200` - OK\n- `201` - Created\n- `400` - Bad Request\n- `401` - Unauthorized\n- `403` - Forbidden\n- `404` - Not Found\n- `500` - Internal Server Error\n\n## Using Repositories in APIs\n\nAlways use repositories for data access:\n\n```typescript\nimport { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';\n\nexport async function GET() {\n  const session = await requireMemberSession();\n\n  // Use repository\n  const vendors = await vendorRepository.list(session.session_jwt);\n\n  return NextResponse.json(vendors);\n}\n```\n\n## CORS Configuration\n\nFor external API access, add CORS headers:\n\n```typescript\nexport async function GET(request: Request) {\n  const response = NextResponse.json({ data: 'value' });\n\n  response.headers.set('Access-Control-Allow-Origin', '*');\n  response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');\n\n  return response;\n}\n```\n\n## Webhooks\n\nHandle webhook POST requests:\n\n```typescript\n// app/api/webhooks/stripe/route.ts\nimport { headers } from 'next/headers';\n\nexport async function POST(request: Request) {\n  const body = await request.text();\n  const signature = headers().get('stripe-signature');\n\n  // Verify webhook signature\n  const event = verifyStripeWebhook(body, signature);\n\n  // Process event\n  if (event.type === 'invoice.paid') {\n    await handleInvoicePaid(event.data);\n  }\n\n  return NextResponse.json({ received: true });\n}\n```\n\n## Common Patterns\n\n### List Endpoint\n\n```typescript\nexport async function GET(request: Request) {\n  const session = await requireMemberSession();\n  const items = await repository.list(session.session_jwt);\n  return NextResponse.json(items);\n}\n```\n\n### Create Endpoint\n\n```typescript\nexport async function POST(request: Request) {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canCreate) {\n    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });\n  }\n\n  const body = await request.json();\n  const item = await repository.create(body, session.session_jwt);\n\n  return NextResponse.json(item, { status: 201 });\n}\n```\n\n### Update Endpoint\n\n```typescript\nexport async function PUT(\n  request: Request,\n  { params }: { params: { id: string } }\n) {\n  const session = await requireMemberSession();\n  const body = await request.json();\n\n  const updated = await repository.update(\n    params.id,\n    body,\n    session.session_jwt\n  );\n\n  return NextResponse.json(updated);\n}\n```\n\n### Delete Endpoint\n\n```typescript\nexport async function DELETE(\n  request: Request,\n  { params }: { params: { id: string } }\n) {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canDelete) {\n    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });\n  }\n\n  await repository.delete(params.id, session.session_jwt);\n\n  return NextResponse.json({ success: true });\n}\n```\n\n## Testing APIs\n\n### With curl\n\n```bash\ncurl http://localhost:3000/api/vendors\n\ncurl -X POST http://localhost:3000/api/vendors \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"Acme Corp\"}'\n```\n\n### With Postman\n\n1. Import the API\n2. Add authentication cookie\n3. Test each endpoint\n\n## Key Files\n\n- **`app/api/`** - All API routes\n- **`lib/api/api/repositories/`** - Data access layer\n- **`lib/auth/stytch/server.ts`** - Auth helpers\n\n## Best Practices\n\n1. **Always check authentication** in protected routes\n2. **Re-check permissions** - don't trust client\n3. **Use repositories** - don't call backend directly\n4. **Validate input** before processing\n5. **Handle errors** gracefully\n6. **Return proper status codes**\n7. **Log errors** for debugging\n\n## Next Steps\n\n👉 **Learn about**: [Using Hooks](./08-using-hooks.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/08-using-hooks.md",
    "content": "# Using Hooks\n\nThis guide explains how to use React hooks for data fetching and state management.\n\n## Hook Types\n\nThe app has two types of hooks:\n\n- **Query Hooks** - Fetch data (GET requests)\n- **Mutation Hooks** - Modify data (POST, PUT, DELETE requests)\n\nAll hooks use **TanStack React Query** for caching and state management.\n\n## Query Hooks (Read Operations)\n\nQuery hooks fetch data and cache it automatically.\n\n### Using a Query Hook\n\n```typescript\n'use client';\nimport { useProfileQuery } from '@/lib/hooks/queries/use-profile-query';\n\nexport function UserProfile() {\n  const { data, isLoading, error } = useProfileQuery();\n\n  if (isLoading) return <div>Loading...</div>;\n  if (error) return <div>Error: {error.message}</div>;\n\n  return <div>Hello, {data.name}</div>;\n}\n```\n\n### Query Hook Lifecycle\n\n```mermaid\ngraph LR\n    A[Component mounts] --> B[Hook called]\n    B --> C{Data in cache?}\n    C -->|Yes| D[Return cached data]\n    C -->|No| E[Fetch from API]\n    D --> F[Background refetch if stale]\n    E --> G[Cache result]\n    F --> G\n    G --> H[Component rerenders]\n```\n\n### Available Query Hooks\n\n**File**: `lib/hooks/queries/`\n\n- **`useProfileQuery()`** - Current user profile\n- **`useSubscriptionQuery()`** - Subscription status\n- **`useProductsQuery()`** - Product list\n- **`useMembersQuery()`** - Team members\n- **`useDocumentsQuery()`** - Document list\n- **`useSessionsQuery()`** - Audit sessions\n- **`useInvoicesQuery()`** - Invoice list\n- **`useVendorsQuery()`** - Vendor list\n\n## Mutation Hooks (Write Operations)\n\nMutation hooks modify data on the server.\n\n### Using a Mutation Hook\n\n```typescript\n'use client';\nimport { useUpdateProfile } from '@/lib/hooks/mutations/use-update-profile';\n\nexport function ProfileForm() {\n  const { mutate, isPending, error } = useUpdateProfile();\n\n  const handleSubmit = () => {\n    mutate({\n      name: 'New Name',\n      email: 'new@example.com'\n    });\n  };\n\n  return (\n    <div>\n      <button onClick={handleSubmit} disabled={isPending}>\n        {isPending ? 'Saving...' : 'Save'}\n      </button>\n      {error && <div>Error: {error.message}</div>}\n    </div>\n  );\n}\n```\n\n### Mutation Flow\n\n```mermaid\ngraph TD\n    A[User clicks button] --> B[Call mutate]\n    B --> C[Send API request]\n    C --> D{Success?}\n    D -->|Yes| E[Invalidate queries]\n    D -->|No| F[Show error]\n    E --> G[Refetch affected data]\n    G --> H[Update UI]\n    F --> H\n```\n\n### Available Mutation Hooks\n\n**File**: `lib/hooks/mutations/`\n\n- **`useUpdateProfile()`** - Update user profile\n- **`useInviteMember()`** - Invite team member\n- **`useRemoveMember()`** - Remove team member\n- **`useResendInvitation()`** - Resend invitation\n- **`useUploadDocument()`** - Upload document\n- **`useDeleteDocument()`** - Delete document\n- **`useChat()`** - AI chat\n- **`useCreateInvoice()`** - Create invoice\n- **`useDeleteInvoice()`** - Delete invoice\n\n## Permission Hook\n\nThe `usePermissions()` hook provides auth state and permission checks.\n\n**File**: `lib/hooks/use-permissions.ts`\n\n```typescript\n'use client';\nimport { usePermissions } from '@/lib/hooks/use-permissions';\n\nexport function PermissionGate() {\n  const {\n    profile,              // User profile\n    roles,                // User roles array\n    permissions,          // Permissions array\n    hasPermission,        // Check single permission\n    hasAnyPermission,     // Check if has any\n    hasAllPermissions,    // Check if has all\n    hasRole,              // Check single role\n    hasAnyRole,           // Check if has any role\n    hasAllRoles,          // Check if has all roles\n    isAuthenticated,      // Boolean\n    isInitialized,        // Boolean (Stytch ready)\n    updateAuthState,      // Manual update function\n  } = usePermissions();\n\n  if (!isAuthenticated) {\n    return <div>Please log in</div>;\n  }\n\n  return (\n    <div>\n      {hasPermission('invoice:create') && <CreateButton />}\n      {hasRole('admin') && <AdminPanel />}\n    </div>\n  );\n}\n```\n\n## React Query Configuration\n\nQuery hooks have default settings:\n\n- **Stale time**: 5 minutes (data considered fresh)\n- **Cache time**: 10 minutes (how long to keep in cache)\n- **Retry**: 3 attempts on failure\n- **Refetch on window focus**: Yes\n\n### Custom Configuration\n\nOverride defaults in individual hooks:\n\n```typescript\nconst { data } = useProfileQuery({\n  staleTime: 60000,      // 1 minute\n  retry: 5,              // Retry 5 times\n  refetchOnWindowFocus: false,\n});\n```\n\n## Advanced Hook Patterns\n\n### Dependent Queries\n\nOnly run query if condition is met:\n\n```typescript\nconst { data: profile } = useProfileQuery();\n\nconst { data: documents } = useDocumentsQuery({\n  enabled: !!profile?.id  // Only fetch if profile exists\n});\n```\n\n### Optimistic Updates\n\nUpdate UI immediately, rollback if fails:\n\n```typescript\nconst queryClient = useQueryClient();\nconst { mutate } = useUpdateProfile({\n  onMutate: async (newData) => {\n    // Cancel outgoing queries\n    await queryClient.cancelQueries({ queryKey: ['profile'] });\n\n    // Get current data\n    const previous = queryClient.getQueryData(['profile']);\n\n    // Optimistically update\n    queryClient.setQueryData(['profile'], newData);\n\n    // Return rollback value\n    return { previous };\n  },\n  onError: (err, newData, context) => {\n    // Rollback on error\n    queryClient.setQueryData(['profile'], context.previous);\n  }\n});\n```\n\n### Manual Refetch\n\nTrigger refetch manually:\n\n```typescript\nconst { data, refetch } = useProfileQuery();\n\n<button onClick={() => refetch()}>\n  Refresh\n</button>\n```\n\n### Invalidate Queries\n\nForce refetch of specific queries:\n\n```typescript\nimport { useQueryClient } from '@tanstack/react-query';\n\nconst queryClient = useQueryClient();\n\n// Invalidate all profile queries\nqueryClient.invalidateQueries({ queryKey: ['profile'] });\n\n// Invalidate specific query\nqueryClient.invalidateQueries({ queryKey: ['invoices', '123'] });\n```\n\n## Custom Hooks\n\nCreate custom hooks for reusable logic:\n\n```typescript\n// lib/hooks/use-vendor.ts\nexport function useVendor(id: string) {\n  return useQuery({\n    queryKey: ['vendors', id],\n    queryFn: () => vendorRepository.get(id),\n    enabled: !!id,\n  });\n}\n\n// Usage\nconst { data: vendor } = useVendor('123');\n```\n\n## Common Patterns\n\n### Loading State\n\n```typescript\nconst { data, isLoading } = useProfileQuery();\n\nif (isLoading) {\n  return <Spinner />;\n}\n\nreturn <Profile data={data} />;\n```\n\n### Error State\n\n```typescript\nconst { data, error, isError } = useProfileQuery();\n\nif (isError) {\n  return <ErrorMessage error={error} />;\n}\n\nreturn <Profile data={data} />;\n```\n\n### Success Callback\n\n```typescript\nconst { mutate } = useUpdateProfile({\n  onSuccess: (data) => {\n    toast.success('Profile updated!');\n    router.push('/dashboard');\n  },\n  onError: (error) => {\n    toast.error(error.message);\n  }\n});\n```\n\n### Combining Hooks\n\n```typescript\nexport function DashboardData() {\n  const { data: profile } = useProfileQuery();\n  const { data: invoices } = useInvoicesQuery();\n  const { data: vendors } = useVendorsQuery();\n\n  const isLoading = !profile || !invoices || !vendors;\n\n  if (isLoading) return <div>Loading...</div>;\n\n  return <Dashboard profile={profile} invoices={invoices} vendors={vendors} />;\n}\n```\n\n## Query Keys\n\nQuery keys identify cached data.\n\n### Simple Key\n\n```typescript\nqueryKey: ['profile']\n```\n\n### Compound Key\n\n```typescript\nqueryKey: ['invoices', filters]\nqueryKey: ['invoice', id]\n```\n\n### Why Query Keys Matter\n\n- **Caching**: Same key = same cached data\n- **Invalidation**: Invalidate by key pattern\n- **Prefetching**: Pre-load data by key\n\n## Prefetching Data\n\nLoad data before it's needed:\n\n```typescript\nimport { useQueryClient } from '@tanstack/react-query';\n\nconst queryClient = useQueryClient();\n\n// Prefetch on hover\n<Link\n  href=\"/vendors\"\n  onMouseEnter={() => {\n    queryClient.prefetchQuery({\n      queryKey: ['vendors'],\n      queryFn: () => vendorRepository.list()\n    });\n  }}\n>\n  Vendors\n</Link>\n```\n\n## React Query DevTools\n\nView cache, queries, and mutations:\n\n```typescript\n// Already included in app/layout.tsx\nimport { ReactQueryDevtools } from '@tanstack/react-query-devtools';\n\n<QueryClientProvider client={queryClient}>\n  <App />\n  <ReactQueryDevtools initialIsOpen={false} />\n</QueryClientProvider>\n```\n\nAccess at bottom-right corner of screen in development.\n\n## Key Files\n\n- **`lib/hooks/queries/`** - All query hooks\n- **`lib/hooks/mutations/`** - All mutation hooks\n- **`lib/hooks/use-permissions.ts`** - Permission hook\n- **`lib/providers/query-provider.tsx`** - React Query setup\n\n## Best Practices\n\n1. **Use hooks in client components** - Add 'use client'\n2. **Handle loading states** - Show spinners/skeletons\n3. **Handle errors** - Show error messages\n4. **Invalidate after mutations** - Keep data fresh\n5. **Use query keys consistently** - Follow naming pattern\n6. **Don't fetch in loops** - Use batch queries instead\n7. **Prefetch for better UX** - Load before user clicks\n\n## Next Steps\n\n👉 **Complete Example**: [Adding a Feature](./09-adding-a-feature.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/09-adding-a-feature.md",
    "content": "# Adding a Feature\n\nThis guide shows you how to add a complete feature from start to finish.\n\n**Example**: Add Vendor Management to the app.\n\n# Adding a Feature\n \n This checklist guides you through adding a new feature (e.g., \"Vendor Management\").\n \n ## 1. Backend Layer\n \n 1. **Define Database Schema**: Add tables (e.g., `vendors`) in your backend.\n 2. **Create API Endpoints**: specific generic REST endpoints (`GET /vendors`, `POST /vendors`).\n 3. **Define Permissions**: Add generic permissions in `src/pkg/auth/rbac.go` (e.g., `vendor:view`).\n \n ## 2. Frontend Data Layer\n \n 1. **Add Permissions**: Update `lib/auth/permissions.ts` to match backend.\n    - 👉 [See Permissions Guide](./03-permissions-and-roles.md)\n 2. **Create Repository**: Add `lib/api/api/repositories/vendor-repository.ts`.\n    - 👉 [See API Request Guide](./05-making-api-requests.md)\n 3. **Create Hooks**: Add `useVendorsQuery` and `useCreateVendorMutation`.\n    - 👉 [See Hooks Guide](./08-using-hooks.md)\n \n ## 3. UI Layer\n \n 1. **Create Page**: Add `app/vendors/page.tsx` (Server Component).\n    - Checks permissions & fetches initial data.\n    - 👉 [See Creating Pages Guide](./06-creating-pages.md)\n 2. **Create Components**: Build `VendorList.tsx` and `VendorForm.tsx` (Client Components).\n    - Uses hooks for interactivity.\n    - 👉 [See Creating Components Guide](./07-creating-components.md)\n 3. **Add Navigation**: Add link to `app/dashboard/layout.tsx`.\n \n ## 4. Verification Check\n \n - [ ] **Auth**: Can unauthenticated users access the page? (Should be NO)\n - [ ] **Permissions**: Can unauthorized roles see the page? (Should be NO)\n - [ ] **Data**: Does the list update after creating a new item?\n - [ ] **Loading**: Is there a loading state?\n \n ## Summary Flow\n \n ```mermaid\n graph TD\n     A[Backend: DB & API] --> B[Frontend: Repository_Layer]\n     B --> C[Frontend: React_Hooks]\n     C --> D[Frontend: UI_Components]\n     D --> E[Frontend: Next.js_Page]\n ```\n\n## Step 1: Define Requirements\n\n**What we need:**\n- Users with `vendor:view` permission can see vendors\n- Users with `vendor:create` permission can add vendors\n- Users with `vendor:edit` permission can modify vendors\n- Users with `vendor:delete` permission can remove vendors\n- Only authenticated users can access vendor pages\n\n## Step 2: Add Permissions\n\n### 2.1 Define Permissions\n\nEdit `lib/auth/permissions.ts`:\n\n```typescript\nexport const PERMISSIONS = {\n  // ... existing permissions\n\n  // Vendor Management (ADD THESE)\n  VENDOR_VIEW: \"vendor:view\",\n  VENDOR_CREATE: \"vendor:create\",\n  VENDOR_EDIT: \"vendor:edit\",\n  VENDOR_DELETE: \"vendor:delete\",\n} as const;\n```\n\n### 2.2 Update Server Permissions\n\nEdit `lib/auth/server-permissions.ts`:\n\n```typescript\nexport interface ServerPermissions {\n  // ... existing properties\n\n  // Add these\n  canViewVendors: boolean;\n  canCreateVendors: boolean;\n  canEditVendors: boolean;\n  canDeleteVendors: boolean;\n}\n\nexport async function getServerPermissions(session): Promise<ServerPermissions> {\n  // ... existing code\n\n  return {\n    // ... existing returns\n\n    // Add these\n    canViewVendors: permissions.includes(PERMISSIONS.VENDOR_VIEW),\n    canCreateVendors: permissions.includes(PERMISSIONS.VENDOR_CREATE),\n    canEditVendors: permissions.includes(PERMISSIONS.VENDOR_EDIT),\n    canDeleteVendors: permissions.includes(PERMISSIONS.VENDOR_DELETE),\n  };\n}\n```\n\n## Step 3: Create Backend API Endpoints\n\n*Note: This assumes you have a backend API. If your backend doesn't have vendor endpoints yet, work with your backend team to create them.*\n\nExpected backend endpoints:\n- `GET /vendors` - List all vendors\n- `GET /vendors/:id` - Get vendor by ID\n- `POST /vendors` - Create vendor\n- `PUT /vendors/:id` - Update vendor\n- `DELETE /vendors/:id` - Delete vendor\n\n## Step 4: Create Repository\n\nCreate `lib/api/api/repositories/vendor-repository.ts`:\n\n```typescript\nimport { apiClient } from \"../client/api-client\";\n\nexport interface Vendor {\n  id: string;\n  name: string;\n  email: string;\n  phone?: string;\n  address?: string;\n  status: \"active\" | \"inactive\";\n  created_at: string;\n  updated_at: string;\n}\n\nclass VendorRepository {\n  async list(sessionToken?: string): Promise<Vendor[]> {\n    const options = sessionToken\n      ? { headers: { Authorization: `Bearer ${sessionToken}` } }\n      : undefined;\n\n    return apiClient.get<Vendor[]>(\"/vendors\", options);\n  }\n\n  async get(id: string, sessionToken?: string): Promise<Vendor> {\n    const options = sessionToken\n      ? { headers: { Authorization: `Bearer ${sessionToken}` } }\n      : undefined;\n\n    return apiClient.get<Vendor>(`/vendors/${id}`, options);\n  }\n\n  async create(data: Omit<Vendor, \"id\" | \"created_at\" | \"updated_at\">): Promise<Vendor> {\n    return apiClient.post<Vendor>(\"/vendors\", data);\n  }\n\n  async update(id: string, data: Partial<Vendor>): Promise<Vendor> {\n    return apiClient.put<Vendor>(`/vendors/${id}`, data);\n  }\n\n  async delete(id: string): Promise<void> {\n    return apiClient.delete<void>(`/vendors/${id}`);\n  }\n}\n\nexport const vendorRepository = new VendorRepository();\n```\n\n## Step 5: Create Query/Mutation Hooks\n\n### 5.1 Create Query Hook\n\nCreate `lib/hooks/queries/use-vendors-query.ts`:\n\n```typescript\n'use client';\nimport { useQuery } from '@tanstack/react-query';\nimport { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';\n\nexport function useVendorsQuery() {\n  return useQuery({\n    queryKey: ['vendors'],\n    queryFn: () => vendorRepository.list(),\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n}\n\nexport function useVendorQuery(id: string) {\n  return useQuery({\n    queryKey: ['vendors', id],\n    queryFn: () => vendorRepository.get(id),\n    enabled: !!id,\n  });\n}\n```\n\n### 5.2 Create Mutation Hooks\n\nCreate `lib/hooks/mutations/use-vendor-mutations.ts`:\n\n```typescript\n'use client';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';\nimport { toast } from 'sonner';\n\nexport function useCreateVendor() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: vendorRepository.create,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['vendors'] });\n      toast.success('Vendor created successfully');\n    },\n    onError: (error: Error) => {\n      toast.error(`Failed to create vendor: ${error.message}`);\n    },\n  });\n}\n\nexport function useUpdateVendor() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({ id, data }: { id: string; data: any }) =>\n      vendorRepository.update(id, data),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({ queryKey: ['vendors'] });\n      queryClient.invalidateQueries({ queryKey: ['vendors', variables.id] });\n      toast.success('Vendor updated successfully');\n    },\n    onError: (error: Error) => {\n      toast.error(`Failed to update vendor: ${error.message}`);\n    },\n  });\n}\n\nexport function useDeleteVendor() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: vendorRepository.delete,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['vendors'] });\n      toast.success('Vendor deleted successfully');\n    },\n    onError: (error: Error) => {\n      toast.error(`Failed to delete vendor: ${error.message}`);\n    },\n  });\n}\n```\n\n## Step 6: Create Page Components\n\n### 6.1 Create Vendors List Page\n\nCreate `app/vendors/page.tsx`:\n\n```typescript\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { VendorList } from './components/vendor-list';\n\nexport const metadata = {\n  title: 'Vendors',\n  description: 'Manage your vendors',\n};\n\nexport default async function VendorsPage() {\n  // Require authentication\n  const session = await requireMemberSession();\n\n  // Check permissions\n  const permissions = await getServerPermissions(session);\n  if (!permissions.canViewVendors) {\n    return <div className=\"p-6\">Access Denied</div>;\n  }\n\n  return (\n    <div className=\"container mx-auto p-6\">\n      <h1 className=\"text-3xl font-bold mb-6\">Vendors</h1>\n      <VendorList />\n    </div>\n  );\n}\n```\n\n### 6.2 Create Vendor List Component\n\nCreate `app/vendors/components/vendor-list.tsx`:\n\n```typescript\n'use client';\nimport { useVendorsQuery } from '@/lib/hooks/queries/use-vendors-query';\nimport { usePermissions } from '@/lib/hooks/use-permissions';\nimport { useDeleteVendor } from '@/lib/hooks/mutations/use-vendor-mutations';\nimport { Button } from '@/components/ui/button';\nimport Link from 'next/link';\n\nexport function VendorList() {\n  const { data: vendors, isLoading } = useVendorsQuery();\n  const { hasPermission } = usePermissions();\n  const { mutate: deleteVendor } = useDeleteVendor();\n\n  if (isLoading) {\n    return <div>Loading vendors...</div>;\n  }\n\n  return (\n    <div>\n      {hasPermission('vendor:create') && (\n        <Link href=\"/vendors/new\">\n          <Button>Create Vendor</Button>\n        </Link>\n      )}\n\n      <table className=\"w-full mt-4\">\n        <thead>\n          <tr>\n            <th>Name</th>\n            <th>Email</th>\n            <th>Status</th>\n            <th>Actions</th>\n          </tr>\n        </thead>\n        <tbody>\n          {vendors?.map(vendor => (\n            <tr key={vendor.id}>\n              <td>{vendor.name}</td>\n              <td>{vendor.email}</td>\n              <td>{vendor.status}</td>\n              <td>\n                <Link href={`/vendors/${vendor.id}`}>View</Link>\n                {hasPermission('vendor:edit') && (\n                  <Link href={`/vendors/${vendor.id}/edit`}>Edit</Link>\n                )}\n                {hasPermission('vendor:delete') && (\n                  <button onClick={() => deleteVendor(vendor.id)}>\n                    Delete\n                  </button>\n                )}\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n```\n\n### 6.3 Create Vendor Detail Page\n\nCreate `app/vendors/[id]/page.tsx`:\n\n```typescript\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';\nimport { VendorDetail } from '../components/vendor-detail';\n\nexport default async function VendorDetailPage({ params }: { params: { id: string } }) {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canViewVendors) {\n    return <div>Access Denied</div>;\n  }\n\n  const vendor = await vendorRepository.get(params.id, session.session_jwt);\n\n  return <VendorDetail vendor={vendor} />;\n}\n```\n\n### 6.4 Create Vendor Form Page\n\nCreate `app/vendors/new/page.tsx`:\n\n```typescript\nimport { requireMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { VendorForm } from '../components/vendor-form';\n\nexport default async function NewVendorPage() {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canCreateVendors) {\n    return <div>Access Denied</div>;\n  }\n\n  return (\n    <div className=\"container mx-auto p-6\">\n      <h1 className=\"text-3xl font-bold mb-6\">Create Vendor</h1>\n      <VendorForm />\n    </div>\n  );\n}\n```\n\n### 6.5 Create Vendor Form Component\n\nCreate `app/vendors/components/vendor-form.tsx`:\n\n```typescript\n'use client';\nimport { useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { useCreateVendor } from '@/lib/hooks/mutations/use-vendor-mutations';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\n\nexport function VendorForm() {\n  const router = useRouter();\n  const { mutate: createVendor, isPending } = useCreateVendor();\n  const [formData, setFormData] = useState({\n    name: '',\n    email: '',\n    phone: '',\n    address: '',\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    createVendor(formData, {\n      onSuccess: () => {\n        router.push('/vendors');\n      },\n    });\n  };\n\n  return (\n    <form onSubmit={handleSubmit} className=\"space-y-4\">\n      <Input\n        label=\"Name\"\n        value={formData.name}\n        onChange={(e) => setFormData({ ...formData, name: e.target.value })}\n        required\n      />\n      <Input\n        label=\"Email\"\n        type=\"email\"\n        value={formData.email}\n        onChange={(e) => setFormData({ ...formData, email: e.target.value })}\n        required\n      />\n      <Input\n        label=\"Phone\"\n        value={formData.phone}\n        onChange={(e) => setFormData({ ...formData, phone: e.target.value })}\n      />\n      <Input\n        label=\"Address\"\n        value={formData.address}\n        onChange={(e) => setFormData({ ...formData, address: e.target.value })}\n      />\n      <Button type=\"submit\" disabled={isPending}>\n        {isPending ? 'Creating...' : 'Create Vendor'}\n      </Button>\n    </form>\n  );\n}\n```\n\n## Step 7: Add to Navigation\n\nEdit your dashboard layout to add vendor link:\n\n```typescript\n// app/dashboard/layout.tsx (or your sidebar component)\n\n<nav>\n  {hasPermission('vendor:view') && (\n    <Link href=\"/vendors\">\n      Vendors\n    </Link>\n  )}\n</nav>\n```\n\n## Step 8: Test the Feature\n\n### 8.1 Test Authentication\n\n1. Visit `/vendors` without logging in\n2. Should redirect to `/auth`\n\n### 8.2 Test Permissions\n\n1. Log in as user without `vendor:view` permission\n2. Visit `/vendors`\n3. Should see \"Access Denied\"\n\n4. Log in as user with `vendor:view` permission\n5. Should see vendor list\n\n### 8.3 Test CRUD Operations\n\n**Create:**\n1. Click \"Create Vendor\"\n2. Fill form\n3. Click submit\n4. Should redirect to list\n5. Should see new vendor\n\n**Read:**\n1. Click vendor name\n2. Should see vendor details\n\n**Update:**\n1. Click \"Edit\"\n2. Change name\n3. Submit\n4. Should update in list\n\n**Delete:**\n1. Click \"Delete\"\n2. Confirm\n3. Should remove from list\n\n## File Creation Summary\n\n```mermaid\ngraph TD\n    A[lib/auth/permissions.ts] --> B[Add VENDOR_* permissions]\n    C[lib/auth/server-permissions.ts] --> D[Add canViewVendors, etc.]\n    E[lib/api/api/repositories/vendor-repository.ts] --> F[NEW: Repository]\n    G[lib/hooks/queries/use-vendors-query.ts] --> H[NEW: Query hooks]\n    I[lib/hooks/mutations/use-vendor-mutations.ts] --> J[NEW: Mutation hooks]\n    K[app/vendors/page.tsx] --> L[NEW: List page]\n    M[app/vendors/new/page.tsx] --> N[NEW: Create page]\n    O[app/vendors/[id]/page.tsx] --> P[NEW: Detail page]\n    Q[app/vendors/components/] --> R[NEW: Components]\n```\n\n## Checklist\n\n- ✅ Define permissions\n- ✅ Update server permissions\n- ✅ Create repository\n- ✅ Create query hooks\n- ✅ Create mutation hooks\n- ✅ Create list page\n- ✅ Create detail page\n- ✅ Create form page\n- ✅ Create components\n- ✅ Add to navigation\n- ✅ Test authentication\n- ✅ Test permissions\n- ✅ Test CRUD operations\n\n## Best Practices Applied\n\n1. **Permission-first** - Check permissions everywhere\n2. **Repository pattern** - Centralized API access\n3. **Hook-based** - Use React Query for data fetching\n4. **Server + Client** - Server components for data, client for interactivity\n5. **Type-safe** - TypeScript interfaces for all data\n6. **Error handling** - Toast notifications on errors\n7. **Loading states** - Show loading indicators\n8. **Cache invalidation** - Refresh data after mutations\n\n## Common Issues\n\n**Issue**: \"Access Denied\" for all users\n\n**Solution**: Check that permissions are added in Stytch dashboard for user roles.\n\n**Issue**: Data not refreshing after create\n\n**Solution**: Ensure `invalidateQueries` is called in mutation hooks.\n\n**Issue**: TypeScript errors on repository\n\n**Solution**: Define proper interfaces for all data types.\n\n## Next Steps\n\nNow you know how to:\n- Add permissions\n- Create repositories\n- Build query/mutation hooks\n- Create protected pages\n- Add to navigation\n\nApply this pattern to add any feature to your app!\n\n---\n\n👉 **Back to**: [Documentation Home](./README.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/10-server-actions.md",
    "content": "# Server Actions\n\nThis guide shows how to create and use Next.js Server Actions for mutations and data operations.\n\n## What are Server Actions?\n\nServer Actions are asynchronous functions that run on the server. They allow you to perform mutations and operations directly from your components without creating API routes.\n\n**Benefits:**\n- Simplified code - no need for separate API routes\n- Better TypeScript integration\n- Automatic request deduplication\n- Progressive enhancement support\n- Works with React Server Components\n\n## When to Use Server Actions\n\n**Use Server Actions for:**\n- Form submissions (login, logout, create, update, delete)\n- Data mutations (creating, updating, deleting records)\n- Operations that require authentication/authorization\n- Actions that need server-side validation\n\n**Don't use Server Actions for:**\n- Webhooks from third-party services (use API routes)\n- OAuth callbacks (use API routes)\n- Public APIs that external services call\n- Complex file uploads with progress tracking\n\n## File Structure\n\nServer Actions live in `lib/actions/`:\n\n```\nlib/actions/\n├── auth/\n│   ├── send-magic-link.ts\n│   └── logout.ts\n└── billing/\n    ├── create-checkout.ts\n    └── cancel-subscription.ts\n```\n\n## Creating a Server Action\n\n### Basic Structure\n\n```typescript\n// lib/actions/auth/logout.ts\n'use server'\n\nimport { redirect } from 'next/navigation';\nimport { cookies } from 'next/headers';\nimport {\n  createActionSuccess,\n  createActionError,\n  type ActionResult\n} from '@/lib/utils/server-action-helpers';\n\nexport async function logout(): Promise<ActionResult<void>> {\n  try {\n    // Clear session cookie\n    cookies().delete('stytch_session_token');\n\n    // Redirect to login\n    redirect('/auth');\n  } catch (error: any) {\n    console.error('[Logout] Error:', error);\n    return createActionError(\n      'Failed to logout',\n      process.env.NODE_ENV === 'development' ? error.message : undefined\n    );\n  }\n}\n```\n\n### With Authentication\n\n```typescript\n// lib/actions/billing/cancel-subscription.ts\n'use server'\n\nimport {\n  requireActionSessionWithPermissions,\n  createActionSuccess,\n  createActionError,\n  requirePermission,\n  type ActionResult\n} from '@/lib/utils/server-action-helpers';\nimport { getPolarClient } from '@/lib/polar/client';\n\nexport async function cancelSubscription(): Promise<ActionResult<{ subscriptionId: string }>> {\n  try {\n    // Require authentication and permissions\n    const { session, permissions } = await requireActionSessionWithPermissions();\n\n    // Check permissions\n    const permError = requirePermission(\n      permissions,\n      (p) => p.canManageSubscriptions,\n      'You do not have permission to cancel subscriptions'\n    );\n    if (permError) return permError;\n\n    // Get organization ID\n    const orgId = permissions.profile?.organization?.organization_id;\n    if (!orgId) {\n      return createActionError('Organization not found');\n    }\n\n    // Call Polar API\n    const client = getPolarClient();\n    const result = await client.subscriptions.cancel({\n      organizationId: orgId\n    });\n\n    return createActionSuccess({\n      subscriptionId: result.id\n    });\n  } catch (error: any) {\n    console.error('[Cancel Subscription] Error:', error);\n    return createActionError(\n      'Failed to cancel subscription',\n      process.env.NODE_ENV === 'development' ? error.message : undefined\n    );\n  }\n}\n```\n\n### With Form Data\n\n```typescript\n// lib/actions/auth/send-magic-link.ts\n'use server'\n\nimport {\n  createActionSuccess,\n  createActionError,\n  withErrorHandling,\n  type ActionResult\n} from '@/lib/utils/server-action-helpers';\nimport { getStytchB2BClient } from '@/lib/auth/stytch/server';\n\nexport async function sendMagicLink(\n  email: string\n): Promise<ActionResult<{ message: string }>> {\n  return withErrorHandling(async () => {\n    // Validate input\n    if (!email || typeof email !== 'string') {\n      return createActionError('Email address is required');\n    }\n\n    const client = getStytchB2BClient();\n\n    // Send magic link\n    await client.magicLinks.email.loginOrSignup({\n      email_address: email.toLowerCase(),\n      login_redirect_url: process.env.NEXT_PUBLIC_APP_BASE_URL + '/authenticate'\n    });\n\n    return createActionSuccess({\n      message: 'If an account exists with that email, a magic link has been sent.'\n    });\n  });\n}\n```\n\n## Using Server Actions in Components\n\n### In Client Components with useTransition\n\n```typescript\n'use client'\n\nimport { useState, useTransition } from 'react';\nimport { sendMagicLink } from '@/lib/actions/auth/send-magic-link';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\n\nexport function MagicLinkForm() {\n  const [email, setEmail] = useState('');\n  const [isPending, startTransition] = useTransition();\n  const [error, setError] = useState<string | null>(null);\n  const [success, setSuccess] = useState(false);\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    setError(null);\n\n    startTransition(async () => {\n      const result = await sendMagicLink(email);\n\n      if (result.success) {\n        setSuccess(true);\n      } else {\n        setError(result.error);\n      }\n    });\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <Input\n        type=\"email\"\n        value={email}\n        onChange={(e) => setEmail(e.target.value)}\n        placeholder=\"Enter your email\"\n        disabled={isPending}\n      />\n\n      {error && <p className=\"text-red-500\">{error}</p>}\n      {success && <p className=\"text-green-500\">Magic link sent!</p>}\n\n      <Button type=\"submit\" disabled={isPending}>\n        {isPending ? 'Sending...' : 'Send Magic Link'}\n      </Button>\n    </form>\n  );\n}\n```\n\n### With React Query (for mutations)\n\n```typescript\n'use client'\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { cancelSubscription } from '@/lib/actions/billing/cancel-subscription';\nimport { Button } from '@/components/ui/button';\n\nexport function CancelSubscriptionButton() {\n  const queryClient = useQueryClient();\n\n  const mutation = useMutation({\n    mutationFn: cancelSubscription,\n    onSuccess: (result) => {\n      if (result.success) {\n        // Invalidate subscription queries to refetch\n        queryClient.invalidateQueries({ queryKey: ['subscription'] });\n        alert('Subscription cancelled successfully');\n      } else {\n        alert(result.error);\n      }\n    }\n  });\n\n  return (\n    <Button\n      onClick={() => mutation.mutate()}\n      disabled={mutation.isPending}\n    >\n      {mutation.isPending ? 'Cancelling...' : 'Cancel Subscription'}\n    </Button>\n  );\n}\n```\n\n### With Form Actions (progressive enhancement)\n\n```typescript\n// lib/actions/auth/send-magic-link.ts\n'use server'\n\nexport async function sendMagicLinkFormAction(formData: FormData) {\n  const email = formData.get('email') as string;\n  return sendMagicLink(email);\n}\n```\n\n```typescript\n'use client'\n\nimport { useFormState, useFormStatus } from 'react-dom';\nimport { sendMagicLinkFormAction } from '@/lib/actions/auth/send-magic-link';\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return (\n    <button type=\"submit\" disabled={pending}>\n      {pending ? 'Sending...' : 'Send Magic Link'}\n    </button>\n  );\n}\n\nexport function MagicLinkForm() {\n  const [state, formAction] = useFormState(sendMagicLinkFormAction, null);\n\n  return (\n    <form action={formAction}>\n      <input\n        type=\"email\"\n        name=\"email\"\n        placeholder=\"Enter your email\"\n        required\n      />\n\n      {state && !state.success && (\n        <p className=\"text-red-500\">{state.error}</p>\n      )}\n      {state?.success && (\n        <p className=\"text-green-500\">Magic link sent!</p>\n      )}\n\n      <SubmitButton />\n    </form>\n  );\n}\n```\n\n## Authentication Helpers\n\nUse the helpers from `lib/utils/server-action-helpers.ts`:\n\n### Optional Authentication\n\n```typescript\nconst session = await getActionSession();\n\nif (!session) {\n  return createActionError('Not authenticated');\n}\n```\n\n### Required Authentication\n\n```typescript\nconst session = await requireActionSession();\n// Throws error if not authenticated - this code only runs if authenticated\n```\n\n### With Permissions\n\n```typescript\nconst { session, permissions } = await requireActionSessionWithPermissions();\n\nconst permError = requirePermission(\n  permissions,\n  (p) => p.canCreateInvoices,\n  'You cannot create invoices'\n);\nif (permError) return permError;\n```\n\n## Error Handling\n\n### Standard Pattern\n\n```typescript\nexport async function myAction(): Promise<ActionResult<MyData>> {\n  try {\n    // Your logic here\n    return createActionSuccess(data);\n  } catch (error: any) {\n    console.error('[My Action] Error:', error);\n    return createActionError(\n      'User-friendly error message',\n      process.env.NODE_ENV === 'development' ? error.message : undefined\n    );\n  }\n}\n```\n\n### With Error Wrapper\n\n```typescript\nexport async function myAction(): Promise<ActionResult<MyData>> {\n  return withErrorHandling(async () => {\n    // Your logic here\n    return createActionSuccess(data);\n  });\n}\n```\n\n## Redirects in Server Actions\n\nUse Next.js `redirect()` for navigation:\n\n```typescript\nimport { redirect } from 'next/navigation';\n\nexport async function logout() {\n  // Clear session\n  cookies().delete('stytch_session_token');\n\n  // Redirect to login\n  redirect('/auth');\n}\n```\n\n## Revalidation\n\nRevalidate cached data after mutations:\n\n```typescript\nimport { revalidatePath, revalidateTag } from 'next/cache';\n\nexport async function updateProfile(data: ProfileData) {\n  // Update profile\n  await profileRepository.update(data);\n\n  // Revalidate the page\n  revalidatePath('/dashboard/settings');\n\n  // Or revalidate by tag\n  revalidateTag('profile');\n\n  return createActionSuccess({ updated: true });\n}\n```\n\n## Best Practices\n\n1. **Always use `'use server'` directive** at the top of Server Action files\n2. **Return `ActionResult<T>`** for consistent error handling\n3. **Validate all inputs** - never trust client data\n4. **Check permissions** - re-validate on the server\n5. **Log errors** with descriptive messages for debugging\n6. **Use TypeScript** for type safety\n7. **Keep actions focused** - one action per operation\n8. **Handle errors gracefully** - return user-friendly messages\n\n## Common Patterns\n\n### Create Operation\n\n```typescript\nexport async function createVendor(\n  data: CreateVendorInput\n): Promise<ActionResult<{ id: string }>> {\n  const { session, permissions } = await requireActionSessionWithPermissions();\n\n  const permError = requirePermission(\n    permissions,\n    (p) => p.canCreateVendors\n  );\n  if (permError) return permError;\n\n  const vendor = await vendorRepository.create(data, session.session_jwt);\n\n  revalidatePath('/dashboard/vendors');\n\n  return createActionSuccess({ id: vendor.id });\n}\n```\n\n### Update Operation\n\n```typescript\nexport async function updateVendor(\n  id: string,\n  data: UpdateVendorInput\n): Promise<ActionResult<void>> {\n  const { session } = await requireActionSessionWithPermissions();\n\n  await vendorRepository.update(id, data, session.session_jwt);\n\n  revalidatePath('/dashboard/vendors');\n  revalidatePath(`/dashboard/vendors/${id}`);\n\n  return createActionSuccessEmpty();\n}\n```\n\n### Delete Operation\n\n```typescript\nexport async function deleteVendor(\n  id: string\n): Promise<ActionResult<void>> {\n  const { session, permissions } = await requireActionSessionWithPermissions();\n\n  const permError = requirePermission(\n    permissions,\n    (p) => p.canDeleteVendors\n  );\n  if (permError) return permError;\n\n  await vendorRepository.delete(id, session.session_jwt);\n\n  revalidatePath('/dashboard/vendors');\n\n  return createActionSuccessEmpty();\n}\n```\n\n## Comparison: API Routes vs Server Actions\n\n| Feature | API Routes | Server Actions |\n|---------|-----------|----------------|\n| Use case | Webhooks, external APIs | Mutations, form submissions |\n| Location | `app/api/` | `lib/actions/` |\n| Directive | None | `'use server'` |\n| Return type | `NextResponse` | `ActionResult<T>` |\n| Authentication | Manual session check | Use helper functions |\n| Type safety | Manual typing | Full TypeScript support |\n| Form integration | Manual fetch | Native form actions |\n| Revalidation | Manual | Built-in with `revalidatePath` |\n\n## Testing Server Actions\n\n```typescript\nimport { sendMagicLink } from '@/lib/actions/auth/send-magic-link';\n\ndescribe('sendMagicLink', () => {\n  it('should send magic link for valid email', async () => {\n    const result = await sendMagicLink('test@example.com');\n\n    expect(result.success).toBe(true);\n  });\n\n  it('should return error for invalid email', async () => {\n    const result = await sendMagicLink('');\n\n    expect(result.success).toBe(false);\n    expect(result.error).toContain('required');\n  });\n});\n```\n\n## Next Steps\n\n👉 **Learn about**: [Adding a Feature](./09-adding-a-feature.md)\n👉 **Learn about**: [Creating APIs](./07-creating-apis.md) (for webhooks)\n"
  },
  {
    "path": "next_b2b_starter/docs/11-feature-guards.md",
    "content": "# Feature Guards\n\nThis guide shows you how to protect features with authentication, permission, and subscription guards.\n\n---\n\n## Overview\n\nFeature guards are security checks that control access to features. The three main types are:\n\n1. **Authentication Guards** - Is the user logged in?\n2. **Permission Guards** - Does the user have the right role/permissions?\n3. **Subscription Guards** - Does the user have an active subscription?\n\nAlways apply guards in this order: Auth → Permissions → Subscription\n\n---\n\n## 1. Authentication Guards\n\n### Server Components\n\n```typescript\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { redirect } from 'next/navigation';\n\nexport default async function ProtectedPage() {\n  const session = await getMemberSession();\n\n  if (!session?.session_jwt) {\n    redirect('/auth');\n  }\n\n  return <div>Protected content</div>;\n}\n```\n\n### Client Components\n\n```typescript\n'use client';\n\nimport { useAuth } from '@/lib/contexts/auth-context';\n\nexport function ProtectedComponent() {\n  const { profile } = useAuth();\n\n  if (!profile) {\n    return <div>Please log in to continue</div>;\n  }\n\n  return <div>Protected content</div>;\n}\n```\n\n### Server Actions\n\n```typescript\n'use server';\n\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { createActionError } from '@/lib/utils/server-action-helpers';\n\nexport async function myAction() {\n  const session = await getMemberSession();\n\n  if (!session?.session_jwt) {\n    return createActionError('Authentication required.');\n  }\n\n  // Your logic here\n}\n```\n\n---\n\n## 2. Permission Guards\n\n### Available Permissions\n\n- `canView` - View organization details\n- `canManageMembers` - Manage team members\n- `canManageSubscriptions` - Manage billing\n- `canCreateResources` - Create resources\n- `canEditResources` - Edit resources\n- `canDeleteResources` - Delete resources\n\n### Server Components\n\n```typescript\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\n\nexport default async function AdminPage() {\n  const session = await getMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canManageSubscriptions) {\n    return <div>You don't have access to this page.</div>;\n  }\n\n  return <div>Admin content</div>;\n}\n```\n\n### Client Components\n\n```typescript\n'use client';\n\nimport { usePermissions } from '@/lib/hooks/use-permissions';\n\nexport function AdminPanel() {\n  const { canManageSubscriptions } = usePermissions();\n\n  if (!canManageSubscriptions) {\n    return <div>Access denied</div>;\n  }\n\n  return <div>Admin panel</div>;\n}\n```\n\n### Server Actions\n\n```typescript\n'use server';\n\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { createActionError } from '@/lib/utils/server-action-helpers';\n\nexport async function deleteResource(id: string) {\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    return createActionError('Authentication required.');\n  }\n\n  const permissions = await getServerPermissions(session);\n  if (!permissions.canDeleteResources) {\n    return createActionError('Insufficient permissions.');\n  }\n\n  // Delete the resource\n}\n```\n\n---\n\n## 3. Subscription Guards\n\n### Server Components\n\n```typescript\nimport { resolveCurrentSubscription } from '@/lib/polar/current-subscription';\n\nexport default async function PremiumFeaturePage() {\n  const subscription = await resolveCurrentSubscription();\n\n  if (!subscription.isActive) {\n    return (\n      <div>\n        <h1>Subscription Required</h1>\n        <p>This feature requires an active subscription.</p>\n        <a href=\"/subscribe\">Subscribe Now</a>\n      </div>\n    );\n  }\n\n  return <div>Premium feature content</div>;\n}\n```\n\n### Client Components\n\n```typescript\n'use client';\n\nimport { useSubscriptionQuery } from '@/lib/hooks/queries/use-subscription-query';\n\nexport function PremiumFeature() {\n  const { data: subscription, isLoading } = useSubscriptionQuery();\n\n  if (isLoading) {\n    return <div>Loading...</div>;\n  }\n\n  if (!subscription?.isActive) {\n    return <div>Please upgrade to access this feature</div>;\n  }\n\n  return <div>Premium feature content</div>;\n}\n```\n\n### Server Actions\n\n```typescript\n'use server';\n\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { resolveCurrentSubscription } from '@/lib/polar/current-subscription';\nimport { createActionError } from '@/lib/utils/server-action-helpers';\n\nexport async function premiumAction() {\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    return createActionError('Authentication required.');\n  }\n\n  const subscription = await resolveCurrentSubscription();\n  if (!subscription.isActive) {\n    return createActionError('Active subscription required.', 'SUBSCRIPTION_REQUIRED');\n  }\n\n  // Your premium logic\n}\n```\n\n---\n\n## 4. Combined Guards (All Three)\n\n### Server Component Example\n\n```typescript\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { resolveCurrentSubscription } from '@/lib/polar/current-subscription';\nimport { redirect } from 'next/navigation';\n\nexport default async function AdvancedFeaturePage() {\n  // 1. Auth guard\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    redirect('/auth');\n  }\n\n  // 2. Permission guard\n  const permissions = await getServerPermissions(session);\n  if (!permissions.canManageSubscriptions) {\n    return <div>You don't have permission to access this page.</div>;\n  }\n\n  // 3. Subscription guard\n  const subscription = await resolveCurrentSubscription();\n  if (!subscription.isActive) {\n    return (\n      <div>\n        <h1>Subscription Required</h1>\n        <p>This advanced feature requires an active subscription.</p>\n      </div>\n    );\n  }\n\n  // All guards passed\n  return <div>Advanced feature content</div>;\n}\n```\n\n### Server Action Example\n\n```typescript\n'use server';\n\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { resolveCurrentSubscription } from '@/lib/polar/current-subscription';\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult\n} from '@/lib/utils/server-action-helpers';\n\ninterface ResourceData {\n  name: string;\n  description: string;\n}\n\nexport async function createPremiumResource(\n  data: ResourceData\n): Promise<ActionResult<{ id: string }>> {\n  // 1. Auth guard\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    return createActionError('Authentication required.');\n  }\n\n  // 2. Permission guard\n  const permissions = await getServerPermissions(session);\n  if (!permissions.canCreateResources) {\n    return createActionError('Insufficient permissions.');\n  }\n\n  // 3. Subscription guard\n  const subscription = await resolveCurrentSubscription();\n  if (!subscription.isActive) {\n    return createActionError(\n      'Active subscription required.',\n      'SUBSCRIPTION_REQUIRED'\n    );\n  }\n\n  // All guards passed - create the resource\n  // const resource = await db.resource.create({ data });\n\n  return createActionSuccess({ id: 'resource-123' });\n}\n```\n\n---\n\n## 5. UI Patterns for Guards\n\n### Loading States\n\n```typescript\n'use client';\n\nimport { useSubscriptionQuery } from '@/lib/hooks/queries/use-subscription-query';\n\nexport function GuardedFeature() {\n  const { data: subscription, isLoading } = useSubscriptionQuery();\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-8\">\n        <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900\" />\n      </div>\n    );\n  }\n\n  if (!subscription?.isActive) {\n    return (\n      <div className=\"p-8 text-center\">\n        <h3>Upgrade Required</h3>\n        <p>Please upgrade to access this feature.</p>\n      </div>\n    );\n  }\n\n  return <FeatureContent />;\n}\n```\n\n### Progressive Enhancement\n\n```typescript\n'use client';\n\nimport { useIsSubscriptionActive } from '@/lib/hooks/queries/use-subscription-query';\n\nexport function ConditionalFeature() {\n  const isActive = useIsSubscriptionActive();\n\n  return (\n    <div>\n      <BasicFeature />\n\n      {isActive ? (\n        <PremiumFeature />\n      ) : (\n        <div className=\"mt-4 p-4 bg-gray-100 rounded\">\n          <p>Upgrade to unlock premium features</p>\n        </div>\n      )}\n    </div>\n  );\n}\n```\n\n### Conditional UI Elements\n\n```typescript\n'use client';\n\nimport { usePermissions } from '@/lib/hooks/use-permissions';\n\nexport function ResourceActions({ resourceId }: { resourceId: string }) {\n  const { canEditResources, canDeleteResources } = usePermissions();\n\n  return (\n    <div className=\"flex gap-2\">\n      <button>View</button>\n\n      {canEditResources && (\n        <button>Edit</button>\n      )}\n\n      {canDeleteResources && (\n        <button>Delete</button>\n      )}\n    </div>\n  );\n}\n```\n\n---\n\n## 6. Error Handling\n\n### Handling Subscription Errors in Forms\n\n```typescript\n'use client';\n\nimport { useState } from 'react';\nimport { useSubscriptionQuery } from '@/lib/hooks/queries/use-subscription-query';\nimport { createPremiumResource } from '@/lib/actions/premium/create-premium-resource';\n\nexport function PremiumFeatureForm() {\n  const { data: subscription } = useSubscriptionQuery();\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n\n  const handleSubmit = async (data: FormData) => {\n    const result = await createPremiumResource({\n      name: data.get('name') as string,\n      description: data.get('description') as string,\n    });\n\n    if (!result.success) {\n      if (result.errorCode === 'SUBSCRIPTION_REQUIRED') {\n        setShowUpgradeModal(true);\n        return;\n      }\n\n      alert(result.error);\n      return;\n    }\n\n    // Success\n    alert('Resource created!');\n  };\n\n  return (\n    <form action={handleSubmit}>\n      {/* Form fields */}\n      <button type=\"submit\">Create</button>\n\n      {showUpgradeModal && (\n        <UpgradeModal onClose={() => setShowUpgradeModal(false)} />\n      )}\n    </form>\n  );\n}\n```\n\n---\n\n## 7. Best Practices\n\n### Always Check on Server\n\n```typescript\n// ❌ Don't rely only on client-side checks\n'use client';\nexport function BadExample() {\n  const { canDelete } = usePermissions();\n\n  const handleDelete = async () => {\n    if (canDelete) {\n      await fetch('/api/delete'); // No server-side check!\n    }\n  };\n}\n\n// ✅ Always check on server too\n'use server';\nexport async function deleteResource(id: string) {\n  const session = await getMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  if (!permissions.canDeleteResources) {\n    return createActionError('Insufficient permissions.');\n  }\n\n  // Proceed with deletion\n}\n```\n\n### Fail Secure\n\n```typescript\n// ❌ Don't show sensitive content by default\nexport function BadExample() {\n  const { data: subscription } = useSubscriptionQuery();\n\n  // Shows premium content during loading!\n  if (subscription?.isActive) {\n    return <PremiumContent />;\n  }\n  return <UpgradePrompt />;\n}\n\n// ✅ Hide sensitive content until verified\nexport function GoodExample() {\n  const { data: subscription, isLoading } = useSubscriptionQuery();\n\n  if (isLoading) return <LoadingState />;\n  if (!subscription?.isActive) return <UpgradePrompt />;\n\n  return <PremiumContent />;\n}\n```\n\n### Use Consistent Error Messages\n\n```typescript\nconst ERROR_MESSAGES = {\n  AUTH_REQUIRED: 'Please log in to continue.',\n  PERMISSION_DENIED: 'You don't have permission to perform this action.',\n  SUBSCRIPTION_REQUIRED: 'This feature requires an active subscription.',\n} as const;\n\nexport async function myAction() {\n  const session = await getMemberSession();\n  if (!session) {\n    return createActionError(ERROR_MESSAGES.AUTH_REQUIRED);\n  }\n\n  // ...\n}\n```\n\n---\n\n## 8. Common Patterns\n\n### Dashboard Layout Guard\n\n```typescript\n// app/dashboard/layout.tsx\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { redirect } from 'next/navigation';\n\nexport default async function DashboardLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const session = await getMemberSession();\n\n  if (!session?.session_jwt) {\n    redirect('/auth?returnTo=/dashboard');\n  }\n\n  return (\n    <div className=\"dashboard-layout\">\n      {children}\n    </div>\n  );\n}\n```\n\n### Settings Page with Permission Tabs\n\n```typescript\n// app/dashboard/settings/page.tsx\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { ProfileTab } from './components/profile-tab';\nimport { MembersTab } from './components/members-tab';\nimport { BillingTab } from './components/billing-tab';\n\nexport default async function SettingsPage() {\n  const session = await getMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  return (\n    <div>\n      <Tabs>\n        <Tab label=\"Profile\">\n          <ProfileTab />\n        </Tab>\n\n        {permissions.canManageMembers && (\n          <Tab label=\"Team Members\">\n            <MembersTab />\n          </Tab>\n        )}\n\n        {permissions.canManageSubscriptions && (\n          <Tab label=\"Billing\">\n            <BillingTab />\n          </Tab>\n        )}\n      </Tabs>\n    </div>\n  );\n}\n```\n\n---\n\n## Summary\n\nFeature guards are essential for security:\n\n1. **Always apply guards in order**: Auth → Permissions → Subscription\n2. **Check on both client and server**: Client for UX, server for security\n3. **Fail secure**: Hide content until verified\n4. **Handle errors gracefully**: Show helpful messages, not stack traces\n5. **Use consistent patterns**: Makes code easier to maintain\n\nSee also:\n- [Authentication Guide](./02-authentication.md)\n- [Permissions & Roles](./03-permissions-and-roles.md)\n- [Payments & Billing](./04-payments-and-billing.md)\n- [Server Actions](./10-server-actions.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/12-subscription-patterns.md",
    "content": "# Subscription & Billing Patterns\n\nThis guide covers subscription management, checkout flows, and billing integration with Polar.sh.\n\n---\n\n## Overview\n\nThe app uses **Polar.sh** for subscription billing with the following features:\n\n- Subscription plans (Basic, Business, Scale)\n- Checkout session creation\n- Payment verification\n- Usage metering\n- Webhook handling\n\n---\n\n## 1. Checking Subscription Status\n\n### Server-Side (Recommended)\n\n```typescript\nimport { resolveCurrentSubscription } from '@/lib/polar/current-subscription';\n\nexport default async function MyPage() {\n  const subscription = await resolveCurrentSubscription();\n\n  if (!subscription.isAuthenticated) {\n    return <div>Please log in</div>;\n  }\n\n  if (!subscription.isActive) {\n    return <div>No active subscription found</div>;\n  }\n\n  // User has active subscription\n  const { subscription: sub } = subscription;\n\n  return (\n    <div>\n      <p>Plan: {sub.productId}</p>\n      <p>Status: {sub.status}</p>\n      <p>Period ends: {sub.currentPeriodEnd?.toLocaleDateString()}</p>\n    </div>\n  );\n}\n```\n\n### Client-Side\n\n```typescript\n'use client';\n\nimport { useSubscriptionQuery } from '@/lib/hooks/queries/use-subscription-query';\n\nexport function SubscriptionBadge() {\n  const { data: subscription, isLoading } = useSubscriptionQuery();\n\n  if (isLoading) {\n    return <div>Loading...</div>;\n  }\n\n  if (!subscription?.isActive) {\n    return <div className=\"text-red-600\">No active subscription</div>;\n  }\n\n  return (\n    <div className=\"text-green-600\">\n      Active until {subscription.subscription?.currentPeriodEnd?.toLocaleDateString()}\n    </div>\n  );\n}\n```\n\n### Subscription State Types\n\n```typescript\ninterface SubscriptionGateState {\n  isAuthenticated: boolean;\n  isActive: boolean;\n  reason: string | null;\n  subscription: {\n    id: string;\n    status: string;\n    productId: string;\n    priceId: string;\n    currentPeriodStart: Date;\n    currentPeriodEnd: Date | null;\n    cancelAtPeriodEnd: boolean;\n    customerId: string;\n  } | null;\n}\n```\n\nPossible `reason` values:\n- `null` - Active subscription\n- `\"NOT_AUTHENTICATED\"` - User not logged in\n- `\"NO_ORGANIZATION\"` - User has no organization\n- `\"INSUFFICIENT_PERMISSIONS\"` - User can't view subscriptions\n- `\"NO_SUBSCRIPTION\"` - No subscription found\n- `\"SUBSCRIPTION_INACTIVE\"` - Subscription exists but not active\n\n---\n\n## 2. Creating Checkout Sessions\n\n### Server Action\n\n```typescript\n// File: lib/actions/billing/create-checkout.ts\n'use server';\n\nimport { getMemberSession } from '@/lib/auth/stytch/server';\nimport { getServerPermissions } from '@/lib/auth/server-permissions';\nimport { createCheckout } from '@/lib/actions/billing/create-checkout';\n\n// Call the action\nconst result = await createCheckout({ planId: 'business-plan' });\n\nif (!result.success) {\n  console.error(result.error);\n}\n// User will be redirected to Polar checkout page\n```\n\n### In a Component\n\n```typescript\n'use client';\n\nimport { useState, useTransition } from 'react';\nimport { createCheckout } from '@/lib/actions/billing/create-checkout';\n\nexport function PlanCard({ plan }: { plan: PolarPlan }) {\n  const [error, setError] = useState<string | null>(null);\n  const [isPending, startTransition] = useTransition();\n\n  const handleSelectPlan = () => {\n    startTransition(async () => {\n      const result = await createCheckout({ planId: plan.id });\n\n      if (!result.success) {\n        setError(result.error);\n      }\n      // On success, user is redirected to Polar checkout\n    });\n  };\n\n  return (\n    <div>\n      <h3>{plan.name}</h3>\n      <p>${plan.price}/month</p>\n\n      {error && <div className=\"text-red-600\">{error}</div>}\n\n      <button\n        onClick={handleSelectPlan}\n        disabled={isPending}\n      >\n        {isPending ? 'Processing...' : 'Subscribe'}\n      </button>\n    </div>\n  );\n}\n```\n\n---\n\n## 3. Handling Checkout Success\n\n### Payment Verification\n\nAfter user completes checkout, Polar redirects to:\n```\nhttps://yourapp.com/dashboard?checkout_id={CHECKOUT_ID}\n```\n\nThe dashboard page verifies the payment:\n\n```typescript\n// app/dashboard/page.tsx\nimport { redirect } from 'next/navigation';\nimport { verifyPayment } from '@/lib/actions/billing/verify-payment';\n\nexport default async function DashboardPage({\n  searchParams,\n}: {\n  searchParams: { checkout_id?: string };\n}) {\n  const checkoutId = searchParams.checkout_id;\n\n  if (checkoutId) {\n    const result = await verifyPayment(checkoutId);\n\n    if (result.success) {\n      // Payment verified, redirect to settings\n      redirect('/dashboard/settings?payment=success');\n    } else {\n      redirect('/dashboard/settings?payment=failed');\n    }\n  }\n\n  // Normal dashboard view\n  redirect('/dashboard/settings');\n}\n```\n\n---\n\n## 4. Webhook Processing\n\nPolar.sh sends webhooks for subscription events to:\n```\nPOST https://yourapp.com/api/billing/webhook\n```\n\n### Webhook Handler\n\n```typescript\n// app/api/billing/webhook/route.ts\nimport { Webhooks } from '@polar-sh/nextjs/webhooks';\n\nconst webhookSecret = process.env.POLAR_WEBHOOK_SECRET;\n\nif (!webhookSecret) {\n  throw new Error('POLAR_WEBHOOK_SECRET not configured');\n}\n\nconst webhooks = new Webhooks({ webhookSecret });\n\nexport async function POST(request: Request) {\n  const payload = await request.text();\n\n  let event;\n  try {\n    event = webhooks.verify(payload, request.headers);\n  } catch (error) {\n    return new Response('Webhook verification failed', { status: 400 });\n  }\n\n  // Handle event\n  switch (event.type) {\n    case 'subscription.created':\n      await handleSubscriptionCreated(event.data);\n      break;\n\n    case 'subscription.updated':\n      await handleSubscriptionUpdated(event.data);\n      break;\n\n    case 'subscription.canceled':\n      await handleSubscriptionCanceled(event.data);\n      break;\n\n    case 'order.paid':\n      await handleOrderPaid(event.data);\n      break;\n  }\n\n  return new Response('OK', { status: 200 });\n}\n```\n\n---\n\n## 5. Canceling Subscriptions\n\n### Server Action\n\n```typescript\nimport { cancelSubscription } from '@/lib/actions/billing/cancel-subscription';\n\nconst result = await cancelSubscription({\n  cancelAtPeriodEnd: true,  // or false for immediate cancellation\n  reason: 'too_expensive',\n  comment: 'Found a cheaper alternative',\n});\n\nif (result.success) {\n  console.log('Subscription will be canceled at', result.data.subscription?.currentPeriodEnd);\n}\n```\n\n### In a Component\n\n```typescript\n'use client';\n\nimport { useState } from 'react';\nimport { cancelSubscription } from '@/lib/actions/billing/cancel-subscription';\n\nexport function CancelSubscriptionButton() {\n  const [isProcessing, setIsProcessing] = useState(false);\n\n  const handleCancel = async () => {\n    if (!confirm('Are you sure you want to cancel your subscription?')) {\n      return;\n    }\n\n    setIsProcessing(true);\n\n    const result = await cancelSubscription({\n      cancelAtPeriodEnd: true,\n      reason: 'other',\n    });\n\n    setIsProcessing(false);\n\n    if (result.success) {\n      alert('Subscription will be canceled at end of period');\n    } else {\n      alert(result.error);\n    }\n  };\n\n  return (\n    <button\n      onClick={handleCancel}\n      disabled={isProcessing}\n      className=\"text-red-600\"\n    >\n      {isProcessing ? 'Processing...' : 'Cancel Subscription'}\n    </button>\n  );\n}\n```\n\n### Cancellation Reasons\n\nAllowed values:\n- `\"too_expensive\"`\n- `\"missing_features\"`\n- `\"switched_service\"`\n- `\"unused\"`\n- `\"customer_service\"`\n- `\"low_quality\"`\n- `\"too_complex\"`\n- `\"other\"`\n\n---\n\n## 6. Usage Metering (Polar Meters)\n\n### Configuration\n\nSet in environment variables:\n```env\nNEXT_PUBLIC_POLAR_METER_ID=74f6f057-f061-4d20-8dc0-43ff9c8704af\n```\n\n### Fetching Usage\n\n```typescript\nimport { getInvoiceUsage } from '@/lib/polar/usage';\n\nconst subscription = await getActiveSubscription({ ... });\n\nif (subscription.subscription) {\n  const usage = await getInvoiceUsage(subscription.subscription);\n\n  if (usage) {\n    console.log('Used:', usage.used);\n    console.log('Included:', usage.included);\n    console.log('Remaining:', usage.remaining);\n  }\n}\n```\n\n### Display Usage in UI\n\n```typescript\n'use client';\n\nexport function UsageDisplay({ usage }: { usage: MeterUsageSummary }) {\n  const percentage = (usage.used / usage.included) * 100;\n\n  return (\n    <div>\n      <div className=\"flex justify-between\">\n        <span>Invoice Usage</span>\n        <span>{usage.used} / {usage.included}</span>\n      </div>\n\n      <div className=\"w-full bg-gray-200 rounded-full h-2\">\n        <div\n          className=\"bg-blue-600 h-2 rounded-full\"\n          style={{ width: `${percentage}%` }}\n        />\n      </div>\n\n      <p className=\"text-sm text-gray-600 mt-1\">\n        {usage.remaining} invoices remaining this period\n      </p>\n    </div>\n  );\n}\n```\n\n---\n\n## 7. Plan Upgrades/Downgrades\n\n### Current Limitation\n\nPolar.sh doesn't support direct plan changes. Users must:\n1. Cancel current subscription\n2. Subscribe to new plan\n\n### Recommended Flow\n\n```typescript\n'use client';\n\nexport function ChangePlanButton({ currentPlanId, newPlanId }: {\n  currentPlanId: string;\n  newPlanId: string;\n}) {\n  const handleChange = async () => {\n    const confirmed = confirm(\n      'To change plans, you need to cancel your current subscription and subscribe to the new plan. Continue?'\n    );\n\n    if (!confirmed) return;\n\n    // Step 1: Cancel current subscription\n    const cancelResult = await cancelSubscription({\n      cancelAtPeriodEnd: false, // Immediate cancellation\n    });\n\n    if (!cancelResult.success) {\n      alert('Failed to cancel subscription');\n      return;\n    }\n\n    // Step 2: Create checkout for new plan\n    const checkoutResult = await createCheckout({ planId: newPlanId });\n\n    if (!checkoutResult.success) {\n      alert('Failed to start checkout');\n    }\n\n    // User will be redirected to Polar checkout\n  };\n\n  return (\n    <button onClick={handleChange}>\n      Change to this plan\n    </button>\n  );\n}\n```\n\n---\n\n## 8. Fetching Available Plans\n\n### Server-Side\n\n```typescript\nimport { getProducts } from '@/lib/actions/billing/get-products';\n\nconst result = await getProducts();\n\nif (result.success) {\n  const plans = result.data; // Array of PolarPlan objects\n\n  plans.forEach(plan => {\n    console.log(plan.name, plan.price, plan.interval);\n  });\n}\n```\n\n### Client-Side\n\n```typescript\n'use client';\n\nimport { useProductsQuery } from '@/lib/hooks/queries/use-products-query';\n\nexport function PlansModal() {\n  const { data: plans, isLoading } = useProductsQuery();\n\n  if (isLoading) {\n    return <div>Loading plans...</div>;\n  }\n\n  return (\n    <div className=\"grid grid-cols-3 gap-4\">\n      {plans?.map(plan => (\n        <PlanCard key={plan.id} plan={plan} />\n      ))}\n    </div>\n  );\n}\n```\n\n### Plan Structure\n\n```typescript\ninterface PolarPlan {\n  id: string;                    // Plan ID\n  name: string;                  // \"Business Plan\"\n  description: string | null;\n  price: number;                 // In dollars (e.g., 99.00)\n  interval: 'month' | 'year';\n  productId: string;             // Polar product ID\n  priceId: string;               // Polar price ID\n  includedSeats: number | null;  // From metadata\n  includedInvoices: number | null; // From metadata\n  benefits: string[];            // Benefit descriptions\n  metadata: Record<string, unknown>;\n}\n```\n\n---\n\n## 9. Common Patterns\n\n### Paywall Component\n\n```typescript\n'use client';\n\nimport { useSubscriptionQuery } from '@/lib/hooks/queries/use-subscription-query';\nimport { createCheckout } from '@/lib/actions/billing/create-checkout';\n\nexport function FeaturePaywall({ children }: { children: React.ReactNode }) {\n  const { data: subscription, isLoading } = useSubscriptionQuery();\n\n  if (isLoading) {\n    return <LoadingSpinner />;\n  }\n\n  if (!subscription?.isActive) {\n    return (\n      <div className=\"p-8 text-center border-2 border-dashed rounded\">\n        <h3 className=\"text-xl font-bold mb-2\">Premium Feature</h3>\n        <p className=\"mb-4\">Upgrade to access this feature</p>\n        <button onClick={() => createCheckout()}>\n          View Plans\n        </button>\n      </div>\n    );\n  }\n\n  return <>{children}</>;\n}\n```\n\n### Subscription Badge\n\n```typescript\n'use client';\n\nimport { useSubscriptionQuery } from '@/lib/hooks/queries/use-subscription-query';\n\nexport function SubscriptionBadge() {\n  const { data } = useSubscriptionQuery();\n\n  if (!data?.isActive) {\n    return <span className=\"badge badge-gray\">Free</span>;\n  }\n\n  return <span className=\"badge badge-green\">Pro</span>;\n}\n```\n\n---\n\n## 10. Best Practices\n\n### Always Check Server-Side\n\n```typescript\n// ✅ Good: Check subscription on server\nexport default async function PremiumPage() {\n  const subscription = await resolveCurrentSubscription();\n\n  if (!subscription.isActive) {\n    redirect('/subscribe');\n  }\n\n  return <PremiumContent />;\n}\n\n// ❌ Bad: Only check on client\nexport default function PremiumPage() {\n  return <PremiumContent />; // Anyone can access!\n}\n```\n\n### Handle Loading States\n\n```typescript\nexport function FeatureWithSubscriptionCheck() {\n  const { data: subscription, isLoading } = useSubscriptionQuery();\n\n  // Show loading state\n  if (isLoading) {\n    return <Skeleton />;\n  }\n\n  // Show upgrade prompt\n  if (!subscription?.isActive) {\n    return <UpgradePrompt />;\n  }\n\n  // Show feature\n  return <PremiumFeature />;\n}\n```\n\n### Cache Subscription Data\n\n```typescript\n// TanStack Query caches subscription for 5 minutes\nconst { data } = useSubscriptionQuery(); // Cached automatically\n\n// Server Actions cache for 5 minutes\nconst subscription = await resolveCurrentSubscription(); // Cached in session\n```\n\n---\n\n## Summary\n\nKey subscription patterns:\n\n1. **Check subscription status** with `resolveCurrentSubscription()` or `useSubscriptionQuery()`\n2. **Create checkout** with `createCheckout()` Server Action\n3. **Verify payment** with `verifyPayment()` after checkout redirect\n4. **Handle webhooks** to keep subscription data in sync\n5. **Cancel subscriptions** with `cancelSubscription()` Server Action\n6. **Track usage** with Polar Meters for usage-based billing\n7. **Always check server-side** for security\n\nSee also:\n- [Feature Guards](./11-feature-guards.md)\n- [Payments & Billing](./04-payments-and-billing.md)\n- [Server Actions](./10-server-actions.md)\n"
  },
  {
    "path": "next_b2b_starter/docs/API-LOGGING.md",
    "content": "# API Request/Response Logging\n\n## Overview\n\nThe frontend now includes comprehensive API logging that captures all requests and responses to the Go backend. This is extremely helpful for debugging authentication issues, API errors, and understanding the data flow.\n\n## Features\n\n✅ **Automatic logging** - All API requests are logged automatically\n✅ **Color-coded console output** - Easy to distinguish requests, responses, and errors\n✅ **Detailed information** - Headers, body, timing, status codes\n✅ **Security** - Sensitive headers (auth tokens, cookies) are partially redacted\n✅ **Performance tracking** - Shows request duration in milliseconds\n✅ **Development-friendly** - Auto-enabled in development mode\n\n## How to Use\n\n### Automatic Logging\n\nAPI logging is **automatically enabled** in development mode. Just open your browser console and you'll see:\n\n- **→ GET /api/auth/profile/me** (blue) - Outgoing requests\n- **← 200 OK (45ms)** (green) - Successful responses\n- **← 403 Forbidden (120ms)** (red) - Error responses\n- **✖ API ERROR Network Error** (red) - Network failures\n\n### Manual Control\n\n#### Enable Logging\n\n```javascript\n// In browser console\n__apiLogger.enable()\n```\n\n#### Disable Logging\n\n```javascript\n// In browser console\n__apiLogger.disable()\n```\n\n#### Check Status\n\n```javascript\n// The logger instance is available globally\nconsole.log(__apiLogger.instance)\n```\n\n## What Gets Logged\n\n### Request Information\n\nFor each outgoing request, you'll see:\n\n- **Method** - GET, POST, PUT, DELETE\n- **Full URL** - Complete endpoint URL\n- **Headers** - All request headers (auth tokens partially redacted)\n- **Body** - Request payload (for POST/PUT)\n- **Timestamp** - When the request was sent\n\n### Response Information\n\nFor each response, you'll see:\n\n- **Status Code** - 200, 401, 403, 500, etc.\n- **Status Text** - OK, Forbidden, etc.\n- **Duration** - How long the request took in milliseconds\n- **Response Headers** - All headers returned by server\n- **Response Body** - The actual data returned\n- **Timestamp** - When the response was received\n\n### Error Information\n\nFor errors, you'll see:\n\n- **Error Message** - What went wrong\n- **Status Code** - HTTP status if available\n- **Duration** - How long before it failed\n- **Error Details** - Full error object for debugging\n- **Stack Trace** - If available\n\n## Example Output\n\n### Successful Request\n\n```\n→ GET http://localhost:8080/api/auth/profile/me\n\nRequest Details:\n┌────────────┬─────────────────────────────────────────────┐\n│ Method     │ GET                                         │\n│ URL        │ http://localhost:8080/api/auth/profile/me   │\n│ Timestamp  │ 2025-12-23T18:30:00.000Z                    │\n└────────────┴─────────────────────────────────────────────┘\n\nHeaders:\n┌───────────────┬──────────────────────────────────────┐\n│ authorization │ Bearer eyJhbGc...truncated...aMA9A    │\n│ accept        │ */*                                  │\n│ content-type  │ application/json                     │\n└───────────────┴──────────────────────────────────────┘\n\n← 200 OK (45ms)\n\nResponse Summary:\n┌───────────┬──────────────────────────────────┐\n│ Status    │ 200 OK                           │\n│ Duration  │ 45ms                             │\n│ Timestamp │ 2025-12-23T18:30:00.045Z         │\n└───────────┴──────────────────────────────────┘\n\nResponse Body:\n{\n  member_id: \"member-test-123\",\n  email: \"user@example.com\",\n  organization_id: 1,\n  permissions: [\"org:view\", \"resource:create\"]\n}\n```\n\n### Error Response\n\n```\n→ GET http://localhost:8080/api/auth/profile/me\n\n← 403 Forbidden (120ms)\n\nResponse Summary:\n┌───────────┬──────────────────────────────────┐\n│ Status    │ 403 Forbidden                    │\n│ Duration  │ 120ms                            │\n│ Timestamp │ 2025-12-23T18:30:00.120Z         │\n└───────────┴──────────────────────────────────┘\n\nResponse Body:\n{\n  error: \"organization not found\",\n  code: \"FORBIDDEN\",\n  details: \"Organization ID could not be resolved\"\n}\n\n✖ API ERROR 403 (120ms)\n\nError Details:\n┌──────────┬────────────────────────────────────────┐\n│ Message  │ organization not found                 │\n│ Status   │ 403                                    │\n│ Duration │ 120ms                                  │\n└──────────┴────────────────────────────────────────┘\n```\n\n## Security & Privacy\n\n### Redacted Information\n\nThe logger automatically redacts sensitive information:\n\n- **Authorization headers** - Only shows first 20 and last 20 characters\n- **Cookies** - Shown as `[REDACTED - Contains session cookies]`\n- **API keys** - Shown as `[REDACTED]`\n\n### Example\n\n```\nHeaders:\n┌───────────────┬────────────────────────────────────────────────┐\n│ authorization │ Bearer eyJhbGciOiJSUzI1Ni...MA9A (partial)    │\n│ cookie        │ [REDACTED - Contains session cookies]         │\n└───────────────┴────────────────────────────────────────────────┘\n```\n\n## Debugging 403 Errors\n\nWhen you see a 403 Forbidden error, the logger helps you:\n\n1. **Check the JWT token** - Verify it's being sent correctly\n2. **Check the organization_id** - See what org ID is in the token\n3. **Check response details** - See why the backend rejected it\n4. **Measure timing** - See if requests are timing out\n\n### Common 403 Causes\n\nBased on the logs, you can identify:\n\n- **Missing authorization header** - Token not attached\n- **Expired token** - Token has expired\n- **Organization mismatch** - Org ID in token doesn't match database\n- **Insufficient permissions** - User doesn't have required role\n- **Account not found** - User account not in database\n\n## Production Usage\n\nThe logger is **automatically disabled** in production builds to:\n- Avoid performance overhead\n- Prevent leaking sensitive data to console\n- Reduce console noise\n\nTo enable logging in production (for debugging):\n\n```javascript\nlocalStorage.setItem('API_LOGGER_ENABLED', 'true')\n// Then reload the page\n```\n\n## Files\n\n- `lib/utils/api-logger.ts` - Logger implementation\n- `lib/api/api/client/api-client.ts` - Integration point\n- `docs/API-LOGGING.md` - This documentation\n\n## Troubleshooting\n\n### Logs not showing?\n\n1. Check you're in development mode (`process.env.NODE_ENV === 'development'`)\n2. Try manually enabling: `__apiLogger.enable()`\n3. Refresh the page after enabling\n4. Check browser console is set to show all levels (not just errors)\n\n### Too much noise?\n\n```javascript\n// Disable logging\n__apiLogger.disable()\n```\n\n### Need to see specific requests?\n\nThe logs are collapsible in the console. Click to expand only the requests you care about.\n\n## Benefits\n\n✅ **Faster debugging** - See exactly what's being sent/received\n✅ **Better error messages** - Understand why requests fail\n✅ **Performance insights** - Identify slow API calls\n✅ **Security auditing** - Verify auth headers are correct\n✅ **Integration testing** - Validate request/response formats\n\n---\n\n**Pro Tip:** Keep the console open while developing to catch API issues immediately!\n"
  },
  {
    "path": "next_b2b_starter/docs/README.md",
    "content": "# Documentation Index\n \n Welcome to the B2B SaaS Starter documentation.\n \n ## What is B2B SaaS Starter?\n \n B2B SaaS Starter is a B2B SaaS application. It's built with Next.js 16, TypeScript, and modern best practices.\n \n ## Tech Stack\n \n - **Framework**: Next.js 16 with App Router\n - **Language**: TypeScript (strict mode)\n - **Styling**: Tailwind CSS + shadcn/ui components\n - **State**: Zustand for global state\n - **Data Fetching**: TanStack React Query\n - **Authentication**: Stytch B2B\n - **Payments**: Stripe/Polar integration\n \n ## Core Guides\n \n 1. **[Getting Started](./01-getting-started.md)**\n    Setup, installation, and first run.\n \n 2. **[Authentication](./02-authentication.md)**\n    Protecting routes and checking login status.\n \n 3. **[Permissions & Roles](./03-permissions-and-roles.md)**\n    Using RBAC (Admin, Manager, Member) and permissions.\n \n 4. **[Payments & Billing](./04-payments-and-billing.md)**\n    Checking subscription status and paywalls.\n \n 5. **[Making API Requests](./05-making-api-requests.md)**\n    Using `apiClient` and Repositories.\n \n 6. **[Creating Pages](./06-creating-pages.md)**\n    Server vs Client components.\n \n 7. **[Creating APIs](./07-creating-apis.md)**\n    Route handlers and security patterns.\n \n 8. **[Using Hooks](./08-using-hooks.md)**\n    Data fetching (Query) and modifying (Mutation).\n \n 9. **[Adding a Feature](./09-adding-a-feature.md)**\n    High-level checklist for comprehensive features.\n\n 10. **[Server Actions](./10-server-actions.md)**\n     Creating and using Server Actions for mutations.\n\n 11. **[Feature Guards](./11-feature-guards.md)**\n     Protecting features with auth, permission, and subscription guards.\n\n 12. **[Subscription Patterns](./12-subscription-patterns.md)**\n     Managing subscriptions, checkout, and billing with Polar.sh.\n\n ## Quick Reference\n \n - **Auth**: `getMemberSession()` (Server) / `useStytchMember()` (Client)\n - **Permissions**: `getServerPermissions()` (Server) / `usePermissions()` (Client)\n - **Data**: `apiClient.get()` or `useQuery()`\n - **Server Actions**: Server-side mutations with auth/permission guards\n \n ## Project Structure\n \n ```\n next_b2b_starter/\n ├── app/                    # Next.js App Router pages\n │   ├── api/               # API routes\n │   ├── auth/              # Login page\n │   └── dashboard/         # Protected pages\n ├── components/            # React components\n │   └── ui/               # shadcn/ui components\n ├── lib/                   # Utilities and business logic\n │   ├── api/              # API client and repositories\n │   ├── auth/             # Authentication utilities\n │   ├── hooks/            # React hooks\n │   └── contexts/         # React contexts\n ├── middleware.ts          # Route protection\n └── docs/                 # This documentation\n ```\n \n ## Core Concepts\n \n ### Server vs Client Components\n \n **Server Components** run on the server. Use them when you need:\n - Direct database/API access\n - Sensitive operations\n - SEO-optimized content\n \n **Client Components** run in the browser. Use them when you need:\n - Interactivity (buttons, forms)\n - Browser APIs\n - React hooks\n \n ### Repository Pattern\n \n Don't call APIs directly. Use repositories:\n \n ```typescript\n // Good\n const profile = await profileRepository.getProfile();\n \n // Avoid\n const response = await fetch('/api/profile');\n ```\n \n ### Permission-First Development\n \n Always check permissions before showing features:\n \n 1. Define permission in `lib/auth/permissions.ts`\n 2. Check permission before rendering UI\n 3. Re-check permission in API route\n \n ## Need Help?\n \n Check `next_b2b_starter/README.md` for project-level info.\n"
  },
  {
    "path": "next_b2b_starter/hooks/use-signup-flow.ts",
    "content": "import { useCallback, useMemo, useRef, useState } from \"react\";\nimport { signupRepository } from \"@/lib/api/api/repositories/signup-repository\";\nimport {\n  SignupOrganization,\n  SignupOwner,\n  SignupResult,\n} from \"@/lib/models/signup.model\";\n\nexport type SignupStep = \"account\" | \"organization\";\n\ninterface UseSignupFlowState {\n  step: SignupStep;\n  owner: SignupOwner;\n  organization: SignupOrganization;\n  isLoading: boolean;\n  error: string | null;\n  emailSent: boolean;\n  result: SignupResult | null;\n  stepIndex: number;\n  canContinueAccount: boolean;\n  canContinueOrganization: boolean;\n  goBack: () => void;\n  goNext: () => void;\n  sendMagicLink: () => Promise<void>;\n  updateOwner: (updates: Partial<SignupOwner>) => void;\n  updateOrganization: (updates: Partial<SignupOrganization>) => void;\n  reset: () => void;\n}\n\nconst defaultOwner: SignupOwner = {\n  fullName: \"\",\n  email: \"\",\n};\n\nconst defaultOrganization: SignupOrganization = {\n  displayName: \"\",\n  industry: \"Technology\",\n};\n\nexport function useSignupFlow(): UseSignupFlowState {\n  const [step, setStep] = useState<SignupStep>(\"account\");\n  const [owner, setOwner] = useState<SignupOwner>(defaultOwner);\n  const [organization, setOrganization] = useState<SignupOrganization>(\n    defaultOrganization\n  );\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [emailSent, setEmailSent] = useState(false);\n  const [result, setResult] = useState<SignupResult | null>(null);\n\n  // Ref to prevent duplicate submissions\n  const isSubmittingRef = useRef(false);\n\n  const stepIndex = useMemo(() => {\n    switch (step) {\n      case \"account\":\n        return 0;\n      case \"organization\":\n        return 1;\n      default:\n        return 0;\n    }\n  }, [step]);\n\n  const canContinueAccount = useMemo(() => {\n    return (\n      owner.fullName.trim().length >= 2 &&\n      /.+@.+\\..+/.test(owner.email)\n    );\n  }, [owner]);\n\n  const canContinueOrganization = useMemo(() => {\n    return (\n      organization.displayName.trim().length >= 2 &&\n      organization.industry.trim().length > 0\n    );\n  }, [organization]);\n\n  const goBack = useCallback(() => {\n    setError(null);\n    if (step === \"organization\") {\n      setStep(\"account\");\n    }\n  }, [step]);\n\n  const goNext = useCallback(() => {\n    setError(null);\n    if (step === \"account\" && canContinueAccount) {\n      setStep(\"organization\");\n    }\n  }, [step, canContinueAccount]);\n\n  const updateOwner = useCallback((updates: Partial<SignupOwner>) => {\n    setOwner((prev) => ({ ...prev, ...updates }));\n    setError(null); // Clear error when user types\n  }, []);\n\n  const updateOrganization = useCallback((updates: Partial<SignupOrganization>) => {\n    setOrganization((prev) => ({ ...prev, ...updates }));\n    setError(null); // Clear error when user types\n  }, []);\n\n  const reset = useCallback(() => {\n    setOwner(defaultOwner);\n    setOrganization(defaultOrganization);\n    setStep(\"account\");\n    setError(null);\n    setEmailSent(false);\n    setResult(null);\n  }, []);\n\n  const sendMagicLink = useCallback(async () => {\n    // Prevent duplicate submissions\n    if (isSubmittingRef.current) return;\n\n    if (!canContinueOrganization) {\n      setError(\"Please fill in all required fields correctly\");\n      return;\n    }\n\n    isSubmittingRef.current = true;\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      // Backend signup endpoint already sends magic link via Stytch\n      // No need to call sendMagicLink() Server Action separately\n      const signupResult = await signupRepository.createOrganizationWithMagicLink(\n        owner,\n        organization\n      );\n\n      setResult(signupResult);\n      setEmailSent(true);\n    } catch (signupError) {\n      const message =\n        signupError instanceof Error\n          ? signupError.message\n          : \"Failed to create account. Please try again.\";\n      setError(message);\n      setEmailSent(false);\n    } finally {\n      setIsLoading(false);\n      isSubmittingRef.current = false;\n    }\n  }, [owner, organization, canContinueOrganization]);\n\n  return {\n    step,\n    owner,\n    organization,\n    isLoading,\n    error,\n    emailSent,\n    result,\n    stepIndex,\n    canContinueAccount,\n    canContinueOrganization,\n    goBack,\n    goNext,\n    sendMagicLink,\n    updateOwner,\n    updateOrganization,\n    reset,\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/hooks/use-toast.ts",
    "content": "// hooks/use-toast.ts\n// Simple toast implementation for now\n\ninterface ToastProps {\n  title?: string;\n  description?: string;\n  variant?: \"default\" | \"destructive\";\n}\n\nexport function useToast() {\n  const toast = ({ title, description, variant }: ToastProps) => {\n    // For now, just use console and alert\n    // This can be replaced with a proper toast library later\n    const message = `${title}${description ? `: ${description}` : ''}`;\n\n    if (variant === \"destructive\") {\n      console.error(message);\n      // In production, this would show a red toast\n    } else {\n      console.log(message);\n      // In production, this would show a success/info toast\n    }\n  };\n\n  return { toast };\n}"
  },
  {
    "path": "next_b2b_starter/lib/actions/auth/consume-magic-link.ts",
    "content": "\"use server\";\n\nimport { cookies } from \"next/headers\";\nimport { getStytchB2BClient } from \"@/lib/auth/stytch/server\";\nimport {\n  SESSION_COOKIE_NAME,\n  SESSION_JWT_COOKIE_NAME,\n} from \"@/lib/auth/constants\";\nimport {\n  getSessionDurationMinutes,\n  getCookieConfig,\n  getSecureCookieConfig,\n} from \"@/lib/auth/server-constants\";\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult,\n} from \"@/lib/utils/server-action-helpers\";\n\nexport interface ConsumeMagicLinkResult {\n  memberAuthenticated: boolean;\n  intermediateSessionToken?: string;\n  member?: {\n    member_id: string;\n    email_address: string;\n    name: string;\n  };\n  organization?: {\n    organization_id: string;\n    organization_name: string;\n  };\n  mfaRequired?: unknown;\n  primaryRequired?: unknown;\n}\n\n/**\n * Consume Magic Link Server Action\n *\n * Exchanges a magic link token for a session.\n * Sets session cookies on successful authentication.\n *\n * @param token - The magic link token from the URL\n * @param sessionDurationMinutes - Optional session duration (defaults to env config)\n * @returns ActionResult with authentication status\n */\nexport async function consumeMagicLink(\n  token: string,\n  sessionDurationMinutes?: number\n): Promise<ActionResult<ConsumeMagicLinkResult>> {\n  if (!token) {\n    return createActionError(\"Magic link token is required.\");\n  }\n\n  const duration = sessionDurationMinutes || getSessionDurationMinutes();\n\n  try {\n    const client = getStytchB2BClient();\n\n    const result = await client.magicLinks.authenticate({\n      magic_links_token: token,\n      session_duration_minutes: duration,\n    });\n\n    if (!result.member_authenticated) {\n      return createActionSuccess({\n        memberAuthenticated: false,\n        intermediateSessionToken: result.intermediate_session_token,\n        member: result.member\n          ? {\n              member_id: result.member.member_id,\n              email_address: result.member.email_address,\n              name: result.member.name,\n            }\n          : undefined,\n        organization: result.organization\n          ? {\n              organization_id: result.organization.organization_id,\n              organization_name: result.organization.organization_name,\n            }\n          : undefined,\n        mfaRequired: result.mfa_required ?? false,\n        primaryRequired: result.primary_required ?? false,\n      });\n    }\n\n    // Set session cookies\n    const cookieStore = await cookies();\n    const maxAgeSeconds = duration * 60;\n\n    if (result.session_token) {\n      cookieStore.set(SESSION_COOKIE_NAME, result.session_token, {\n        ...getSecureCookieConfig(),\n        maxAge: maxAgeSeconds,\n      });\n    }\n\n    if (result.session_jwt) {\n      cookieStore.set(SESSION_JWT_COOKIE_NAME, result.session_jwt, {\n        ...getCookieConfig(),\n        maxAge: maxAgeSeconds,\n      });\n    }\n\n    return createActionSuccess({\n      memberAuthenticated: true,\n      member: result.member\n        ? {\n            member_id: result.member.member_id,\n            email_address: result.member.email_address,\n            name: result.member.name,\n          }\n        : undefined,\n      organization: result.organization\n        ? {\n            organization_id: result.organization.organization_id,\n            organization_name: result.organization.organization_name,\n          }\n        : undefined,\n    });\n  } catch (error: any) {\n    const errorMessage =\n      error?.error_message || error?.message || \"Unable to verify magic link.\";\n\n    return createActionError(errorMessage);\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/auth/logout.ts",
    "content": "\"use server\";\n\nimport { redirect } from \"next/navigation\";\nimport { cookies } from \"next/headers\";\nimport { getStytchB2BClient } from \"@/lib/auth/stytch/server\";\nimport {\n  SESSION_COOKIE_NAME,\n  SESSION_JWT_COOKIE_NAME,\n} from \"@/lib/auth/constants\";\n\n/**\n * Logout Server Action\n *\n * Revokes the Stytch session and clears session cookies.\n * Redirects user to the specified path or home page.\n *\n * @param returnTo - Optional path to redirect to after logout (must start with /)\n */\nexport async function logout(returnTo?: string): Promise<never> {\n  const cookieStore = await cookies();\n\n  const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value;\n  const sessionJwt = cookieStore.get(SESSION_JWT_COOKIE_NAME)?.value;\n\n  // Revoke the session with Stytch if we have a token\n  if (sessionToken || sessionJwt) {\n    try {\n      const client = getStytchB2BClient();\n\n      if (sessionToken) {\n        await client.sessions.revoke({ session_token: sessionToken });\n        console.info(\"[Logout] Session revoked via session_token\");\n      } else if (sessionJwt) {\n        await client.sessions.revoke({ session_jwt: sessionJwt });\n        console.info(\"[Logout] Session revoked via session_jwt\");\n      }\n    } catch (error) {\n      // Silently fail - user is logging out anyway\n      // Session might already be expired or invalid\n      console.warn(\"[Logout] Failed to revoke session (continuing anyway):\", error);\n    }\n  }\n\n  // Clear session cookies\n  cookieStore.delete(SESSION_COOKIE_NAME);\n  cookieStore.delete(SESSION_JWT_COOKIE_NAME);\n\n  console.info(\"[Logout] Session cookies cleared\");\n\n  // Validate returnTo path for security\n  const redirectPath = resolveReturnTo(returnTo);\n\n  // Redirect to the specified path or home\n  redirect(redirectPath);\n}\n\n/**\n * Resolve and validate the returnTo parameter\n * Prevents open redirect vulnerabilities\n */\nfunction resolveReturnTo(returnTo?: string): string {\n  if (!returnTo) return \"/\";\n\n  const trimmed = returnTo.trim();\n\n  // Must start with / and must NOT start with // (which could be a protocol-relative URL)\n  if (!trimmed.startsWith(\"/\") || trimmed.startsWith(\"//\")) {\n    console.warn(\n      \"[Logout] Invalid returnTo path (using default):\",\n      trimmed\n    );\n    return \"/\";\n  }\n\n  return trimmed;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/auth/send-magic-link.ts",
    "content": "\"use server\";\n\nimport {\n  getStytchB2BClient,\n  getOrganizationIdsForMemberSearch,\n} from \"@/lib/auth/stytch/server\";\nimport {\n  createActionSuccess,\n  createActionError,\n  type ActionResult,\n} from \"@/lib/utils/server-action-helpers\";\n\n/**\n * Send Magic Link Server Action\n *\n * Validates that an email belongs to an existing member before sending a magic link.\n * This prevents unknown users from receiving authentication emails.\n *\n * @param email - The email address to send the magic link to\n * @returns ActionResult with success message or error\n */\nexport async function sendMagicLink(\n  email: string\n): Promise<ActionResult<{ message: string }>> {\n  try {\n    // Validate input\n    if (!email || typeof email !== \"string\") {\n      return createActionError(\"Email address is required\");\n    }\n\n    const client = getStytchB2BClient();\n    const organizationIds = await getOrganizationIdsForMemberSearch();\n\n    if (!organizationIds.length) {\n      console.error(\n        \"[Magic Link] No organization IDs configured for member search.\"\n      );\n      return createActionError(\n        \"Unable to process request. Please try again later.\"\n      );\n    }\n\n    // Search for members with this email across all organizations\n    // This checks if the user exists in ANY organization\n    const searchResult = await client.organizations.members.search({\n      organization_ids: organizationIds,\n      query: {\n        operator: \"AND\",\n        operands: [\n          {\n            filter_name: \"member_emails\",\n            filter_value: [email.toLowerCase()],\n          },\n        ],\n      },\n    });\n\n    // If no members found, reject without revealing this fact\n    if (!searchResult.members || searchResult.members.length === 0) {\n      // Return success to prevent user enumeration\n      // But don't actually send an email\n      console.info(\n        \"[Magic Link] No member found for email (not revealing to client):\",\n        email\n      );\n      return createActionSuccess({\n        message:\n          \"If an account exists with that email, a magic link has been sent.\",\n      });\n    }\n\n    // Member exists - prepare login redirect URL\n    const redirectUrl = process.env.NEXT_PUBLIC_APP_BASE_URL\n      ? `${process.env.NEXT_PUBLIC_APP_BASE_URL}/authenticate`\n      : \"http://localhost:3000/authenticate\";\n\n    const memberOrganizationIds = Array.from(\n      new Set(\n        (searchResult.members ?? [])\n          .map((member) => member.organization_id)\n          .filter((orgId): orgId is string => Boolean(orgId))\n      )\n    );\n\n    if (memberOrganizationIds.length === 0) {\n      console.warn(\n        \"[Magic Link] Member search succeeded but no organization IDs were returned for email:\",\n        email\n      );\n      return createActionSuccess({\n        message:\n          \"If an account exists with that email, a magic link has been sent.\",\n      });\n    }\n\n    if (memberOrganizationIds.length > 1) {\n      console.warn(\n        \"[Magic Link] Email is associated with multiple organizations; issuing login link for all memberships.\",\n        {\n          email,\n          organizationIds: memberOrganizationIds,\n        }\n      );\n    }\n\n    // Send magic link for each organization the member belongs to\n    await Promise.all(\n      memberOrganizationIds.map((organizationId) =>\n        client.magicLinks.email.loginOrSignup({\n          email_address: email,\n          organization_id: organizationId,\n          login_redirect_url: redirectUrl,\n        })\n      )\n    );\n\n    console.info(\"[Magic Link] Successfully sent magic link to:\", email);\n\n    return createActionSuccess({\n      message:\n        \"If an account exists with that email, a magic link has been sent.\",\n    });\n  } catch (error: any) {\n    console.error(\"[Magic Link] Error sending magic link:\", error);\n\n    // Return generic error to prevent user enumeration\n    return createActionError(\n      \"Unable to process request. Please try again later.\",\n      process.env.NODE_ENV === \"development\" ? error.message : undefined\n    );\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/billing/cancel-subscription.ts",
    "content": "\"use server\";\n\nimport { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions } from \"@/lib/auth/server-permissions\";\nimport { getActiveSubscription } from \"@/lib/polar/subscription\";\nimport { getPolarClient } from \"@/lib/polar/client\";\nimport type { SubscriptionCancel } from \"@polar-sh/sdk/models/components/subscriptioncancel\";\nimport type { CustomerCancellationReason } from \"@polar-sh/sdk/models/components/customercancellationreason\";\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult\n} from \"@/lib/utils/server-action-helpers\";\n\nconst ALLOWED_REASONS = new Set([\n  \"too_expensive\",\n  \"missing_features\",\n  \"switched_service\",\n  \"unused\",\n  \"customer_service\",\n  \"low_quality\",\n  \"too_complex\",\n  \"other\",\n]);\n\ninterface CancelSubscriptionParams {\n  cancelAtPeriodEnd?: boolean;\n  reason?: string | null;\n  comment?: string | null;\n}\n\ninterface CancelSubscriptionData {\n  success: boolean;\n  cancelAtPeriodEnd: boolean;\n  status: string | null;\n  subscription: {\n    id: string;\n    status: string;\n    cancelAtPeriodEnd: boolean;\n    currentPeriodEnd: string | null;\n  } | null;\n}\n\n/**\n * Cancel Subscription Server Action\n *\n * Updates a subscription with cancellation settings.\n * Can cancel immediately or at the end of the billing period.\n *\n * @param params - Optional cancellation parameters\n */\nexport async function cancelSubscription(\n  params?: CancelSubscriptionParams\n): Promise<ActionResult<CancelSubscriptionData>> {\n  const client = getPolarClient();\n\n  if (!client) {\n    return createActionError(\n      \"Polar billing is not configured.\",\n      \"Missing Polar client\"\n    );\n  }\n\n  // Authenticate user\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    return createActionError(\"Authentication required.\");\n  }\n\n  // Check permissions\n  const permissions = await getServerPermissions(session);\n  const profile = permissions.profile;\n\n  if (!profile?.organization?.organization_id) {\n    return createActionError(\n      \"Organization context required to manage subscriptions.\"\n    );\n  }\n\n  if (!permissions.canManageSubscriptions) {\n    console.info(\"[Polar] Subscription cancel forbidden - insufficient permissions\", {\n      memberId: profile.member_id,\n    });\n    return createActionError(\n      \"You do not have access to manage subscriptions.\",\n      \"Missing subscription management permissions\"\n    );\n  }\n\n  // Parse parameters\n  const cancelAtPeriodEnd =\n    typeof params?.cancelAtPeriodEnd === \"boolean\" ? params.cancelAtPeriodEnd : true;\n\n  const reason =\n    typeof params?.reason === \"string\" && ALLOWED_REASONS.has(params.reason)\n      ? params.reason\n      : undefined;\n\n  const comment =\n    typeof params?.comment === \"string\" && params.comment.trim().length > 0\n      ? params.comment.trim()\n      : undefined;\n\n  // Get active subscription\n  const subscriptionResult = await getActiveSubscription({\n    externalCustomerId: profile.organization.organization_id,\n    customerEmail: profile.email,\n    organizationId: profile.organization.organization_id,\n  });\n\n  const subscription = subscriptionResult.subscription;\n\n  if (!subscription) {\n    return createActionError(\n      \"No active subscription to update.\",\n      \"User does not have an active subscription\"\n    );\n  }\n\n  // Prepare update payload\n  const subscriptionUpdatePayload: SubscriptionCancel = {\n    cancelAtPeriodEnd,\n  };\n\n  if (reason) {\n    subscriptionUpdatePayload.customerCancellationReason = reason as CustomerCancellationReason;\n  }\n\n  if (comment) {\n    subscriptionUpdatePayload.customerCancellationComment = comment;\n  }\n\n  // Update subscription\n  try {\n    await client.subscriptions.update({\n      id: subscription.id,\n      subscriptionUpdate: subscriptionUpdatePayload,\n    });\n  } catch (error) {\n    console.error(\"[Polar] Failed to update subscription cancellation\", error);\n    return createActionError(\n      \"Failed to update subscription status. Please try again.\",\n      error instanceof Error ? error.message : \"Unknown error\"\n    );\n  }\n\n  // Refresh subscription data\n  const refreshed = await getActiveSubscription({\n    externalCustomerId: profile.organization.organization_id,\n    customerEmail: profile.email,\n    organizationId: profile.organization.organization_id,\n  });\n\n  return createActionSuccess({\n    success: true,\n    cancelAtPeriodEnd,\n    status: refreshed.status,\n    subscription: refreshed.subscription\n      ? {\n          id: refreshed.subscription.id,\n          status: refreshed.subscription.status,\n          cancelAtPeriodEnd: refreshed.subscription.cancelAtPeriodEnd,\n          currentPeriodEnd: refreshed.subscription.currentPeriodEnd?.toISOString() ?? null,\n        }\n      : null,\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/billing/create-checkout.ts",
    "content": "\"use server\";\n\nimport { redirect } from \"next/navigation\";\nimport { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions } from \"@/lib/auth/server-permissions\";\nimport { getPolarClient } from \"@/lib/polar/client\";\nimport { getDefaultPlan, getPlanById, getPlanByProductId, type PolarPlan } from \"@/lib/polar/plans\";\nimport { getActiveSubscription } from \"@/lib/polar/subscription\";\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult\n} from \"@/lib/utils/server-action-helpers\";\n\nfunction getAppBaseUrl(): string {\n  return (\n    process.env.NEXT_PUBLIC_APP_BASE_URL ??\n    process.env.APP_BASE_URL ??\n    \"http://localhost:3000\"\n  );\n}\n\ninterface CreateCheckoutParams {\n  planId?: string;\n  products?: string[];\n}\n\ninterface CheckoutData {\n  checkoutId: string;\n  checkoutUrl: string;\n}\n\n/**\n * Create Checkout Server Action\n *\n * Creates a Polar checkout session for a subscription plan.\n * Validates permissions, checks for existing subscriptions, and redirects to Polar checkout.\n *\n * @param params - Optional planId or products array\n */\nexport async function createCheckout(\n  params?: CreateCheckoutParams\n): Promise<ActionResult<CheckoutData> | never> {\n  const client = getPolarClient();\n\n  if (!client || !process.env.POLAR_ACCESS_TOKEN) {\n    return createActionError(\n      \"Polar billing is not configured.\",\n      \"Missing Polar client or access token\"\n    );\n  }\n\n  // Authenticate user\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    console.info(\"[Polar] Checkout attempted without authentication\");\n    return createActionError(\"Authentication required.\");\n  }\n\n  // Check permissions\n  const permissions = await getServerPermissions(session);\n  const profile = permissions.profile;\n\n  if (!profile) {\n    console.warn(\"[Polar] Checkout aborted - profile unavailable\");\n    return createActionError(\"Profile not available.\");\n  }\n\n  if (!permissions.canManageSubscriptions) {\n    console.info(\"[Polar] Checkout forbidden - insufficient permissions\", {\n      memberId: profile.member_id,\n    });\n    return createActionError(\n      \"You do not have access to manage subscriptions.\",\n      \"Missing subscription management permissions\"\n    );\n  }\n\n  try {\n    // Fetch all products from Polar to validate the plan\n    const productsResponse = await client.products.list({\n      isArchived: false,\n    });\n\n    const polarProducts = productsResponse.result.items;\n\n    // Transform products to PolarPlan format\n    const availablePlans: PolarPlan[] = polarProducts\n      .map((product): PolarPlan | null => {\n        const price = product.prices?.[0];\n        if (!price || !price.id) {\n          console.warn(\"[Polar] Product missing price, skipping\", {\n            productId: product.id,\n            name: product.name,\n          });\n          return null;\n        }\n\n        const metadata = (product.metadata ?? {}) as Record<string, unknown>;\n        const planId = typeof metadata.plan_id === \"string\" ? metadata.plan_id : product.id;\n\n        const includedSeats =\n          typeof metadata.included_seats === \"number\"\n            ? metadata.included_seats\n            : typeof metadata.max_seats === \"number\"\n              ? metadata.max_seats\n              : typeof metadata.seats === \"number\"\n                ? metadata.seats\n                : null;\n\n        const includedInvoices =\n          typeof metadata.included_invoices === \"number\"\n            ? metadata.included_invoices\n            : typeof metadata.invoice_limit === \"number\"\n              ? metadata.invoice_limit\n              : typeof metadata.invoices === \"number\"\n                ? metadata.invoices\n                : null;\n\n        const benefits =\n          product.benefits?.map((b) => b.description).filter(Boolean) ?? [];\n\n        const plan: PolarPlan = {\n          id: planId,\n          name: product.name,\n          description: product.description ?? null,\n          price:\n            price.amountType === \"fixed\" && price.priceAmount\n              ? price.priceAmount / 100\n              : 0,\n          interval: (price.recurringInterval as \"month\" | \"year\") ?? \"month\",\n          productId: product.id,\n          priceId: price.id,\n          includedSeats,\n          includedInvoices,\n          benefits,\n          metadata,\n        };\n\n        return plan;\n      })\n      .filter((plan): plan is PolarPlan => plan !== null);\n\n    console.info(\"[Polar] Fetched products for checkout\", {\n      productsCount: availablePlans.length,\n      productIds: availablePlans.map((p) => p.productId),\n    });\n\n    // Determine which plan/products to use\n    const planId = params?.planId;\n    const products = params?.products?.map((p) => p.trim()).filter(Boolean) ?? [];\n\n    let selectedPlan = getPlanById(availablePlans, planId);\n\n    if (selectedPlan) {\n      products.splice(0, products.length, selectedPlan.productId);\n    }\n\n    if (products.length === 0) {\n      const defaultPlan = getDefaultPlan(availablePlans);\n      if (defaultPlan) {\n        selectedPlan = defaultPlan;\n        products.push(defaultPlan.productId);\n      } else {\n        return createActionError(\n          \"No Polar product configured.\",\n          \"No products available for checkout\"\n        );\n      }\n    }\n\n    if (!selectedPlan && planId) {\n      console.warn(\"[Polar] Requested plan not found, defaulting to product list\", {\n        planId,\n      });\n    }\n\n    if (!selectedPlan && products.length > 0) {\n      selectedPlan = getPlanByProductId(availablePlans, products[0]) ?? null;\n    }\n\n    const organizationId = profile.organization?.organization_id ?? null;\n\n    // Check if user already has an active subscription\n    // If they do, they should use the update endpoint instead\n    const existingSubscription = await getActiveSubscription({\n      externalCustomerId: organizationId,\n      customerEmail: profile.email,\n      organizationId,\n    });\n\n    if (existingSubscription.isActive && existingSubscription.subscription) {\n      console.warn(\"[Polar] User already has active subscription, should use update endpoint\", {\n        subscriptionId: existingSubscription.subscription.id,\n        currentProductId: existingSubscription.subscription.productId,\n        requestedPlanId: selectedPlan?.id,\n      });\n\n      return createActionError(\n        \"You already have an active subscription. Please use the plan switcher to upgrade or downgrade your existing subscription.\",\n        `Existing subscription: ${existingSubscription.subscription.id}`\n      );\n    }\n\n    // Prepare metadata\n    const accountId =\n      typeof profile.account_id === \"number\"\n        ? String(profile.account_id)\n        : profile.account_id ?? null;\n\n    const metadata: Record<string, string> = {};\n    if (organizationId) {\n      metadata.organization_id = organizationId;\n    }\n    if (accountId) {\n      metadata.account_id = accountId;\n    }\n    if (selectedPlan) {\n      metadata.plan_id = selectedPlan.id;\n    }\n\n    const customerMetadata: Record<string, string> = {};\n    if (organizationId) {\n      customerMetadata.organization_id = organizationId;\n    }\n\n    console.info(\"[Polar] Creating checkout session\", {\n      organizationId,\n      email: profile.email,\n      products,\n      planId: selectedPlan?.id ?? null,\n    });\n\n    // Create Polar checkout session\n    const checkout = await client.checkouts.create({\n      products,\n      successUrl: `${getAppBaseUrl()}/dashboard?checkout_id={CHECKOUT_ID}`,\n      returnUrl: `${getAppBaseUrl()}/subscribe-required`,\n      externalCustomerId: organizationId ?? undefined,\n      customerEmail: profile.email,\n      customerName: profile.name ?? undefined,\n      metadata: Object.keys(metadata).length ? metadata : undefined,\n      customerMetadata: Object.keys(customerMetadata).length\n        ? customerMetadata\n        : undefined,\n    });\n\n    console.info(\"[Polar] Checkout session created\", {\n      checkoutId: checkout.id,\n      url: checkout.url,\n    });\n\n    // Redirect to Polar checkout page\n    redirect(checkout.url);\n  } catch (error) {\n    // Re-throw redirect errors - Next.js uses these internally\n    if (error instanceof Error && error.message === \"NEXT_REDIRECT\") {\n      throw error;\n    }\n    console.error(\"[Polar] Failed to create checkout session\", error);\n    return createActionError(\n      \"Failed to start Polar checkout session.\",\n      error instanceof Error ? error.message : \"Unknown error\"\n    );\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/billing/get-products.ts",
    "content": "\"use server\";\n\nimport { fetchProducts } from \"@/lib/polar/server-products\";\nimport type { PolarPlan } from \"@/lib/polar/plans\";\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult,\n} from \"@/lib/utils/server-action-helpers\";\n\n/**\n * Get Products Server Action\n *\n * Fetches all active products from Polar with authentication and permission checks.\n * This replaces the /api/billing/products API route.\n *\n * @returns ActionResult with products array or error\n */\nexport async function getProducts(): Promise<ActionResult<PolarPlan[]>> {\n  const result = await fetchProducts();\n\n  if (!result.success) {\n    return createActionError(result.error ?? \"Failed to fetch products\");\n  }\n\n  return createActionSuccess(result.products ?? []);\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/billing/get-subscription-status.ts",
    "content": "\"use server\";\n\nimport {\n  resolveCurrentSubscription,\n  type SubscriptionGateState,\n} from \"@/lib/polar/current-subscription\";\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult,\n} from \"@/lib/utils/server-action-helpers\";\n\n/**\n * Get Subscription Status Server Action\n *\n * Fetches the current subscription status from Polar.\n * This replaces the /api/billing/status API route.\n *\n * @returns ActionResult with subscription state or error\n */\nexport async function getSubscriptionStatus(): Promise<\n  ActionResult<SubscriptionGateState>\n> {\n  const state = await resolveCurrentSubscription();\n\n  if (!state.isAuthenticated) {\n    return createActionError(state.reason ?? \"Authentication required.\");\n  }\n\n  if (state.reason === \"INSUFFICIENT_PERMISSIONS\") {\n    return createActionError(\"You cannot view subscription details.\");\n  }\n\n  return createActionSuccess(state);\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/actions/billing/verify-payment.ts",
    "content": "\"use server\";\n\nimport { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { apiClient } from \"@/lib/api/api/client/api-client\";\nimport {\n  createActionError,\n  createActionSuccess,\n  type ActionResult\n} from \"@/lib/utils/server-action-helpers\";\n\ninterface BillingStatus {\n  organization_id: number;\n  external_id?: string;\n  has_active_subscription: boolean;\n  can_process_invoices: boolean;\n  invoice_count: number;\n  reason: string;\n  checked_at: string;\n}\n\n/**\n * Verify Payment Server Action\n *\n * Verifies a payment from a Polar checkout session by calling the Go backend.\n * Updates the organization's subscription status in the database.\n *\n * @param sessionId - The Polar checkout session ID\n */\nexport async function verifyPayment(\n  sessionId: string\n): Promise<ActionResult<BillingStatus>> {\n  // Authenticate user\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    console.info(\"[Billing] verify-payment attempted without authentication\");\n    return createActionError(\"Authentication required.\");\n  }\n\n  // Validate session_id\n  if (!sessionId || typeof sessionId !== \"string\") {\n    return createActionError(\n      \"session_id is required.\",\n      \"Invalid or missing session_id parameter\"\n    );\n  }\n\n  try {\n    console.info(\"[Billing] Verifying payment from checkout session\", {\n      sessionId,\n    });\n\n    // Call Go backend to verify payment\n    const billingStatus = await apiClient.post<BillingStatus>(\n      \"/subscriptions/verify-payment\",\n      { session_id: sessionId }\n    );\n\n    console.info(\"[Billing] Payment verification successful\", {\n      sessionId,\n      hasActiveSubscription: billingStatus.has_active_subscription,\n      reason: billingStatus.reason,\n    });\n\n    return createActionSuccess(billingStatus);\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : \"Unknown error\";\n    console.error(\"[Billing] Payment verification failed\", {\n      sessionId,\n      error: errorMessage,\n    });\n\n    // Check if it's a 404 (session not found)\n    if (errorMessage.includes(\"404\")) {\n      return createActionError(\n        \"Checkout session not found.\",\n        errorMessage\n      );\n    }\n\n    return createActionError(\n      \"Failed to verify payment.\",\n      errorMessage\n    );\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/client/api-client.ts",
    "content": "// lib/api/client/api-client.ts\n\nimport {\n  SESSION_COOKIE_NAME,\n  SESSION_JWT_COOKIE_NAME,\n  AUTH_ROUTES,\n} from \"@/lib/auth/constants\";\nimport {\n  AccessTokenPayload,\n  decodeAccessToken,\n  isTokenExpired,\n} from \"@/lib/auth/token-utils\";\nimport { apiLogger } from \"@/lib/utils/api-logger\";\n\nexport interface ApiClientConfig {\n  baseUrl: string;\n  defaultHeaders: Record<string, string>;\n}\n\nexport interface RequestOptions {\n  headers?: Record<string, string>;\n  skipAuth?: boolean;\n}\n\nexport class ApiClient {\n  private config: ApiClientConfig;\n\n  constructor(config?: Partial<ApiClientConfig>) {\n    // Server-side: use internal Docker network URL (Node.js needs absolute URLs)\n    // Client-side: use relative URL (browser adds origin, reverse proxy handles routing)\n    const baseUrl =\n      config?.baseUrl ||\n      (typeof window === \"undefined\"\n        ? process.env.API_BASE_URL_INTERNAL || \"http://localhost:8080/api\"\n        : process.env.NEXT_PUBLIC_API_BASE_URL || \"/api\");\n\n    this.config = {\n      baseUrl,\n      defaultHeaders: {\n        ...config?.defaultHeaders,\n      },\n    };\n  }\n\n  async get<T>(endpoint: string, options?: RequestOptions): Promise<T> {\n    return this.request<T>(endpoint, {\n      method: \"GET\",\n      ...this.applyOptions(undefined, options),\n    });\n  }\n\n  async post<T>(\n    endpoint: string,\n    body?: any,\n    options?: RequestOptions\n  ): Promise<T> {\n    return this.request<T>(endpoint, {\n      method: \"POST\",\n      ...this.applyOptions(body, options),\n    });\n  }\n\n  async put<T>(\n    endpoint: string,\n    body?: any,\n    options?: RequestOptions\n  ): Promise<T> {\n    return this.request<T>(endpoint, {\n      method: \"PUT\",\n      ...this.applyOptions(body, options),\n    });\n  }\n\n  async delete<T>(endpoint: string, options?: RequestOptions): Promise<T> {\n    return this.request<T>(endpoint, {\n      method: \"DELETE\",\n      ...this.applyOptions(undefined, options),\n    });\n  }\n\n  getBaseUrl(): string {\n    return this.config.baseUrl;\n  }\n\n  private async request<T>(\n    endpoint: string,\n    options: RequestInit & { skipAuth?: boolean }\n  ): Promise<T> {\n    const url = `${this.config.baseUrl}${endpoint}`;\n    const requestId = apiLogger.generateRequestId();\n    const requestStartTime = Date.now();\n\n    const headers: Record<string, string> = {\n      ...this.config.defaultHeaders,\n      ...(options.headers as Record<string, string> | undefined),\n    };\n\n    const { skipAuth, ...restOptions } = options;\n    const shouldAttachAuth = !skipAuth && !headers[\"Authorization\"];\n    let attachedAccessToken: string | null = null;\n\n    if (shouldAttachAuth) {\n      const token = await resolveAccessToken();\n      if (token) {\n        headers[\"Authorization\"] = `Bearer ${token}`;\n        attachedAccessToken = token;\n      }\n    } else {\n      const headerValue = headers[\"Authorization\"];\n      attachedAccessToken =\n        typeof headerValue === \"string\" && headerValue.startsWith(\"Bearer \")\n          ? headerValue.slice(\"Bearer \".length)\n          : null;\n    }\n\n    const requestInit: RequestInit = {\n      ...restOptions,\n      headers,\n      credentials: \"include\",\n    };\n\n    // Log outgoing request\n    apiLogger.logRequest(requestId, {\n      method: options.method || \"GET\",\n      url,\n      headers,\n      body: options.body,\n      timestamp: requestStartTime,\n    });\n\n    try {\n      const response = await fetch(url, requestInit);\n      const responseTime = Date.now();\n\n      // Extract response headers\n      const responseHeaders: Record<string, string> = {};\n      response.headers.forEach((value, key) => {\n        responseHeaders[key] = value;\n      });\n\n      // Clone response to read body for logging\n      const responseClone = response.clone();\n      let responseBody: any;\n      try {\n        responseBody = await responseClone.json();\n      } catch {\n        // Response is not JSON\n        responseBody = null;\n      }\n\n      // Log response\n      apiLogger.logResponse(requestId, {\n        status: response.status,\n        statusText: response.statusText,\n        headers: responseHeaders,\n        body: responseBody,\n        duration: responseTime - requestStartTime,\n        timestamp: responseTime,\n      });\n\n      if (response.status === 401 && !skipAuth) {\n        return this.handleUnauthorizedResponse<T>({\n          url,\n          requestInit,\n          headers,\n          attachedAccessToken,\n        });\n      }\n\n      if (!response.ok) {\n        const error = await this.buildApiError(response);\n\n        // Log error response\n        apiLogger.logError(requestId, {\n          message: error.message,\n          status: response.status,\n          details: error,\n          duration: Date.now() - requestStartTime,\n          timestamp: Date.now(),\n        });\n\n        throw error;\n      }\n\n      return response.json() as Promise<T>;\n    } catch (error) {\n      // Log network or other errors\n      apiLogger.logError(requestId, {\n        message: error instanceof Error ? error.message : \"Unknown error\",\n        details: error,\n        duration: Date.now() - requestStartTime,\n        timestamp: Date.now(),\n      });\n\n      throw error;\n    }\n  }\n\n  private prepareBody(body: any): BodyInit | undefined {\n    if (!body) return undefined;\n    if (body instanceof FormData) return body;\n    return JSON.stringify(body);\n  }\n\n  private applyOptions(\n    body: any,\n    options?: RequestOptions\n  ): RequestInit & { skipAuth?: boolean } {\n    const headers = this.prepareHeaders(body, options?.headers);\n\n    return {\n      body: this.prepareBody(body),\n      headers,\n      skipAuth: options?.skipAuth,\n    };\n  }\n\n  private prepareHeaders(\n    body: any,\n    customHeaders?: Record<string, string>\n  ): Record<string, string> {\n    const headers = { ...customHeaders };\n\n    if (!(body instanceof FormData) && body && !headers[\"Content-Type\"]) {\n      headers[\"Content-Type\"] = \"application/json\";\n    }\n\n    return headers;\n  }\n\n  private async handleUnauthorizedResponse<T>({\n    url,\n    requestInit,\n    headers,\n    attachedAccessToken,\n  }: {\n    url: string;\n    requestInit: RequestInit;\n    headers: Record<string, string>;\n    attachedAccessToken: string | null;\n  }): Promise<T> {\n    const tokenState = classifyTokenState(attachedAccessToken);\n\n    const refreshedToken =\n      tokenState === \"valid\"\n        ? await resolveAccessToken({ forceRefresh: true })\n        : await refreshToken();\n\n    if (!refreshedToken) {\n      await logoutUser();\n      throw new Error(\"Session expired\");\n    }\n\n    headers[\"Authorization\"] = `Bearer ${refreshedToken}`;\n\n    const retryResponse = await fetch(url, {\n      ...requestInit,\n      headers,\n    });\n\n    if (!retryResponse.ok) {\n      if (retryResponse.status === 401) {\n        await logoutUser();\n        throw new Error(\"Session expired\");\n      }\n\n      throw await this.buildApiError(retryResponse);\n    }\n\n    return retryResponse.json() as Promise<T>;\n  }\n\n  private async buildApiError(response: Response): Promise<Error> {\n    let errorMessage = response.statusText;\n\n    try {\n      const errorData = await response.json();\n      if (errorData && typeof errorData.message === \"string\") {\n        errorMessage = errorData.message;\n      }\n    } catch {\n      // Ignore JSON parsing errors and fall back to status text.\n    }\n\n    return new Error(`API Error ${response.status}: ${errorMessage}`);\n  }\n}\n\n// Export singleton instance\nexport const apiClient = new ApiClient();\n\ntype ResolveAccessTokenOptions = {\n  forceRefresh?: boolean;\n};\n\nlet cachedToken: string | null = null;\nlet cachedPayload: AccessTokenPayload | null = null;\nlet stytchClientPromise: Promise<any> | null = null;\nlet refreshPromise: Promise<string | null> | null = null;\nlet refreshPromiseTimeout: NodeJS.Timeout | null = null;\n\n// Retry configuration\nconst MAX_REFRESH_RETRIES = 3;\nconst REFRESH_RETRY_DELAYS = [1000, 2000, 4000]; // Exponential backoff: 1s, 2s, 4s\nconst REFRESH_PROMISE_TIMEOUT_MS = 10000; // 10 seconds max wait for shared refresh\n\nexport async function resolveAccessToken(\n  options: ResolveAccessTokenOptions = {}\n): Promise<string | null> {\n  const { forceRefresh = false } = options;\n\n  if (!forceRefresh && cachedToken && cachedPayload && !isTokenExpired(cachedPayload)) {\n    return cachedToken;\n  }\n\n  if (!forceRefresh) {\n    const storedToken = await readStoredAccessToken();\n\n    if (storedToken) {\n      const payload = decodeAccessToken(storedToken);\n      if (payload && !isTokenExpired(payload)) {\n        updateCachedToken(storedToken, payload);\n\n        if (typeof window !== \"undefined\") {\n          persistBrowserSessionJwt(storedToken);\n        }\n\n        return storedToken;\n      }\n    }\n  }\n\n  const refreshedToken = await refreshToken();\n  if (refreshedToken) {\n    return refreshedToken;\n  }\n\n  await clearSessionCookies();\n  return null;\n}\n\n// Helper function to wait/sleep for retry delays\nasync function sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// Helper function to perform refresh with retry logic\nasync function refreshTokenWithRetry(attempt: number = 0): Promise<string | null> {\n  try {\n    const token = await performTokenRefresh();\n\n    if (!token) {\n      if (attempt < MAX_REFRESH_RETRIES - 1) {\n        const delay = REFRESH_RETRY_DELAYS[attempt];\n        await sleep(delay);\n        return refreshTokenWithRetry(attempt + 1);\n      }\n      return null;\n    }\n\n    const payload = decodeAccessToken(token);\n    if (!payload || isTokenExpired(payload)) {\n      // Retry if token is invalid and we have retries left\n      if (attempt < MAX_REFRESH_RETRIES - 1) {\n        const delay = REFRESH_RETRY_DELAYS[attempt];\n        resetCachedToken();\n        await sleep(delay);\n        return refreshTokenWithRetry(attempt + 1);\n      }\n\n      resetCachedToken();\n      return null;\n    }\n\n    updateCachedToken(token, payload);\n\n    if (typeof window !== \"undefined\") {\n      persistBrowserSessionJwt(token);\n    }\n\n    return token;\n  } catch {\n    if (attempt < MAX_REFRESH_RETRIES - 1) {\n      const delay = REFRESH_RETRY_DELAYS[attempt];\n      await sleep(delay);\n      return refreshTokenWithRetry(attempt + 1);\n    }\n\n    resetCachedToken();\n    return null;\n  }\n}\n\nexport async function refreshToken(): Promise<string | null> {\n  if (!refreshPromise) {\n    // Set up timeout for the refresh promise\n    if (refreshPromiseTimeout) {\n      clearTimeout(refreshPromiseTimeout);\n    }\n\n    refreshPromise = refreshTokenWithRetry()\n      .finally(() => {\n        if (refreshPromiseTimeout) {\n          clearTimeout(refreshPromiseTimeout);\n          refreshPromiseTimeout = null;\n        }\n        refreshPromise = null;\n      });\n\n    // Add timeout to prevent hanging on shared refresh promise\n    refreshPromiseTimeout = setTimeout(() => {\n      if (refreshPromise) {\n        refreshPromise = null;\n      }\n      refreshPromiseTimeout = null;\n    }, REFRESH_PROMISE_TIMEOUT_MS);\n  }\n\n  return refreshPromise;\n}\n\nasync function performTokenRefresh(): Promise<string | null> {\n  if (typeof window === \"undefined\") {\n    return tryRefreshServerToken();\n  }\n\n  return fetchSessionJwtFromApi();\n}\n\nasync function readStoredAccessToken(): Promise<string | null> {\n  if (typeof window === \"undefined\") {\n    try {\n      const { cookies } = await import(\"next/headers\");\n      const cookieStore = await cookies();\n      return cookieStore.get(SESSION_JWT_COOKIE_NAME)?.value ?? null;\n    } catch {\n      return null;\n    }\n  }\n\n  return readBrowserCookie(SESSION_JWT_COOKIE_NAME);\n}\n\nasync function readServerSessionToken(): Promise<string | null> {\n  if (typeof window !== \"undefined\") {\n    return null;\n  }\n\n  try {\n    const { cookies } = await import(\"next/headers\");\n    const cookieStore = await cookies();\n    return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;\n  } catch {\n    return null;\n  }\n}\n\nasync function clearSessionCookies(): Promise<void> {\n  resetCachedToken();\n  const expiry = \"Thu, 01 Jan 1970 00:00:00 GMT\";\n\n  if (typeof document !== \"undefined\") {\n    deleteBrowserCookie(SESSION_COOKIE_NAME, expiry);\n    deleteBrowserCookie(SESSION_JWT_COOKIE_NAME, expiry);\n    return;\n  }\n\n  try {\n    const nextHeaders = await import(\"next/headers\");\n    const cookieStore = await nextHeaders.cookies();\n    const mutableStore = cookieStore as unknown as {\n      delete?: (name: string) => void;\n    };\n\n    mutableStore.delete?.(SESSION_COOKIE_NAME);\n    mutableStore.delete?.(SESSION_JWT_COOKIE_NAME);\n  } catch {\n    // Swallow errors - this best-effort cleanup should not break callers.\n  }\n}\n\nasync function logoutUser(): Promise<void> {\n  await clearSessionCookies();\n\n  if (typeof window !== \"undefined\") {\n    const currentPath = window.location.pathname + window.location.search;\n    const returnTo = encodeURIComponent(currentPath);\n    const target = AUTH_ROUTES.LOGIN ?? \"/login\";\n    window.location.href = `${target}?returnTo=${returnTo}`;\n  }\n}\n\nfunction deleteBrowserCookie(name: string, expiry: string) {\n  document.cookie = `${name}=; path=/; expires=${expiry}; max-age=0`;\n}\n\nfunction readBrowserCookie(name: string): string | null {\n  if (typeof document === \"undefined\") {\n    return null;\n  }\n\n  const escapedName = escapeRegExp(name);\n  const match = document.cookie.match(\n    new RegExp(`(?:^|; )${escapedName}=([^;]*)`)\n  );\n  return match ? decodeURIComponent(match[1]) : null;\n}\n\nfunction escapeRegExp(value: string): string {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction updateCachedToken(\n  token: string | null,\n  payload: AccessTokenPayload | null\n) {\n  cachedToken = token;\n  cachedPayload = payload;\n}\n\nfunction resetCachedToken() {\n  cachedToken = null;\n  cachedPayload = null;\n}\n\n// Export for logout cleanup\nexport { resetCachedToken };\n\nasync function exchangeSessionTokenForJwt(\n  sessionToken: string,\n  sessionDurationMinutes: number = 480 // Default 8 hours\n): Promise<string | null> {\n  if (typeof window !== \"undefined\") {\n    return null;\n  }\n\n  try {\n    const client = await loadStytchB2BClient();\n    if (!client) {\n      return null;\n    }\n\n    const response = (await client.sessions.authenticate({\n      session_token: sessionToken,\n      session_duration_minutes: sessionDurationMinutes,\n    } as any)) as { session_jwt?: string | null };\n\n    return response?.session_jwt ?? null;\n  } catch {\n    return null;\n  }\n}\n\nasync function loadStytchB2BClient(): Promise<any | null> {\n  if (typeof window !== \"undefined\") {\n    return null;\n  }\n\n  if (!stytchClientPromise) {\n    stytchClientPromise = createStytchB2BClient().catch(() => {\n      stytchClientPromise = null;\n      return null;\n    });\n  }\n\n  return stytchClientPromise;\n}\n\nasync function createStytchB2BClient(): Promise<any | null> {\n  // Ensure server-only execution\n  if (typeof window !== \"undefined\") {\n    throw new Error(\"createStytchB2BClient must only be called on server\");\n  }\n\n  const projectId = process.env.STYTCH_PROJECT_ID;\n  const secret = process.env.STYTCH_SECRET;\n\n  if (!projectId || !secret) {\n    return null;\n  }\n\n  const projectEnv =\n    process.env.STYTCH_PROJECT_ENV ||\n    process.env.NEXT_PUBLIC_STYTCH_PROJECT_ENV ||\n    \"test\";\n\n  const { default: Stytch, envs } = await import(\"stytch\");\n\n  return new Stytch.B2BClient({\n    project_id: projectId,\n    secret,\n    env: projectEnv === \"live\" ? envs.live : envs.test,\n  });\n}\n\nasync function tryRefreshServerToken(): Promise<string | null> {\n  const sessionToken = await readServerSessionToken();\n  if (!sessionToken) {\n    return null;\n  }\n\n  // Use default duration (480 minutes / 8 hours)\n  return exchangeSessionTokenForJwt(sessionToken);\n}\n\nfunction persistBrowserSessionJwt(token: string): void {\n  if (typeof window === \"undefined\") {\n    return;\n  }\n\n  // Use hardcoded default for browser context (8 hours)\n  const maxAgeSeconds = 480 * 60; // 28800 seconds\n  const secure = window.location.protocol === \"https:\";\n\n  const cookieParts = [\n    `${SESSION_JWT_COOKIE_NAME}=${encodeURIComponent(token)}`,\n    \"path=/\",\n    `max-age=${maxAgeSeconds}`,\n    \"SameSite=Lax\",\n  ];\n\n  if (secure) {\n    cookieParts.push(\"Secure\");\n  }\n\n  document.cookie = cookieParts.join(\"; \");\n}\n\nasync function fetchSessionJwtFromApi(): Promise<string | null> {\n  try {\n    const response = await fetch(\"/api/auth/session/refresh\", {\n      method: \"POST\",\n      credentials: \"include\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    if (!response.ok) {\n      return null;\n    }\n\n    const data = await response.json();\n    const token =\n      typeof data?.sessionJwt === \"string\" && data.sessionJwt.length > 0\n        ? data.sessionJwt\n        : null;\n\n    if (token) {\n      persistBrowserSessionJwt(token);\n    }\n\n    return token;\n  } catch {\n    return null;\n  }\n}\n\ntype TokenState = \"none\" | \"invalid\" | \"expired\" | \"valid\";\n\nfunction classifyTokenState(token: string | null): TokenState {\n  if (!token) {\n    return \"none\";\n  }\n\n  const payload = decodeAccessToken(token);\n  if (!payload) {\n    return \"invalid\";\n  }\n\n  return isTokenExpired(payload) ? \"expired\" : \"valid\";\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/auth.dto.ts",
    "content": "export interface BootstrapOrganizationRequestDto {\n  org_display_name: string;\n  owner_email: string;\n  owner_password: string;\n  owner_name: string;\n}\n\n// Magic Link Signup DTOs\nexport interface SignupMagicLinkRequestDto {\n  org_display_name: string;\n  owner_email: string;\n  owner_name: string;\n  owner_password: string; // Auto-generated secure password for backend compatibility\n  industry?: string;\n}\n\nexport interface SignupMagicLinkResponseDto {\n  message: string;\n  org_id: string;\n  org_name?: string;\n  display_name: string;\n  owner_email: string;\n  owner_name: string;\n  magic_link_sent: boolean;\n}\n\nexport interface BootstrapOrganizationResponseDto {\n  org_id: string;\n  org_name?: string;\n  display_name: string;\n  owner_user_id: string;\n  owner_email: string;\n  owner_name: string;\n  login_url: string;\n}\n\nexport interface LoginRequestDto {\n  email: string;\n  password: string;\n}\n\nexport interface LoginResponseDto {\n  access_token: string;\n  token_type?: string;\n  expires_in?: number;\n  refresh_token?: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/cognitive.dto.ts",
    "content": "// lib/api/api/dto/cognitive.dto.ts\n\nexport type ChatRole = \"user\" | \"assistant\" | \"system\";\n\nexport interface ChatSessionDto {\n  id: number;\n  title: string;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface ChatMessageDto {\n  id: number;\n  session_id: number;\n  role: ChatRole;\n  content: string;\n  referenced_docs?: number[];\n  tokens_used: number;\n  created_at: string;\n}\n\nexport interface SimilarDocumentDto {\n  id: number;\n  document_id: number;\n  content_preview: string;\n  similarity_score: number;\n}\n\nexport interface ChatRequestDto {\n  session_id?: number;\n  message: string;\n  use_rag?: boolean;\n  max_documents?: number;\n  context_history?: number;\n}\n\nexport interface ChatResponseDto {\n  session_id: number;\n  message: ChatMessageDto;\n  referenced_docs?: SimilarDocumentDto[];\n  tokens_used: number;\n}\n\nexport interface ListSessionsResponseDto {\n  sessions: ChatSessionDto[];\n}\n\nexport interface SessionMessagesResponseDto {\n  messages: ChatMessageDto[];\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/document.dto.ts",
    "content": "// lib/api/api/dto/document.dto.ts\n\nexport type DocumentStatus = \"pending\" | \"processing\" | \"processed\" | \"failed\";\n\nexport interface DocumentDto {\n  id: number;\n  title: string;\n  file_name: string;\n  content_type: string;\n  file_size: number;\n  status: DocumentStatus;\n  extracted_text?: string;\n  metadata?: Record<string, unknown>;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface ListDocumentsRequestDto {\n  status?: DocumentStatus;\n  limit?: number;\n  offset?: number;\n}\n\nexport interface ListDocumentsResponseDto {\n  documents: DocumentDto[];\n  total: number;\n  limit: number;\n  offset: number;\n}\n\nexport interface UploadDocumentResponseDto {\n  id: number;\n  title: string;\n  file_name: string;\n  content_type: string;\n  file_size: number;\n  status: DocumentStatus;\n  created_at: string;\n  updated_at: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/member.dto.ts",
    "content": "// lib/api/api/dto/member.dto.ts\n\n/**\n * Member Management DTOs\n * These match the expected backend API structure\n */\n\n// Member List DTOs\nexport interface MemberListRequestDto {\n  organization_id: string;\n  page?: number;\n  page_size?: number;\n  status?: \"active\" | \"pending\" | \"inactive\";\n}\n\nexport interface MemberListResponseDto {\n  members: MemberDto[];\n  total: number;\n}\n\nexport interface MemberDto {\n  member_id: string;\n  email: string;\n  name: string;\n  roles: string[];\n  status: string;\n  email_verified: boolean;\n  created_at: string;\n  updated_at: string;\n}\n\n// Invite Member DTOs\nexport interface InviteMemberRequestDto {\n  email: string;\n  name: string;\n  role_slug: string;\n}\n\nexport interface InviteMemberResponseDto {\n  success: boolean;\n  member_id?: string;\n  message?: string;\n  invite_link?: string;\n}\n\n// Remove Member DTOs\nexport interface RemoveMemberRequestDto {\n  member_id: string;\n  organization_id: string;\n}\n\nexport interface RemoveMemberResponseDto {\n  success: boolean;\n  message?: string;\n}\n\n// Profile DTOs\nexport interface UpdateProfileRequestDto {\n  name?: string;\n  avatar_url?: string;\n}\n\nexport interface UpdateProfileResponseDto {\n  success: boolean;\n  message?: string;\n}\n\n// Resend Invitation DTO\nexport interface ResendInvitationRequestDto {\n  member_id: string;\n}\n\nexport interface ResendInvitationResponseDto {\n  success: boolean;\n  message?: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/organization.dto.ts",
    "content": "export interface CreateOrganizationRequestDto {\n  name: string;\n  industry: string;\n}\n\nexport interface CreateOrganizationResponseDto {\n  organization_id: number;\n  name: string;\n  industry: string;\n  created_at: string;\n  updated_at: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/profile.dto.ts",
    "content": "/**\n * Profile DTOs - Mirror backend ProfileResponse structure\n */\n\nexport interface ProfileOrganizationDto {\n  organization_id: string;\n  slug: string;\n  name: string;\n  status: string;\n}\n\nexport interface ProfileResponseDto {\n  // Stytch member details\n  member_id: string;\n  email: string;\n  name: string;\n  roles: string[];\n  permissions: string[];\n  email_verified: boolean;\n  status: string;\n\n  // Organization details\n  organization: ProfileOrganizationDto;\n\n  // Internal account details\n  account_id: number;\n  created_at: string;\n  updated_at: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/dto/rbac.dto.ts",
    "content": "// lib/api/api/dto/rbac.dto.ts\n\nexport interface RbacPermissionDto {\n  id: string;\n  resource: string;\n  action: string;\n  display_name: string;\n  description: string;\n  category: string;\n}\n\nexport interface RbacRoleDto {\n  id: string;\n  name: string;\n  description: string;\n  typical_users: string;\n  permissions: RbacPermissionDto[];\n}\n\nexport interface RbacRolesResponseDto {\n  roles: RbacRoleDto[];\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/repositories/cognitive-repository.ts",
    "content": "// lib/api/api/repositories/cognitive-repository.ts\n\nimport { apiClient } from \"../client/api-client\";\nimport {\n  ChatSessionDto,\n  ChatMessageDto,\n  ChatRequestDto,\n  ChatResponseDto,\n  ListSessionsResponseDto,\n  SessionMessagesResponseDto,\n  SimilarDocumentDto,\n} from \"../dto/cognitive.dto\";\nimport {\n  ChatSession,\n  ChatMessage,\n  ChatRequest,\n  ChatResponse,\n  SimilarDocument,\n  ChatRole,\n} from \"@/lib/models/cognitive.model\";\n\nclass CognitiveRepository {\n  /**\n   * Send a chat message\n   */\n  async chat(request: ChatRequest): Promise<ChatResponse> {\n    const payload: ChatRequestDto = {\n      session_id: request.sessionId,\n      message: request.message,\n      use_rag: request.useRag,\n      max_documents: request.maxDocuments,\n      context_history: request.contextHistory,\n    };\n\n    type ChatApiResponse =\n      | ChatResponseDto\n      | {\n          data?: ChatResponseDto;\n        };\n\n    const response = await apiClient.post<ChatApiResponse>(\n      \"/example_cognitive/chat\",\n      payload\n    );\n\n    const dto: ChatResponseDto =\n      \"data\" in response && response.data\n        ? response.data\n        : (response as ChatResponseDto);\n\n    return {\n      sessionId: dto.session_id,\n      message: this.toMessage(dto.message),\n      referencedDocs: dto.referenced_docs?.map((doc) => this.toSimilarDocument(doc)),\n      tokensUsed: dto.tokens_used,\n    };\n  }\n\n  /**\n   * List chat sessions\n   */\n  async listSessions(): Promise<ChatSession[]> {\n    type ListSessionsApiResponse =\n      | ListSessionsResponseDto\n      | {\n          data?: ListSessionsResponseDto;\n          sessions?: ChatSessionDto[];\n        };\n\n    const response = await apiClient.get<ListSessionsApiResponse>(\n      \"/example_cognitive/sessions\"\n    );\n\n    const sessions: ChatSessionDto[] =\n      \"data\" in response && response.data\n        ? response.data.sessions\n        : (response as { sessions?: ChatSessionDto[] }).sessions ??\n          (response as ListSessionsResponseDto).sessions ??\n          [];\n\n    return sessions.map((dto) => this.toSession(dto));\n  }\n\n  /**\n   * Get messages for a session\n   */\n  async getSessionMessages(sessionId: number): Promise<ChatMessage[]> {\n    const response = await apiClient.get<ChatMessageDto[] | SessionMessagesResponseDto>(\n      `/example_cognitive/sessions/${sessionId}/messages`\n    );\n\n    // Handle direct array response (actual API format)\n    if (Array.isArray(response)) {\n      return response.map((dto) => this.toMessage(dto));\n    }\n\n    // Handle wrapped response (fallback for backward compatibility)\n    const messages: ChatMessageDto[] = response.messages ?? [];\n    return messages.map((dto) => this.toMessage(dto));\n  }\n\n  /**\n   * Transform DTO to ChatSession model\n   */\n  private toSession(dto: ChatSessionDto): ChatSession {\n    return {\n      id: dto.id,\n      title: dto.title,\n      createdAt: new Date(dto.created_at),\n      updatedAt: new Date(dto.updated_at),\n    };\n  }\n\n  /**\n   * Transform DTO to ChatMessage model\n   */\n  private toMessage(dto: ChatMessageDto): ChatMessage {\n    return {\n      id: dto.id,\n      sessionId: dto.session_id,\n      role: dto.role as ChatRole,\n      content: dto.content,\n      referencedDocs: dto.referenced_docs,\n      tokensUsed: dto.tokens_used,\n      createdAt: new Date(dto.created_at),\n    };\n  }\n\n  /**\n   * Transform DTO to SimilarDocument model\n   */\n  private toSimilarDocument(dto: SimilarDocumentDto): SimilarDocument {\n    return {\n      id: dto.id,\n      documentId: dto.document_id,\n      contentPreview: dto.content_preview,\n      similarityScore: dto.similarity_score,\n    };\n  }\n}\n\nexport const cognitiveRepository = new CognitiveRepository();\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/repositories/document-repository.ts",
    "content": "// lib/api/api/repositories/document-repository.ts\n\nimport { apiClient } from \"../client/api-client\";\nimport {\n  DocumentDto,\n  ListDocumentsResponseDto,\n  UploadDocumentResponseDto,\n} from \"../dto/document.dto\";\nimport {\n  Document,\n  DocumentListResponse,\n  DocumentListFilter,\n  DocumentStatus,\n} from \"@/lib/models/document.model\";\n\nclass DocumentRepository {\n  /**\n   * Upload a document (PDF)\n   */\n  async uploadDocument(file: File, title: string): Promise<Document> {\n    const formData = new FormData();\n    formData.append(\"file\", file);\n    formData.append(\"title\", title);\n\n    const response = await apiClient.post<UploadDocumentResponseDto>(\n      \"/example_documents/upload\",\n      formData\n    );\n\n    return this.toDocument(response);\n  }\n\n  /**\n   * List documents with optional filters\n   */\n  async listDocuments(options?: DocumentListFilter): Promise<DocumentListResponse> {\n    const params = new URLSearchParams();\n\n    if (options?.status) {\n      params.append(\"status\", options.status);\n    }\n    if (options?.limit) {\n      params.append(\"limit\", String(options.limit));\n    }\n    if (options?.offset) {\n      params.append(\"offset\", String(options.offset));\n    }\n\n    const queryString = params.toString();\n    const endpoint = queryString\n      ? `/example_documents?${queryString}`\n      : \"/example_documents\";\n\n    type ListDocumentsApiResponse =\n      | ListDocumentsResponseDto\n      | {\n          data?: ListDocumentsResponseDto;\n          documents?: DocumentDto[];\n          total?: number;\n        };\n\n    const response = await apiClient.get<ListDocumentsApiResponse>(endpoint);\n\n    // Handle flexible response format\n    const dto: ListDocumentsResponseDto =\n      \"data\" in response && response.data\n        ? response.data\n        : {\n            documents:\n              (response as { documents?: DocumentDto[] }).documents ??\n              (response as ListDocumentsResponseDto).documents ??\n              [],\n            total:\n              (response as { total?: number }).total ??\n              (response as ListDocumentsResponseDto).total ??\n              0,\n            limit: options?.limit ?? 50,\n            offset: options?.offset ?? 0,\n          };\n\n    return {\n      documents: (dto.documents ?? []).map((item) => this.toDocument(item)),\n      total: dto.total ?? dto.documents?.length ?? 0,\n      limit: dto.limit,\n      offset: dto.offset,\n    };\n  }\n\n  /**\n   * Delete a document\n   */\n  async deleteDocument(id: number): Promise<boolean> {\n    await apiClient.delete(`/example_documents/${id}`);\n    return true;\n  }\n\n  /**\n   * Transform DTO to Document model\n   */\n  private toDocument(dto: DocumentDto | UploadDocumentResponseDto): Document {\n    return {\n      id: dto.id,\n      title: dto.title,\n      fileName: dto.file_name,\n      contentType: dto.content_type,\n      fileSize: dto.file_size,\n      status: dto.status as DocumentStatus,\n      extractedText: \"extracted_text\" in dto ? dto.extracted_text : undefined,\n      metadata: \"metadata\" in dto ? dto.metadata : undefined,\n      createdAt: new Date(dto.created_at),\n      updatedAt: new Date(dto.updated_at),\n    };\n  }\n}\n\nexport const documentRepository = new DocumentRepository();\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/repositories/member-repository.ts",
    "content": "// lib/api/api/repositories/member-repository.ts\n\nimport { apiClient } from \"../client/api-client\";\nimport {\n  MemberListResponseDto,\n  InviteMemberRequestDto,\n  InviteMemberResponseDto,\n  RemoveMemberRequestDto,\n  RemoveMemberResponseDto,\n  MemberDto,\n  UpdateProfileRequestDto,\n  UpdateProfileResponseDto,\n  ResendInvitationRequestDto,\n  ResendInvitationResponseDto,\n} from \"../dto/member.dto\";\nimport { ProfileResponseDto } from \"../dto/profile.dto\";\nimport {\n  OrganizationMember,\n  UserProfile,\n  InviteMemberRequest,\n  InviteMemberResponse,\n  UpdateProfileRequest,\n  MemberListResponse,\n  MemberRole,\n} from \"@/lib/models/member.model\";\n\nclass MemberRepository {\n  /**\n   * Get current user profile\n   */\n  async getProfile(): Promise<UserProfile> {\n    type ProfileApiResponse =\n      | ProfileResponseDto\n      | {\n          data?: ProfileResponseDto;\n          profile?: ProfileResponseDto;\n          success?: boolean;\n          message?: string;\n        };\n\n    const response = await apiClient.get<ProfileApiResponse>(\"/auth/profile/me\");\n\n    const profileDto =\n      (response as { data?: ProfileResponseDto }).data ??\n      (response as { profile?: ProfileResponseDto }).profile ??\n      (response as ProfileResponseDto);\n\n    if (!profileDto || !profileDto.member_id) {\n      const errorMessage =\n        (response as { message?: string }).message ||\n        \"Profile response did not include member information\";\n      throw new Error(errorMessage);\n    }\n\n    return this.toUserProfile(profileDto);\n  }\n\n  /**\n   * Update user profile\n   */\n  async updateProfile(request: UpdateProfileRequest): Promise<UserProfile> {\n    const payload: UpdateProfileRequestDto = {\n      name: request.name,\n      avatar_url: request.avatarUrl,\n    };\n\n    type UpdateProfileApiResponse = UpdateProfileResponseDto & {\n      profile?: ProfileResponseDto;\n      data?: ProfileResponseDto;\n    };\n\n    const response = await apiClient.put<UpdateProfileApiResponse>(\n      \"/auth/profile/me\",\n      payload\n    );\n\n    if (response.profile || response.data) {\n      return this.toUserProfile(response.profile ?? response.data!);\n    }\n\n    if (!response.success) {\n      throw new Error(response.message || \"Failed to update profile\");\n    }\n\n    // Fetch updated profile after successful update\n    return this.getProfile();\n  }\n\n  /**\n   * Get organization members list\n   * You can optionally scope results by organization and pagination settings.\n   */\n  async getMembers(options?: {\n    organizationId?: string;\n    page?: number;\n    pageSize?: number;\n  }): Promise<MemberListResponse> {\n    const params = new URLSearchParams();\n\n    if (options?.organizationId) {\n      params.append(\"organization_id\", options.organizationId);\n    }\n\n    if (options?.page) {\n      params.append(\"page\", String(options.page));\n    }\n\n    if (options?.pageSize) {\n      params.append(\"page_size\", String(options.pageSize));\n    }\n\n    const queryString = params.toString();\n    const endpoint = queryString ? `/auth/members?${queryString}` : \"/auth/members\";\n\n    type MemberListApiResponse =\n      | MemberListResponseDto\n      | {\n          data?: MemberListResponseDto;\n          members?: MemberDto[];\n          total?: number;\n          success?: boolean;\n        };\n\n    const response = await apiClient.get<MemberListApiResponse>(endpoint);\n\n    const dto: MemberListResponseDto = \"data\" in response && response.data\n      ? response.data\n      : {\n          members:\n            (response as { members?: MemberDto[] }).members ??\n            (response as MemberListResponseDto).members,\n          total:\n            (response as { total?: number }).total ??\n            (response as MemberListResponseDto).total,\n        };\n\n    return {\n      members: (dto.members ?? []).map((item) => this.toOrganizationMember(item)),\n      totalCount: dto.total ?? dto.members?.length ?? 0,\n      hasMore: false, // Backend doesn't provide this, calculate if needed\n    };\n  }\n\n  /**\n   * Invite a new member\n   */\n  async inviteMember(\n    request: InviteMemberRequest,\n    organizationId: string\n  ): Promise<InviteMemberResponse> {\n    const payload: InviteMemberRequestDto = {\n      email: request.email,\n      name: request.name,\n      role_slug: request.role,\n    };\n\n    const response = await apiClient.post<InviteMemberResponseDto>(\n      \"/auth/members\",\n      payload\n    );\n\n    return {\n      success: response.success,\n      memberId: response.member_id,\n      message: response.message,\n      inviteLink: response.invite_link,\n    };\n  }\n\n  /**\n   * Remove member from organization\n   */\n  async removeMember(memberId: string): Promise<boolean> {\n    const response = await apiClient.delete<RemoveMemberResponseDto>(\n      `/auth/members/${memberId}`\n    );\n\n    return response.success;\n  }\n\n  /**\n   * Resend invitation to pending member\n   */\n  async resendInvitation(memberId: string): Promise<boolean> {\n    const response = await apiClient.post<ResendInvitationResponseDto>(\n      `/members/${memberId}/resend-invitation`,\n      { member_id: memberId }\n    );\n\n    return response.success;\n  }\n\n  /**\n   * Transform DTO to UserProfile model\n   * Extracts first non-Stytch role from roles array as the primary role\n   */\n  private toUserProfile(dto: ProfileResponseDto): UserProfile {\n    // Log received DTO for debugging\n    console.log(\"[MemberRepository] Profile DTO received:\", {\n      member_id: dto.member_id,\n      email: dto.email,\n      name: dto.name,\n      roles: dto.roles,\n      organization: dto.organization,\n    });\n\n    // Extract first non-stytch role as the primary role with null safety\n    const roles = dto.roles || [];\n    const primaryRole =\n      roles.find((r) => !r.startsWith(\"stytch_\")) || roles[0] || \"member\";\n    const normalizedRole: MemberRole = [\"admin\", \"manager\", \"member\"].includes(\n      primaryRole\n    )\n      ? (primaryRole as MemberRole)\n      : \"member\";\n\n    return {\n      id: dto.member_id,\n      email: dto.email,\n      name: dto.name,\n      avatarUrl: undefined, // Not in backend response\n      role: normalizedRole,\n      organizationId: dto.organization?.organization_id || \"\",\n      organizationName: dto.organization?.name || \"\",\n    };\n  }\n\n  /**\n   * Transform DTO to OrganizationMember model\n   * Extracts first non-Stytch role from roles array as the primary role\n   */\n  private toOrganizationMember(dto: MemberDto): OrganizationMember {\n    // Extract first non-stytch role as the primary role with null safety\n    const roles = dto.roles || [];\n    const primaryRole =\n      roles.find((r) => !r.startsWith(\"stytch_\")) || roles[0] || \"member\";\n    const normalizedRole: MemberRole = [\"admin\", \"manager\", \"member\"].includes(\n      primaryRole\n    )\n      ? (primaryRole as MemberRole)\n      : \"member\";\n\n    return {\n      id: dto.member_id,\n      email: dto.email,\n      name: dto.name,\n      role: normalizedRole,\n      status: dto.status as \"active\" | \"pending\" | \"inactive\",\n      avatarUrl: undefined, // Not in backend response\n      joinedAt: new Date(dto.created_at),\n      invitedAt: undefined, // Not in backend response\n      invitedBy: undefined, // Not in backend response\n    };\n  }\n}\n\nexport const memberRepository = new MemberRepository();\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/repositories/profile-repository.ts",
    "content": "/**\n * Profile Repository - Fetch current user profile with computed permissions\n */\n\nimport { apiClient } from \"../client/api-client\";\nimport type { ProfileResponseDto } from \"../dto/profile.dto\";\n\nclass ProfileRepository {\n  /**\n   * Get current user profile with backend-computed permissions\n   * Backend resolves Stytch RBAC policy and returns expanded permissions\n   *\n   * @param sessionToken - Optional JWT token. If provided, uses this token directly.\n   *                      If not provided, API client will read JWT from cookies automatically.\n   *                      Server-side calls should pass the token explicitly.\n   *                      Client-side calls can omit it to use automatic cookie-based auth.\n   * @returns Profile with computed permissions array\n   */\n  async getProfile(sessionToken?: string): Promise<ProfileResponseDto> {\n    const options = sessionToken\n      ? {\n          headers: {\n            Authorization: `Bearer ${sessionToken}`,\n          },\n        }\n      : undefined;\n\n    type ProfileApiResponse =\n      | ProfileResponseDto\n      | {\n          data?: ProfileResponseDto;\n          success?: boolean;\n          message?: string;\n        };\n\n    const response = await apiClient.get<ProfileApiResponse>(\n      \"/auth/profile/me\",\n      options\n    );\n\n    // Backend wraps response in { data: {...}, success: true }\n    // Extract the actual profile data\n    const profileDto =\n      (response as { data?: ProfileResponseDto }).data ??\n      (response as ProfileResponseDto);\n\n    if (!profileDto || !profileDto.member_id) {\n      const errorMessage =\n        (response as { message?: string }).message ||\n        \"Profile response did not include member information\";\n      throw new Error(errorMessage);\n    }\n\n    return profileDto;\n  }\n}\n\nexport const profileRepository = new ProfileRepository();\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/repositories/rbac-repository.ts",
    "content": "// lib/api/api/repositories/rbac-repository.ts\n\nimport { apiClient } from \"../client/api-client\";\nimport type {\n  RbacRolesResponseDto,\n  RbacRoleDto,\n  RbacPermissionDto,\n} from \"../dto/rbac.dto\";\n\nexport interface RbacPermission {\n  id: string;\n  resource: string;\n  action: string;\n  displayName: string;\n  description: string;\n  category: string;\n}\n\nexport interface RbacRole {\n  id: string;\n  name: string;\n  description: string;\n  typicalUsers: string;\n  permissions: RbacPermission[];\n}\n\nclass RbacRepository {\n  private cachedRoles: RbacRole[] | null = null;\n\n  /**\n   * Fetch all RBAC roles from backend.\n   * Response is cached in-memory since role definitions are static.\n   */\n  async getRoles(forceRefresh = false): Promise<RbacRole[]> {\n    if (!forceRefresh && this.cachedRoles) {\n      return this.cachedRoles;\n    }\n\n    const response = await apiClient.get<RbacRolesResponseDto>(\"/rbac/roles\", {\n      skipAuth: true,\n    });\n\n    const roles = (response.roles ?? []).map((role) => this.toRbacRole(role));\n    this.cachedRoles = roles;\n    return roles;\n  }\n\n  private toRbacRole(dto: RbacRoleDto): RbacRole {\n    return {\n      id: dto.id,\n      name: dto.name,\n      description: dto.description,\n      typicalUsers: dto.typical_users,\n      permissions: (dto.permissions ?? []).map((permission) =>\n        this.toRbacPermission(permission)\n      ),\n    };\n  }\n\n  private toRbacPermission(dto: RbacPermissionDto): RbacPermission {\n    return {\n      id: dto.id,\n      resource: dto.resource,\n      action: dto.action,\n      displayName: dto.display_name,\n      description: dto.description,\n      category: dto.category,\n    };\n  }\n}\n\nexport const rbacRepository = new RbacRepository();\n"
  },
  {
    "path": "next_b2b_starter/lib/api/api/repositories/signup-repository.ts",
    "content": "import { apiClient } from \"../client/api-client\";\nimport {\n  BootstrapOrganizationRequestDto,\n  BootstrapOrganizationResponseDto,\n  SignupMagicLinkRequestDto,\n  SignupMagicLinkResponseDto,\n} from \"../dto/auth.dto\";\nimport {\n  SignupOrganization,\n  SignupOwner,\n  SignupResult,\n} from \"@/lib/models/signup.model\";\nimport { generateSecurePassword } from \"@/lib/utils/password-generator\";\n\nclass SignupRepository {\n  // Legacy method with password (kept for backwards compatibility)\n  async bootstrapOrganization(\n    owner: SignupOwner & { password?: string },\n    organization: SignupOrganization\n  ): Promise<SignupResult> {\n    const payload: BootstrapOrganizationRequestDto = {\n      org_display_name: organization.displayName,\n      owner_email: owner.email,\n      owner_password: owner.password || \"\", // Fallback for legacy\n      owner_name: owner.fullName,\n    };\n\n    const response = await apiClient.post<BootstrapOrganizationResponseDto>(\n      \"/auth/signup\",\n      payload,\n      { skipAuth: true }\n    );\n\n    return this.toSignupResult(response);\n  }\n\n  // New magic link signup method\n  async createOrganizationWithMagicLink(\n    owner: SignupOwner,\n    organization: SignupOrganization\n  ): Promise<SignupResult> {\n    // Generate a secure random password for backend compatibility\n    // Users won't see or use this - they authenticate via magic link\n    const securePassword = generateSecurePassword(24);\n\n    const payload: SignupMagicLinkRequestDto = {\n      org_display_name: organization.displayName,\n      owner_email: owner.email,\n      owner_name: owner.fullName,\n      owner_password: securePassword,\n      industry: organization.industry || \"Technology\", // Fallback to Technology if not set\n    };\n\n    // Debug logging (remove in production)\n    console.log(\"Signup payload being sent:\", {\n      ...payload,\n      owner_password: \"[REDACTED]\", // Don't log actual password\n    });\n\n    const response = await apiClient.post<SignupMagicLinkResponseDto>(\n      \"/auth/signup\",\n      payload,\n      { skipAuth: true }\n    );\n\n    return this.toMagicLinkResult(response);\n  }\n\n  private toSignupResult(dto: BootstrapOrganizationResponseDto): SignupResult {\n    return {\n      orgId: dto.org_id,\n      orgName: dto.org_name ?? dto.display_name,\n      displayName: dto.display_name,\n      ownerUserId: dto.owner_user_id,\n      ownerEmail: dto.owner_email,\n      ownerName: dto.owner_name,\n      loginUrl: dto.login_url,\n    };\n  }\n\n  private toMagicLinkResult(dto: SignupMagicLinkResponseDto): SignupResult {\n    return {\n      orgId: dto.org_id,\n      orgName: dto.org_name ?? dto.display_name,\n      displayName: dto.display_name,\n      ownerUserId: \"\", // Not returned in magic link flow\n      ownerEmail: dto.owner_email,\n      ownerName: dto.owner_name,\n      loginUrl: \"/auth\", // Redirect to auth page after magic link\n    };\n  }\n}\n\nexport const signupRepository = new SignupRepository();\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/README.md",
    "content": "# Permission System Guide\n\nThis guide explains how to use the permission-driven UI system in the AP-Cash Frontend application.\n\n## Overview\n\nThe permission system is built on top of Stytch B2B authentication and provides:\n- Role-based access control (RBAC)\n- Permission-based UI rendering\n- Wildcard permission support (`resource:*`)\n- Client and server-side guards\n\n## Architecture\n\n### 1. Stytch Integration\n- Roles are stored in the Stytch member object at `member.roles[]`\n- Each role is an object: `{ role_id: string, sources: [...] }`\n- We extract `role_id` values (e.g., \"member\", \"approver\", \"admin\")\n- Roles are mapped to permissions in our application\n- Backend manages role definitions and assignments\n\n### 2. Permission Flow\n```\nStytch Session → Roles → Permissions → UI Components\n```\n\n## Files Structure\n\n```\nlib/auth/\n├── permissions.ts          # Permission constants & role mappings\n├── permission-utils.ts     # Permission check utilities\n└── stytch/                 # Stytch integration\n\nlib/hooks/\n└── use-permissions.ts      # React hook for permissions\n\ncomponents/auth/\n├── can.tsx                 # Inline permission wrapper\n└── permission-gate.tsx     # Page-level permission guard\n\nmiddleware.ts               # Route protection\n```\n\n## Usage Examples\n\n### 1. Page-Level Protection\n\nProtect entire pages using `PermissionGate`:\n\n```tsx\n// app/dashboard/approvals/page.tsx\nimport { PermissionGate } from \"@/components/auth/permission-gate\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n\nexport default function ApprovalsPage() {\n  return (\n    <PermissionGate required={[PERMISSIONS.APPROVAL_VIEW]}>\n      <ApprovalsPageContent />\n    </PermissionGate>\n  );\n}\n```\n\n### 2. Conditional UI Rendering\n\nShow/hide UI elements using the `Can` component:\n\n```tsx\nimport { Can } from \"@/components/auth/can\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n\nfunction InvoiceActions() {\n  return (\n    <>\n      {/* Single permission */}\n      <Can permission={PERMISSIONS.INVOICE_CREATE}>\n        <Button>Create Invoice</Button>\n      </Can>\n\n      {/* Multiple permissions (ANY - OR logic) */}\n      <Can anyPermission={[PERMISSIONS.INVOICE_VIEW, PERMISSIONS.INVOICE_CREATE]}>\n        <InvoiceList />\n      </Can>\n\n      {/* Multiple permissions (ALL - AND logic) */}\n      <Can allPermissions={[PERMISSIONS.INVOICE_VIEW, PERMISSIONS.APPROVAL_APPROVE]}>\n        <ComplexAction />\n      </Can>\n\n      {/* With fallback */}\n      <Can\n        permission={PERMISSIONS.INVOICE_DELETE}\n        fallback={<DisabledButton />}\n      >\n        <DeleteButton />\n      </Can>\n    </>\n  );\n}\n```\n\n### 3. Using the Hook\n\nAccess permissions programmatically:\n\n```tsx\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n\nfunction MyComponent() {\n  const {\n    hasPermission,\n    hasAnyPermission,\n    roles,\n    permissions,\n    isAuthenticated\n  } = usePermissions();\n\n  // Check single permission\n  const canCreate = hasPermission(PERMISSIONS.INVOICE_CREATE);\n\n  // Check multiple permissions\n  const canViewOrEdit = hasAnyPermission([\n    PERMISSIONS.INVOICE_VIEW,\n    PERMISSIONS.INVOICE_CREATE\n  ]);\n\n  // Use in logic\n  const handleAction = () => {\n    if (!hasPermission(PERMISSIONS.APPROVAL_APPROVE)) {\n      toast.error(\"You don't have permission to approve invoices\");\n      return;\n    }\n    // ... perform action\n  };\n\n  return <div>User has {permissions.length} permissions</div>;\n}\n```\n\n### 4. Navigation Filtering\n\nFilter navigation items by permissions:\n\n```tsx\nimport { usePermissions } from \"@/lib/hooks/use-permissions\";\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n\nconst navigation = [\n  {\n    name: \"Invoices\",\n    href: \"/dashboard/invoices\",\n    permission: PERMISSIONS.INVOICE_VIEW,\n  },\n  {\n    name: \"Approvals\",\n    href: \"/dashboard/approvals\",\n    permission: PERMISSIONS.APPROVAL_VIEW,\n  },\n];\n\nfunction Sidebar() {\n  const { hasPermission } = usePermissions();\n\n  const visibleNav = navigation.filter(item =>\n    !item.permission || hasPermission(item.permission)\n  );\n\n  return (\n    <nav>\n      {visibleNav.map(item => (\n        <Link key={item.href} href={item.href}>\n          {item.name}\n        </Link>\n      ))}\n    </nav>\n  );\n}\n```\n\n## Available Permissions\n\n### Invoice Management\n- `invoice:create` — Upload and create new invoice records\n- `invoice:view` — View invoice details\n- `invoice:delete` — Delete invoices from the system (admin only)\n\n### Duplicate Handling\n- `duplicate:view` — View duplicate detection results\n- `duplicate:resolve` — Resolve duplicate flags (admin only)\n\n### Approval Workflow\n- `approval:view` — View pending approvals and history\n- `approval:approve` — Approve or reject invoices in the workflow\n\n### Payment Optimization\n- `payment_optimization:schedule` — Schedule payment runs (admin only)\n- `payment_optimization:export` — Export payment files (admin only)\n- `payment_optimization:execute` — Execute or reschedule payments (admin only)\n\n### Audit Trail\n- `audit:view` — View audit log timeline and summaries\n\n### Organization Management\n- `org:view` — View organization settings and roster (admin only)\n- `org:manage` — Manage organization settings and members (admin only)\n\n## Role Permissions\n\n### Member\n- `invoice:create`\n- `invoice:view`\n- `duplicate:view`\n\n### Approver\n- All Member permissions\n- `approval:view`\n- `approval:approve`\n- `duplicate:view`\n\n### Admin\n- All permissions listed in this guide, including organization management, payment optimization, and duplicate resolution\n\n## Wildcard Permissions\n\nThe system supports wildcard permissions using `*`:\n\n```tsx\n// Grant all actions for a resource\nconst permissions = ['invoice:*'];\n\n// This matches:\n// - invoice:view\n// - invoice:create\n// - invoice:delete\n\n// Check wildcard permission\nhasPermission('invoice:create'); // true if user has 'invoice:*'\n```\n\n## Server-Side Protection\n\n### Middleware (Route Protection)\nThe middleware automatically protects dashboard routes:\n\n```typescript\n// middleware.ts\n// Automatically protects:\n// - /dashboard/*\n// - /settings\n// - /metrics\n// - /audit\n\n// Redirects to /auth if no session found\n```\n\n### API Client (401 Handling)\nThe API client automatically handles 401 responses:\n\n```typescript\n// On 401:\n// 1. Clears session cookies\n// 2. Redirects to /auth?returnTo={currentPath}\n```\n\n## Best Practices\n\n### 1. Always Use Constants\n```tsx\n// ✅ Good\nimport { PERMISSIONS } from \"@/lib/auth/permissions\";\n<Can permission={PERMISSIONS.INVOICE_CREATE}>\n\n// ❌ Bad\n<Can permission=\"invoice:create\">\n```\n\n### 2. Guard at Page Level\n```tsx\n// ✅ Good - Guard the entire page\nexport default function InvoicePage() {\n  return (\n    <PermissionGate required={[PERMISSIONS.INVOICE_VIEW]}>\n      <InvoicePageContent />\n    </PermissionGate>\n  );\n}\n\n// ❌ Bad - Relying only on navigation filters\n// Users can still access the URL directly\n```\n\n### 3. Show Helpful Fallbacks\n```tsx\n// ✅ Good\n<Can\n  permission={PERMISSIONS.INVOICE_DELETE}\n  fallback={\n    <Tooltip content=\"You don't have permission to delete\">\n      <Button disabled>Delete</Button>\n    </Tooltip>\n  }\n>\n  <Button>Delete</Button>\n</Can>\n\n// ❌ Bad - Hiding without explanation\n<Can permission={PERMISSIONS.INVOICE_DELETE}>\n  <Button>Delete</Button>\n</Can>\n```\n\n### 4. Backend Validation\n**Always validate permissions on the backend!**\n\nClient-side permission checks are for UX only. The backend must enforce permissions.\n\n## Troubleshooting\n\n### Permissions Not Working\n1. Check Stytch session is initialized: `isInitialized === true`\n2. Verify roles exist: `console.log(member?.roles)` - should be array of `{ role_id, sources }`\n3. Check role_id values: `console.log(member?.roles?.map(r => r.role_id))`\n4. Check role mapping in `lib/auth/permissions.ts` matches your role_id values\n\n### Navigation Items Not Showing\n1. Ensure permission constant is imported correctly\n2. Check `usePermissions` hook is available (client component)\n3. Verify navigation filter logic in sidebar\n\n### 401 Redirect Loop\n1. Check middleware excludes public routes\n2. Verify session cookies are being set correctly\n3. Check API endpoints don't return 401 for valid sessions\n\n## Adding New Permissions\n\n1. Add to `PERMISSIONS` constant:\n```typescript\n// lib/auth/permissions.ts\nexport const PERMISSIONS = {\n  // ... existing\n  NEW_RESOURCE_VIEW: 'new_resource:view',\n  NEW_RESOURCE_CREATE: 'new_resource:create',\n} as const;\n```\n\n2. Update role mappings:\n```typescript\nexport const ROLE_PERMISSIONS = {\n  admin: [\n    // ... existing admin permissions\n    PERMISSIONS.NEW_RESOURCE_VIEW,\n    PERMISSIONS.NEW_RESOURCE_CREATE,\n  ],\n  approver: [\n    // ... approver permissions\n    PERMISSIONS.NEW_RESOURCE_VIEW,\n  ],\n  member: [\n    // ... member permissions\n    PERMISSIONS.NEW_RESOURCE_VIEW,\n  ],\n};\n```\n\n3. Use in components:\n```tsx\n<Can permission={PERMISSIONS.NEW_RESOURCE_CREATE}>\n  <CreateButton />\n</Can>\n```\n\n## Testing Permissions\n\n```tsx\n// Test with different roles\nfunction PermissionDebug() {\n  const { roles, permissions, hasPermission } = usePermissions();\n\n  return (\n    <div>\n      <h3>Current Roles: {roles.join(', ')}</h3>\n      <h3>Permissions ({permissions.length}):</h3>\n      <ul>\n        {permissions.map(p => <li key={p}>{p}</li>)}\n      </ul>\n      <h3>Permission Checks:</h3>\n      <p>Can create invoice: {hasPermission(PERMISSIONS.INVOICE_CREATE) ? '✅' : '❌'}</p>\n      <p>Can approve: {hasPermission(PERMISSIONS.APPROVAL_APPROVE) ? '✅' : '❌'}</p>\n    </div>\n  );\n}\n```\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/bootstrap.ts",
    "content": "import { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions } from \"@/lib/auth/server-permissions\";\n\nexport async function authBootstrap() {\n  const session = await getMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  // Signal to clear cache if no valid session found\n  const shouldClearCache = !permissions.profile;\n\n  return {\n    profile: permissions.profile,\n    roles: permissions.roles,\n    permissions: permissions.permissions,\n    shouldClearCache,\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/config-types.ts",
    "content": "/**\n * Type-safe configuration for Stytch authentication\n * These types define the shape of config passed from server to client\n */\n\nexport interface StytchClientConfig {\n  publicToken: string;\n  sessionDurationMinutes: number;\n  loginPath: string;\n  logoutPath: string;\n  redirectPath: string;\n  baseUrl: string;\n}\n\nexport interface CookieConfiguration {\n  sessionCookieName: string;\n  sessionJwtCookieName: string;\n  httpOnly: boolean;\n  sameSite: \"lax\" | \"strict\" | \"none\";\n  secure: boolean;\n  path: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/constants.ts",
    "content": "/**\n * Centralized Authentication Constants\n * Now supports both server and client contexts with proper separation\n */\n\n// Static constants (safe for both contexts)\nexport const SESSION_COOKIE_NAME = \"stytch_session\";\nexport const SESSION_JWT_COOKIE_NAME = \"stytch_session_jwt\";\nexport const TOKEN_EXPIRY_GRACE_SECONDS = 60;\n\n// Auth Routes (static, safe everywhere)\n// Note: LOGOUT and MAGIC_LINK routes migrated to Server Actions (see lib/actions/auth/)\nexport const AUTH_ROUTES = {\n  LOGIN: \"/auth\",\n  CONSUME_MAGIC_LINK: \"/api/auth/consume-magic-link\",  // External Stytch callback (must remain)\n  SESSION_REFRESH: \"/api/auth/session/refresh\",        // Token refresh endpoint (must remain)\n  AUTHENTICATE_REDIRECT: \"/authenticate\",\n  DASHBOARD: \"/dashboard\",\n} as const;\n\n\n\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/permission-utils.ts",
    "content": "/**\n * Permission Utility Functions\n * Supports wildcard permissions (e.g., \"invoice:*\" grants all invoice actions)\n */\n\nimport type { Permission } from './permissions';\n\n/**\n * Match a granted permission against a required permission\n * Supports wildcard matching: \"resource:*\" matches all actions for that resource\n *\n * @example\n * matchesPermission('invoice:*', 'invoice:create') // true\n * matchesPermission('invoice:view', 'invoice:create') // false\n * matchesPermission('invoice:create', 'invoice:create') // true\n */\nexport function matchesPermission(\n  grantedPermission: string,\n  requiredPermission: string\n): boolean {\n  // Direct match\n  if (grantedPermission === requiredPermission) {\n    return true;\n  }\n\n  // Parse permission format: \"resource:action\"\n  const grantedParts = grantedPermission.split(':');\n  const requiredParts = requiredPermission.split(':');\n\n  // Must have 2 parts\n  if (grantedParts.length !== 2 || requiredParts.length !== 2) {\n    return false;\n  }\n\n  const [grantedResource, grantedAction] = grantedParts;\n  const [requiredResource, requiredAction] = requiredParts;\n\n  // Resource must match\n  if (grantedResource !== requiredResource) {\n    return false;\n  }\n\n  // Wildcard grants all actions for the resource\n  if (grantedAction === '*') {\n    return true;\n  }\n\n  // Action must match\n  return grantedAction === requiredAction;\n}\n\n/**\n * Check if user has a specific permission\n *\n * @param userPermissions - Array of permissions from backend\n * @param requiredPermission - Permission to check (e.g., \"invoice:create\")\n * @returns true if user has the permission (directly or via wildcard)\n */\nexport function hasPermission(\n  userPermissions: string[],\n  requiredPermission: string\n): boolean {\n  return userPermissions.some(grantedPermission =>\n    matchesPermission(grantedPermission, requiredPermission)\n  );\n}\n\n/**\n * Check if user has ANY of the specified permissions (OR logic)\n *\n * @param userPermissions - Array of permissions from backend\n * @param requiredPermissions - Array of permissions to check\n * @returns true if user has at least one of the permissions\n *\n * @example\n * hasAnyPermission(permissions, ['invoice:create', 'invoice:view'])\n * // true if user has either permission\n */\nexport function hasAnyPermission(\n  userPermissions: string[],\n  requiredPermissions: string[]\n): boolean {\n  if (requiredPermissions.length === 0) {\n    return true;\n  }\n\n  return requiredPermissions.some(permission =>\n    hasPermission(userPermissions, permission)\n  );\n}\n\n/**\n * Check if user has ALL of the specified permissions (AND logic)\n *\n * @param userPermissions - Array of permissions from backend\n * @param requiredPermissions - Array of permissions to check\n * @returns true if user has all permissions\n *\n * @example\n * hasAllPermissions(permissions, ['invoice:view', 'invoice:create'])\n * // true only if user has both permissions\n */\nexport function hasAllPermissions(\n  userPermissions: string[],\n  requiredPermissions: string[]\n): boolean {\n  if (requiredPermissions.length === 0) {\n    return true;\n  }\n\n  return requiredPermissions.every(permission =>\n    hasPermission(userPermissions, permission)\n  );\n}\n\n/**\n * Check if user has a specific role\n *\n * @param userRoles - Array of role names from Stytch session\n * @param role - Role to check (e.g., \"admin\", \"manager\", \"member\")\n * @returns true if user has the role\n */\nexport function hasRole(userRoles: string[], role: string): boolean {\n  return userRoles.includes(role);\n}\n\n/**\n * Check if user has ANY of the specified roles\n *\n * @param userRoles - Array of role names from Stytch session\n * @param roles - Array of roles to check\n * @returns true if user has at least one of the roles\n */\nexport function hasAnyRole(userRoles: string[], roles: string[]): boolean {\n  return roles.some(role => userRoles.includes(role));\n}\n\n/**\n * Check if user has ALL of the specified roles\n *\n * @param userRoles - Array of role names from Stytch session\n * @param roles - Array of roles to check\n * @returns true if user has all roles\n */\nexport function hasAllRoles(userRoles: string[], roles: string[]): boolean {\n  return roles.every(role => userRoles.includes(role));\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/permissions.ts",
    "content": "/**\n * Permission System\n * Based on Stytch RBAC - permissions are computed by backend\n * Frontend receives expanded permissions array from /auth/profile/me\n */\n\n// Permission constants (actual Stytch RBAC permissions only)\nexport const PERMISSIONS = {\n  // Resource Management\n  RESOURCE_VIEW: \"resource:view\",\n  RESOURCE_CREATE: \"resource:create\",\n  RESOURCE_EDIT: \"resource:edit\",\n  RESOURCE_DELETE: \"resource:delete\",\n  RESOURCE_APPROVE: \"resource:approve\",\n\n  // Organization Management\n  ORG_VIEW: \"org:view\",\n  ORG_MANAGE: \"org:manage\",\n\n  // Invoice Management\n  INVOICE_VIEW: \"invoice:view\",\n  INVOICE_CREATE: \"invoice:create\",\n  INVOICE_UPLOAD: \"invoice:upload\",\n  INVOICE_DELETE: \"invoice:delete\",\n\n  // Approvals\n  APPROVALS_VIEW: \"approvals:view\",\n  APPROVALS_APPROVE: \"approvals:approve\",\n\n  // Duplicates\n  DUPLICATES_VIEW: \"duplicates:view\",\n  DUPLICATES_RESOLVE: \"duplicates:resolve\",\n\n  // Payment Optimization\n  PAYMENT_OPTIMIZATION_SCHEDULE: \"payment:schedule\",\n  PAYMENT_OPTIMIZATION_EXPORT: \"payment:export\",\n  PAYMENT_OPTIMIZATION_EXECUTE: \"payment:execute\",\n\n  // Audit\n  AUDIT_VIEW: \"audit:view\",\n} as const;\n\nexport type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];\n\n/**\n * Permission resource and action groups for easier filtering\n */\nexport const PERMISSION_GROUPS = {\n  RESOURCE: [\n    PERMISSIONS.RESOURCE_VIEW,\n    PERMISSIONS.RESOURCE_CREATE,\n    PERMISSIONS.RESOURCE_EDIT,\n    PERMISSIONS.RESOURCE_DELETE,\n    PERMISSIONS.RESOURCE_APPROVE,\n  ],\n  ORGANIZATION: [PERMISSIONS.ORG_VIEW, PERMISSIONS.ORG_MANAGE],\n  INVOICES: [\n    PERMISSIONS.INVOICE_VIEW,\n    PERMISSIONS.INVOICE_CREATE,\n    PERMISSIONS.INVOICE_UPLOAD,\n    PERMISSIONS.INVOICE_DELETE,\n  ],\n  APPROVALS: [PERMISSIONS.APPROVALS_VIEW, PERMISSIONS.APPROVALS_APPROVE],\n  DUPLICATES: [PERMISSIONS.DUPLICATES_VIEW, PERMISSIONS.DUPLICATES_RESOLVE],\n  PAYMENTS: [\n    PERMISSIONS.PAYMENT_OPTIMIZATION_SCHEDULE,\n    PERMISSIONS.PAYMENT_OPTIMIZATION_EXPORT,\n    PERMISSIONS.PAYMENT_OPTIMIZATION_EXECUTE,\n  ],\n  AUDIT: [PERMISSIONS.AUDIT_VIEW],\n} as const;\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/server-constants.ts",
    "content": "import \"server-only\";\n\n// Server-only: Read session duration from environment\nexport function getSessionDurationMinutes(): number {\n  return (\n    Number(process.env.NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES ?? \"480\") ||\n    480\n  );\n}\n\n// Server-only: Cookie config builder\nexport function getCookieConfig() {\n  const isProduction = process.env.NODE_ENV === \"production\";\n\n  return {\n    httpOnly: true, // Prevent XSS attacks from accessing cookies\n    sameSite: \"lax\" as const,\n    secure: isProduction,\n    path: \"/\",\n  };\n}\n\nexport function getSecureCookieConfig() {\n  return {\n    ...getCookieConfig(),\n    httpOnly: true,\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/server-permissions.ts",
    "content": "/**\n * Server-Side Permission Utilities\n * Fetch permissions from backend API with proper cache control\n */\n\nimport type { B2BSessionsAuthenticateResponse } from \"stytch\";\n\nimport { profileRepository } from \"@/lib/api/api/repositories/profile-repository\";\nimport type { ProfileResponseDto } from \"@/lib/api/api/dto/profile.dto\";\nimport { PERMISSIONS } from \"./permissions\";\nimport type { Permission } from \"./permissions\";\n\nexport interface ServerPermissions {\n  profile: ProfileResponseDto | null;\n  roles: string[];\n  permissions: string[];\n  canViewInvoices: boolean;\n  canCreateInvoices: boolean;\n  canUploadInvoices: boolean;\n  canDeleteInvoices: boolean;\n  canViewApprovals: boolean;\n  canApproveInvoices: boolean;\n  canViewDuplicates: boolean;\n  canResolveDuplicates: boolean;\n  canSchedulePayments: boolean;\n  canExportPayments: boolean;\n  canExecutePayments: boolean;\n  canViewAudit: boolean;\n  canViewOrganization: boolean;\n  canManageOrganization: boolean;\n  canManageSubscriptions: boolean; // Derived from org:manage\n  backendAvailable: boolean;\n  backendError?: string | null;\n}\n\nconst KNOWN_PERMISSIONS = new Set<string>(Object.values(PERMISSIONS));\n\n/**\n * Compute all permissions for the user based on their session\n * Fetches permissions from backend API - NO CACHING to ensure fresh data\n *\n * Architecture:\n * 1. Get email/name from session.member (Stytch session object)\n * 2. Fetch permissions from backend /auth/profile/me API\n * 3. Backend computes permissions from Stytch RBAC\n * 4. Backend validates permissions on every API call (security maintained)\n */\nexport async function getServerPermissions(\n  session: B2BSessionsAuthenticateResponse | null\n): Promise<ServerPermissions> {\n  const emptyPermissions: ServerPermissions = {\n    profile: null,\n    roles: [],\n    permissions: [],\n    canViewInvoices: false,\n    canCreateInvoices: false,\n    canUploadInvoices: false,\n    canDeleteInvoices: false,\n    canViewApprovals: false,\n    canApproveInvoices: false,\n    canViewDuplicates: false,\n    canResolveDuplicates: false,\n    canSchedulePayments: false,\n    canExportPayments: false,\n    canExecutePayments: false,\n    canViewAudit: false,\n    canViewOrganization: false,\n    canManageOrganization: false,\n    canManageSubscriptions: false,\n    backendAvailable: true,\n    backendError: null,\n  };\n\n  if (!session || !session.session_jwt) {\n    return emptyPermissions;\n  }\n\n  try {\n    // Fetch profile with backend-computed permissions\n    const profile = await profileRepository.getProfile(session.session_jwt);\n\n    if (!profile) {\n      return emptyPermissions;\n    }\n\n    // Defensive: Handle missing or undefined permissions/roles fields\n    const rawPermissions = Array.isArray(profile.permissions)\n      ? profile.permissions\n      : [];\n    const rawRoles = Array.isArray(profile.roles)\n      ? profile.roles\n      : [];\n\n    // Filter to only valid string permissions (trust backend as source of truth)\n    const permissions = rawPermissions.filter(\n      (permission): permission is string =>\n        typeof permission === 'string' && permission.trim().length > 0\n    );\n    const roles = rawRoles.filter(\n      (role): role is string => typeof role === 'string'\n    );\n\n    return {\n      profile,\n      roles,\n      permissions: permissions as string[],\n      canViewInvoices: permissions.includes(PERMISSIONS.INVOICE_VIEW),\n      canCreateInvoices: permissions.includes(PERMISSIONS.INVOICE_CREATE),\n      canUploadInvoices: permissions.includes(PERMISSIONS.INVOICE_UPLOAD),\n      canDeleteInvoices: permissions.includes(PERMISSIONS.INVOICE_DELETE),\n      canViewApprovals: permissions.includes(PERMISSIONS.APPROVALS_VIEW),\n      canApproveInvoices: permissions.includes(PERMISSIONS.APPROVALS_APPROVE),\n      canViewDuplicates: permissions.includes(PERMISSIONS.DUPLICATES_VIEW),\n      canResolveDuplicates: permissions.includes(\n        PERMISSIONS.DUPLICATES_RESOLVE\n      ),\n      canSchedulePayments: permissions.includes(\n        PERMISSIONS.PAYMENT_OPTIMIZATION_SCHEDULE\n      ),\n      canExportPayments: permissions.includes(\n        PERMISSIONS.PAYMENT_OPTIMIZATION_EXPORT\n      ),\n      canExecutePayments: permissions.includes(\n        PERMISSIONS.PAYMENT_OPTIMIZATION_EXECUTE\n      ),\n      canViewAudit: permissions.includes(PERMISSIONS.AUDIT_VIEW),\n      canViewOrganization: permissions.includes(PERMISSIONS.ORG_VIEW),\n      canManageOrganization: permissions.includes(PERMISSIONS.ORG_MANAGE),\n      canManageSubscriptions: permissions.includes(PERMISSIONS.ORG_MANAGE),\n      backendAvailable: true,\n      backendError: null,\n    };\n  } catch (error) {\n    const code = (error as any)?.cause?.code;\n    const baseMessage = error instanceof Error ? error.message : \"Unknown error\";\n    const errorMessage = code ? `${code}: ${baseMessage}` : baseMessage;\n\n    console.error('[Auth] Failed to fetch profile from backend:', {\n      message: baseMessage,\n      code,\n      error,\n    });\n\n    return {\n      ...emptyPermissions,\n      backendAvailable: false,\n      backendError: errorMessage,\n    };\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/stytch/server.ts",
    "content": "import Stytch, { envs as stytchEnvs } from \"stytch\";\nimport type { B2BSessionsAuthenticateResponse } from \"stytch\";\nimport { cookies } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport { cache } from \"react\";\n\nimport { buildLoginUrl } from \"@/lib/auth/stytch\";\nimport { SESSION_COOKIE_NAME, SESSION_JWT_COOKIE_NAME } from \"@/lib/auth/constants\";\n\n/**\n * Decode JWT token and extract claims without verification\n * This is safe because Stytch has already verified the token\n */\nexport function decodeJWT(token: string): Record<string, any> | null {\n  try {\n    // JWT format: header.payload.signature\n    const parts = token.split(\".\");\n    if (parts.length !== 3) {\n      return null;\n    }\n\n    // Decode the payload (second part)\n    const payload = parts[1];\n    const decodedPayload = Buffer.from(payload, \"base64url\").toString(\"utf-8\");\n    return JSON.parse(decodedPayload);\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Extract roles from JWT token claims\n * Stytch B2B stores roles in namespaced claim: https://stytch.com/session.roles\n */\nexport function extractRolesFromJWT(token: string): string[] {\n  const claims = decodeJWT(token);\n  if (!claims) return [];\n\n  // Stytch uses namespaced claims for session data\n  const stytchSession = claims[\"https://stytch.com/session\"] as { roles?: unknown[] } | undefined;\n  if (stytchSession && Array.isArray(stytchSession.roles)) {\n    return stytchSession.roles.filter(\n      (r: unknown): r is string => typeof r === \"string\"\n    );\n  }\n\n  // Fallback: check top-level roles (some configurations)\n  if (Array.isArray(claims.roles)) {\n    return claims.roles.filter((r): r is string => typeof r === \"string\");\n  }\n\n  return [];\n}\n\ntype RequireSessionOptions = {\n  returnTo?: string;\n};\n\ntype StytchClient = InstanceType<typeof Stytch.B2BClient>;\n\nlet client: StytchClient | null = null;\nlet organizationIdPromise: Promise<string[]> | null = null;\n\nfunction requiredEnv(name: string, value: string | undefined): string {\n  if (!value) {\n    throw new Error(\n      `Missing ${name} environment variable for Stytch configuration.`\n    );\n  }\n  return value;\n}\n\nexport function getStytchB2BClient(): StytchClient {\n  if (client) return client;\n\n  const projectId = requiredEnv(\n    \"STYTCH_PROJECT_ID\",\n    process.env.STYTCH_PROJECT_ID\n  );\n  const secret = requiredEnv(\"STYTCH_SECRET\", process.env.STYTCH_SECRET);\n  const projectEnv =\n    process.env.STYTCH_PROJECT_ENV ||\n    process.env.NEXT_PUBLIC_STYTCH_PROJECT_ENV ||\n    \"test\";\n\n  client = new Stytch.B2BClient({\n    project_id: projectId,\n    secret,\n    env: projectEnv === \"live\" ? stytchEnvs.live : stytchEnvs.test,\n  });\n\n  return client;\n}\n\nfunction parseAllowedOrganizationIdsEnv(): string[] {\n  const raw = process.env.STYTCH_ALLOWED_ORGANIZATION_IDS;\n  if (!raw) return [];\n\n  return raw\n    .split(\",\")\n    .map((value) => value.trim())\n    .filter((value) => value.length > 0);\n}\n\nasync function loadOrganizationIdsFromStytch(): Promise<string[]> {\n  if (organizationIdPromise) {\n    return organizationIdPromise;\n  }\n\n  organizationIdPromise = (async () => {\n    const discoveredIds = new Set<string>();\n    const stytch = getStytchB2BClient();\n    let cursor: string | undefined;\n\n    do {\n      const payload: { cursor?: string; limit?: number } = { limit: 100 };\n      if (cursor) {\n        payload.cursor = cursor;\n      }\n\n      const response = (await stytch.organizations.search(payload)) as {\n        organizations: Array<{ organization_id: string }>;\n        results_metadata?: { next_cursor?: string | null };\n      };\n\n      response.organizations.forEach((org) => {\n        if (org?.organization_id) {\n          discoveredIds.add(org.organization_id);\n        }\n      });\n\n      cursor = response.results_metadata?.next_cursor ?? undefined;\n    } while (cursor);\n\n    return Array.from(discoveredIds);\n  })().catch((error) => {\n    organizationIdPromise = null;\n    throw error;\n  });\n\n  return organizationIdPromise;\n}\n\nexport async function getOrganizationIdsForMemberSearch(): Promise<string[]> {\n  const configuredIds = parseAllowedOrganizationIdsEnv();\n  if (configuredIds.length > 0) {\n    return configuredIds;\n  }\n\n  const discoveredIds = await loadOrganizationIdsFromStytch();\n  if (discoveredIds.length === 0) {\n    throw new Error(\n      \"Unable to determine Stytch organization IDs. Provide STYTCH_ALLOWED_ORGANIZATION_IDS or ensure at least one organization exists.\"\n    );\n  }\n\n  return discoveredIds;\n}\n\nexport const getMemberSession = cache(\n  async (): Promise<B2BSessionsAuthenticateResponse | null> => {\n    const cookieStore = await cookies();\n    const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value;\n    const sessionJwt = cookieStore.get(SESSION_JWT_COOKIE_NAME)?.value;\n\n    if (!sessionToken && !sessionJwt) {\n      return null;\n    }\n\n    try {\n      const client = getStytchB2BClient();\n\n      let session: B2BSessionsAuthenticateResponse;\n\n      if (sessionJwt) {\n        session = (await client.sessions.authenticateJwt({\n          session_jwt: sessionJwt,\n        } as any)) as B2BSessionsAuthenticateResponse;\n      } else if (sessionToken) {\n        session = await client.sessions.authenticate({\n          session_token: sessionToken,\n        });\n      } else {\n        return null;\n      }\n\n      return session;\n    } catch {\n      // Session verification failed (expected for expired/invalid sessions)\n      return null;\n    }\n  }\n);\n\nexport async function requireMemberSession(\n  options?: RequireSessionOptions\n): Promise<B2BSessionsAuthenticateResponse> {\n  const session = await getMemberSession();\n\n  if (!session) {\n    const redirectTarget = buildLoginUrl({\n      returnTo: options?.returnTo,\n    });\n    redirect(redirectTarget);\n  }\n\n  return session;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/stytch-client.ts",
    "content": "/**\n * Client-safe utilities that use passed config instead of reading env\n * These functions are pure and can be safely used in client components\n */\n\nimport type { StytchClientConfig } from \"./config-types\";\n\nexport interface LoginUrlOptions {\n  returnTo?: string;\n}\n\nexport interface LogoutUrlOptions {\n  returnTo?: string;\n}\n\nfunction sanitizeReturnTo(value: string | undefined): string | undefined {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  if (!trimmed.startsWith(\"/\") || trimmed.startsWith(\"//\")) {\n    return undefined;\n  }\n  return trimmed;\n}\n\nfunction resolveRoute(\n  baseUrl: string,\n  path: string,\n  fallback: string\n): URL {\n  const trimmed = path?.trim();\n\n  if (!trimmed) {\n    return new URL(fallback, `${baseUrl}/`);\n  }\n\n  if (/^https?:\\/\\//i.test(trimmed)) {\n    return new URL(trimmed);\n  }\n\n  const cleanPath = trimmed.startsWith(\"/\") ? trimmed : `/${trimmed}`;\n  return new URL(cleanPath, `${baseUrl}/`);\n}\n\nexport function buildLoginUrl(\n  config: StytchClientConfig,\n  options?: LoginUrlOptions\n): string {\n  const url = resolveRoute(config.baseUrl, config.loginPath, \"/auth\");\n  const returnTo = sanitizeReturnTo(options?.returnTo);\n\n  if (returnTo) {\n    url.searchParams.set(\"returnTo\", returnTo);\n  }\n\n  return url.toString();\n}\n\nexport function buildLogoutUrl(\n  config: StytchClientConfig,\n  options?: LogoutUrlOptions\n): string {\n  const url = resolveRoute(\n    config.baseUrl,\n    config.logoutPath,\n    \"/api/auth/logout\"\n  );\n  const returnTo = sanitizeReturnTo(options?.returnTo);\n\n  if (returnTo) {\n    url.searchParams.set(\"returnTo\", returnTo);\n  }\n\n  return url.toString();\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/stytch-server.ts",
    "content": "/**\n * Server-only utilities for building auth URLs and config\n * These read from process.env and are NOT bundled into client code\n */\n\n\nimport \"server-only\";\nimport type { StytchClientConfig } from \"./config-types\";\n\nexport interface LoginUrlOptions {\n  returnTo?: string;\n}\n\nexport interface LogoutUrlOptions {\n  returnTo?: string;\n}\n\nfunction sanitizeReturnTo(value: string | undefined): string | undefined {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  if (!trimmed.startsWith(\"/\") || trimmed.startsWith(\"//\")) {\n    return undefined;\n  }\n  return trimmed;\n}\n\nexport function getBaseUrl(): string {\n  let baseUrl =\n    process.env.APP_BASE_URL ||\n    process.env.NEXT_PUBLIC_BASE_URL ||\n    process.env.NEXT_PUBLIC_APP_BASE_URL ||\n    process.env.APP_URL ||\n    null;\n\n  const resolved = baseUrl || \"http://localhost:3000\";\n  return resolved.replace(/\\/$/, \"\");\n}\n\nfunction resolveRoute(value: string | undefined, fallback: string): URL {\n  const base = getBaseUrl();\n  const trimmed = value?.trim();\n\n  if (!trimmed) {\n    return new URL(fallback, `${base}/`);\n  }\n\n  if (/^https?:\\/\\//i.test(trimmed)) {\n    return new URL(trimmed);\n  }\n\n  const path = trimmed.startsWith(\"/\") ? trimmed : `/${trimmed}`;\n  return new URL(path, `${base}/`);\n}\n\nexport function buildLoginUrl(options?: LoginUrlOptions): string {\n  const url = resolveRoute(process.env.NEXT_PUBLIC_STYTCH_LOGIN_PATH, \"/auth\");\n  const returnTo = sanitizeReturnTo(options?.returnTo);\n\n  if (returnTo) {\n    url.searchParams.set(\"returnTo\", returnTo);\n  }\n\n  return url.toString();\n}\n\nexport function buildLogoutUrl(options?: LogoutUrlOptions): string {\n  const url = resolveRoute(\n    process.env.NEXT_PUBLIC_STYTCH_LOGOUT_PATH,\n    \"/api/auth/logout\"\n  );\n  const returnTo = sanitizeReturnTo(options?.returnTo);\n\n  if (returnTo) {\n    url.searchParams.set(\"returnTo\", returnTo);\n  }\n\n  return url.toString();\n}\n\n/**\n * Build complete client config from environment variables\n * This should only be called on the server\n */\nexport function buildStytchClientConfig(): StytchClientConfig {\n  const publicToken = process.env.NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN;\n  if (!publicToken) {\n    throw new Error(\"NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN is required\");\n  }\n\n  return {\n    publicToken,\n    sessionDurationMinutes:\n      Number(process.env.NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES ?? \"480\") ||\n      480,\n    loginPath: process.env.NEXT_PUBLIC_STYTCH_LOGIN_PATH || \"/auth\",\n    logoutPath:\n      process.env.NEXT_PUBLIC_STYTCH_LOGOUT_PATH || \"/api/auth/logout\",\n    redirectPath:\n      process.env.NEXT_PUBLIC_STYTCH_REDIRECT_PATH || \"/authenticate\",\n    baseUrl: getBaseUrl(),\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/stytch.ts",
    "content": "// Authentication URL builders for Stytch\n// These functions handle URL construction with proper safety checks\n\nexport interface LoginUrlOptions {\n  returnTo?: string;\n}\n\nexport interface LogoutUrlOptions {\n  returnTo?: string;\n}\n\nexport function sanitizeReturnTo(value: string | undefined): string | undefined {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  if (!trimmed.startsWith(\"/\") || trimmed.startsWith(\"//\")) {\n    return undefined;\n  }\n  return trimmed;\n}\n\nexport function getBaseUrl(): string {\n  if (typeof window !== \"undefined\") {\n    return window.location.origin.replace(/\\/$/, \"\");\n  }\n\n  let baseUrl =\n    process.env.APP_BASE_URL ||\n    process.env.NEXT_PUBLIC_BASE_URL ||\n    process.env.NEXT_PUBLIC_APP_BASE_URL ||\n    process.env.APP_URL ||\n    null;\n\n  const resolved = baseUrl || \"http://localhost:3000\";\n  return resolved.replace(/\\/$/, \"\");\n}\n\nfunction resolveRoute(value: string | undefined, fallback: string): URL {\n  const base = getBaseUrl();\n  const trimmed = value?.trim();\n\n  if (!trimmed) {\n    return new URL(fallback, `${base}/`);\n  }\n\n  if (/^https?:\\/\\//i.test(trimmed)) {\n    return new URL(trimmed);\n  }\n\n  const path = trimmed.startsWith(\"/\") ? trimmed : `/${trimmed}`;\n  return new URL(path, `${base}/`);\n}\n\nexport function buildLoginUrl(options?: LoginUrlOptions): string {\n  const url = resolveRoute(process.env.NEXT_PUBLIC_STYTCH_LOGIN_PATH, \"/auth\");\n  const returnTo = sanitizeReturnTo(options?.returnTo);\n\n  if (returnTo) {\n    url.searchParams.set(\"returnTo\", returnTo);\n  }\n\n  return url.toString();\n}\n\nexport function buildLogoutUrl(options?: LogoutUrlOptions): string {\n  const url = resolveRoute(process.env.NEXT_PUBLIC_STYTCH_LOGOUT_PATH, \"/api/auth/logout\");\n  const returnTo = sanitizeReturnTo(options?.returnTo);\n\n  if (returnTo) {\n    url.searchParams.set(\"returnTo\", returnTo);\n  }\n\n  return url.toString();\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/subscription.ts",
    "content": "\nimport type { Member, MemberSession } from \"stytch\";\n\nimport {\n  buildLoginUrl as buildStytchLoginUrl,\n  buildLogoutUrl as buildStytchLogoutUrl,\n  getBaseUrl as getStytchBaseUrl,\n} from \"@/lib/auth/stytch-server\";\n\nconst SUBSCRIPTION_CLAIM = \"https://api.yourdomain.com/subscription\";\nconst SUBSCRIPTION_FLAG = \"https://api.yourdomain.com/has_active_subscription\";\n\n/**\n * Check if a user has an active subscription\n * This function is safe for both server and client contexts\n */\nexport function hasActiveSubscription(\n  session: Pick<MemberSession, \"custom_claims\" | \"roles\"> | undefined | null,\n  member?: Pick<Member, \"roles\" | \"trusted_metadata\"> | null\n): boolean {\n  if (!session && !member) return false;\n\n  const customClaims = session?.custom_claims ?? {};\n  const trustedMetadata = (member?.trusted_metadata ?? {}) as Record<\n    string,\n    unknown\n  >;\n\n  const status =\n    (customClaims[SUBSCRIPTION_CLAIM] as string | undefined) ||\n    (trustedMetadata[\"subscription_status\"] as string | undefined);\n\n  if (status) {\n    const normalized = status.toLowerCase();\n    if ([\"active\", \"trialing\", \"grace\"].includes(normalized)) return true;\n    if ([\"canceled\", \"cancelled\", \"inactive\", \"past_due\"].includes(normalized))\n      return false;\n  }\n\n  const roles = new Set<string>();\n  session?.roles?.forEach((role) => roles.add(role));\n  member?.roles?.forEach((role) => {\n    if (role?.role_id) {\n      roles.add(role.role_id);\n    }\n  });\n\n  if (roles.has(\"subscriber\")) return true;\n\n  const hasFlag =\n    (customClaims[SUBSCRIPTION_FLAG] as boolean | undefined) ??\n    (trustedMetadata[\"has_active_subscription\"] as boolean | undefined);\n\n  if (typeof hasFlag === \"boolean\") {\n    return hasFlag;\n  }\n\n  return true;\n}\n\n/**\n * Get base URL - server-only function\n * @throws Error if called on client\n */\nexport function getBaseUrl(): string {\n  if (typeof window !== \"undefined\") {\n    throw new Error(\"getBaseUrl() must only be called on server\");\n  }\n\n  return getStytchBaseUrl();\n}\n\nexport interface LoginUrlOptions {\n  returnTo?: string;\n}\n\n/**\n * Build login URL - server-only function\n * @throws Error if called on client\n */\nexport function buildLoginUrl(options?: LoginUrlOptions) {\n  return buildStytchLoginUrl({\n    returnTo: options?.returnTo,\n  });\n}\n\nexport interface LogoutUrlOptions {\n  returnTo?: string;\n}\n\n/**\n * Build logout URL - server-only function\n * @throws Error if called on client\n */\nexport function buildLogoutUrl(options?: LogoutUrlOptions) {\n  return buildStytchLogoutUrl({\n    returnTo: options?.returnTo,\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/auth/token-utils.ts",
    "content": "import { jwtDecode, type JwtPayload } from \"jwt-decode\";\n\n\n\nexport type AccessTokenPayload = JwtPayload & {\n  [key: string]: unknown;\n};\n\nexport function decodeAccessToken(\n  token: string | null\n): AccessTokenPayload | null {\n  if (!token) {\n    return null;\n  }\n\n  // Validate JWT structure (must have 3 parts separated by dots)\n  const parts = token.split(\".\");\n  if (parts.length !== 3) {\n    console.warn(\"[Token] Invalid JWT structure: expected 3 parts, got\", parts.length);\n    return null;\n  }\n\n  try {\n    const decoded = jwtDecode<AccessTokenPayload>(token);\n\n    // Validate required JWT claims\n    if (!decoded.exp || !decoded.iat) {\n      console.warn(\"[Token] Missing required claims (exp, iat)\");\n      return null;\n    }\n\n    return decoded;\n  } catch (error) {\n    console.warn(\"[Token] Failed to decode JWT:\", error instanceof Error ? error.message : \"Unknown error\");\n    return null;\n  }\n}\n\nexport function isTokenExpired(\n  tokenOrPayload: string | AccessTokenPayload | null,\n  graceSeconds: number = 60\n): boolean {\n  const payload =\n    typeof tokenOrPayload === \"string\"\n      ? decodeAccessToken(tokenOrPayload)\n      : tokenOrPayload;\n\n  if (!payload || typeof payload.exp !== \"number\") {\n    return true;\n  }\n\n  const nowSeconds = Math.floor(Date.now() / 1000);\n  const expiresAt = payload.exp;\n  const isExpired = expiresAt <= nowSeconds + graceSeconds;\n\n  // Log if token is close to expiring for debugging\n  if (isExpired) {\n    const timeToExpiry = expiresAt - nowSeconds;\n    console.debug(\"[Token] Token expired or expiring soon:\", {\n      expiresAt: new Date(expiresAt * 1000).toISOString(),\n      timeToExpiry: `${timeToExpiry}s`,\n      graceSeconds,\n    });\n  }\n\n  return isExpired;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/contexts/auth-context.tsx",
    "content": "'use client';\n\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\n\nimport type { ProfileResponseDto } from \"@/lib/api/api/dto/profile.dto\";\nimport {\n  hasPermission as checkPermission,\n  hasAnyPermission as checkAnyPermission,\n  hasAllPermissions as checkAllPermissions,\n  hasRole as checkRole,\n  hasAnyRole as checkAnyRole,\n  hasAllRoles as checkAllRoles,\n} from \"@/lib/auth/permission-utils\";\nimport { isTokenExpired } from \"@/lib/auth/token-utils\";\nimport { SESSION_JWT_COOKIE_NAME } from \"@/lib/auth/constants\";\n\nconst STORAGE_KEY = \"apcash.auth.state\";\nconst SESSION_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours in milliseconds\n\ntype AuthState = {\n  profile: ProfileResponseDto | null;\n  roles: string[];\n  permissions: string[];\n  expiresAt?: number; // Unix timestamp in milliseconds\n};\n\nfunction sanitizeRoles(roles: unknown): string[] {\n  if (!Array.isArray(roles)) {\n    return [];\n  }\n\n  return roles\n    .filter((role): role is string => typeof role === \"string\")\n    .map((role) => role.trim())\n    .filter(Boolean);\n}\n\nfunction sanitizePermissions(permissions: unknown): string[] {\n  if (!Array.isArray(permissions)) {\n    return [];\n  }\n\n  // Trust backend as source of truth - only validate type and filter Stytch internal permissions\n  return permissions.filter(\n    (permission): permission is string =>\n      typeof permission === \"string\" &&\n      permission.trim().length > 0 &&\n      !permission.startsWith('stytch.')\n  );\n}\n\nexport interface AuthContextValue {\n  profile: ProfileResponseDto | null;\n  roles: string[];\n  permissions: string[];\n  hasPermission: (permission: string) => boolean;\n  hasAnyPermission: (permissions: string[]) => boolean;\n  hasAllPermissions: (permissions: string[]) => boolean;\n  hasRole: (role: string) => boolean;\n  hasAnyRole: (roles: string[]) => boolean;\n  hasAllRoles: (roles: string[]) => boolean;\n  isInitialized: boolean;\n  isAuthenticated: boolean;\n  updateAuthState: (state: Partial<AuthState>) => void;\n  clearAuthState: () => void;\n}\n\nconst AuthContext = createContext<AuthContextValue | null>(null);\n\nfunction readStoredState(): AuthState | null {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  try {\n    const raw = window.sessionStorage.getItem(STORAGE_KEY);\n    if (!raw) {\n      return null;\n    }\n\n    const parsed = JSON.parse(raw) as AuthState;\n    if (!parsed || typeof parsed !== \"object\") {\n      return null;\n    }\n\n    // Check if cached data has expired\n    if (parsed.expiresAt && Date.now() > parsed.expiresAt) {\n      console.info(\"[Auth] Cached auth state expired, clearing\");\n      window.sessionStorage.removeItem(STORAGE_KEY);\n      return null;\n    }\n\n    return {\n      profile: parsed.profile ?? null,\n      roles: sanitizeRoles(parsed.roles),\n      permissions: sanitizePermissions(parsed.permissions),\n      expiresAt: parsed.expiresAt,\n    };\n  } catch (error) {\n    console.warn(\"[Auth] Failed to parse stored auth state\", error);\n    return null;\n  }\n}\n\nfunction persistState(state: AuthState): void {\n  if (typeof window === \"undefined\") {\n    return;\n  }\n\n  try {\n    if (!state.profile) {\n      window.sessionStorage.removeItem(STORAGE_KEY);\n      return;\n    }\n\n    // Calculate expiration time (8 hours from now)\n    const expiresAt = Date.now() + SESSION_DURATION_MS;\n\n    const payload: AuthState = {\n      profile: state.profile,\n      roles: state.roles,\n      permissions: state.permissions,\n      expiresAt,\n    };\n\n    window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));\n  } catch (error) {\n    console.warn(\"[Auth] Failed to persist auth state\", error);\n  }\n}\n\nfunction readBrowserCookie(name: string): string | null {\n  if (typeof document === \"undefined\") {\n    return null;\n  }\n\n  try {\n    const value = `; ${document.cookie}`;\n    const parts = value.split(`; ${name}=`);\n    if (parts.length === 2) {\n      return parts.pop()?.split(';').shift() || null;\n    }\n    return null;\n  } catch {\n    return null;\n  }\n}\n\nfunction isSessionTokenValid(): boolean {\n  const token = readBrowserCookie(SESSION_JWT_COOKIE_NAME);\n  if (!token) {\n    return false;\n  }\n\n  // Check if token is expired\n  return !isTokenExpired(token);\n}\n\nexport interface AuthProviderProps {\n  initialProfile: ProfileResponseDto | null;\n  initialRoles: string[];\n  initialPermissions: string[];\n  shouldClearCache?: boolean;\n  children: ReactNode;\n}\n\nexport function AuthProvider({\n  initialProfile,\n  initialRoles,\n  initialPermissions,\n  shouldClearCache = false,\n  children,\n}: AuthProviderProps) {\n  const [state, setState] = useState<AuthState>({\n    profile: initialProfile,\n    roles: sanitizeRoles(initialRoles),\n    permissions: sanitizePermissions(initialPermissions),\n  });\n  const [isHydrated, setIsHydrated] = useState(false);\n\n  // Sync with server-provided props or fall back to cached client state\n  useEffect(() => {\n    // If server signals to clear cache, do so immediately\n    if (shouldClearCache) {\n      console.info(\"[Auth] Server requested cache clear\");\n      window.sessionStorage.removeItem(STORAGE_KEY);\n      setIsHydrated(true);\n      return;\n    }\n\n    if (initialProfile) {\n      // Only update state if it's different from initial values\n      setState((prev) => {\n        const needsUpdate =\n          prev.profile?.email !== initialProfile.email ||\n          prev.roles.length !== initialRoles.length ||\n          prev.permissions.length !== initialPermissions.length;\n\n        if (needsUpdate) {\n          return {\n            profile: initialProfile,\n            roles: sanitizeRoles(initialRoles),\n            permissions: sanitizePermissions(initialPermissions),\n          };\n        }\n        return prev;\n      });\n      setIsHydrated(true);\n      return;\n    }\n\n    // No server session - check if cached data is still valid\n    const stored = readStoredState();\n    if (stored?.profile) {\n      // Validate session token before using cached data\n      if (isSessionTokenValid()) {\n        setState(stored);\n      } else {\n        // Token expired or missing - clear cache\n        console.info(\"[Auth] Session token invalid, clearing cached auth state\");\n        window.sessionStorage.removeItem(STORAGE_KEY);\n      }\n    }\n\n    setIsHydrated(true);\n  }, [initialProfile, initialRoles, initialPermissions, shouldClearCache]);\n\n  // Persist whenever state changes (and after hydration to avoid SSR mismatch)\n  useEffect(() => {\n    if (!isHydrated) return;\n    persistState(state);\n  }, [state, isHydrated]);\n\n  const value = useMemo<AuthContextValue>(() => {\n    const { profile, roles, permissions } = state;\n\n    return {\n      profile,\n      roles,\n      permissions,\n      hasPermission: (permission: string) => checkPermission(permissions, permission),\n      hasAnyPermission: (required: string[]) => checkAnyPermission(permissions, required),\n      hasAllPermissions: (required: string[]) => checkAllPermissions(permissions, required),\n      hasRole: (role: string) => checkRole(roles, role),\n      hasAnyRole: (required: string[]) => checkAnyRole(roles, required),\n      hasAllRoles: (required: string[]) => checkAllRoles(roles, required),\n      isInitialized: isHydrated,\n      isAuthenticated: !!profile,\n      updateAuthState: (next: Partial<AuthState>) =>\n        setState((prev) => ({\n          profile:\n            next.profile === undefined ? prev.profile : next.profile ?? null,\n          roles:\n            next.roles === undefined\n              ? prev.roles\n              : sanitizeRoles(next.roles),\n          permissions:\n            next.permissions === undefined\n              ? prev.permissions\n              : sanitizePermissions(next.permissions),\n        })),\n      clearAuthState: () => {\n        // Clear sessionStorage\n        if (typeof window !== \"undefined\") {\n          window.sessionStorage.removeItem(STORAGE_KEY);\n        }\n        // Reset state\n        setState({\n          profile: null,\n          roles: [],\n          permissions: [],\n        });\n      },\n    };\n  }, [state, isHydrated]);\n\n  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n\nexport function useAuthContext(): AuthContextValue | null {\n  return useContext(AuthContext);\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/contexts/stytch-config-context.tsx",
    "content": "\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { StytchClientConfig } from \"@/lib/auth/config-types\";\n\nconst StytchConfigContext = createContext<StytchClientConfig | null>(null);\n\nexport function StytchConfigProvider({\n  children,\n  config,\n}: {\n  children: React.ReactNode;\n  config: StytchClientConfig;\n}) {\n  return (\n    <StytchConfigContext.Provider value={config}>\n      {children}\n    </StytchConfigContext.Provider>\n  );\n}\n\nexport function useStytchConfig(): StytchClientConfig {\n  const config = useContext(StytchConfigContext);\n  if (!config) {\n    throw new Error(\"useStytchConfig must be used within StytchConfigProvider\");\n  }\n  return config;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-chat.ts",
    "content": "// lib/hooks/mutations/use-chat.ts\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { queryKeys } from \"../queries/query-keys\";\nimport { cognitiveRepository } from \"@/lib/api/api/repositories/cognitive-repository\";\nimport type { ChatRequest, ChatResponse } from \"@/lib/models/cognitive.model\";\n\nexport function useChat() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: (request: ChatRequest) => cognitiveRepository.chat(request),\n\n    onSuccess: (data: ChatResponse) => {\n      // Invalidate sessions list to include new session if created\n      queryClient.invalidateQueries({\n        queryKey: queryKeys.cognitive.sessions(),\n      });\n\n      // Invalidate messages for this session\n      if (data.sessionId) {\n        queryClient.invalidateQueries({\n          queryKey: queryKeys.cognitive.messages(data.sessionId),\n        });\n      }\n    },\n\n    onError: (error, variables) => {\n      console.error(\"[Mutation] Chat message failed:\", {\n        error,\n        sessionId: variables.sessionId,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-delete-document.ts",
    "content": "// lib/hooks/mutations/use-delete-document.ts\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { queryKeys } from \"../queries/query-keys\";\nimport { documentRepository } from \"@/lib/api/api/repositories/document-repository\";\n\ninterface DeleteDocumentVariables {\n  documentId: number;\n}\n\nexport function useDeleteDocument() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({ documentId }: DeleteDocumentVariables) =>\n      documentRepository.deleteDocument(documentId),\n\n    onSuccess: (_data, variables) => {\n      // Invalidate documents list to refetch without deleted document\n      queryClient.invalidateQueries({\n        queryKey: queryKeys.documents.all,\n      });\n    },\n\n    onError: (error, variables) => {\n      console.error(\"[Mutation] Document deletion failed:\", {\n        error,\n        documentId: variables.documentId,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-invite-member.ts",
    "content": "/**\n * Invite Member Mutation\n *\n * Handles member invitation with automatic members list invalidation.\n */\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\nimport { queryKeys } from \"../queries/query-keys\";\nimport type {\n  InviteMemberRequest,\n  InviteMemberResponse,\n} from \"@/lib/models/member.model\";\n\ninterface InviteMemberVariables {\n  request: InviteMemberRequest;\n  organizationId: string;\n}\n\nexport function useInviteMember() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({ request, organizationId }: InviteMemberVariables) =>\n      memberRepository.inviteMember(request, organizationId),\n\n    // On success, invalidate members list to refetch with new member\n    onSuccess: (data: InviteMemberResponse, variables) => {\n      console.info(\"[Mutation] Member invited successfully:\", {\n        memberId: data.memberId,\n        organizationId: variables.organizationId,\n      });\n\n      // Invalidate all member lists for this organization\n      queryClient.invalidateQueries({\n        queryKey: queryKeys.members.all,\n      });\n    },\n\n    // On error, log the failure\n    onError: (error, variables) => {\n      console.error(\"[Mutation] Member invitation failed:\", {\n        error,\n        email: variables.request.email,\n        organizationId: variables.organizationId,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-remove-member.ts",
    "content": "/**\n * Remove Member Mutation\n *\n * Handles member removal with optimistic UI updates.\n * Immediately removes member from UI, then rolls back on error.\n */\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\nimport { queryKeys } from \"../queries/query-keys\";\nimport type { MemberListResponse } from \"@/lib/models/member.model\";\n\ninterface RemoveMemberVariables {\n  memberId: string;\n}\n\nexport function useRemoveMember() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({ memberId }: RemoveMemberVariables) =>\n      memberRepository.removeMember(memberId),\n\n    // Optimistic update: Remove member from UI immediately\n    onMutate: async ({ memberId }) => {\n      // Cancel any outgoing refetches\n      await queryClient.cancelQueries({\n        queryKey: queryKeys.members.all,\n      });\n\n      // Snapshot all members queries for this organization\n      const previousQueries = queryClient.getQueriesData<MemberListResponse>({\n        queryKey: queryKeys.members.all,\n      });\n\n      // Optimistically remove the member from all cached queries\n      queryClient.setQueriesData<MemberListResponse>(\n        { queryKey: queryKeys.members.all },\n        (old) => {\n          if (!old) return old;\n\n          return {\n            ...old,\n            members: old.members.filter((member) => member.id !== memberId),\n            totalCount: old.totalCount - 1,\n          };\n        }\n      );\n\n      // Return context for rollback\n      return { previousQueries };\n    },\n\n    // On success, log it\n    onSuccess: (_data, variables) => {\n      console.info(\"[Mutation] Member removed successfully:\", {\n        memberId: variables.memberId,\n      });\n    },\n\n    // On error, rollback to previous state\n    onError: (error, variables, context) => {\n      console.error(\"[Mutation] Member removal failed:\", {\n        error,\n        memberId: variables.memberId,\n      });\n\n      // Restore all previous queries\n      if (context?.previousQueries) {\n        context.previousQueries.forEach(([queryKey, data]) => {\n          queryClient.setQueryData(queryKey, data);\n        });\n      }\n    },\n\n    // Always refetch to ensure consistency\n    onSettled: () => {\n      queryClient.invalidateQueries({\n        queryKey: queryKeys.members.all,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-resend-invitation.ts",
    "content": "/**\n * Resend Invitation Mutation\n *\n * Handles resending invitation emails to pending members.\n * No optimistic updates needed since UI doesn't change.\n */\n\nimport { useMutation } from \"@tanstack/react-query\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\n\ninterface ResendInvitationVariables {\n  memberId: string;\n}\n\nexport function useResendInvitation() {\n  return useMutation({\n    mutationFn: ({ memberId }: ResendInvitationVariables) =>\n      memberRepository.resendInvitation(memberId),\n\n    // On success, log it\n    onSuccess: (_data, variables) => {\n      console.info(\"[Mutation] Invitation resent successfully:\", {\n        memberId: variables.memberId,\n      });\n    },\n\n    // On error, log the failure\n    onError: (error, variables) => {\n      console.error(\"[Mutation] Resend invitation failed:\", {\n        error,\n        memberId: variables.memberId,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-update-profile.ts",
    "content": "/**\n * Update Profile Mutation\n *\n * Handles profile updates with optimistic UI updates and automatic cache invalidation.\n */\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\nimport { queryKeys } from \"../queries/query-keys\";\nimport type { UpdateProfileRequest, UserProfile } from \"@/lib/models/member.model\";\n\nexport function useUpdateProfile() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: (request: UpdateProfileRequest) =>\n      memberRepository.updateProfile(request),\n\n    // Optimistic update: Update UI immediately before API call completes\n    onMutate: async (newProfile) => {\n      // Cancel any outgoing refetches\n      await queryClient.cancelQueries({\n        queryKey: queryKeys.profile.detail(),\n      });\n\n      // Snapshot the previous value\n      const previousProfile = queryClient.getQueryData<UserProfile>(\n        queryKeys.profile.detail()\n      );\n\n      // Optimistically update the cache\n      if (previousProfile) {\n        queryClient.setQueryData<UserProfile>(\n          queryKeys.profile.detail(),\n          (old) =>\n            old\n              ? {\n                  ...old,\n                  name: newProfile.name ?? old.name,\n                  avatarUrl: newProfile.avatarUrl ?? old.avatarUrl,\n                }\n              : old\n        );\n      }\n\n      // Return context with previous value for rollback\n      return { previousProfile };\n    },\n\n    // On success, update cache with fresh data from server\n    onSuccess: (data) => {\n      queryClient.setQueryData(queryKeys.profile.detail(), data);\n    },\n\n    // On error, rollback to previous value\n    onError: (error, _variables, context) => {\n      if (context?.previousProfile) {\n        queryClient.setQueryData(\n          queryKeys.profile.detail(),\n          context.previousProfile\n        );\n      }\n      console.error(\"[Mutation] Profile update failed:\", error);\n    },\n\n    // Always refetch after error or success to ensure consistency\n    onSettled: () => {\n      queryClient.invalidateQueries({\n        queryKey: queryKeys.profile.detail(),\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/mutations/use-upload-document.ts",
    "content": "// lib/hooks/mutations/use-upload-document.ts\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { queryKeys } from \"../queries/query-keys\";\nimport { documentRepository } from \"@/lib/api/api/repositories/document-repository\";\nimport type { Document } from \"@/lib/models/document.model\";\n\ninterface UploadDocumentVariables {\n  file: File;\n  title: string;\n}\n\nexport function useUploadDocument() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({ file, title }: UploadDocumentVariables) =>\n      documentRepository.uploadDocument(file, title),\n\n    onSuccess: (data: Document) => {\n      // Invalidate documents list to refetch with new document\n      queryClient.invalidateQueries({\n        queryKey: queryKeys.documents.all,\n      });\n    },\n\n    onError: (error, variables) => {\n      console.error(\"[Mutation] Document upload failed:\", {\n        error,\n        fileName: variables.file.name,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/query-keys.ts",
    "content": "/**\n * Query Keys Factory\n *\n * Centralized query keys for TanStack Query.\n * This ensures type safety, consistency, and easy invalidation.\n *\n * Pattern:\n * - `all`: Base key for the entire resource\n * - `lists()`: All lists of this resource\n * - `list(filters)`: Specific filtered list\n * - `details()`: All detail views\n * - `detail(id)`: Specific detail view\n */\n\n// import type { InvoiceListFilter } from \"@/lib/models/invoice-list.model\";\n// import type { ApprovalRequestListFilter } from \"@/lib/models/approval-request.model\";\n// import type { PaymentOptimizationListFilter } from \"@/lib/models/payment-optimization.model\";\n// import type { AuditListFilter } from \"@/lib/models/audit-trail.model\";\n// import type { PerformanceAnalyticsDateRange } from \"@/lib/models/performance-analytics.model\";\nimport type { DocumentListFilter } from \"@/lib/models/document.model\";\n\n// Temporary type placeholders until models are created\ntype InvoiceListFilter = any;\ntype ApprovalRequestListFilter = any;\ntype PaymentOptimizationListFilter = any;\ntype AuditListFilter = any;\ntype PerformanceAnalyticsDateRange = any;\n\nexport const queryKeys = {\n  /**\n   * Profile query keys\n   */\n  profile: {\n    all: [\"profile\"] as const,\n    detail: () => [...queryKeys.profile.all, \"detail\"] as const,\n  },\n\n  /**\n   * Members query keys\n   */\n  members: {\n    all: [\"members\"] as const,\n    lists: () => [...queryKeys.members.all, \"list\"] as const,\n    list: (filters: {\n      organizationId?: string;\n      page?: number;\n      pageSize?: number;\n    }) => [...queryKeys.members.lists(), filters] as const,\n    detail: (memberId: string) =>\n      [...queryKeys.members.all, \"detail\", memberId] as const,\n  },\n\n  /**\n   * Subscription query keys\n   */\n  subscription: {\n    all: [\"subscription\"] as const,\n    status: () => [...queryKeys.subscription.all, \"status\"] as const,\n  },\n\n  /**\n   * Products query keys (Polar billing plans)\n   */\n  products: {\n    all: [\"products\"] as const,\n    lists: () => [...queryKeys.products.all, \"list\"] as const,\n    list: [...[\"products\"], \"list\"] as const,\n  },\n\n  /**\n   * Invoices query keys\n   */\n  invoices: {\n    all: [\"invoices\"] as const,\n    lists: () => [...queryKeys.invoices.all, \"list\"] as const,\n    list: (filters: InvoiceListFilter) =>\n      [...queryKeys.invoices.lists(), filters] as const,\n    details: () => [...queryKeys.invoices.all, \"detail\"] as const,\n    detail: (invoiceId: number) =>\n      [...queryKeys.invoices.details(), invoiceId] as const,\n    duplicates: () => [...queryKeys.invoices.all, \"duplicates\"] as const,\n    duplicate: (invoiceId: number) =>\n      [...queryKeys.invoices.duplicates(), invoiceId] as const,\n  },\n\n  /**\n   * Approvals query keys\n   */\n  approvals: {\n    all: [\"approvals\"] as const,\n    lists: () => [...queryKeys.approvals.all, \"list\"] as const,\n    list: (filters: ApprovalRequestListFilter) =>\n      [...queryKeys.approvals.lists(), filters] as const,\n    details: () => [...queryKeys.approvals.all, \"detail\"] as const,\n    detail: (approvalId: number) =>\n      [...queryKeys.approvals.details(), approvalId] as const,\n  },\n\n  /**\n   * Payments query keys\n   */\n  payments: {\n    all: [\"payments\"] as const,\n    lists: () => [...queryKeys.payments.all, \"list\"] as const,\n    list: (filters: PaymentOptimizationListFilter) =>\n      [...queryKeys.payments.lists(), filters] as const,\n    details: () => [...queryKeys.payments.all, \"detail\"] as const,\n    detail: (paymentId: number) =>\n      [...queryKeys.payments.details(), paymentId] as const,\n  },\n\n  /**\n   * Audit trail query keys\n   */\n  audit: {\n    all: [\"audit\"] as const,\n    summaries: () => [...queryKeys.audit.all, \"summaries\"] as const,\n    summaryList: (filters: AuditListFilter) =>\n      [...queryKeys.audit.summaries(), filters] as const,\n    timelines: () => [...queryKeys.audit.all, \"timelines\"] as const,\n    timeline: (invoiceId: number) =>\n      [...queryKeys.audit.timelines(), invoiceId] as const,\n  },\n\n  /**\n   * Performance Analytics query keys\n   */\n  analytics: {\n    all: [\"analytics\"] as const,\n    roi: () => [...queryKeys.analytics.all, \"roi\"] as const,\n    roiMetrics: (dateRange: PerformanceAnalyticsDateRange) =>\n      [...queryKeys.analytics.roi(), dateRange] as const,\n    breakdown: () => [...queryKeys.analytics.all, \"breakdown\"] as const,\n    savingsBreakdown: (dateRange: PerformanceAnalyticsDateRange) =>\n      [...queryKeys.analytics.breakdown(), dateRange] as const,\n  },\n\n  /**\n   * Dashboard query keys\n   */\n  dashboard: {\n    all: [\"dashboard\"] as const,\n    data: () => [...queryKeys.dashboard.all, \"data\"] as const,\n  },\n\n  /**\n   * Documents query keys\n   */\n  documents: {\n    all: [\"documents\"] as const,\n    lists: () => [\"documents\", \"list\"] as const,\n    list: (filters: DocumentListFilter) =>\n      [\"documents\", \"list\", filters] as const,\n    details: () => [\"documents\", \"detail\"] as const,\n    detail: (documentId: number) =>\n      [\"documents\", \"detail\", documentId] as const,\n  },\n\n  /**\n   * Cognitive / Chat query keys\n   */\n  cognitive: {\n    all: [\"cognitive\"] as const,\n    sessions: () => [\"cognitive\", \"sessions\"] as const,\n    messages: (sessionId: number) =>\n      [\"cognitive\", \"messages\", sessionId] as const,\n  },\n} as const;\n\n/**\n * Helper function to invalidate all queries for a resource\n *\n * @example\n * ```ts\n * // Invalidate all profile queries\n * queryClient.invalidateQueries({ queryKey: queryKeys.profile.all });\n *\n * // Invalidate specific members list\n * queryClient.invalidateQueries({\n *   queryKey: queryKeys.members.list({ organizationId: 'org-123' })\n * });\n * ```\n */\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/use-documents-query.ts",
    "content": "// lib/hooks/queries/use-documents-query.ts\n\nimport { useQuery, type UseQueryOptions } from \"@tanstack/react-query\";\nimport { queryKeys } from \"./query-keys\";\nimport { documentRepository } from \"@/lib/api/api/repositories/document-repository\";\nimport type { DocumentListResponse, DocumentListFilter } from \"@/lib/models/document.model\";\n\ninterface UseDocumentsQueryOptions {\n  status?: DocumentListFilter[\"status\"];\n  limit?: number;\n  offset?: number;\n  enabled?: boolean;\n}\n\nexport function useDocumentsQuery(\n  options: UseDocumentsQueryOptions = {},\n  queryOptions?: Omit<\n    UseQueryOptions<DocumentListResponse, Error>,\n    \"queryKey\" | \"queryFn\" | \"enabled\"\n  >\n) {\n  const { status, limit = 50, offset = 0, enabled = true } = options;\n\n  const filters: DocumentListFilter = { status, limit, offset };\n\n  return useQuery({\n    queryKey: queryKeys.documents.list(filters),\n    queryFn: () => documentRepository.listDocuments(filters),\n    enabled,\n    staleTime: 2 * 60 * 1000, // 2 minutes\n    gcTime: 5 * 60 * 1000, // 5 minutes cache\n    retry: 1,\n    refetchOnWindowFocus: false,\n    ...queryOptions,\n  });\n}\n\n/**\n * Convenience hook to get documents array directly\n */\nexport function useDocuments(options: UseDocumentsQueryOptions = {}) {\n  const { data } = useDocumentsQuery(options);\n  return data?.documents ?? [];\n}\n\n/**\n * Hook to get total document count\n */\nexport function useDocumentCount(options: UseDocumentsQueryOptions = {}) {\n  const { data } = useDocumentsQuery(options);\n  return data?.total ?? 0;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/use-members-query.ts",
    "content": "/**\n * Members Query Hook\n *\n * Fetches and caches organization members list.\n * Only fetches when organizationId is provided (lazy loading).\n */\n\nimport { useQuery, type UseQueryOptions } from \"@tanstack/react-query\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\nimport { queryKeys } from \"./query-keys\";\nimport type { MemberListResponse } from \"@/lib/models/member.model\";\n\ninterface UseMembersQueryOptions {\n  organizationId?: string;\n  page?: number;\n  pageSize?: number;\n  enabled?: boolean;\n}\n\nexport function useMembersQuery(\n  options: UseMembersQueryOptions = {},\n  queryOptions?: Omit<\n    UseQueryOptions<MemberListResponse, Error>,\n    \"queryKey\" | \"queryFn\" | \"enabled\"\n  >\n) {\n  const {\n    organizationId,\n    page = 1,\n    pageSize = 50,\n    enabled = true,\n  } = options;\n\n  return useQuery({\n    queryKey: queryKeys.members.list({ organizationId, page, pageSize }),\n    queryFn: () =>\n      memberRepository.getMembers({ organizationId, page, pageSize }),\n\n    // Only fetch if organizationId is provided and enabled is true\n    enabled: Boolean(organizationId) && enabled,\n\n    // Members data is fresh for 5 minutes\n    staleTime: 5 * 60 * 1000,\n\n    // Cache for 10 minutes\n    gcTime: 10 * 60 * 1000,\n\n    // Retry once on failure\n    retry: 1,\n\n    // Don't refetch on window focus\n    refetchOnWindowFocus: false,\n\n    ...queryOptions,\n  });\n}\n\n/**\n * Hook to get members array with safe defaults\n */\nexport function useMembers(options: UseMembersQueryOptions = {}) {\n  const { data } = useMembersQuery(options);\n  return data?.members ?? [];\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/use-products-query.ts",
    "content": "import { useQuery, type UseQueryOptions } from \"@tanstack/react-query\";\n\nimport { getProducts } from \"@/lib/actions/billing/get-products\";\nimport type { PolarPlan } from \"@/lib/polar/plans\";\nimport { queryKeys } from \"./query-keys\";\n\n/**\n * Fetch products from Polar\n *\n * Returns all active products with pricing and metadata.\n * Uses Server Action instead of API route.\n */\nexport function useProductsQuery(\n  options?: Omit<\n    UseQueryOptions<PolarPlan[], Error>,\n    \"queryKey\" | \"queryFn\"\n  >\n) {\n  return useQuery({\n    queryKey: queryKeys.products.list,\n    queryFn: async (): Promise<PolarPlan[]> => {\n      const result = await getProducts();\n\n      if (!result.success) {\n        throw new Error(result.error ?? \"Failed to fetch products\");\n      }\n\n      return result.data;\n    },\n    staleTime: 5 * 60 * 1000, // 5 minutes\n    gcTime: 10 * 60 * 1000, // 10 minutes\n    refetchOnWindowFocus: false,\n    refetchOnMount: false,\n    ...options,\n  });\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/use-profile-query.ts",
    "content": "/**\n * Profile Query Hook\n *\n * Fetches and caches the current user's profile.\n * Data is cached globally and reused across components.\n */\n\nimport { useQuery, type UseQueryOptions } from \"@tanstack/react-query\";\nimport { memberRepository } from \"@/lib/api/api/repositories/member-repository\";\nimport { queryKeys } from \"./query-keys\";\nimport type { UserProfile } from \"@/lib/models/member.model\";\n\nexport function useProfileQuery(\n  options?: Omit<\n    UseQueryOptions<UserProfile, Error>,\n    \"queryKey\" | \"queryFn\"\n  >\n) {\n  return useQuery({\n    queryKey: queryKeys.profile.detail(),\n    queryFn: () => memberRepository.getProfile(),\n\n    // Keep profile fresh for 10 minutes (longer than default)\n    staleTime: 10 * 60 * 1000,\n\n    // Cache for 30 minutes (profile doesn't change often)\n    gcTime: 30 * 60 * 1000,\n\n    // Retry failed requests once\n    retry: 1,\n\n    // Don't refetch on window focus (profile rarely changes)\n    refetchOnWindowFocus: false,\n\n    ...options,\n  });\n}\n\n/**\n * Hook to get profile data with safe defaults\n *\n * Returns null if profile is not loaded yet\n */\nexport function useProfile() {\n  const { data } = useProfileQuery();\n  return data ?? null;\n}\n\n/**\n * Hook to get organization ID from profile\n */\nexport function useOrganizationId() {\n  const profile = useProfile();\n  return profile?.organizationId ?? null;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/use-sessions-query.ts",
    "content": "// lib/hooks/queries/use-sessions-query.ts\n\nimport { useQuery, type UseQueryOptions } from \"@tanstack/react-query\";\nimport { queryKeys } from \"./query-keys\";\nimport { cognitiveRepository } from \"@/lib/api/api/repositories/cognitive-repository\";\nimport type { ChatSession, ChatMessage } from \"@/lib/models/cognitive.model\";\n\ninterface UseSessionsQueryOptions {\n  enabled?: boolean;\n}\n\nexport function useSessionsQuery(\n  options: UseSessionsQueryOptions = {},\n  queryOptions?: Omit<\n    UseQueryOptions<ChatSession[], Error>,\n    \"queryKey\" | \"queryFn\" | \"enabled\"\n  >\n) {\n  const { enabled = true } = options;\n\n  return useQuery({\n    queryKey: queryKeys.cognitive.sessions(),\n    queryFn: () => cognitiveRepository.listSessions(),\n    enabled,\n    staleTime: 1 * 60 * 1000, // 1 minute\n    gcTime: 5 * 60 * 1000, // 5 minutes cache\n    retry: 1,\n    refetchOnWindowFocus: false,\n    ...queryOptions,\n  });\n}\n\n/**\n * Convenience hook to get sessions array directly\n */\nexport function useSessions(options: UseSessionsQueryOptions = {}) {\n  const { data } = useSessionsQuery(options);\n  return data ?? [];\n}\n\ninterface UseSessionMessagesQueryOptions {\n  sessionId: number;\n  enabled?: boolean;\n}\n\nexport function useSessionMessagesQuery(\n  options: UseSessionMessagesQueryOptions,\n  queryOptions?: Omit<\n    UseQueryOptions<ChatMessage[], Error>,\n    \"queryKey\" | \"queryFn\" | \"enabled\"\n  >\n) {\n  const { sessionId, enabled = true } = options;\n\n  return useQuery({\n    queryKey: queryKeys.cognitive.messages(sessionId),\n    queryFn: () => cognitiveRepository.getSessionMessages(sessionId),\n    enabled: enabled && sessionId > 0,\n    staleTime: 30 * 1000, // 30 seconds\n    gcTime: 5 * 60 * 1000, // 5 minutes cache\n    retry: 1,\n    refetchOnWindowFocus: false,\n    ...queryOptions,\n  });\n}\n\n/**\n * Convenience hook to get messages array directly\n */\nexport function useSessionMessages(sessionId: number, enabled: boolean = true) {\n  const { data } = useSessionMessagesQuery({ sessionId, enabled });\n  return data ?? [];\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/queries/use-subscription-query.ts",
    "content": "/**\n * Subscription Query Hook\n *\n * Fetches and caches the current subscription status from Polar.\n * Uses Server Action instead of API route.\n */\n\nimport { useQuery, type UseQueryOptions } from \"@tanstack/react-query\";\nimport { queryKeys } from \"./query-keys\";\nimport { getSubscriptionStatus } from \"@/lib/actions/billing/get-subscription-status\";\nimport type { SubscriptionGateState } from \"@/lib/polar/current-subscription\";\n\nasync function fetchSubscriptionStatus(): Promise<SubscriptionGateState> {\n  const result = await getSubscriptionStatus();\n\n  if (!result.success) {\n    throw new Error(result.error ?? \"Unable to load subscription status\");\n  }\n\n  return result.data;\n}\n\nexport function useSubscriptionQuery(\n  options?: Omit<\n    UseQueryOptions<SubscriptionGateState, Error>,\n    \"queryKey\" | \"queryFn\"\n  >\n) {\n  return useQuery({\n    queryKey: queryKeys.subscription.status(),\n    queryFn: fetchSubscriptionStatus,\n\n    // Subscription status is fresh for 5 minutes\n    staleTime: 5 * 60 * 1000,\n\n    // Cache for 15 minutes\n    gcTime: 15 * 60 * 1000,\n\n    // Retry once on failure\n    retry: 1,\n\n    // Don't refetch on window focus\n    refetchOnWindowFocus: false,\n\n    ...options,\n  });\n}\n\n/**\n * Hook to get subscription state with safe defaults\n */\nexport function useSubscription() {\n  const { data } = useSubscriptionQuery();\n  return data ?? null;\n}\n\n/**\n * Hook to check if subscription is active\n */\nexport function useIsSubscriptionActive() {\n  const subscription = useSubscription();\n  return subscription?.isActive ?? false;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/hooks/use-permissions.ts",
    "content": "/**\n * React Hook for Permission Checking\n * Integrates with Stytch B2B session to provide permission-based access control\n */\n\n'use client';\n\nimport { useMemo } from 'react';\nimport { useStytchMember } from '@stytch/nextjs/b2b';\nimport {\n  hasRole,\n  hasAnyRole,\n  hasAllRoles,\n} from '@/lib/auth/permission-utils';\nimport type { Permission } from '@/lib/auth/permissions';\nimport type { ProfileResponseDto } from '@/lib/api/api/dto/profile.dto';\nimport { useAuthContext } from '@/lib/contexts/auth-context';\n\nexport interface UsePermissionsReturn {\n  /**\n   * User profile details supplied by backend\n   */\n  profile: ProfileResponseDto | null;\n\n  /**\n   * Array of role names from Stytch session\n   */\n  roles: string[];\n\n  /**\n   * Array of all permissions granted to the user\n   */\n  permissions: string[];\n\n  /**\n   * Check if user has a specific permission\n   * Supports wildcard matching (e.g., \"invoice:*\")\n   */\n  hasPermission: (permission: string) => boolean;\n\n  /**\n   * Check if user has ANY of the specified permissions (OR logic)\n   */\n  hasAnyPermission: (permissions: string[]) => boolean;\n\n  /**\n   * Check if user has ALL of the specified permissions (AND logic)\n   */\n  hasAllPermissions: (permissions: string[]) => boolean;\n\n  /**\n   * Check if user has a specific role\n   */\n  hasRole: (role: string) => boolean;\n\n  /**\n   * Check if user has ANY of the specified roles\n   */\n  hasAnyRole: (roles: string[]) => boolean;\n\n  /**\n   * Check if user has ALL of the specified roles\n   */\n  hasAllRoles: (roles: string[]) => boolean;\n\n  /**\n   * Whether the Stytch member is initialized\n   */\n  isInitialized: boolean;\n\n  /**\n   * Whether the user is authenticated\n   */\n  isAuthenticated: boolean;\n\n  /**\n   * Update cached auth state (used when profile changes client-side)\n   */\n  updateAuthState: (state: {\n    profile?: ProfileResponseDto | null;\n    roles?: string[];\n    permissions?: Permission[];\n  }) => void;\n}\n\n/**\n * Hook to access and check user permissions based on Stytch roles\n *\n * @returns Permission checking utilities and user state\n *\n * @example\n * function MyComponent() {\n *   const { hasPermission, hasAnyPermission, isAuthenticated } = usePermissions();\n *\n *   if (!isAuthenticated) return <Login />;\n *\n *   return (\n *     <>\n *       {hasPermission('invoice:create') && <CreateButton />}\n *       {hasAnyPermission(['invoice:view', 'invoice:*']) && <InvoiceList />}\n *     </>\n *   );\n * }\n */\nexport function usePermissions(): UsePermissionsReturn {\n  const context = useAuthContext();\n\n  // Safely get member, handling cases where provider might not be available\n  let member, isInitialized;\n  try {\n    const stytchData = useStytchMember();\n    member = stytchData.member;\n    isInitialized = stytchData.isInitialized;\n  } catch (error) {\n    // If Stytch provider is not available, return empty state\n    member = null;\n    isInitialized = false;\n  }\n\n  // Extract roles from Stytch member\n  // member.roles can be an array of strings (slugs) or objects\n  const roles = useMemo(() => {\n    const rawRoles = member?.roles;\n\n    if (!rawRoles) return [];\n\n    if (Array.isArray(rawRoles)) {\n      return rawRoles\n        .map(role => {\n          if (typeof role === 'string') {\n            return role;\n          }\n\n          if (role && typeof role === 'object') {\n            return (\n              (role as any).role_id ??\n              (role as any).slug ??\n              (role as any).name ??\n              (role as any).id\n            );\n          }\n\n          return undefined;\n        })\n        .filter((role): role is string => typeof role === 'string');\n    }\n\n    if (typeof rawRoles === 'string') {\n      return [rawRoles];\n    }\n\n    return [];\n  }, [member]);\n\n  // Note: This hook is deprecated - use server-side permissions instead\n  // Permissions should be computed on server and passed down as props\n  const permissions = useMemo(() => {\n    return [] as Permission[];\n  }, []);\n\n  // Create memoized permission check functions\n  // Note: These won't work correctly without server-computed permissions\n  const permissionChecks = useMemo(() => ({\n    hasPermission: (_permission: string) => false,\n    hasAnyPermission: (_perms: string[]) => false,\n    hasAllPermissions: (_perms: string[]) => false,\n    hasRole: (role: string) => hasRole(roles, role),\n    hasAnyRole: (rolesToCheck: string[]) => hasAnyRole(roles, rolesToCheck),\n    hasAllRoles: (rolesToCheck: string[]) => hasAllRoles(roles, rolesToCheck),\n  }), [roles]);\n\n  if (context) {\n    return {\n      profile: context.profile,\n      roles: context.roles,\n      permissions: context.permissions,\n      hasPermission: context.hasPermission,\n      hasAnyPermission: context.hasAnyPermission,\n      hasAllPermissions: context.hasAllPermissions,\n      hasRole: context.hasRole,\n      hasAnyRole: context.hasAnyRole,\n      hasAllRoles: context.hasAllRoles,\n      isInitialized: context.isInitialized,\n      isAuthenticated: context.isAuthenticated,\n      updateAuthState: context.updateAuthState,\n    };\n  }\n\n  return {\n    profile: null,\n    roles,\n    permissions,\n    ...permissionChecks,\n    isInitialized,\n    isAuthenticated: !!member && isInitialized,\n    updateAuthState: () => undefined,\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/models/cognitive.model.ts",
    "content": "// lib/models/cognitive.model.ts\n\nexport type ChatRole = \"user\" | \"assistant\" | \"system\";\n\nexport interface ChatSession {\n  id: number;\n  title: string;\n  createdAt: Date;\n  updatedAt: Date;\n}\n\nexport interface ChatMessage {\n  id: number;\n  sessionId: number;\n  role: ChatRole;\n  content: string;\n  referencedDocs?: number[];\n  tokensUsed: number;\n  createdAt: Date;\n}\n\nexport interface SimilarDocument {\n  id: number;\n  documentId: number;\n  contentPreview: string;\n  similarityScore: number;\n}\n\nexport interface ChatRequest {\n  sessionId?: number;\n  message: string;\n  useRag?: boolean;\n  maxDocuments?: number;\n  contextHistory?: number;\n}\n\nexport interface ChatResponse {\n  sessionId: number;\n  message: ChatMessage;\n  referencedDocs?: SimilarDocument[];\n  tokensUsed: number;\n}\n\nexport const ChatHelpers = {\n  getRoleConfig: (role: ChatRole) => {\n    const configs: Record<ChatRole, { label: string; bgColor: string; align: \"left\" | \"right\" }> = {\n      user: {\n        label: \"You\",\n        bgColor: \"bg-blue-600 text-white\",\n        align: \"right\",\n      },\n      assistant: {\n        label: \"AI\",\n        bgColor: \"bg-gray-100 text-gray-900\",\n        align: \"left\",\n      },\n      system: {\n        label: \"System\",\n        bgColor: \"bg-amber-100 text-amber-900\",\n        align: \"left\",\n      },\n    };\n    return configs[role] || configs.assistant;\n  },\n\n  formatTimestamp: (date: Date): string => {\n    return new Intl.DateTimeFormat(\"en-US\", {\n      hour: \"numeric\",\n      minute: \"2-digit\",\n      hour12: true,\n    }).format(date);\n  },\n\n  truncateTitle: (title: string, maxLength: number = 30): string => {\n    if (title.length <= maxLength) return title;\n    return `${title.substring(0, maxLength)}...`;\n  },\n};\n"
  },
  {
    "path": "next_b2b_starter/lib/models/document.model.ts",
    "content": "// lib/models/document.model.ts\n\nexport type DocumentStatus = \"pending\" | \"processing\" | \"processed\" | \"failed\";\n\nexport interface Document {\n  id: number;\n  title: string;\n  fileName: string;\n  contentType: string;\n  fileSize: number;\n  status: DocumentStatus;\n  extractedText?: string;\n  metadata?: Record<string, unknown>;\n  createdAt: Date;\n  updatedAt: Date;\n}\n\nexport interface DocumentListResponse {\n  documents: Document[];\n  total: number;\n  limit: number;\n  offset: number;\n}\n\nexport interface DocumentListFilter {\n  status?: DocumentStatus;\n  limit?: number;\n  offset?: number;\n}\n\nexport const DocumentHelpers = {\n  getStatusConfig: (status: DocumentStatus) => {\n    const configs: Record<DocumentStatus, { label: string; color: string; bgColor: string }> = {\n      pending: {\n        label: \"Pending\",\n        color: \"text-amber-700\",\n        bgColor: \"bg-amber-100 border-amber-200\",\n      },\n      processing: {\n        label: \"Processing\",\n        color: \"text-blue-700\",\n        bgColor: \"bg-blue-100 border-blue-200\",\n      },\n      processed: {\n        label: \"Processed\",\n        color: \"text-emerald-700\",\n        bgColor: \"bg-emerald-100 border-emerald-200\",\n      },\n      failed: {\n        label: \"Failed\",\n        color: \"text-red-700\",\n        bgColor: \"bg-red-100 border-red-200\",\n      },\n    };\n    return configs[status] || configs.pending;\n  },\n\n  formatFileSize: (bytes: number): string => {\n    if (bytes === 0) return \"0 B\";\n    const k = 1024;\n    const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;\n  },\n\n  formatDate: (date: Date): string => {\n    return new Intl.DateTimeFormat(\"en-US\", {\n      month: \"short\",\n      day: \"numeric\",\n      year: \"numeric\",\n    }).format(date);\n  },\n};\n"
  },
  {
    "path": "next_b2b_starter/lib/models/member.model.ts",
    "content": "// lib/models/member.model.ts\n\n/**\n * Member and User Management Models\n */\n\nexport type MemberRole = \"admin\" | \"manager\" | \"member\";\nexport type MemberStatus = \"active\" | \"pending\" | \"inactive\";\n\n/**\n * Organization Member\n */\nexport interface OrganizationMember {\n  id: string;\n  email: string;\n  name?: string;\n  role: MemberRole;\n  status: MemberStatus;\n  avatarUrl?: string;\n  joinedAt: Date;\n  invitedAt?: Date;\n  invitedBy?: string;\n}\n\n/**\n * Current User Profile\n */\nexport interface UserProfile {\n  id: string;\n  email: string;\n  name?: string;\n  avatarUrl?: string;\n  role: MemberRole;\n  organizationId: string;\n  organizationName: string;\n}\n\n/**\n * Member Invitation Request\n */\nexport interface InviteMemberRequest {\n  email: string;\n  name: string;\n  role: MemberRole;\n  sendEmail?: boolean;\n}\n\n/**\n * Member Invitation Response\n */\nexport interface InviteMemberResponse {\n  success: boolean;\n  memberId?: string;\n  message?: string;\n  inviteLink?: string;\n}\n\n/**\n * Update Profile Request\n */\nexport interface UpdateProfileRequest {\n  name?: string;\n  avatarUrl?: string;\n}\n\n/**\n * Member List Response\n */\nexport interface MemberListResponse {\n  members: OrganizationMember[];\n  totalCount: number;\n  hasMore: boolean;\n}\n\n/**\n * Helper functions for member management\n */\nexport const MemberHelpers = {\n  /**\n   * Get role display configuration\n   */\n  getRoleConfig: (role: MemberRole) => {\n    const configs = {\n      admin: {\n        label: \"Admin\",\n        color: \"bg-blue-100 text-blue-700 border-blue-200\",\n        description: \"Full system control and member management\",\n      },\n      manager: {\n        label: \"Manager\",\n        color: \"bg-emerald-100 text-emerald-700 border-emerald-200\",\n        description: \"Elevated access - edit, delete, and approve resources\",\n      },\n      member: {\n        label: \"Member\",\n        color: \"bg-gray-100 text-gray-700 border-gray-200\",\n        description: \"Basic access - view and create resources\",\n      },\n    };\n    return configs[role] || configs.member;\n  },\n\n  /**\n   * Get status configuration\n   */\n  getStatusConfig: (status: MemberStatus) => {\n    const configs = {\n      active: {\n        label: \"Active\",\n        color: \"bg-emerald-100 text-emerald-700\",\n        dotColor: \"bg-emerald-500\",\n      },\n      pending: {\n        label: \"Pending\",\n        color: \"bg-amber-100 text-amber-700\",\n        dotColor: \"bg-amber-500\",\n      },\n      inactive: {\n        label: \"Inactive\",\n        color: \"bg-gray-100 text-gray-500\",\n        dotColor: \"bg-gray-400\",\n      },\n    };\n    return configs[status] || configs.inactive;\n  },\n\n  /**\n   * Get initials from name or email\n   */\n  getInitials: (name?: string, email?: string): string => {\n    const source = name || email || \"?\";\n    const parts = source.trim().split(/\\s+/);\n\n    if (parts.length > 1) {\n      // Multiple words - use first letter of first and last\n      return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n    } else if (email && !name) {\n      // Email only - use first two letters\n      return email.substring(0, 2).toUpperCase();\n    } else {\n      // Single word - use first two letters\n      return source.substring(0, 2).toUpperCase();\n    }\n  },\n\n  /**\n   * Generate avatar background color from string\n   */\n  getAvatarColor: (str: string): string => {\n    const colors = [\n      \"bg-blue-500\",\n      \"bg-purple-500\",\n      \"bg-pink-500\",\n      \"bg-emerald-500\",\n      \"bg-amber-500\",\n      \"bg-cyan-500\",\n      \"bg-rose-500\",\n      \"bg-indigo-500\",\n    ];\n\n    // Handle undefined or empty string\n    if (!str || str.length === 0) {\n      return colors[0];\n    }\n\n    let hash = 0;\n    for (let i = 0; i < str.length; i++) {\n      hash = str.charCodeAt(i) + ((hash << 5) - hash);\n    }\n\n    return colors[Math.abs(hash) % colors.length];\n  },\n\n  /**\n   * Check if user can manage members based on role\n   */\n  canManageMembers: (role: MemberRole): boolean => {\n    return role === \"admin\";\n  },\n\n  /**\n   * Format member joined date\n   */\n  formatJoinedDate: (date: Date | string): string => {\n    const d = typeof date === \"string\" ? new Date(date) : date;\n    const now = new Date();\n    const diffTime = Math.abs(now.getTime() - d.getTime());\n    const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));\n\n    if (diffDays === 0) {\n      return \"Today\";\n    } else if (diffDays === 1) {\n      return \"Yesterday\";\n    } else if (diffDays < 7) {\n      return `${diffDays} days ago`;\n    } else if (diffDays < 30) {\n      const weeks = Math.floor(diffDays / 7);\n      return `${weeks} ${weeks === 1 ? \"week\" : \"weeks\"} ago`;\n    } else if (diffDays < 365) {\n      const months = Math.floor(diffDays / 30);\n      return `${months} ${months === 1 ? \"month\" : \"months\"} ago`;\n    } else {\n      return d.toLocaleDateString(\"en-US\", {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      });\n    }\n  },\n};\n"
  },
  {
    "path": "next_b2b_starter/lib/models/signup.model.ts",
    "content": "export interface SignupOwner {\n  fullName: string;\n  email: string;\n}\n\nexport interface SignupOrganization {\n  displayName: string;\n  industry: string;\n}\n\nexport interface SignupDraft {\n  owner: SignupOwner;\n  organization: SignupOrganization;\n}\n\nexport interface CreatedUser {\n  id: number;\n  email: string;\n  fullName: string;\n  createdAt: Date;\n}\n\nexport interface AuthTokens {\n  accessToken: string;\n  tokenType: string;\n  expiresIn?: number;\n  refreshToken?: string;\n}\n\nexport interface CreatedOrganization {\n  id: number;\n  name: string;\n  industry: string;\n  createdAt: Date;\n  updatedAt: Date;\n}\n\nexport interface SignupResult {\n  orgId: string;\n  orgName: string;\n  displayName: string;\n  ownerUserId: string;\n  ownerEmail: string;\n  ownerName: string;\n  loginUrl: string;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/client.ts",
    "content": "import { Polar } from \"@polar-sh/sdk\";\n\nlet cachedClient: Polar | null = null;\n\nfunction createPolarClient(): Polar | null {\n  if (typeof window !== \"undefined\") {\n    throw new Error(\"Polar SDK client must only be instantiated on the server.\");\n  }\n\n  const accessToken = process.env.POLAR_ACCESS_TOKEN;\n  if (!accessToken) {\n    return null;\n  }\n\n  const server = process.env.NODE_ENV === \"production\" ? \"production\" : \"sandbox\";\n\n  return new Polar({\n    accessToken,\n    server,\n  });\n}\n\nexport function getPolarClient(): Polar | null {\n  if (cachedClient) {\n    return cachedClient;\n  }\n\n  const client = createPolarClient();\n  if (!client) {\n    return null;\n  }\n\n  cachedClient = client;\n  return cachedClient;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/config.ts",
    "content": "const meterId = process.env.NEXT_PUBLIC_POLAR_METER_ID ?? null;\n\nexport const POLAR_METER_ID = meterId;\n\n/**\n * Check if Polar billing is enabled\n *\n * Polar is enabled if the POLAR_ACCESS_TOKEN environment variable is set.\n * Products are fetched dynamically from Polar API.\n */\nexport function isPolarEnabled(): boolean {\n  return Boolean(process.env.POLAR_ACCESS_TOKEN);\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/current-subscription.ts",
    "content": "import { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions } from \"@/lib/auth/server-permissions\";\nimport { getActiveSubscription } from \"@/lib/polar/subscription\";\nimport { getInvoiceUsage } from \"@/lib/polar/usage\";\n\nexport interface SubscriptionSnapshot {\n  id: string;\n  status: string;\n  currentPeriodStart: string;\n  currentPeriodEnd: string | null;\n  cancelAtPeriodEnd: boolean;\n  customerId: string;\n  productId: string;\n  productName: string | null;\n  productMetadata: Record<string, unknown> | null;\n  trialEnd: string | null;\n  // Additional Polar properties\n  trialStart: string | null;\n  recurringInterval: string;\n  metadata: Record<string, unknown> | null;\n  customFieldData: Record<string, unknown> | null;\n  customerCancellationReason: string | null;\n  customerCancellationComment: string | null;\n}\n\nexport interface UsageSnapshot {\n  meterId: string;\n  customerId: string;\n  included: number;\n  used: number;\n  remaining: number;\n  periodStart: string;\n  periodEnd: string;\n}\n\nexport interface SubscriptionGateState {\n  isAuthenticated: boolean;\n  isActive: boolean;\n  reason?: string;\n  status?: string | null;\n  productId: string | null;\n  meterId: string | null;\n  planId: string | null;\n  subscription: SubscriptionSnapshot | null;\n  usage: UsageSnapshot | null;\n  backendAvailable: boolean;\n  backendError?: string | null;\n}\n\nexport async function resolveCurrentSubscription(): Promise<SubscriptionGateState> {\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    console.info(\"[Polar] Subscription state: unauthenticated\");\n    return {\n      isAuthenticated: false,\n      isActive: false,\n      reason: \"UNAUTHENTICATED\",\n      status: null,\n      productId: null,\n      meterId: null,\n      planId: null,\n      subscription: null,\n      usage: null,\n      backendAvailable: true,\n      backendError: null,\n    };\n  }\n\n  const permissions = await getServerPermissions(session);\n  if (!permissions.backendAvailable) {\n    console.warn(\"[Polar] Subscription state: backend unavailable\", {\n      error: permissions.backendError,\n    });\n\n    return {\n      isAuthenticated: true,\n      isActive: false,\n      reason: \"BACKEND_UNAVAILABLE\",\n      status: null,\n      productId: null,\n      meterId: null,\n      planId: null,\n      subscription: null,\n      usage: null,\n      backendAvailable: false,\n      backendError: permissions.backendError ?? \"Service temporarily unavailable\",\n    };\n  }\n\n  const profile = permissions.profile;\n  if (!profile) {\n    console.warn(\"[Polar] Subscription state: profile unavailable\");\n    return {\n      isAuthenticated: true,\n      isActive: false,\n      reason: \"PROFILE_UNAVAILABLE\",\n      status: null,\n      productId: null,\n      meterId: null,\n      planId: null,\n      subscription: null,\n      usage: null,\n      backendAvailable: true,\n      backendError: null,\n    };\n  }\n\n  if (!permissions.canManageSubscriptions) {\n    console.info(\"[Polar] Subscription state: insufficient permissions\", {\n      permissions: permissions.permissions,\n    });\n    return {\n      isAuthenticated: true,\n      isActive: false,\n      reason: \"INSUFFICIENT_PERMISSIONS\",\n      status: null,\n      productId: null,\n      meterId: null,\n      planId: null,\n      subscription: null,\n      usage: null,\n      backendAvailable: true,\n      backendError: null,\n    };\n  }\n\n  const result = await getActiveSubscription({\n    externalCustomerId: profile.organization?.organization_id,\n    customerEmail: profile.email,\n    organizationId: profile.organization?.organization_id,\n  });\n\n  const { subscription, isActive, status, meterId, productId, planId, reason } = result;\n\n  const usage = subscription && isActive ? await getInvoiceUsage(subscription) : null;\n  const productName = subscription?.product?.name ?? null;\n  const productMetadata =\n    subscription && subscription.product?.metadata\n      ? (subscription.product.metadata as Record<string, unknown>)\n      : null;\n\n  const state: SubscriptionGateState = {\n    isAuthenticated: true,\n    isActive,\n    reason,\n    status,\n    productId,\n    meterId,\n    planId: planId ?? null,\n      subscription: subscription\n        ? {\n            id: subscription.id,\n            status: subscription.status,\n            currentPeriodStart: subscription.currentPeriodStart.toISOString(),\n            currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,\n            cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,\n            customerId: subscription.customerId,\n            productId: subscription.productId,\n            productName,\n            productMetadata,\n            trialEnd: subscription.trialEnd?.toISOString() ?? null,\n            // Additional Polar properties\n            trialStart: subscription.trialStart?.toISOString() ?? null,\n            recurringInterval: subscription.recurringInterval,\n            metadata: subscription.metadata ?? null,\n          customFieldData: subscription.customFieldData ?? null,\n          customerCancellationReason: subscription.customerCancellationReason ?? null,\n          customerCancellationComment: subscription.customerCancellationComment ?? null,\n        }\n      : null,\n    usage: usage\n      ? {\n          meterId: usage.meterId,\n          customerId: usage.customerId,\n          included: usage.included,\n          used: usage.used,\n          remaining: usage.remaining,\n          periodStart: usage.periodStart.toISOString(),\n          periodEnd: usage.periodEnd.toISOString(),\n        }\n      : null,\n    backendAvailable: true,\n    backendError: null,\n  };\n\n  console.info(\"[Polar] Subscription state resolved\", {\n    isActive: state.isActive,\n    reason: state.reason,\n    status: state.status,\n    productId: state.productId,\n    meterId: state.meterId,\n    planId: state.planId,\n    usage: state.usage\n      ? {\n          used: state.usage.used,\n          remaining: state.usage.remaining,\n          included: state.usage.included,\n        }\n      : undefined,\n    backendAvailable: state.backendAvailable,\n    backendError: state.backendError,\n  });\n\n  return state;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/plans.ts",
    "content": "export interface PolarPlan {\n  id: string;\n  name: string;\n  description: string | null;\n  price: number; // USD per month\n  interval: \"month\" | \"year\";\n  productId: string;\n  priceId: string | null;\n  includedSeats: number | null;\n  includedInvoices: number | null;\n  benefits: string[];\n  badge?: string;\n  metadata?: Record<string, unknown>;\n}\n\n/**\n * Plans are now fetched dynamically from Polar via /api/billing/products\n * Use the useProductsQuery() hook to fetch products in React components\n */\n\nexport function getPlanById(\n  plans: PolarPlan[],\n  planId: string | null | undefined\n): PolarPlan | null {\n  if (!planId) return null;\n  return plans.find((plan) => plan.id === planId) ?? null;\n}\n\nexport function getPlanByProductId(\n  plans: PolarPlan[],\n  productId: string | null | undefined\n): PolarPlan | null {\n  if (!productId) return null;\n  return plans.find((plan) => plan.productId === productId) ?? null;\n}\n\nexport function getDefaultPlan(plans: PolarPlan[]): PolarPlan | null {\n  return plans[0] ?? null;\n}\n\n/**\n * Extract plan information from Polar product metadata\n *\n * Polar now exposes product benefits and metadata directly.\n * This function attempts to extract plan info from the product object\n * and falls back to static plans if not available.\n */\nexport function getPlanFromProduct(product: {\n  id: string;\n  name: string;\n  metadata?: Record<string, unknown> | null;\n  benefits?: Array<{ description: string }> | null;\n  prices?: Array<{\n    id?: string;\n    recurringInterval?: string;\n    amount?: number;\n    currency?: string;\n  }> | null;\n}): Partial<PolarPlan> | null {\n  if (!product) {\n    return null;\n  }\n\n  const metadata = product.metadata ?? {};\n  const benefits = product.benefits?.map((b) => b.description) ?? [];\n  const price = product.prices?.[0];\n\n  if (!price || !price.id) {\n    return null;\n  }\n\n  // Try to extract plan ID from metadata\n  const planId = typeof metadata.plan_id === \"string\" ? metadata.plan_id : null;\n\n  // Extract seats and invoices from metadata if available\n  const includedSeats =\n    typeof metadata.included_seats === \"number\"\n      ? metadata.included_seats\n      : typeof metadata.seats === \"number\"\n        ? metadata.seats\n        : undefined;\n\n  const includedInvoices =\n    typeof metadata.included_invoices === \"number\"\n      ? metadata.included_invoices\n      : typeof metadata.invoices === \"number\"\n        ? metadata.invoices\n        : undefined;\n\n  console.info(\"[Polar] Extracted plan from product metadata\", {\n    productId: product.id,\n    productName: product.name,\n    planId,\n    includedSeats,\n    includedInvoices,\n    benefits: benefits.length,\n    hasPrice: Boolean(price),\n  });\n\n  return {\n    id: planId ?? product.id,\n    name: product.name,\n    productId: product.id,\n    priceId: price?.id ?? null,\n    benefits,\n    includedSeats,\n    includedInvoices,\n    price: price.amount ? price.amount / 100 : undefined, // Convert cents to dollars\n    interval: (price.recurringInterval as \"month\" | undefined) ?? \"month\",\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/server-meters.ts",
    "content": "/**\n * Server-side Meters Fetching\n *\n * This module provides server-side data fetching for Polar meters and usage data.\n * Use this in Server Components and Server Actions.\n */\n\nimport { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions } from \"@/lib/auth/server-permissions\";\nimport { listMeters } from \"@/lib/polar/usage\";\nimport { POLAR_METER_ID } from \"@/lib/polar/config\";\n\ninterface MeterInfo {\n  id: string;\n  name: string;\n  aggregation: string | null;\n  filter: Record<string, unknown> | null;\n}\n\ninterface FetchMetersResult {\n  success: boolean;\n  data?: {\n    meters: MeterInfo[];\n    invoiceMeter: MeterInfo | null;\n    organizationUsage: unknown | null;\n    customerUsage: unknown | null;\n  };\n  error?: string;\n}\n\n/**\n * Fetch meters data from Polar (Server-side)\n *\n * Fetches all available meters and invoice meter details with authentication and permission checks.\n * This function can only be called from Server Components or Server Actions.\n *\n * @returns Meters data or error\n */\nexport async function fetchMeters(): Promise<FetchMetersResult> {\n  // Authentication check\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    console.info(\"[Polar Meters] Unauthenticated request\");\n    return {\n      success: false,\n      error: \"Authentication required.\",\n    };\n  }\n\n  const permissions = await getServerPermissions(session);\n  if (!permissions.backendAvailable) {\n    console.warn(\"[Polar Meters] Backend unavailable\", {\n      error: permissions.backendError,\n    });\n    return {\n      success: false,\n      error: \"Service temporarily unavailable\",\n    };\n  }\n\n  const profile = permissions.profile;\n  if (!profile) {\n    console.warn(\"[Polar Meters] Profile unavailable\");\n    return {\n      success: false,\n      error: \"Profile not available.\",\n    };\n  }\n\n  if (!permissions.canManageSubscriptions) {\n    console.info(\"[Polar Meters] Forbidden - insufficient permissions\", {\n      memberId: profile.member_id,\n    });\n    return {\n      success: false,\n      error: \"You do not have access to manage subscriptions.\",\n    };\n  }\n\n  try {\n    // Fetch all meters\n    const meters = await listMeters();\n\n    // Find the invoice meter details\n    const invoiceMeter = meters.find((m) => m.id === POLAR_METER_ID);\n\n    console.info(\"[Polar Meters] Meters data fetched successfully\", {\n      totalMeters: meters.length,\n      invoiceMeterId: POLAR_METER_ID,\n      hasInvoiceMeter: Boolean(invoiceMeter),\n      organizationId: profile.organization?.organization_id,\n    });\n\n    // NOTE: Customer usage API not yet available in SDK\n    // When available, we can fetch per-customer usage here\n\n    return {\n      success: true,\n      data: {\n        // All available meters\n        meters: meters.map((m) => ({\n          id: m.id,\n          name: m.name,\n          aggregation: m.aggregation?.func ?? null,\n          filter: m.filter,\n        })),\n\n        // Invoice meter details\n        invoiceMeter: invoiceMeter\n          ? {\n              id: invoiceMeter.id,\n              name: invoiceMeter.name,\n              aggregation: invoiceMeter.aggregation?.func ?? null,\n              filter: invoiceMeter.filter,\n            }\n          : null,\n\n        // Placeholders for when SDK supports customer usage endpoint\n        organizationUsage: null,\n        customerUsage: null,\n      },\n    };\n  } catch (error) {\n    console.error(\"[Polar Meters] Failed to fetch meters data\", {\n      error: error instanceof Error ? error.message : String(error),\n      organizationId: profile.organization?.organization_id,\n    });\n\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : \"Failed to fetch meters data\",\n    };\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/server-products.ts",
    "content": "/**\n * Server-side Products Fetching\n *\n * This module provides server-side data fetching for Polar products.\n * Use this in Server Components and Server Actions.\n * For Client Components, use the useProductsQuery hook instead.\n */\n\nimport { getMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions } from \"@/lib/auth/server-permissions\";\nimport { getPolarClient } from \"@/lib/polar/client\";\nimport type { PolarPlan } from \"./plans\";\n\ninterface FetchProductsResult {\n  success: boolean;\n  products?: PolarPlan[];\n  error?: string;\n}\n\n/**\n * Fetch products from Polar (Server-side)\n *\n * Fetches all active products from Polar with authentication and permission checks.\n * This function can only be called from Server Components or Server Actions.\n *\n * @returns Products array or error\n */\nexport async function fetchProducts(): Promise<FetchProductsResult> {\n  // Authentication check\n  const session = await getMemberSession();\n  if (!session?.session_jwt) {\n    console.info(\"[Polar Products] Unauthenticated request\");\n    return {\n      success: false,\n      error: \"Authentication required.\",\n    };\n  }\n\n  const permissions = await getServerPermissions(session);\n  if (!permissions.backendAvailable) {\n    console.warn(\"[Polar Products] Backend unavailable\", {\n      error: permissions.backendError,\n    });\n    return {\n      success: false,\n      error: \"Service temporarily unavailable\",\n    };\n  }\n\n  const profile = permissions.profile;\n  if (!profile) {\n    console.warn(\"[Polar Products] Profile unavailable\");\n    return {\n      success: false,\n      error: \"Profile not available.\",\n    };\n  }\n\n  if (!permissions.canManageSubscriptions) {\n    console.info(\"[Polar Products] Forbidden - insufficient permissions\", {\n      memberId: profile.member_id,\n    });\n    return {\n      success: false,\n      error: \"You do not have access to manage subscriptions.\",\n    };\n  }\n\n  const client = getPolarClient();\n  if (!client) {\n    console.warn(\"[Polar Products] Polar client unavailable\");\n    return {\n      success: false,\n      error: \"Billing service not configured.\",\n    };\n  }\n\n  try {\n    console.info(\"[Polar Products] Fetching products\");\n\n    // Fetch all products from Polar\n    const response = await client.products.list({\n      // Only return active, non-archived products\n      isArchived: false,\n    });\n\n    const products = response.result.items;\n\n    console.info(\"[Polar Products] Products fetched successfully\", {\n      count: products.length,\n      products: products.map((p) => ({\n        id: p.id,\n        name: p.name,\n        pricesCount: p.prices?.length ?? 0,\n      })),\n    });\n\n    // Transform products to PolarPlan format\n    const transformedProducts: PolarPlan[] = products\n      .reduce((acc, product) => {\n        const price = product.prices?.[0];\n        if (!price || !price.id) {\n          console.warn(\"[Polar Products] Product has no usable price\", {\n            productId: product.id,\n            name: product.name,\n          });\n          return acc;\n        }\n\n        const metadata = (product.metadata ?? {}) as Record<string, unknown>;\n\n        const includedSeats =\n          typeof metadata.included_seats === \"number\"\n            ? metadata.included_seats\n            : typeof metadata.max_seats === \"number\"\n              ? metadata.max_seats\n              : typeof metadata.seats === \"number\"\n                ? metadata.seats\n                : null;\n\n        const includedInvoices =\n          typeof metadata.included_invoices === \"number\"\n            ? metadata.included_invoices\n            : typeof metadata.invoice_limit === \"number\"\n              ? metadata.invoice_limit\n              : typeof metadata.invoices === \"number\"\n                ? metadata.invoices\n                : null;\n\n        const benefits =\n          product.benefits?.map((b) => b.description).filter(Boolean) ?? [];\n\n        const planId = typeof metadata.plan_id === \"string\" ? metadata.plan_id : product.id;\n\n        const plan: PolarPlan = {\n          id: planId,\n          name: product.name,\n          description: product.description ?? null,\n          price:\n            price.amountType === \"fixed\" && price.priceAmount\n              ? price.priceAmount / 100\n              : 0,\n          interval: (price.recurringInterval as \"month\" | \"year\") ?? \"month\",\n          productId: product.id,\n          priceId: price.id,\n          includedSeats,\n          includedInvoices,\n          benefits,\n          metadata,\n        };\n\n        acc.push(plan);\n        return acc;\n      }, [] as PolarPlan[])\n      .sort((a, b) => a.price - b.price);\n\n    return {\n      success: true,\n      products: transformedProducts,\n    };\n  } catch (error) {\n    console.error(\"[Polar Products] Failed to fetch products\", {\n      error: error instanceof Error ? error.message : String(error),\n      organizationId: profile.organization?.organization_id,\n    });\n\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : \"Failed to fetch products\",\n    };\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/subscription.ts",
    "content": "import type { Polar } from \"@polar-sh/sdk\";\nimport type { Customer } from \"@polar-sh/sdk/models/components/customer\";\nimport type { Subscription } from \"@polar-sh/sdk/models/components/subscription\";\nimport type { SubscriptionsListRequest } from \"@polar-sh/sdk/models/operations/subscriptionslist\";\n\nimport { getPolarClient } from \"@/lib/polar/client\";\nimport { POLAR_METER_ID } from \"@/lib/polar/config\";\n\nconst ACTIVE_STATUSES = new Set<Subscription[\"status\"]>([\"active\", \"trialing\"]);\n\nexport interface SubscriptionLookupInput {\n  externalCustomerId?: string | null;\n  customerEmail?: string | null;\n  organizationId?: string | null;\n}\n\nexport interface SubscriptionStatusResult {\n  subscription: Subscription | null;\n  customer: Customer | null;\n  isActive: boolean;\n  status: Subscription[\"status\"] | null;\n  meterId: string | null;\n  productId: string | null;\n  planId: string | null;\n  reason?: \"POLAR_UNCONFIGURED\" | \"CUSTOMER_NOT_FOUND\" | \"NO_ACTIVE_SUBSCRIPTION\" | \"UNKNOWN_ERROR\";\n}\n\nexport async function getActiveSubscription(\n  input: SubscriptionLookupInput\n): Promise<SubscriptionStatusResult> {\n  const client = getPolarClient();\n  if (!client) {\n    console.warn(\"[Polar] Subscription lookup skipped - Polar client unavailable\");\n    return {\n      subscription: null,\n      customer: null,\n      isActive: true,\n      status: null,\n      meterId: POLAR_METER_ID ?? null,\n      productId: null,\n      planId: null,\n      reason: \"POLAR_UNCONFIGURED\",\n    };\n  }\n\n  const externalCustomerId =\n    input.externalCustomerId ?? input.organizationId ?? null;\n\n  const request: SubscriptionsListRequest = {\n    active: true,\n    limit: 1,\n  };\n\n  if (externalCustomerId) {\n    request.externalCustomerId = externalCustomerId;\n  }\n\n  let customer: Customer | null = null;\n\n  try {\n    if (!request.externalCustomerId && input.customerEmail) {\n      customer = await findCustomerByEmail(client, input.customerEmail);\n      if (customer) {\n        request.customerId = customer.id;\n        console.debug(\"[Polar] Found customer by email\", {\n          customerId: customer.id,\n          email: input.customerEmail,\n        });\n      }\n    }\n\n    const iterator = await client.subscriptions.list(request);\n    const firstPage = iterator.result;\n    const subscription = firstPage.items[0] ?? null;\n\n    if (!customer && subscription) {\n      customer = subscription.customer;\n    }\n\n    if (!subscription) {\n      console.info(\"[Polar] No active subscription found\", {\n        hasCustomer: Boolean(customer),\n        externalCustomerId,\n      });\n      return {\n        subscription: null,\n        customer,\n        isActive: false,\n        status: null,\n        meterId: POLAR_METER_ID ?? null,\n        productId: null,\n        planId: null,\n        reason: customer ? \"NO_ACTIVE_SUBSCRIPTION\" : \"CUSTOMER_NOT_FOUND\",\n      };\n    }\n\n    const isActive = ACTIVE_STATUSES.has(subscription.status);\n    const metadata = (subscription.metadata ?? {}) as Record<string, unknown>;\n    const rawPlanId = metadata[\"plan_id\"];\n    const planId =\n      typeof rawPlanId === \"string\" && rawPlanId.trim().length > 0 ? rawPlanId.trim() : null;\n\n    // Log complete metadata for debugging\n    console.info(\"[Polar] Subscription lookup result\", {\n      subscriptionId: subscription.id,\n      status: subscription.status,\n      isActive,\n      customerId: subscription.customerId,\n      planId,\n      productId: subscription.productId,\n      // Full metadata dump\n      subscriptionMetadata: subscription.metadata,\n      customerMetadata: subscription.customer?.metadata,\n      productName: subscription.product?.name,\n      productMetadata: subscription.product?.metadata,\n      // Additional important fields\n      trialStart: subscription.trialStart,\n      trialEnd: subscription.trialEnd,\n      cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,\n      currentPeriodStart: subscription.currentPeriodStart,\n      currentPeriodEnd: subscription.currentPeriodEnd,\n      recurringInterval: subscription.recurringInterval,\n      // NOTE: subscription.meters[] is always empty - use client.meters.customers() API instead\n    });\n\n    return {\n      subscription,\n      customer,\n      isActive,\n      status: subscription.status,\n      meterId: POLAR_METER_ID ?? null,\n      productId: subscription.productId ?? null,\n      planId,\n      reason: isActive ? undefined : \"NO_ACTIVE_SUBSCRIPTION\",\n    };\n  } catch (error) {\n    console.error(\"[Polar] Failed to load subscription\", error);\n    return {\n      subscription: null,\n      customer,\n      isActive: false,\n      status: null,\n      meterId: POLAR_METER_ID ?? null,\n      productId: null,\n      planId: null,\n      reason: \"UNKNOWN_ERROR\",\n    };\n  }\n}\n\nasync function findCustomerByEmail(\n  client: Polar,\n  email: string\n): Promise<Customer | null> {\n  try {\n    console.debug(\"[Polar] Searching for customer by email\", { email });\n    const iterator = await client.customers.list({\n      email,\n      limit: 1,\n    });\n    const result = iterator.result;\n    if (result.items[0]) {\n      console.debug(\"[Polar] Customer lookup succeeded\", {\n        customerId: result.items[0].id,\n      });\n    } else {\n      console.info(\"[Polar] Customer lookup returned empty\", { email });\n    }\n    return result.items[0] ?? null;\n  } catch (error) {\n    console.error(\"[Polar] Failed to find customer by email\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/polar/usage.ts",
    "content": "import type { Subscription } from \"@polar-sh/sdk/models/components/subscription\";\nimport type { Meter } from \"@polar-sh/sdk/models/components/meter\";\n\nimport { getPolarClient } from \"@/lib/polar/client\";\nimport { POLAR_METER_ID } from \"@/lib/polar/config\";\n\nexport interface MeterUsageSummary {\n  meterId: string;\n  customerId: string;\n  included: number;\n  used: number;\n  remaining: number;\n  periodStart: Date;\n  periodEnd: Date;\n}\n\n/**\n * Get invoice usage using Polar Meters Quantities API\n *\n * Finds the meter by name \"invoice.processed\" with count aggregation,\n * then fetches usage via meters.quantities() API.\n */\nexport async function getInvoiceUsage(\n  subscription: Subscription\n): Promise<MeterUsageSummary | null> {\n  const client = getPolarClient();\n  if (!client) {\n    return null;\n  }\n\n  try {\n    const start = subscription.currentPeriodStart;\n    const end = subscription.currentPeriodEnd ?? new Date();\n\n    // Get external customer ID from subscription (note: it's 'externalId' in the SDK)\n    const externalCustomerId = subscription.customer?.externalId ?? null;\n\n    // 1. List all meters to find the invoice meter\n    const meters = await listMeters();\n\n\n    // 2. Helper function to recursively search for matching filter clause\n    const hasMatchingFilter = (clauses: any[]): boolean => {\n      for (const clause of clauses) {\n        // Check if this is a direct filter clause with property field\n        if ('property' in clause) {\n          const matches = (\n            clause.property === \"name\" &&\n            clause.value === \"invoice.processed\" &&\n            (clause.operator === \"eq\" || clause.operator === \"equals\")\n          );\n\n          if (matches) {\n            return true;\n          }\n        }\n\n        // Check if this is a nested filter with its own clauses array\n        if ('clauses' in clause && Array.isArray(clause.clauses)) {\n          if (hasMatchingFilter(clause.clauses)) {\n            return true;\n          }\n        }\n      }\n      return false;\n    };\n\n    // 3. Find meter that tracks \"invoice.processed\" events with count aggregation\n    // The event name is in the filter clauses (potentially nested), not the meter name\n    const invoiceMeter = meters.find((m) => {\n      // Skip archived meters - we only want active meters\n      if (m.archivedAt) {\n        return false;\n      }\n\n      // Check if meter has count aggregation\n      if (m.aggregation?.func !== \"count\") {\n        return false;\n      }\n\n      // Check if filter exists\n      const filter = m.filter;\n      if (!filter || !filter.clauses) {\n        return false;\n      }\n\n      // Recursively search for matching filter clause\n      return hasMatchingFilter(filter.clauses);\n    });\n\n    // 4. Use only the found meter - no hardcoded fallbacks\n    if (!invoiceMeter) {\n      return null;\n    }\n\n    const meterId = invoiceMeter.id;\n\n    // 5. Fetch quantities with customer filtering\n    const response = await client.meters.quantities({\n      id: meterId,\n      startTimestamp: start,\n      endTimestamp: end,\n      interval: \"month\",\n      customerId: subscription.customerId,\n    });\n\n    // 5. Extract total usage and get included invoices from product metadata\n    const used = response.total;\n\n    // Get included invoices from product metadata\n    const productMetadata = subscription.product?.metadata ?? {};\n    const included =\n      typeof productMetadata.included_invoices === \"number\"\n        ? productMetadata.included_invoices\n        : typeof productMetadata.invoice_limit === \"number\"\n          ? productMetadata.invoice_limit\n          : typeof productMetadata.invoices === \"number\"\n            ? productMetadata.invoices\n            : 1000; // Default fallback if not in metadata\n\n    const remaining = Math.max(0, included - used);\n\n    return {\n      meterId,\n      customerId: subscription.customerId,\n      included,\n      used,\n      remaining,\n      periodStart: start,\n      periodEnd: end,\n    };\n  } catch {\n    return null;\n  }\n}\n\n/**\n * List all available meters\n *\n * Returns all meters configured in Polar (e.g., \"Invoice Processing\")\n * Use this to discover available meters and their configurations.\n */\nexport async function listMeters(): Promise<Meter[]> {\n  const client = getPolarClient();\n  if (!client) {\n    return [];\n  }\n\n  try {\n    const response = await client.meters.list({});\n    return response.result.items;\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Get customer usage for a specific meter\n *\n * NOTE: The SDK doesn't have a `customers()` endpoint yet.\n * This function is a placeholder for when that endpoint is available.\n * Currently returns null.\n */\nexport async function getCustomerMeterUsage(_meterId: string) {\n  // Not yet implemented - SDK lacks meters.customers() endpoint\n  return null;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/providers/query-provider.tsx",
    "content": "\"use client\";\n\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { ReactQueryDevtools } from \"@tanstack/react-query-devtools\";\nimport { useState, type ReactNode } from \"react\";\n\n/**\n * Query Client Provider\n *\n * Configures TanStack Query with sensible defaults for AP Cash:\n * - 5 minute stale time (data stays fresh for 5 minutes)\n * - 10 minute garbage collection (cache persists for 10 minutes)\n * - Single retry on failure\n * - No aggressive refetching on window focus\n */\n\nfunction makeQueryClient() {\n  return new QueryClient({\n    defaultOptions: {\n      queries: {\n        // Data is considered fresh for 5 minutes\n        staleTime: 5 * 60 * 1000,\n\n        // Cache data for 10 minutes after last use\n        gcTime: 10 * 60 * 1000,\n\n        // Retry failed requests once\n        retry: 1,\n\n        // Don't refetch on window focus (prevents aggressive refetching)\n        refetchOnWindowFocus: false,\n\n        // Don't refetch on mount - respect staleTime instead\n        refetchOnMount: false,\n\n        // Don't refetch on reconnect by default\n        refetchOnReconnect: false,\n      },\n      mutations: {\n        // Retry mutations once on network errors\n        retry: 1,\n      },\n    },\n  });\n}\n\n// Browser: Create query client once\nlet browserQueryClient: QueryClient | undefined = undefined;\n\nfunction getQueryClient() {\n  if (typeof window === \"undefined\") {\n    // Server: Always create a new query client\n    return makeQueryClient();\n  } else {\n    // Browser: Create query client if it doesn't exist\n    if (!browserQueryClient) {\n      browserQueryClient = makeQueryClient();\n    }\n    return browserQueryClient;\n  }\n}\n\ninterface QueryProviderProps {\n  children: ReactNode;\n}\n\nexport function QueryProvider({ children }: QueryProviderProps) {\n  // NOTE: Avoid useState when initializing the query client if you don't\n  // have a suspense boundary between this and the code that may\n  // suspend because React will throw away the client on the initial\n  // render if it suspends and there is no boundary\n  const queryClient = getQueryClient();\n\n  return (\n    <QueryClientProvider client={queryClient}>\n      {children}\n      {process.env.NODE_ENV === \"development\" && (\n        <ReactQueryDevtools\n          initialIsOpen={false}\n          buttonPosition=\"bottom-right\"\n        />\n      )}\n    </QueryClientProvider>\n  );\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/stores/sidebar-store.ts",
    "content": "import { create } from \"zustand\";\n\ntype SidebarState = {\n  isCollapsed: boolean;\n  preferredCollapsed: boolean;\n  isAutoCollapsed: boolean;\n  toggle: () => void;\n  setCollapsed: (collapsed: boolean) => void;\n  setAutoCollapsed: (shouldCollapse: boolean) => void;\n};\n\nexport const useSidebarStore = create<SidebarState>((set, get) => ({\n  isCollapsed: false,\n  preferredCollapsed: false,\n  isAutoCollapsed: false,\n  toggle: () => {\n    const { isAutoCollapsed } = get();\n\n    if (isAutoCollapsed) {\n      return;\n    }\n\n    set((state) => {\n      const next = !state.preferredCollapsed;\n      return {\n        preferredCollapsed: next,\n        isCollapsed: next,\n      };\n    });\n  },\n  setCollapsed: (collapsed) =>\n    set((state) => ({\n      preferredCollapsed: collapsed,\n      isCollapsed: state.isAutoCollapsed ? true : collapsed,\n    })),\n  setAutoCollapsed: (shouldCollapse) =>\n    set((state) => ({\n      isAutoCollapsed: shouldCollapse,\n      isCollapsed: shouldCollapse ? true : state.preferredCollapsed,\n    })),\n}));\n"
  },
  {
    "path": "next_b2b_starter/lib/types/loadable.ts",
    "content": "export enum LoadableState {\n  NotInitiated = \"notInitiated\",\n  Loading = \"loading\",\n  Success = \"success\",\n  Failure = \"failure\",\n  Empty = \"empty\"\n}\n\nexport type Loadable<T> = \n  | { state: LoadableState.NotInitiated; existing?: undefined; error?: undefined }\n  | { state: LoadableState.Loading; existing?: T; error?: undefined }\n  | { state: LoadableState.Success; data: T; existing?: undefined; error?: undefined }\n  | { state: LoadableState.Failure; error: Error; existing?: undefined }\n  | { state: LoadableState.Empty; existing?: undefined; error?: undefined };\n\nexport const LoadableHelpers = {\n  notInitiated: <T>(): Loadable<T> => ({ state: LoadableState.NotInitiated }),\n  \n  loading: <T>(existing?: T): Loadable<T> => ({ \n    state: LoadableState.Loading, \n    existing \n  }),\n  \n  success: <T>(data: T): Loadable<T> => ({ \n    state: LoadableState.Success, \n    data \n  }),\n  \n  failure: <T>(error: Error): Loadable<T> => ({ \n    state: LoadableState.Failure, \n    error \n  }),\n  \n  empty: <T>(): Loadable<T> => ({ state: LoadableState.Empty }),\n  \n  getValue: <T>(loadable: Loadable<T>): T | undefined => {\n    switch (loadable.state) {\n      case LoadableState.Success:\n        return loadable.data;\n      case LoadableState.Loading:\n        return loadable.existing;\n      default:\n        return undefined;\n    }\n  },\n  \n  isLoading: <T>(loadable: Loadable<T>): boolean => \n    loadable.state === LoadableState.Loading,\n  \n  isSuccess: <T>(loadable: Loadable<T>): boolean =>\n    loadable.state === LoadableState.Success,\n  \n  isFailure: <T>(loadable: Loadable<T>): boolean =>\n    loadable.state === LoadableState.Failure,\n  \n  isEmpty: <T>(loadable: Loadable<T>): boolean =>\n    loadable.state === LoadableState.Empty,\n  \n  isNotInitiated: <T>(loadable: Loadable<T>): boolean =>\n    loadable.state === LoadableState.NotInitiated,\n  \n  getError: <T>(loadable: Loadable<T>): Error | undefined =>\n    loadable.state === LoadableState.Failure ? loadable.error : undefined\n};"
  },
  {
    "path": "next_b2b_starter/lib/utils/api-logger.ts",
    "content": "/**\n * API Request/Response Logger\n *\n * Provides comprehensive logging for all API requests and responses\n * with color-coded console output for easy debugging.\n */\n\nexport interface LoggedRequest {\n  method: string;\n  url: string;\n  headers: Record<string, string>;\n  body?: any;\n  timestamp: number;\n}\n\nexport interface LoggedResponse {\n  status: number;\n  statusText: string;\n  headers: Record<string, string>;\n  body?: any;\n  duration: number;\n  timestamp: number;\n}\n\nexport interface LoggedError {\n  message: string;\n  status?: number;\n  details?: any;\n  duration: number;\n  timestamp: number;\n}\n\nclass ApiLogger {\n  private enabled: boolean;\n  private pendingRequests: Map<string, number>;\n\n  constructor() {\n    // Enable in development or when explicitly enabled\n    this.enabled =\n      typeof window !== \"undefined\" &&\n      (process.env.NODE_ENV === \"development\" ||\n        localStorage.getItem(\"API_LOGGER_ENABLED\") === \"true\");\n\n    this.pendingRequests = new Map();\n  }\n\n  /**\n   * Enable/disable API logging\n   */\n  setEnabled(enabled: boolean): void {\n    this.enabled = enabled;\n    if (typeof window !== \"undefined\") {\n      if (enabled) {\n        localStorage.setItem(\"API_LOGGER_ENABLED\", \"true\");\n      } else {\n        localStorage.removeItem(\"API_LOGGER_ENABLED\");\n      }\n    }\n  }\n\n  /**\n   * Log an outgoing API request\n   */\n  logRequest(requestId: string, request: LoggedRequest): void {\n    if (!this.enabled) return;\n\n    this.pendingRequests.set(requestId, request.timestamp);\n\n    const style = \"color: #2196F3; font-weight: bold;\";\n    const resetStyle = \"color: inherit; font-weight: normal;\";\n\n    console.groupCollapsed(\n      `%c→ ${request.method} %c${request.url}`,\n      style,\n      resetStyle\n    );\n\n    // Log request details\n    console.log(\"%cRequest Details:\", \"font-weight: bold;\");\n    console.table({\n      Method: request.method,\n      URL: request.url,\n      Timestamp: new Date(request.timestamp).toISOString(),\n    });\n\n    // Log headers\n    console.log(\"%cHeaders:\", \"font-weight: bold; color: #9C27B0;\");\n    console.table(this.sanitizeHeaders(request.headers));\n\n    // Log body if present\n    if (request.body) {\n      console.log(\"%cBody:\", \"font-weight: bold; color: #FF9800;\");\n      try {\n        const parsed = typeof request.body === \"string\"\n          ? JSON.parse(request.body)\n          : request.body;\n        console.log(parsed);\n      } catch {\n        console.log(request.body);\n      }\n    }\n\n    console.groupEnd();\n  }\n\n  /**\n   * Log a successful API response\n   */\n  logResponse(requestId: string, response: LoggedResponse): void {\n    if (!this.enabled) return;\n\n    const startTime = this.pendingRequests.get(requestId);\n    const duration = startTime ? response.timestamp - startTime : response.duration;\n    this.pendingRequests.delete(requestId);\n\n    const isSuccess = response.status >= 200 && response.status < 300;\n    const style = isSuccess\n      ? \"color: #4CAF50; font-weight: bold;\"\n      : \"color: #FF5722; font-weight: bold;\";\n    const resetStyle = \"color: inherit; font-weight: normal;\";\n\n    console.groupCollapsed(\n      `%c← ${response.status} %c${response.statusText} %c(${duration}ms)`,\n      style,\n      resetStyle,\n      \"color: #757575; font-size: 0.9em;\"\n    );\n\n    // Log response summary\n    console.log(\"%cResponse Summary:\", \"font-weight: bold;\");\n    console.table({\n      Status: `${response.status} ${response.statusText}`,\n      Duration: `${duration}ms`,\n      Timestamp: new Date(response.timestamp).toISOString(),\n    });\n\n    // Log response headers\n    console.log(\"%cResponse Headers:\", \"font-weight: bold; color: #9C27B0;\");\n    console.table(response.headers);\n\n    // Log response body\n    if (response.body) {\n      console.log(\"%cResponse Body:\", \"font-weight: bold; color: #4CAF50;\");\n      console.log(response.body);\n    }\n\n    console.groupEnd();\n  }\n\n  /**\n   * Log an API error\n   */\n  logError(requestId: string, error: LoggedError): void {\n    if (!this.enabled) return;\n\n    const startTime = this.pendingRequests.get(requestId);\n    const duration = startTime ? error.timestamp - startTime : error.duration;\n    this.pendingRequests.delete(requestId);\n\n    const style = \"color: #F44336; font-weight: bold;\";\n    const resetStyle = \"color: inherit; font-weight: normal;\";\n\n    console.groupCollapsed(\n      `%c✖ API ERROR %c${error.status || \"Network Error\"} %c(${duration}ms)`,\n      style,\n      resetStyle,\n      \"color: #757575; font-size: 0.9em;\"\n    );\n\n    // Log error summary\n    console.log(\"%cError Details:\", \"font-weight: bold; color: #F44336;\");\n    console.table({\n      Message: error.message,\n      Status: error.status || \"N/A\",\n      Duration: `${duration}ms`,\n      Timestamp: new Date(error.timestamp).toISOString(),\n    });\n\n    // Log error details\n    if (error.details) {\n      console.log(\"%cError Details:\", \"font-weight: bold; color: #FF5722;\");\n      console.log(error.details);\n    }\n\n    // Log stack trace if available\n    if (error.details instanceof Error) {\n      console.log(\"%cStack Trace:\", \"font-weight: bold; color: #FF9800;\");\n      console.error(error.details);\n    }\n\n    console.groupEnd();\n  }\n\n  /**\n   * Sanitize sensitive headers before logging\n   */\n  private sanitizeHeaders(headers: Record<string, string>): Record<string, string> {\n    const sanitized: Record<string, string> = {};\n    const sensitiveKeys = [\"authorization\", \"cookie\", \"set-cookie\", \"x-api-key\"];\n\n    for (const [key, value] of Object.entries(headers)) {\n      const lowerKey = key.toLowerCase();\n\n      if (sensitiveKeys.includes(lowerKey)) {\n        // Show partial value for debugging\n        if (lowerKey === \"authorization\" && value.startsWith(\"Bearer \")) {\n          const token = value.slice(7);\n          sanitized[key] = `Bearer ${token.slice(0, 20)}...${token.slice(-20)}`;\n        } else if (lowerKey === \"cookie\") {\n          sanitized[key] = \"[REDACTED - Contains session cookies]\";\n        } else {\n          sanitized[key] = \"[REDACTED]\";\n        }\n      } else {\n        sanitized[key] = value;\n      }\n    }\n\n    return sanitized;\n  }\n\n  /**\n   * Generate a unique request ID\n   */\n  generateRequestId(): string {\n    return `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;\n  }\n}\n\n// Singleton instance\nexport const apiLogger = new ApiLogger();\n\n// Utility functions for common logging patterns\nexport const enableApiLogging = () => apiLogger.setEnabled(true);\nexport const disableApiLogging = () => apiLogger.setEnabled(false);\n\n// Export for window access (debugging)\nif (typeof window !== \"undefined\") {\n  (window as any).__apiLogger = {\n    enable: enableApiLogging,\n    disable: disableApiLogging,\n    instance: apiLogger,\n  };\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/utils/password-generator.ts",
    "content": "/**\n * Generates a cryptographically secure random password\n * This is used for backend compatibility when using magic link authentication\n * Users won't see or use this password - they authenticate via magic link\n */\nexport function generateSecurePassword(length: number = 24): string {\n  const uppercase = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n  const lowercase = \"abcdefghijklmnopqrstuvwxyz\";\n  const numbers = \"0123456789\";\n  const symbols = \"!@#$%^&*()_+-=[]{}|;:,.<>?\";\n\n  const allChars = uppercase + lowercase + numbers + symbols;\n\n  // Ensure at least one character from each category\n  let password = \"\";\n  password += uppercase[Math.floor(Math.random() * uppercase.length)];\n  password += lowercase[Math.floor(Math.random() * lowercase.length)];\n  password += numbers[Math.floor(Math.random() * numbers.length)];\n  password += symbols[Math.floor(Math.random() * symbols.length)];\n\n  // Fill the rest randomly\n  for (let i = password.length; i < length; i++) {\n    const randomIndex = Math.floor(Math.random() * allChars.length);\n    password += allChars[randomIndex];\n  }\n\n  // Shuffle the password to randomize character positions\n  return password\n    .split(\"\")\n    .sort(() => Math.random() - 0.5)\n    .join(\"\");\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/utils/server-action-helpers.ts",
    "content": "/**\n * Server Action Helpers\n *\n * Utility functions and types for Next.js Server Actions.\n * Provides consistent patterns for authentication, permissions, and error handling.\n */\n\nimport { getMemberSession, requireMemberSession } from \"@/lib/auth/stytch/server\";\nimport { getServerPermissions, type ServerPermissions } from \"@/lib/auth/server-permissions\";\nimport type { B2BSessionsAuthenticateResponse } from \"stytch\";\n\n/**\n * Standard result type for Server Actions\n * Ensures consistent return values across all actions\n */\nexport type ActionResult<T = void> =\n  | { success: true; data: T }\n  | { success: false; error: string; details?: string };\n\n/**\n * Session result type for auth helpers\n */\nexport type SessionResult = {\n  session: B2BSessionsAuthenticateResponse | null;\n  permissions: ServerPermissions | null;\n};\n\n/**\n * Get the current member session (optional - returns null if not authenticated)\n * Use this when authentication is optional\n */\nexport async function getActionSession(): Promise<B2BSessionsAuthenticateResponse | null> {\n  return await getMemberSession();\n}\n\n/**\n * Require authentication for a Server Action\n * Throws an error if user is not authenticated\n * Use this when authentication is mandatory\n */\nexport async function requireActionSession(): Promise<B2BSessionsAuthenticateResponse> {\n  return await requireMemberSession();\n}\n\n/**\n * Get session and permissions together\n * Returns null for both if not authenticated\n */\nexport async function getActionSessionWithPermissions(): Promise<SessionResult> {\n  const session = await getMemberSession();\n\n  if (!session) {\n    return { session: null, permissions: null };\n  }\n\n  const permissions = await getServerPermissions(session);\n\n  return { session, permissions };\n}\n\n/**\n * Require session and permissions together\n * Throws error if not authenticated\n */\nexport async function requireActionSessionWithPermissions(): Promise<{\n  session: B2BSessionsAuthenticateResponse;\n  permissions: ServerPermissions;\n}> {\n  const session = await requireMemberSession();\n  const permissions = await getServerPermissions(session);\n\n  return { session, permissions };\n}\n\n/**\n * Create a standardized error result\n * Use this to return errors from Server Actions\n */\nexport function createActionError(\n  error: string,\n  details?: string\n): ActionResult<never> {\n  return {\n    success: false,\n    error,\n    details,\n  };\n}\n\n/**\n * Create a standardized success result\n * Use this to return success from Server Actions\n */\nexport function createActionSuccess<T>(data: T): ActionResult<T> {\n  return {\n    success: true,\n    data,\n  };\n}\n\n/**\n * Create a success result with no data\n */\nexport function createActionSuccessEmpty(): ActionResult<void> {\n  return {\n    success: true,\n    data: undefined,\n  };\n}\n\n/**\n * Wrap a Server Action with error handling\n * Catches errors and returns them as ActionResult\n */\nexport async function withErrorHandling<T>(\n  fn: () => Promise<ActionResult<T>>\n): Promise<ActionResult<T>> {\n  try {\n    return await fn();\n  } catch (error: any) {\n    console.error(\"[Server Action Error]\", error);\n\n    return createActionError(\n      \"An unexpected error occurred\",\n      process.env.NODE_ENV === \"development\" ? error.message : undefined\n    );\n  }\n}\n\n/**\n * Check if user has required permission\n * Returns error result if permission is missing\n */\nexport function requirePermission(\n  permissions: ServerPermissions,\n  check: (p: ServerPermissions) => boolean,\n  errorMessage = \"You do not have permission to perform this action\"\n): ActionResult<void> | null {\n  if (!check(permissions)) {\n    return createActionError(errorMessage, \"Permission denied\");\n  }\n  return null;\n}\n"
  },
  {
    "path": "next_b2b_starter/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}"
  },
  {
    "path": "next_b2b_starter/lint/README.md",
    "content": "This directory exists to satisfy the Next.js `next lint` command, which currently expects a `lint` project folder.\nIt intentionally remains empty so ESLint quickly no-ops on it.\n"
  },
  {
    "path": "next_b2b_starter/next-env.d.ts",
    "content": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\nimport \"./.next/dev/types/routes.d.ts\";\n\n// NOTE: This file should not be edited\n// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.\n"
  },
  {
    "path": "next_b2b_starter/next.config.ts",
    "content": "import type { NextConfig } from \"next\";\n\nconst withDefault = (value: string | undefined, fallback: string) => {\n  const trimmed = value?.trim();\n  return trimmed && trimmed.length > 0 ? trimmed : fallback;\n};\n\nconst isDevelopment = process.env.NODE_ENV === 'development';\n\nconst ContentSecurityPolicy = [\n  \"default-src 'self';\",\n  // Note: Add 'unsafe-eval' back if Google Tag Manager is needed in production\n  \"script-src 'self' 'unsafe-inline' https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com https://youtube-nocookie.com https://www.googletagmanager.com;\",\n  \"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;\",\n  \"img-src 'self' data: https://www.youtube.com https://youtube.com https://img.youtube.com https://i.ytimg.com https://fonts.gstatic.com;\",\n  \"font-src 'self' https://fonts.gstatic.com;\",\n  isDevelopment\n    ? \"connect-src 'self' http://localhost:8080 https://test.stytch.com https://api.stytch.com https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com;\"\n    : \"connect-src 'self' https://api.stytch.com https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com;\",\n  \"frame-src https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com https://youtube-nocookie.com;\",\n  \"media-src 'self' https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com;\",\n  \"object-src 'none';\",\n  \"base-uri 'self';\",\n  \"frame-ancestors 'self';\",\n].join(\" \");\n\nconst nextConfig: NextConfig = {\n  // Enable standalone output for Docker optimization\n  // This reduces the Docker image size by ~75% by including only required files\n  output: \"standalone\",\n\n  compress: true,\n  images: {\n    formats: [\"image/avif\", \"image/webp\"],\n  },\n  experimental: {\n    optimizePackageImports: [\"lucide-react\"],\n  },\n\n  env: {\n    NEXT_PUBLIC_STYTCH_LOGIN_PATH: withDefault(\n      process.env.NEXT_PUBLIC_STYTCH_LOGIN_PATH,\n      \"/auth\"\n    ),\n    NEXT_PUBLIC_STYTCH_REDIRECT_PATH: withDefault(\n      process.env.NEXT_PUBLIC_STYTCH_REDIRECT_PATH,\n      \"/authenticate\"\n    ),\n    NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES: withDefault(\n      process.env.NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES,\n      \"2880\"\n    ),\n    NEXT_PUBLIC_STYTCH_PROJECT_ENV: withDefault(\n      process.env.NEXT_PUBLIC_STYTCH_PROJECT_ENV,\n      process.env.STYTCH_PROJECT_ENV || \"test\"\n    ),\n    NEXT_PUBLIC_APP_BASE_URL: withDefault(\n      process.env.NEXT_PUBLIC_APP_BASE_URL,\n      process.env.APP_BASE_URL || \"http://localhost:3000\"\n    ),\n  },\n\n  async headers() {\n    return [\n      // Security Headers\n      {\n        source: \"/:path*\",\n        headers: [\n          {\n            key: \"X-DNS-Prefetch-Control\",\n            value: \"on\",\n          },\n          {\n            key: \"Strict-Transport-Security\",\n            value: \"max-age=63072000; includeSubDomains; preload\",\n          },\n          {\n            key: \"X-Content-Type-Options\",\n            value: \"nosniff\",\n          },\n          {\n            key: \"X-Frame-Options\",\n            value: \"SAMEORIGIN\",\n          },\n          {\n            key: \"X-XSS-Protection\",\n            value: \"1; mode=block\",\n          },\n          {\n            key: \"Referrer-Policy\",\n            value: \"strict-origin-when-cross-origin\",\n          },\n          {\n            key: \"Permissions-Policy\",\n            value: \"camera=(), microphone=(), geolocation=(), interest-cohort=()\",\n          },\n          {\n            key: \"Content-Security-Policy\",\n            value: ContentSecurityPolicy.replace(/\\s{2,}/g, \" \").trim(),\n          },\n          {\n            key: \"Cross-Origin-Resource-Policy\",\n            value: \"same-site\",\n          },\n          {\n            key: \"Cross-Origin-Opener-Policy\",\n            value: \"same-origin-allow-popups\",\n          },\n        ],\n      },\n      // SEO Image Caching\n      {\n        source: \"/opengraph-image.png\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=0, s-maxage=31536000, immutable\",\n          },\n        ],\n      },\n      {\n        source: \"/twitter-image.png\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=0, s-maxage=31536000, immutable\",\n          },\n        ],\n      },\n      {\n        source: \"/icon.png\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=0, s-maxage=31536000, immutable\",\n          },\n        ],\n      },\n      {\n        source: \"/favicon.ico\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=0, s-maxage=31536000, immutable\",\n          },\n        ],\n      },\n      {\n        source: \"/apple-touch-icon.png\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=0, s-maxage=31536000, immutable\",\n          },\n        ],\n      },\n      {\n        source: \"/screenshot.webp\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=0, s-maxage=31536000, immutable\",\n          },\n        ],\n      },\n      // Static Assets Caching\n      {\n        source: \"/fonts/:path*\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=31536000, immutable\",\n          },\n        ],\n      },\n      {\n        source: \"/_next/static/:path*\",\n        headers: [\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=31536000, immutable\",\n          },\n        ],\n      },\n    ];\n  },\n\n  async redirects() {\n    return [\n      // Redirects have been removed for the starter kit\n    ];\n  },\n};\n\nexport default nextConfig;\n"
  },
  {
    "path": "next_b2b_starter/package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"next build\",\n    \"dev\": \"next dev\",\n    \"start\": \"next start\",\n    \"lint\": \"PORT=0 next lint\"\n  },\n  \"pnpm\": {\n    \"onlyBuiltDependencies\": [\n      \"sharp\",\n      \"unrs-resolver\"\n    ]\n  },\n  \"dependencies\": {\n    \"@hookform/resolvers\": \"^5.2.1\",\n    \"@polar-sh/nextjs\": \"^0.4.11\",\n    \"@polar-sh/sdk\": \"^0.40.2\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-avatar\": \"^1.1.10\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slider\": \"^1.2.2\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@stytch/nextjs\": \"^21.13.0\",\n    \"@stytch/vanilla-js\": \"^5.38.1\",\n    \"@tanstack/react-query\": \"^5.90.5\",\n    \"@tanstack/react-query-devtools\": \"^5.90.2\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"framer-motion\": \"^12.23.24\",\n    \"jwt-decode\": \"^4.0.0\",\n    \"lucide-react\": \"^0.542.0\",\n    \"next\": \"16.0.10\",\n    \"nodemailer\": \"^6.10.1\",\n    \"react\": \"latest\",\n    \"react-day-picker\": \"^9.11.0\",\n    \"react-dom\": \"latest\",\n    \"react-dropzone\": \"^14.3.8\",\n    \"react-hook-form\": \"^7.62.0\",\n    \"recharts\": \"^3.3.0\",\n    \"sonner\": \"^2.0.7\",\n    \"stytch\": \"^12.42.1\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"typescript\": \"5.7.3\",\n    \"zod\": \"^4.1.5\",\n    \"zustand\": \"^5.0.8\"\n  },\n  \"optionalDependencies\": {\n    \"@types/nodemailer\": \"^6.4.20\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"22.10.7\",\n    \"@types/react\": \"19.0.7\",\n    \"@types/react-dom\": \"19.0.3\",\n    \"autoprefixer\": \"10.4.20\",\n    \"canvas\": \"^3.2.0\",\n    \"eslint\": \"^9.17.0\",\n    \"eslint-config-next\": \"^16.0.10\",\n    \"postcss\": \"8.5.1\",\n    \"sharp\": \"^0.34.5\",\n    \"tailwindcss\": \"3.4.17\"\n  }\n}\n"
  },
  {
    "path": "next_b2b_starter/postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "next_b2b_starter/proxy.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport type { NextRequest } from \"next/server\";\nimport {\n  SESSION_COOKIE_NAME,\n  SESSION_JWT_COOKIE_NAME,\n} from \"@/lib/auth/constants\";\n\n// Canonical host for SEO\nconst CANONICAL_HOST = \"yourdomain.com\";\n\n// Protected routes that require authentication\nconst PROTECTED_ROUTES = [\"/dashboard\", \"/settings\"];\n\n// Public routes that don't require authentication\nconst PUBLIC_ROUTES = [\"/auth\", \"/authenticate\", \"/api/auth\"];\n\nexport function proxy(request: NextRequest) {\n  const { pathname } = request.nextUrl;\n  const url = request.nextUrl.clone();\n  const host = request.headers.get(\"host\") || \"\";\n\n  // Skip redirects for local/dev hosts\n  const isLocalhost =\n    host.startsWith(\"localhost\") || host.startsWith(\"127.0.0.1\");\n\n  // 1. SEO: Force apex domain (remove www.) - skip for localhost/dev\n  if (!isLocalhost && host === `www.${CANONICAL_HOST}`) {\n    url.hostname = CANONICAL_HOST;\n    return NextResponse.redirect(url, 301);\n  }\n\n  // 2. SEO: Enforce HTTPS on canonical domain\n  if (!isLocalhost && host === CANONICAL_HOST) {\n    const proto = request.headers.get(\"x-forwarded-proto\");\n    if (proto && proto !== \"https\") {\n      url.protocol = \"https:\";\n      return NextResponse.redirect(url, 301);\n    }\n  }\n\n  // 3. Check if route is public (no auth needed)\n  const isPublicRoute = PUBLIC_ROUTES.some((route) =>\n    pathname.startsWith(route)\n  );\n  if (isPublicRoute) {\n    return NextResponse.next();\n  }\n\n  // 4. Check if route is protected (auth required)\n  const isProtectedRoute = PROTECTED_ROUTES.some((route) =>\n    pathname.startsWith(route)\n  );\n  if (!isProtectedRoute) {\n    return NextResponse.next();\n  }\n\n  // 5. Check for session cookies\n  const sessionToken = request.cookies.get(SESSION_COOKIE_NAME);\n  const sessionJwt = request.cookies.get(SESSION_JWT_COOKIE_NAME);\n\n  // If no session, redirect to login\n  if (!sessionToken && !sessionJwt) {\n    const loginUrl = new URL(\"/auth\", request.url);\n    loginUrl.searchParams.set(\"returnTo\", pathname);\n    return NextResponse.redirect(loginUrl);\n  }\n\n  // Session exists, allow access\n  return NextResponse.next();\n}\n\nexport const config = {\n  matcher: [\n    /*\n     * Match all request paths except:\n     * - _next/static (static files)\n     * - _next/image (image optimization files)\n     * - favicon.ico (favicon file)\n     * - public files (images, etc.)\n     */\n    \"/((?!_next/static|_next/image|favicon.ico|icon.png|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\",\n  ],\n};\n"
  },
  {
    "path": "next_b2b_starter/scripts/convert-svg-to-png.js",
    "content": "const sharp = require('sharp');\nconst fs = require('fs');\nconst path = require('path');\n\nasync function convertSVGtoPNG() {\n  try {\n    // Convert OpenGraph SVG to PNG\n    const ogSvg = fs.readFileSync(path.join(__dirname, '../public/opengraph-image.svg'));\n    await sharp(ogSvg)\n      .resize(1200, 630)\n      .png()\n      .toFile(path.join(__dirname, '../public/opengraph-image.png'));\n    console.log('✅ OpenGraph image created (1200x630)');\n\n    // Convert Twitter SVG to PNG\n    const twitterSvg = fs.readFileSync(path.join(__dirname, '../public/twitter-image.svg'));\n    await sharp(twitterSvg)\n      .resize(1200, 600)\n      .png()\n      .toFile(path.join(__dirname, '../public/twitter-image.png'));\n    console.log('✅ Twitter image created (1200x600)');\n\n  } catch (error) {\n    console.error('Error converting SVG to PNG:', error);\n  }\n}\n\nconvertSVGtoPNG();"
  },
  {
    "path": "next_b2b_starter/scripts/generate-favicons.js",
    "content": "const sharp = require('sharp');\nconst fs = require('fs');\nconst path = require('path');\n\nasync function generateFavicons() {\n  try {\n    // Read the existing icon.png\n    const iconPath = path.join(__dirname, '../public/icon.png');\n    const iconBuffer = fs.readFileSync(iconPath);\n\n    // Generate favicon-16x16.png\n    await sharp(iconBuffer)\n      .resize(16, 16)\n      .png()\n      .toFile(path.join(__dirname, '../public/favicon-16x16.png'));\n    console.log('✅ favicon-16x16.png created');\n\n    // Generate favicon-32x32.png\n    await sharp(iconBuffer)\n      .resize(32, 32)\n      .png()\n      .toFile(path.join(__dirname, '../public/favicon-32x32.png'));\n    console.log('✅ favicon-32x32.png created');\n\n    // Generate apple-touch-icon.png (180x180)\n    await sharp(iconBuffer)\n      .resize(180, 180)\n      .png()\n      .toFile(path.join(__dirname, '../public/apple-touch-icon.png'));\n    console.log('✅ apple-touch-icon.png created (180x180)');\n\n    // Generate android-chrome-192x192.png\n    await sharp(iconBuffer)\n      .resize(192, 192)\n      .png()\n      .toFile(path.join(__dirname, '../public/android-chrome-192x192.png'));\n    console.log('✅ android-chrome-192x192.png created');\n\n    // Generate android-chrome-512x512.png\n    await sharp(iconBuffer)\n      .resize(512, 512)\n      .png()\n      .toFile(path.join(__dirname, '../public/android-chrome-512x512.png'));\n    console.log('✅ android-chrome-512x512.png created');\n\n    // Create favicon.ico (we'll use the 32x32 for now as a placeholder)\n    await sharp(iconBuffer)\n      .resize(32, 32)\n      .png()\n      .toFile(path.join(__dirname, '../public/favicon.ico'));\n    console.log('✅ favicon.ico created (placeholder)');\n\n  } catch (error) {\n    console.error('Error generating favicons:', error);\n  }\n}\n\ngenerateFavicons();"
  },
  {
    "path": "next_b2b_starter/scripts/generate-og-images.js",
    "content": "// Script to generate OpenGraph and Twitter images\nconst { createCanvas, registerFont } = require('canvas');\nconst fs = require('fs');\nconst path = require('path');\n\n// Create OpenGraph image (1200x630)\nfunction createOGImage() {\n  const canvas = createCanvas(1200, 630);\n  const ctx = canvas.getContext('2d');\n\n  // Background gradient\n  const gradient = ctx.createLinearGradient(0, 0, 1200, 630);\n  gradient.addColorStop(0, '#0FA8A0');\n  gradient.addColorStop(1, '#0C7A75');\n  ctx.fillStyle = gradient;\n  ctx.fillRect(0, 0, 1200, 630);\n\n  // Add pattern overlay\n  ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';\n  for (let i = 0; i < 20; i++) {\n    ctx.beginPath();\n    ctx.arc(Math.random() * 1200, Math.random() * 630, Math.random() * 100 + 50, 0, Math.PI * 2);\n    ctx.fill();\n  }\n\n  // Logo area\n  ctx.fillStyle = '#FFFFFF';\n  ctx.font = 'bold 48px sans-serif';\n  ctx.fillText('B2B SaaS Starter', 100, 100);\n\n  // Main headline\n  ctx.font = 'bold 64px sans-serif';\n  ctx.fillText('Launch Your SaaS Fast', 100, 250);\n  ctx.fillText('Production Ready', 100, 330);\n\n  // Subheadline\n  ctx.font = '32px sans-serif';\n  ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';\n  ctx.fillText('Modern Next.js Starter with Auth & Billing', 100, 420);\n  ctx.fillText('for fast moving teams', 100, 470);\n\n  // CTA area\n  ctx.fillStyle = '#FFFFFF';\n  ctx.fillRect(100, 520, 250, 60);\n  ctx.fillStyle = '#0FA8A0';\n  ctx.font = 'bold 24px sans-serif';\n  ctx.fillText('Get Started Free', 140, 560);\n\n  // Save image\n  const buffer = canvas.toBuffer('image/png');\n  fs.writeFileSync(path.join(__dirname, '../public/opengraph-image.png'), buffer);\n  console.log('✅ OpenGraph image created (1200x630)');\n}\n\n// Create Twitter image (1200x600)\nfunction createTwitterImage() {\n  const canvas = createCanvas(1200, 600);\n  const ctx = canvas.getContext('2d');\n\n  // Background gradient\n  const gradient = ctx.createLinearGradient(0, 0, 1200, 600);\n  gradient.addColorStop(0, '#0FA8A0');\n  gradient.addColorStop(1, '#0C7A75');\n  ctx.fillStyle = gradient;\n  ctx.fillRect(0, 0, 1200, 600);\n\n  // Add pattern overlay\n  ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';\n  for (let i = 0; i < 20; i++) {\n    ctx.beginPath();\n    ctx.arc(Math.random() * 1200, Math.random() * 600, Math.random() * 100 + 50, 0, Math.PI * 2);\n    ctx.fill();\n  }\n\n  // Logo area\n  ctx.fillStyle = '#FFFFFF';\n  ctx.font = 'bold 48px sans-serif';\n  ctx.fillText('B2B SaaS Starter', 100, 90);\n\n  // Main headline\n  ctx.font = 'bold 64px sans-serif';\n  ctx.fillText('Launch Your SaaS', 100, 240);\n  ctx.fillText('In Minutes', 100, 320);\n\n  // Subheadline\n  ctx.font = '32px sans-serif';\n  ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';\n  ctx.fillText('Production Ready Starter Kit', 100, 410);\n\n  // Features\n  ctx.font = '24px sans-serif';\n  ctx.fillText('✓ Auth & Billing  ✓ Team Management  ✓ Modern UI', 100, 480);\n\n  // Save image\n  const buffer = canvas.toBuffer('image/png');\n  fs.writeFileSync(path.join(__dirname, '../public/twitter-image.png'), buffer);\n  console.log('✅ Twitter image created (1200x600)');\n}\n\n// Generate both images\ncreateOGImage();\ncreateTwitterImage();"
  },
  {
    "path": "next_b2b_starter/stores/ui-store.ts",
    "content": "/**\n * UI Store (Zustand)\n *\n * Manages client-only UI state such as:\n * - Active tabs\n * - Sidebar visibility\n * - Modal states\n * - UI preferences\n *\n * This store does NOT handle server data (use TanStack Query for that).\n */\n\nimport { create } from \"zustand\";\n\ninterface UIState {\n  // Settings page active tab\n  activeSettingsTab: string;\n  setActiveSettingsTab: (tab: string) => void;\n\n  // Sidebar state (for future use)\n  isSidebarOpen: boolean;\n  toggleSidebar: () => void;\n  setSidebarOpen: (isOpen: boolean) => void;\n\n  // Modal states (for future use)\n  isPlansModalOpen: boolean;\n  setPlansModalOpen: (isOpen: boolean) => void;\n\n  // Reset all UI state\n  reset: () => void;\n}\n\nconst initialState = {\n  activeSettingsTab: \"profile\",\n  isSidebarOpen: true,\n  isPlansModalOpen: false,\n};\n\nexport const useUIStore = create<UIState>((set) => ({\n  ...initialState,\n\n  // Settings tab\n  setActiveSettingsTab: (tab) => set({ activeSettingsTab: tab }),\n\n  // Sidebar\n  toggleSidebar: () =>\n    set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),\n  setSidebarOpen: (isOpen) => set({ isSidebarOpen: isOpen }),\n\n  // Modals\n  setPlansModalOpen: (isOpen) => set({ isPlansModalOpen: isOpen }),\n\n  // Reset\n  reset: () => set(initialState),\n}));\n\n/**\n * Selector hooks for optimized re-renders\n *\n * Use these instead of the base store to prevent unnecessary re-renders.\n *\n * @example\n * ```tsx\n * // Bad: Component re-renders on ANY state change\n * const { activeSettingsTab } = useUIStore();\n *\n * // Good: Component re-renders only when activeSettingsTab changes\n * const activeSettingsTab = useActiveSettingsTab();\n * ```\n */\n\nexport const useActiveSettingsTab = () =>\n  useUIStore((state) => state.activeSettingsTab);\n\nexport const useIsSidebarOpen = () =>\n  useUIStore((state) => state.isSidebarOpen);\n\nexport const useIsPlansModalOpen = () =>\n  useUIStore((state) => state.isPlansModalOpen);\n"
  },
  {
    "path": "next_b2b_starter/tailwind.config.ts",
    "content": "import type { Config } from 'tailwindcss';\n\nconst config: Config = {\n  darkMode: ['class'],\n  content: [\n    './pages/**/*.{ts,tsx}',\n    './components/**/*.{ts,tsx}',\n    './app/**/*.{ts,tsx}',\n    './src/**/*.{ts,tsx}',\n  ],\n  prefix: '',\n  theme: {\n  \tcontainer: {\n  \t\tcenter: true,\n  \t\tpadding: '2rem',\n  \t\tscreens: {\n  \t\t\t'2xl': '1400px'\n  \t\t}\n  \t},\n  \textend: {\n  \t\tfontFamily: {\n  \t\t\tsans: [\n  \t\t\t\t'var(--font-sans)',\n  \t\t\t\t'ui-sans-serif',\n  \t\t\t\t'system-ui',\n  \t\t\t\t'sans-serif'\n  \t\t\t]\n  \t\t},\n  \t\tcolors: {\n  \t\t\tborder: 'hsl(var(--border))',\n  \t\t\tinput: 'hsl(var(--input))',\n  \t\t\tring: 'hsl(var(--ring))',\n  \t\t\tbackground: 'hsl(var(--background))',\n  \t\t\tforeground: 'hsl(var(--foreground))',\n  \t\t\tprimary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--primary))',\n  \t\t\t\tforeground: 'hsl(var(--primary-foreground))'\n  \t\t\t},\n  \t\t\tsecondary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--secondary))',\n  \t\t\t\tforeground: 'hsl(var(--secondary-foreground))'\n  \t\t\t},\n  \t\t\tdestructive: {\n  \t\t\t\tDEFAULT: 'hsl(var(--destructive))',\n  \t\t\t\tforeground: 'hsl(var(--destructive-foreground))'\n  \t\t\t},\n  \t\t\tmuted: {\n  \t\t\t\tDEFAULT: 'hsl(var(--muted))',\n  \t\t\t\tforeground: 'hsl(var(--muted-foreground))'\n  \t\t\t},\n  \t\t\taccent: {\n  \t\t\t\tDEFAULT: 'hsl(var(--accent))',\n  \t\t\t\tforeground: 'hsl(var(--accent-foreground))'\n  \t\t\t},\n  \t\t\tpopover: {\n  \t\t\t\tDEFAULT: 'hsl(var(--popover))',\n  \t\t\t\tforeground: 'hsl(var(--popover-foreground))'\n  \t\t\t},\n  \t\t\tcard: {\n  \t\t\t\tDEFAULT: 'hsl(var(--card))',\n  \t\t\t\tforeground: 'hsl(var(--card-foreground))'\n  \t\t\t}\n  \t\t},\n  \t\tborderRadius: {\n  \t\t\tlg: 'var(--radius)',\n  \t\t\tmd: 'calc(var(--radius) - 2px)',\n  \t\t\tsm: 'calc(var(--radius) - 4px)'\n  \t\t},\n  \t\tkeyframes: {\n  \t\t\t'accordion-down': {\n  \t\t\t\tfrom: {\n  \t\t\t\t\theight: '0'\n  \t\t\t\t},\n  \t\t\t\tto: {\n  \t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n  \t\t\t\t}\n  \t\t\t},\n  \t\t\t'accordion-up': {\n  \t\t\t\tfrom: {\n  \t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n  \t\t\t\t},\n  \t\t\t\tto: {\n  \t\t\t\t\theight: '0'\n  \t\t\t\t}\n  \t\t\t}\n  \t\t},\n  \t\tanimation: {\n  \t\t\t'accordion-down': 'accordion-down 0.2s ease-out',\n  \t\t\t'accordion-up': 'accordion-up 0.2s ease-out'\n  \t\t}\n  \t}\n  },\n  plugins: [require('tailwindcss-animate')],\n};\n\nexport default config;\n"
  },
  {
    "path": "next_b2b_starter/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2017\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\n        \"./*\"\n      ]\n    }\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \"app/lib/placeholder-data.ts\",\n    \"scripts/seed.js\",\n    \"lib/api/api/dto/error-response\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\"\n  ]\n}\n"
  },
  {
    "path": "setup.sh",
    "content": "#!/bin/bash\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Store the root directory\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nBACKEND_DIR=\"$ROOT_DIR/go-b2b-starter\"\n\n# Cleanup function\ncleanup() {\n    echo -e \"\\n${YELLOW}🛑 Shutting down dependency services...${NC}\"\n    echo -e \"${BLUE}→ Stopping Docker containers...${NC}\"\n    cd \"$BACKEND_DIR\"\n    make stop-deps\n    echo -e \"${GREEN}✓ Cleanup complete${NC}\"\n    exit 0\n}\n\n# Function to wait for PostgreSQL\nwait_for_postgres() {\n    echo -e \"${BLUE}→ Waiting for PostgreSQL to be ready...${NC}\"\n    local max_attempts=30\n    local attempt=0\n\n    cd \"$BACKEND_DIR\"\n\n    while [ $attempt -lt $max_attempts ]; do\n        # Check if the postgres container is healthy\n        if docker compose -f deps/docker-compose.yml ps postgres | grep -q \"healthy\"; then\n            echo -e \"${GREEN}✓ PostgreSQL is ready${NC}\"\n            cd \"$ROOT_DIR\"\n            return 0\n        fi\n\n        attempt=$((attempt + 1))\n        echo -e \"${YELLOW}  Waiting... ($attempt/$max_attempts)${NC}\"\n        sleep 2\n    done\n\n    echo -e \"${RED}✗ PostgreSQL failed to start within timeout${NC}\"\n    cd \"$ROOT_DIR\"\n    return 1\n}\n\n# Main execution\necho -e \"${GREEN}╔════════════════════════════════════════╗${NC}\"\necho -e \"${GREEN}║   Initializing Development Environment   ║${NC}\"\necho -e \"${GREEN}╔════════════════════════════════════════╝${NC}\"\necho \"\"\n\n# Phase 0: Initialize Configuration\necho -e \"${BLUE}📝 Phase 0: Initializing Configuration${NC}\"\n\n# Backend Config\nif [ ! -f \"$BACKEND_DIR/app.env\" ]; then\n    echo -e \"${YELLOW}  Creating backend config from example...${NC}\"\n    cp \"$BACKEND_DIR/example.env\" \"$BACKEND_DIR/app.env\"\nelse\n    echo -e \"${GREEN}  Backend config exists${NC}\"\nfi\n\n# Frontend Config\nFRONTEND_DIR=\"$ROOT_DIR/next_b2b_starter\"\nif [ ! -f \"$FRONTEND_DIR/.env.local\" ]; then\n    echo -e \"${YELLOW}  Creating frontend config from example...${NC}\"\n    cp \"$FRONTEND_DIR/.env.example\" \"$FRONTEND_DIR/.env.local\"\nelse\n    echo -e \"${GREEN}  Frontend config exists${NC}\"\nfi\necho \"\"\n\n# Phase 1: Start Docker Dependencies\necho -e \"${BLUE}📦 Phase 1: Starting Docker Dependencies${NC}\"\ncd \"$BACKEND_DIR\"\nmake run-deps\n\nif [ $? -ne 0 ]; then\n    echo -e \"${RED}✗ Failed to start Docker dependencies${NC}\"\n    exit 1\nfi\necho -e \"${GREEN}✓ Docker containers started${NC}\"\necho \"\"\n\n# Phase 2: Wait for PostgreSQL\necho -e \"${BLUE}🔍 Phase 2: Checking PostgreSQL Health${NC}\"\nwait_for_postgres\nif [ $? -ne 0 ]; then\n    cleanup\n    exit 1\nfi\necho \"\"\n\n# Phase 3: Run Database Migrations\necho -e \"${BLUE}🗄️  Phase 3: Running Database Migrations${NC}\"\ncd \"$BACKEND_DIR\"\nmake migrateup\n\nif [ $? -ne 0 ]; then\n    echo -e \"${RED}✗ Database migrations failed${NC}\"\n    cleanup\n    exit 1\nfi\necho -e \"${GREEN}✓ Migrations completed${NC}\"\necho \"\"\n\n# Success Output\necho -e \"${GREEN}╔══════════════════════════════════════════════╗${NC}\"\necho -e \"${GREEN}║   ✅ Environment Ready! 🚀                   ║${NC}\"\necho -e \"${GREEN}╚══════════════════════════════════════════════╝${NC}\"\necho \"\"\necho -e \"You can now run the services in separate terminal tabs:\"\necho \"\"\necho -e \"${BLUE}1. Run Backend (with Air hot-reload):${NC}\"\necho -e \"   cd go-b2b-starter && make dev\"\necho \"\"\necho -e \"${BLUE}2. Run Frontend:${NC}\"\necho -e \"   cd next_b2b_starter && pnpm dev\"\necho \"\"\n"
  }
]