commit b089eccc7570a38c0cda03e3c8a117268e5b3e09 Author: Mohammed Alquraini Date: Tue Dec 16 18:54:41 2025 +0400 chore: Update dependencies and regenerate build artifacts. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..02b5e57 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.go] +indent_style = tab + +[*.{js,ts,tsx,json,yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46e581a --- /dev/null +++ b/.gitignore @@ -0,0 +1,101 @@ +# --- Global OS Files --- +.DS_Store +Thumbs.db + +# --- Global Editor Files --- +.idea/ +.vscode/ +*.swp +*.swo + +# --- Secrets (Safety Net) --- +# We ignore these here just in case someone accidentally +# creates an .env file in the root. +.env +.env.* +# But allow .env.example and example.env files (templates for users) +!.env.example +!**/.env.example +!**/example.env +*.pem +*.key + +# --- Claude Code --- +.claude/ + +# --- Logs --- +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# --- Docker --- +# If you mount volumes locally +.docker/ +postgres_data/ +redis_data/ + +# --- Additional Safety Nets --- +# These provide defense-in-depth, even though subdirectory +# .gitignore files may already cover these patterns +tmp/ +temp/ + +# Additional IDE files +*.sublime-* +.vscode-test/ + +# ============================================================================== +# PROJECT-SPECIFIC IGNORES +# ============================================================================== + +# --- Go Backend (go-b2b-starter/) --- +# Go Environment Files +go-b2b-starter/.env +go-b2b-starter/.env.* +go-b2b-starter/app.env + +# Binaries and Build Artifacts +go-b2b-starter/bin/ +go-b2b-starter/dist/ +go-b2b-starter/*.exe +go-b2b-starter/*.exe~ +go-b2b-starter/*.dll +go-b2b-starter/*.so +go-b2b-starter/*.dylib +go-b2b-starter/*.test +go-b2b-starter/main +go-b2b-starter/src/main/main +go-b2b-starter/src/bin/main + +# Go Temporary Files +go-b2b-starter/tmp/ +go-b2b-starter/temp/ + +# Go Test Coverage +go-b2b-starter/coverage.out +go-b2b-starter/coverage.html +go-b2b-starter/coverage.txt +go-b2b-starter/coverage/ + +# Go Vendor +go-b2b-starter/vendor/ + +# --- Next.js Frontend (next_b2b_starter/) --- +# Next.js Environment Files +next_b2b_starter/.env.local +next_b2b_starter/.env.production +next_b2b_starter/.env.development +next_b2b_starter/.env + +# Dependencies +next_b2b_starter/node_modules/ + +# Next.js Build Output +next_b2b_starter/.next/ +next_b2b_starter/out/ +next_b2b_starter/build/ + +# TypeScript Build Info +next_b2b_starter/*.tsbuildinfo +next_b2b_starter/.turbo/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a1eb43 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mohammed Salim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e603c5a --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Production SaaS Starter Kit + + [![Go Report Card](https://goreportcard.com/badge/github.com/moasq/production-saas-starter)](https://goreportcard.com/report/github.com/moasq/production-saas-starter) + [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) + + **The high-performance, self-hosted alternative to Indie Hacker boilerplates.** + + **Modular Monolith. Hexagonal Architecture. Enterprise practices for every scale.** + +![Dashboard Preview](docs/dashboard.png) + +## 🔓 The "No Lock-In" Guarantee + +This kit is built on a simple premise: **You own the infrastructure.** + +Most "modern" stacks quietly lock you into Vercel, AWS Lambda, or Supabase. We give you standard, battle-tested Docker containers. + +- **Ready to deploy anywhere:** Runs on a $5 DigitalOcean Droplet, AWS ECS, Google Cloud Run, or bare metal. +- **No "Serverless" Tax:** Usage costs don't scale exponentially with your traffic. +- **Full Control:** We provide standard `Dockerfiles`. Eject at any time. + +## 💎 Business Value > Tech Specs + +We chose boring, reliable technology so you can focus on building value. + +| Capability | The Technical Reality | The Business Value | +| :--- | :--- | :--- | +| **Operational Stability** | Go (Golang) Backend | Compile to a single binary. Low memory footprint. High concurrency. | +| **Type-Safe Reliability** | SQLC + Postgres | Eliminate runtime SQL errors before they reach production. | +| **Gateway Security** | Middleware Pipeline | Zero-Trust pipeline handles Auth & RBAC before business logic executes. | +| **Cognitive Governance** | Hexagonal Architecture | Business logic is isolated from tools. Swap Stripe for Paddle without rewriting the core. | +| **Hermetic Dev** | Docker Compose | Onboarding a new dev takes 10 minutes, not 2 days. | + +## ✨ Features Matrix + +| Feature | Implementation | Description | +| :--- | :--- | :--- | +| **Multi-Tenancy** | ✅ Organization Isolation | Data is logically isolated by `organization_id`. | +| **RBAC** | ✅ Role-Based Access | Granular permission checks (`can:edit_billing`). | +| **AI & RAG** | ✅ LLM Pipeline | Pre-configured OpenAI/Mistral client with vector embeddings. | +| **OCR** | ✅ Document Parsing | Extract text from PDFs and Images instantly. | +| **File Storage** | ✅ S3 Compatible | Ready-to-use Cloudflare R2 / AWS S3 integration. | +| **Billing** | ✅ Merchant of Record | Integrated Subscriptions, Invoices, and Webhook Sync. | +| **Database** | ✅ Postgres + SQLC | Type-safe SQL. No GORM magic blackboxing. | +| **Cache** | ✅ Redis | Fast session management and caching. | + +## 🏗 Architecture + +We use a **Modular Monolith** structure. This gives you the code modularity of microservices without the operational complexity. + +![Architecture Diagram](docs/architecture.png) + +```mermaid +graph TD + User((User)) -->|HTTPS| API[Go API Gateway] + + subgraph "Core (Hexagonal)" + API -->|Auth Middleware| Guard{"RBAC Check"} + Guard -->|Context| Handler["Handler Layer"] + Handler -->|DTO| Service["Service Layer (Business Logic)"] + Service -->|Interface| Repo["Repository Adapter"] + end + + Repo -->|SQL| DB[(Postgres)] + Repo -->|Cache| Redis[(Redis)] + Service -->|Async| Async["Async Worker (In-Memory)"] +``` + +## 🚀 Getting Started + + ### 0. Prerequisites + + > [!IMPORTANT] + > Before running any commands, please read **[SETUP.md](./SETUP.md)** to ensure your environment is ready. + + You will need: + * **Docker & Docker Compose** (Required for Database) + * **Go 1.23+** (Required for Backend) + * **Node.js 20+ & pnpm** (Required for Frontend) + * **Make** (Required for running commands) + + ### 1. The One-Line Setup + This command sets executable permissions, generates local environment keys, and boots the infrastructure. + + ```bash + # Make script executable and run it + chmod +x setup.sh && ./setup.sh + ``` + + **What this script does:** + + 1. **Config:** Copies `.env.example` to `.env` (if missing). + 2. **Infra:** Starts Postgres (Port 5432) & Redis (Port 6379) in Docker. + 3. **Schema:** Runs `migrate up` to create tables. + + ### 2. Start the Servers + + Open two terminal tabs: + + **Backend:** + ```bash + cd go-b2b-starter + make dev + ``` + + **Frontend:** + ```bash + cd next_b2b_starter + pnpm dev + ``` + + ### 3. Verify it works + + * **Frontend:** [http://localhost:3000](http://localhost:3000) (Login Screen) + * **Backend API:** [http://localhost:8080/health](http://localhost:8080/health) (Should return `{"status":"OK"}`) + + ### 🛑 Troubleshooting + + If the containers start but the app isn't working, view the logs: + + ```bash + docker compose logs -f postgres + # or + make dev # Shows backend logs directly in terminal + ``` + +## 🚀 Consulting & Services + + This kit is designed to be self-service, but growing startups often need specialized expertise to move faster. + + I accept a limited number of high-touch projects. + + ### How we can work together: + + 1. **Managed Deployment:** + Skip the DevOps learning curve. I will provision your infrastructure (AWS, GCP, DigitalOcean), configure the production environment (Postgres, Redis, CI/CD), and hand you the keys to a live, secure application. + + 2. **Custom Feature Development:** + Need advanced capabilities like **SAML/SSO**, **Usage-based Billing**, or **RAG Pipelines**? I will architect and implement these features directly into your repository, ensuring they fit the Hexagonal pattern perfectly. + + 3. **Architecture Migration & Audit:** + Migrating from Node.js/Python or scaling a legacy Go app? I offer deep-dive code reviews and architectural roadmaps to ensure your system can handle the next 10x of growth. + + **Interested?** +Email me with a brief summary of your stack and your biggest current bottleneck. + + **[m.salim@apflowhq.com](mailto:m.salim@apflowhq.com)** + + ### Contact + * **X (Twitter):** [**@foundmod**](https://x.com/foundmod) — *DMs Open* + +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c177621 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +We take the security of this starter kit seriously. If you find a vulnerability, please **DO NOT** open a public issue. + +Instead, please email **contact@example.com**. + +We will acknowledge your email within 48 hours. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..724b3e0 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,43 @@ +# 🛠️ Setup Guide + +This document covers the manual steps to verify your environment if `setup.sh` is not sufficient. + +## 1. Environment Variables + +The kit comes with example files. You need to copy them to the "live" filenames. + +### Backend (`go-b2b-starter`) +```bash +cp go-b2b-starter/example.env go-b2b-starter/app.env +``` +Open `app.env` and fill in the keys: +* `DB_SOURCE`: Your Postgres connection string. +* `STYTCH_PROJECT_ID`: From Stytch Dashboard. +* `POLAR_ACCESS_TOKEN`: From Polar.sh. + +### Frontend (`next_b2b_starter`) +```bash +cp next_b2b_starter/.env.example next_b2b_starter/.env.local +``` +Update `.env.local` with your public API keys. + +## 2. Docker Dependencies + +If you prefer running dependencies manually (without `setup.sh`): + +```bash +cd go-b2b-starter +docker compose -f deps/docker-compose.yml up -d postgres redis +``` + +## 3. Database Migrations + +Once Docker is running, you must apply the schema: + +```bash +cd go-b2b-starter +make migrateup +``` + +## 4. Troubleshooting +If the backend fails to start, verify that Redis is reachable on port `6379`. diff --git a/docs/dashboard.png b/docs/dashboard.png new file mode 100644 index 0000000..61f25c5 Binary files /dev/null and b/docs/dashboard.png differ diff --git a/go-b2b-starter/.air.toml b/go-b2b-starter/.air.toml new file mode 100644 index 0000000..59729c6 --- /dev/null +++ b/go-b2b-starter/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./src/main/main.go" + delay = 1000 + exclude_dir = ["tmp", "vendor", "testdata", "frontend-starter", "next_b2b_starter", "deps"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = true + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/go-b2b-starter/.dockerignore b/go-b2b-starter/.dockerignore new file mode 100644 index 0000000..9254835 --- /dev/null +++ b/go-b2b-starter/.dockerignore @@ -0,0 +1,44 @@ +# Environment and configuration files +app.env +.env +.air.toml +example.env + +# Version control +.git +.gitignore +.gitlab-ci.yml + +# IDE and editor files +.idea +.vscode +.claude + +# Project files +Makefile +README.md + +# Directories +docs/ +deps/ +deployment/ +scripts/ +tmp/ +bin/ +storage/ +.scannerwork/ +coverage/ +/src/pkg/db/postgres/seed +/src/pkg/db/postgres/sqlc/migrations +/src/pkg/db/postgres/sqlc/query +/src/pkg/db/postgres/sqlc.yml + +# Build artifacts +*.log +*.out +*.env +*.sql + +# Docker files +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/go-b2b-starter/.gitlab-ci.yml b/go-b2b-starter/.gitlab-ci.yml new file mode 100644 index 0000000..a5ecd2e --- /dev/null +++ b/go-b2b-starter/.gitlab-ci.yml @@ -0,0 +1,33 @@ +stages: + - test + - build + +run-tests: + stage: test + image: golang:1.22.4 + script: + - mkdir -p coverage + - bash scripts/run_tests_with_coverage.sh + artifacts: + paths: + - coverage/ + reports: + coverage_report: + coverage_format: cobertura + path: coverage/coverage.xml + coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' + +build-image: + stage: build + image: docker:latest + services: + - docker:dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA + - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest + - docker push $CI_REGISTRY_IMAGE:latest + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: on_success diff --git a/go-b2b-starter/Dockerfile b/go-b2b-starter/Dockerfile new file mode 100644 index 0000000..c7ab810 --- /dev/null +++ b/go-b2b-starter/Dockerfile @@ -0,0 +1,52 @@ +# Builder stage +FROM golang:1.23.7-alpine3.20 AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go mod and sum files first for better caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the source code +COPY . . + +# Build the application with additional flags for production +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /main ./src/main/main.go + +# Final stage - using Alpine for smaller image with necessary system files +FROM alpine:3.20 + +# Install necessary packages and clean up +RUN apk add --no-cache ca-certificates tzdata && \ + rm -rf /var/cache/apk/* + +# Create non-root user +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +# Copy only the binary from builder +COPY --from=builder /main /app/main + +# Set proper permissions +RUN chown -R appuser:appgroup /app && \ + chmod +x /app/main + +# Use non-root user +USER appuser + +# Image metadata +LABEL org.opencontainers.image.title="B2B SaaS Starter Backend" \ + org.opencontainers.image.description="Go backend for B2B SaaS Starter" \ + org.opencontainers.image.vendor="B2B SaaS Starter" \ + org.opencontainers.image.version="1.0.0" \ + org.opencontainers.image.source="https://github.com/yourusername/b2b-saas-starter" + +# Expose the port your app runs on +EXPOSE 8080 + +# Command to run the application +ENTRYPOINT ["/app/main"] \ No newline at end of file diff --git a/go-b2b-starter/Makefile b/go-b2b-starter/Makefile new file mode 100644 index 0000000..beaadba --- /dev/null +++ b/go-b2b-starter/Makefile @@ -0,0 +1,99 @@ +COMPOSE_FILE := deps/docker-compose.yml +MIGRATION_PATH ?= schema/migration +POSTGRES_HOST ?= localhost +POSTGRES_PORT ?= 5432 +POSTGRES_DB ?= mydatabase +POSTGRES_USER ?= user +POSTGRES_PASSWORD ?= password +CONTAINER_NAME ?= deps-postgis-1 +MIGRATION_NAME ?= init_schema +MIGRATION_DIR ?= ./src/pkg/db/postgres/sqlc/migrations +SQLC_DIR ?= src/pkg/db/postgres/sqlc + +# Start the necessary docker containers +run-deps: + docker compose -f $(COMPOSE_FILE) up --build -d + +# Stop and remove docker containers +stop-deps: + docker compose -f $(COMPOSE_FILE) down -v + +# Create a new database migration file +create-migration: + @migrate create -ext sql -dir $(MIGRATION_DIR) -seq $(MIGRATION_NAME) + @echo "Migration created in $(MIGRATION_DIR) with name $(MIGRATION_NAME)" + +# Apply all up migrations +# Apply all up migrations +migrateup: + @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 + +# Apply all down migrations +migratedown: + @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 + +sqlc: + @docker compose -f $(COMPOSE_FILE) run --rm -w /workspace/$(SQLC_DIR) cli sqlc generate + +# Create a new module +create-module: + bash scripts/create_module.sh $(type) $(name) + if [ "$(db)" = "postgres" ]; then bash scripts/setup_db.sh $(type) $(name); fi + +# Run the server +server: + go run src/main/main.go + +# build the app +build: + go build -o bin/ src/main/main.go + +# install dependencies +deps: + go mod tidy + +# swagger +# swagger +swagger: + @docker compose -f $(COMPOSE_FILE) run --rm cli swag init -g main/main.go -d src --parseDependency --parseInternal --exclude src/api/example_resource -o src/docs/gen + +# Run the server with Air (Live Reload) +dev: + @docker compose -f $(COMPOSE_FILE) run --rm -T --service-ports cli air + +reload-profile: + @source ~/.bashrc + +test: + @bash scripts/run_tests_with_coverage.sh + +# Clear RBAC and JWKS caches from Redis +clear-rbac-cache: + @echo "Clearing RBAC and JWKS Redis caches..." + @redis-cli DEL "stytch:rbac:policy" || echo " ✗ Failed to clear stytch:rbac:policy (may not exist)" + @redis-cli DEL "stytch:jwks:cache" || echo " ✗ Failed to clear stytch:jwks:cache (may not exist)" + @echo "✓ Cache clearing attempted (caches will auto-expire if not manually cleared)" + + +.PHONY: \ + build \ + clear-rbac-cache \ + create-migration \ + create-module \ + create-seed-country \ + deps \ + generate-seed-file \ + generate-changed-seed-file \ + generate-migrations-file \ + generate-down-migrations-file \ + migratedown \ + migrateup \ + push-to-do \ + reload-profile \ + run-deps \ + seed-db \ + server \ + sonar-scanner \ + sqlc \ + swagger \ + test diff --git a/go-b2b-starter/README.md b/go-b2b-starter/README.md new file mode 100644 index 0000000..1ed4b7e --- /dev/null +++ b/go-b2b-starter/README.md @@ -0,0 +1,41 @@ +# Go B2B Starter Backend + +Professional Modular Monolith backend for B2B SaaS. + +## ⚡️ Quick Start + +```bash +# 1. Start dependencies (Postgres, Redis) +make run-deps + +# 2. Run migrations +make migrateup + +# 3. Start server with live reload +make dev +``` + +## 🏗 Architecture + +We use a **Modular Monolith** architecture with **Clean Architecture** within each module. + +- **`src/app/`**: Feature modules (Billing, Organizations, etc.) +- **`src/pkg/`**: Shared core (Auth, Database, Logger) +- **`src/api/`**: Shared API definitions +- **Generators**: `sqlc` (Database), `swag` (API Docs) + +## 📚 Documentation + +- **[Architecture Guide](./docs/01-architecture.md)** - Understand the layers +- **[Adding a Module](./docs/02-adding-a-module.md)** - How to create new features +- **[API & Auth](./docs/03-api-and-auth.md)** - Security and Request flow + +## 🛠 Key Commands + +| Command | Description | +|---------|-------------| +| `make dev` | Start server with Air (Live Reload) | +| `make create-module type=app name=foo` | Generate a new module scaffold | +| `make migrateup` | Apply DB migrations | +| `make sqlc` | Generate type-safe DB code | +| `make swagger` | Generate Swagger docs | diff --git a/go-b2b-starter/deps/Dockerfile b/go-b2b-starter/deps/Dockerfile new file mode 100644 index 0000000..627d6dd --- /dev/null +++ b/go-b2b-starter/deps/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25.5-alpine + +WORKDIR /workspace + +# Install system dependencies +RUN apk add --no-cache git make bash curl + +# Install Go tools +RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest && \ + go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \ + go install github.com/swaggo/swag/cmd/swag@latest && \ + go install github.com/air-verse/air@latest + +CMD ["bash"] diff --git a/go-b2b-starter/deps/docker-compose.yml b/go-b2b-starter/deps/docker-compose.yml new file mode 100644 index 0000000..7d61b94 --- /dev/null +++ b/go-b2b-starter/deps/docker-compose.yml @@ -0,0 +1,51 @@ +services: + postgres: + image: pgvector/pgvector:pg17 + environment: + POSTGRES_DB: mydatabase + POSTGRES_USER: user + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d mydatabase"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:alpine + platform: linux/arm64 + ports: + - "6379:6379" + volumes: + - redis_data:/data + + cli: + build: + context: . + dockerfile: Dockerfile + image: go-b2b-starter-cli + volumes: + - ../:/workspace + working_dir: /workspace + environment: + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + - POSTGRES_DB=mydatabase + - REDIS_HOST=redis + - REDIS_PORT=6379 + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + + +volumes: + postgres_data: + redis_data: diff --git a/go-b2b-starter/docs/01-architecture.md b/go-b2b-starter/docs/01-architecture.md new file mode 100644 index 0000000..55e1455 --- /dev/null +++ b/go-b2b-starter/docs/01-architecture.md @@ -0,0 +1,61 @@ +# Backend Architecture + + The backend is a **Modular Monolith** designed for scalability and separation of concerns. + + ## High-Level Structure + + ``` + src/ + ├── app/ # Feature Modules (Domain Logic) + │ ├── billing/ + │ ├── organizations/ + │ └── [your-module]/ + │ + ├── pkg/ # Shared Infrastructure (The "Platform") + │ ├── db/ + │ ├── server/ + │ └── auth/ + │ + ├── api/ # Shared Contracts (DTOs, Interfaces) + └── main/ # Entry Point (Wiring) + ``` + + ## Analysis of a Module + + Each module in `src/app` follows **Clean Architecture**: + + ``` + src/app/billing/ + ├── api/ # Delivery Layer (Gin Handlers) + │ └── handler.go # HTTP -> Service + │ + ├── app/ # Application Layer (Use Cases) + │ └── service.go # Business Logic + │ + ├── domain/ # Domain Layer (Core) + │ ├── entities.go # Data Structures + │ └── repository.go # Interface Definitions + │ + └── infra/ # Infrastructure Layer (External) + └── repository.go # GORM Implementation + ``` + + ## Key Principles + + 1. **Dependency Rule**: Inner layers (Domain) rely on nothing. Outer layers (Infra) rely on inner layers. + 2. **Modules are Isolated**: Modules verify other modules via Public Interfaces (in `domain`), not direct DB access. + 3. **Shared Kernel**: `src/pkg` contains code shared by everything (Postgres connection, Logging, etc). + + ## Request Flow + + ```mermaid + sequenceDiagram + Client->>Handler: HTTP Request + Handler->>Service: Call Use Case + Service->>Repository: Get Data (Interface) + Repository->>DB: SQL Query (Implementation) + DB-->>Repository: Result + Repository-->>Service: Entity + Service-->>Handler: DTO + Handler-->>Client: JSON Response + ``` diff --git a/go-b2b-starter/docs/02-adding-a-module.md b/go-b2b-starter/docs/02-adding-a-module.md new file mode 100644 index 0000000..d0fead7 --- /dev/null +++ b/go-b2b-starter/docs/02-adding-a-module.md @@ -0,0 +1,62 @@ +# Adding a New Module + + Use the built-in generator to create a new module following Clean Architecture. + + ## The Fast Way + + ```bash + make create-module type=app name=projects + ``` + + This command: + 1. Creates `src/app/projects/` folder structure. + 2. Generates boilerplate files (Service, Handler, Repository). + 3. Wires it up to the dependency injection container. + + ## The Manual Way (Understanding the Files) + + If you were to do it manually, here is what you would build: + + ### 1. Define the Entity (`domain/projects.go`) + + ```go + package domain + + type Project struct { + ID int32 + Name string + OrganizationID int32 + } + ``` + + ### 2. Define the Interface (`domain/repository.go`) + + ```go + type ProjectRepository interface { + Create(ctx context.Context, p *Project) error + } + ``` + + ### 3. Implement Repository (`infra/repository.go`) + + ```go + type Repository struct { + db *gorm.DB + } + + func (r *Repository) Create(ctx context.Context, p *domain.Project) error { + return r.db.Create(p).Error + } + ``` + + ### 4. Wire it all up (`cmd/init.go`) + + This is the dependency injection step. + + ```go + func InitModule(container *dig.Container) { + container.Provide(NewRepository) + container.Provide(NewService) + container.Provide(NewHandler) + } + ``` diff --git a/go-b2b-starter/docs/03-api-and-auth.md b/go-b2b-starter/docs/03-api-and-auth.md new file mode 100644 index 0000000..fb823e6 --- /dev/null +++ b/go-b2b-starter/docs/03-api-and-auth.md @@ -0,0 +1,51 @@ +# API & Authentication + + ## Framework + + We use **Gin** for the HTTP layer. It is fast, familiar, and middleware-friendly. + + ## Authentication Middleware + + All protected routes use the `auth.Middleware`. + + ### How it Works + + 1. **Extracts Token**: Checks `Authorization: Bearer ` header or cookies. + 2. **Verifies Token**: Calls Stytch API (cached via JWKS) to validate the JWT. + 3. **Injects Context**: Adds `OrganizationID` and `MemberID` to the Gin context. + + ### Usage in Code + + In your `api/handler.go`: + + ```go + func (h *Handler) CreateProject(c *gin.Context) { + // 1. Get User Context + ctx := auth.GetRequestContext(c) + + // ctx.OrganizationID is now available and verified + + // 2. Bind JSON + var req CreateProjectReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + // 3. Call Service + err := h.service.Create(c.Request.Context(), ctx.OrganizationID, req) + } + ``` + + ## Request Validation + + We use **native Gin binding** with struct tags. + + ```go + type CreateProjectReq struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + } + ``` + + If validation fails, `ShouldBindJSON` returns an error automatically. diff --git a/go-b2b-starter/docs/README.md b/go-b2b-starter/docs/README.md new file mode 100644 index 0000000..6e0d374 --- /dev/null +++ b/go-b2b-starter/docs/README.md @@ -0,0 +1,109 @@ +# Go B2B SaaS Starter Kit + +A production-ready Go backend for B2B SaaS applications with multi-tenant architecture, authentication, billing, and file management. + +## Quick Start + +```bash +make run-deps # Start PostgreSQL & Redis +make migrateup # Run migrations +make dev # Start dev server with hot reload +``` + +## Documentation + +### Core Systems +- **[Architecture](./architecture.md)** - Clean Architecture, dependency injection, module patterns +- **[Database](./database.md)** - SQLC workflow, migrations, store adapters +- **[Authentication](./authentication.md)** - Stytch integration, RBAC, middleware +- **[Billing](./billing.md)** - Polar.sh integration, subscriptions, paywall + +### Infrastructure +- **[File Manager](./file-manager.md)** - R2 storage and file operations +- **[Event Bus](./event-bus.md)** - Event-driven architecture patterns +- **[API Development](./api-development.md)** - Guide to building new endpoints + +## Project Structure + +The codebase follows Clean Architecture with three main layers: + +**API Layer** (`src/api/`) - HTTP handlers and routes +**Application Layer** (`src/app/`) - Business logic organized by modules +**Shared Layer** (`src/pkg/`) - Reusable infrastructure packages + +Each application module contains: +- `domain/` - Entities, interfaces, business rules +- `app/` - Services (use cases) +- `infra/` - Repository implementations +- `module.go` - Dependency injection setup + +## Common Commands + +```bash +# Development +make dev # Run dev server with hot reload (Air) +make server # Run server without hot reload +make build # Build production binary + +# Dependencies +make run-deps # Start PostgreSQL & Redis in Docker +make stop-deps # Stop and remove Docker containers + +# Database +make migrateup # Apply all migrations +make migratedown # Rollback migrations +make sqlc # Generate code from SQL +make create-migration # Create new migration file + +# Code Generation +make swagger # Generate Swagger docs + +# Testing +make test # Run tests with coverage + +# Utilities +make clear-rbac-cache # Clear RBAC and JWKS caches from Redis +``` + +## Tech Stack + +- **Language**: Go 1.25+ +- **HTTP**: Gin framework +- **Database**: PostgreSQL with SQLC +- **Auth**: Stytch B2B +- **Payments**: Polar.sh +- **Storage**: Cloudflare R2 +- **DI**: uber-go/dig + +## Environment Setup + +Copy `example.env` to `app.env` and configure: + +```env +# Database +DATABASE_HOST=localhost +DATABASE_NAME=b2b_starter + +# Authentication +STYTCH_PROJECT_ID=your-project-id +STYTCH_SECRET=your-secret + +# Billing +POLAR_ACCESS_TOKEN=your-token +POLAR_WEBHOOK_SECRET=your-secret + +# File Storage +R2_ACCOUNT_ID=your-account +R2_ACCESS_KEY_ID=your-key +R2_SECRET_ACCESS_KEY=your-secret +R2_BUCKET_NAME=files +``` + +See `example.env` for all configuration options. + +## Getting Started + +1. **Understand the architecture**: Read [Architecture](./architecture.md) +2. **Set up the database**: Follow [Database](./database.md) +3. **Configure authentication**: See [Authentication](./authentication.md) +4. **Build your first API**: Follow [API Development](./api-development.md) diff --git a/go-b2b-starter/docs/api-development.md b/go-b2b-starter/docs/api-development.md new file mode 100644 index 0000000..6da9d35 --- /dev/null +++ b/go-b2b-starter/docs/api-development.md @@ -0,0 +1,396 @@ +# API Development Guide + +Step-by-step guide to building new API endpoints following Clean Architecture patterns. + +## Overview + +Building an API endpoint involves these layers: + +1. **Domain** - Entity and repository interface +2. **Infrastructure** - Repository implementation +3. **Application** - Service with business logic +4. **API** - HTTP handler and routes + +## Step 1: Database Layer + +### Create Migration + +Add migration files in `src/pkg/db/postgres/sqlc/migrations/`: + +```sql +-- 000015_create_resources.up.sql +CREATE TABLE app.resources ( + id SERIAL PRIMARY KEY, + organization_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_resources_org ON app.resources(organization_id); +``` + +### Write SQL Queries + +In `src/pkg/db/postgres/sqlc/query/resources.sql`: + +```sql +-- name: GetResourceByID :one +SELECT * FROM app.resources WHERE id = $1; + +-- name: CreateResource :one +INSERT INTO app.resources (organization_id, name, status) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: ListResources :many +SELECT * FROM app.resources +WHERE organization_id = $1 +ORDER BY created_at DESC; +``` + +### Generate Code + +```bash +make sqlc +``` + +### Create Store Interface + +In `src/pkg/db/adapters/resource_store.go`: + +```go +type ResourceStore interface { + GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) + CreateResource(ctx context.Context, arg sqlc.CreateResourceParams) (sqlc.Resource, error) + ListResources(ctx context.Context, orgID int32) ([]sqlc.Resource, error) +} +``` + +### Implement Adapter + +In `src/pkg/db/postgres/adapter_impl/resource_store.go`: + +```go +type resourceStore struct { + store sqlc.Store +} + +func NewResourceStore(store sqlc.Store) adapters.ResourceStore { + return &resourceStore{store: store} +} + +func (s *resourceStore) GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) { + return s.store.GetResourceByID(ctx, id) +} +``` + +### Register in DI + +In `src/pkg/db/inject.go`: + +```go +container.Provide(func(sqlcStore sqlc.Store) adapters.ResourceStore { + return adapter_impl.NewResourceStore(sqlcStore) +}) +``` + +## Step 2: Domain Layer + +### Create Entity + +In `src/app/resources/domain/entity.go`: + +```go +type Resource struct { + ID int32 + OrganizationID int32 + Name string + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (r *Resource) Validate() error { + if r.Name == "" { + return ErrResourceNameRequired + } + return nil +} +``` + +### Define Repository Interface + +In `src/app/resources/domain/repository.go`: + +```go +type ResourceRepository interface { + Create(ctx context.Context, resource *Resource) (*Resource, error) + GetByID(ctx context.Context, id int32) (*Resource, error) + List(ctx context.Context, orgID int32) ([]*Resource, error) +} +``` + +## Step 3: Infrastructure Layer + +### Implement Repository + +In `src/app/resources/infra/repositories/resource_repository.go`: + +```go +type resourceRepository struct { + store adapters.ResourceStore +} + +func NewResourceRepository(store adapters.ResourceStore) domain.ResourceRepository { + return &resourceRepository{store: store} +} + +func (r *resourceRepository) Create(ctx context.Context, resource *domain.Resource) (*domain.Resource, error) { + params := sqlc.CreateResourceParams{ + OrganizationID: resource.OrganizationID, + Name: resource.Name, + Status: resource.Status, + } + + dbResource, err := r.store.CreateResource(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %w", err) + } + + return toDomainResource(dbResource), nil +} +``` + +## Step 4: Application Layer + +### Define Service Interface + +In `src/app/resources/app/services/resource_service_interface.go`: + +```go +type ResourceService interface { + CreateResource(ctx context.Context, orgID int32, req *CreateResourceRequest) (*domain.Resource, error) + GetResource(ctx context.Context, id int32) (*domain.Resource, error) + ListResources(ctx context.Context, orgID int32) ([]*domain.Resource, error) +} +``` + +### Implement Service + +In `src/app/resources/app/services/resource_service.go`: + +```go +type resourceService struct { + repo domain.ResourceRepository +} + +func NewResourceService(repo domain.ResourceRepository) ResourceService { + return &resourceService{repo: repo} +} + +func (s *resourceService) CreateResource( + ctx context.Context, + orgID int32, + req *CreateResourceRequest, +) (*domain.Resource, error) { + // Validate request + if err := req.Validate(); err != nil { + return nil, err + } + + // Create entity + resource := &domain.Resource{ + OrganizationID: orgID, + Name: req.Name, + Status: "active", + } + + // Persist + return s.repo.Create(ctx, resource) +} +``` + +## Step 5: API Layer + +### Create Handler + +In `src/api/resources/handler.go`: + +```go +type Handler struct { + service services.ResourceService +} + +func NewHandler(service services.ResourceService) *Handler { + return &Handler{service: service} +} + +func (h *Handler) CreateResource(c *gin.Context) { + // Get auth context + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(401, gin.H{"error": "unauthorized"}) + return + } + + // Parse request + var req services.CreateResourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "invalid request"}) + return + } + + // Call service + resource, err := h.service.CreateResource(c.Request.Context(), reqCtx.OrganizationID, &req) + if err != nil { + c.JSON(500, gin.H{"error": "failed to create resource"}) + return + } + + c.JSON(201, resource) +} +``` + +### Register Routes + +In `src/api/resources/routes.go`: + +```go +type Routes struct { + handler *Handler + authMiddleware *auth.Middleware +} + +func NewRoutes(handler *Handler, authMiddleware *auth.Middleware) *Routes { + return &Routes{handler: handler, authMiddleware: authMiddleware} +} + +func (r *Routes) Register(router *gin.Engine) { + apiGroup := router.Group("/api/resources") + apiGroup.Use(r.authMiddleware.RequireAuth()) + apiGroup.Use(r.authMiddleware.RequireOrganization()) + { + apiGroup.POST("", + auth.RequirePermissionFunc("resource", "create"), + r.handler.CreateResource) + + apiGroup.GET("/:id", r.handler.GetResource) + apiGroup.GET("", r.handler.ListResources) + } +} +``` + +## Step 6: Module Registration + +### Create Module + +In `src/app/resources/module.go`: + +```go +type Module struct { + container *dig.Container +} + +func NewModule(container *dig.Container) *Module { + return &Module{container: container} +} + +func (m *Module) RegisterDependencies() error { + // Repository + if err := m.container.Provide(func(store adapters.ResourceStore) domain.ResourceRepository { + return repositories.NewResourceRepository(store) + }); err != nil { + return err + } + + // Service + if err := m.container.Provide(func(repo domain.ResourceRepository) services.ResourceService { + return services.NewResourceService(repo) + }); err != nil { + return err + } + + return nil +} +``` + +### Initialize Module + +In `src/app/resources/cmd/init.go`: + +```go +func Init(container *dig.Container) error { + module := NewModule(container) + return module.RegisterDependencies() +} +``` + +### Register API + +In `src/api/resources/provider.go`: + +```go +func RegisterDependencies(container *dig.Container) error { + // Register handler + if err := container.Provide(func(service services.ResourceService) *Handler { + return NewHandler(service) + }); err != nil { + return err + } + + // Register routes + if err := container.Provide(func( + handler *Handler, + authMiddleware *auth.Middleware, + ) *Routes { + return NewRoutes(handler, authMiddleware) + }); err != nil { + return err + } + + return nil +} +``` + +## Quick Reference + +### File Structure + +``` +src/app/resources/ +├── domain/ +│ ├── entity.go +│ ├── repository.go +│ └── errors.go +├── app/services/ +│ ├── resource_service_interface.go +│ └── resource_service.go +├── infra/repositories/ +│ └── resource_repository.go +├── cmd/init.go +└── module.go + +src/api/resources/ +├── handler.go +├── routes.go +└── provider.go +``` + +### Common Response Codes + +- `200` - Success +- `201` - Created +- `400` - Bad Request +- `401` - Unauthorized +- `403` - Forbidden +- `404` - Not Found +- `500` - Internal Server Error + +## Next Steps + +- **Add tests**: Unit tests for service, integration tests for repository +- **Add Swagger docs**: Document API with Swagger annotations +- **Add validation**: Request/response validation +- **Add events**: Publish domain events for cross-module communication diff --git a/go-b2b-starter/docs/architecture.md b/go-b2b-starter/docs/architecture.md new file mode 100644 index 0000000..507a116 --- /dev/null +++ b/go-b2b-starter/docs/architecture.md @@ -0,0 +1,238 @@ +# Architecture Guide + +The codebase uses Clean Architecture with dependency injection to maintain separation of concerns and testability. + +## Clean Architecture Layers + +The project is organized into four distinct layers: + +**1. Domain Layer** - Business entities and rules (innermost) +**2. Application Layer** - Use cases and business logic +**3. Infrastructure Layer** - External services and data access +**4. API Layer** - HTTP handlers and routes (outermost) + +### Dependency Flow + +Dependencies point **inward only**: + +``` +API → Application → Domain ← Infrastructure +``` + +- Domain layer has zero external dependencies +- Infrastructure implements domain interfaces +- Outer layers depend on inner layers, never the reverse + +## Layer Responsibilities + +### Domain Layer (`src/app/{module}/domain/`) + +The core business logic layer. + +**Contains:** +- Entities with business rules +- Repository interfaces (contracts) +- Domain errors +- Validation logic + +**Key principle**: No external dependencies. Pure business logic only. + +### Application Layer (`src/app/{module}/app/`) + +Orchestrates domain operations to implement use cases. + +**Contains:** +- Service interfaces and implementations +- Request/response types +- Transaction boundaries +- Business workflow coordination + +**Key principle**: Uses domain interfaces, never infrastructure directly. + +### Infrastructure Layer (`src/app/{module}/infra/`) + +Implements domain interfaces using concrete technologies. + +**Contains:** +- Repository implementations +- Database adapters +- External service clients +- Type conversions (domain ↔ database) + +**Key principle**: Depends on domain interfaces. Hidden behind abstractions. + +### API Layer (`src/api/{module}/`) + +Handles HTTP concerns. + +**Contains:** +- HTTP handlers +- Route definitions +- Request validation +- Response formatting + +**Key principle**: Thin layer that delegates to application services. + +## Dependency Injection + +Uses [uber-go/dig](https://github.com/uber-go/dig) for automatic dependency injection. + +### Core Pattern + +```go +// 1. Define interface in domain +type ResourceRepository interface { + GetByID(ctx context.Context, id int32) (*Resource, error) +} + +// 2. Implement in infrastructure +type resourceRepository struct { + store adapters.ResourceStore +} + +// 3. Register in DI container +container.Provide(func(store adapters.ResourceStore) domain.ResourceRepository { + return NewResourceRepository(store) +}) + +// 4. Inject into services +container.Provide(func(repo domain.ResourceRepository) services.ResourceService { + return services.NewResourceService(repo) +}) +``` + +### Benefits + +- Automatic dependency resolution +- Easy testing with mocks +- Clear dependency graph +- No manual wiring + +## Module Pattern + +Each business module follows a standard structure: + +``` +src/app/{module}/ +├── domain/ # Entities, interfaces +├── app/services/ # Business logic +├── infra/ # Implementations +├── cmd/init.go # Initialization +└── module.go # DI registration +``` + +### Module Registration + +Every module has a `module.go` file that registers its dependencies: + +- Repositories (infrastructure → domain interface) +- Services (application layer) +- Event listeners (if applicable) + +### Initialization Order + +Defined in `src/main/cmd/init_mods.go`: + +1. **Infrastructure** - Database, logging, server +2. **Shared Services** - File storage, event bus, payments +3. **Authentication** - Redis, Stytch, auth middleware +4. **Domain Modules** - Organizations, billing, etc. +5. **API Layer** - Route registration + +**Why order matters**: Each phase depends on previous phases being initialized. + +## Resolver Pattern + +Bridges authentication with domain modules without creating circular dependencies. + +### Problem + +Auth middleware needs to convert provider IDs (Stytch) to database IDs, but can't depend on domain modules directly. + +### Solution + +Define minimal interfaces in auth package: + +```go +// Auth defines what it needs +type OrganizationResolver interface { + ResolveByProviderID(ctx context.Context, providerID string) (int32, error) +} +``` + +Domain modules implement via adapters: + +```go +// Module provides implementation +type orgResolverAdapter struct { + repo domain.ResourceRepository +} +``` + +Wired together in `init_mods.go` during initialization. + +## Best Practices + +### Constructor Pattern + +Always return interfaces, not concrete types: + +```go +// ✅ Good +func NewResourceService(repo domain.ResourceRepository) services.ResourceService { + return &resourceService{repo: repo} +} + +// ❌ Bad +func NewResourceService(repo domain.ResourceRepository) *resourceService { + return &resourceService{repo: repo} +} +``` + +### Context Handling + +Context is always the first parameter: + +```go +func (s *service) CreateResource(ctx context.Context, req *Request) error +``` + +### Error Wrapping + +Add context to errors before returning: + +```go +if err := s.repo.Create(ctx, resource); err != nil { + return fmt.Errorf("failed to create resource: %w", err) +} +``` + +### Explicit Dependencies + +All dependencies through constructor parameters: + +```go +func NewResourceService( + repo domain.ResourceRepository, + eventBus eventbus.EventBus, + logger logger.Logger, +) services.ResourceService +``` + +Never use global variables or hidden dependencies. + +## File Locations + +| Pattern | File | +|---------|------| +| DI container setup | `src/main/cmd/root.go` | +| Module initialization order | `src/main/cmd/init_mods.go` | +| Module DI registration | `src/app/*/module.go` | +| Package initialization | `src/pkg/*/cmd/init.go` | +| API route setup | `src/api/provider.go` | + +## Next Steps + +- **Database operations**: See [Database Guide](./database.md) +- **Authentication setup**: See [Authentication Guide](./authentication.md) +- **Building APIs**: See [API Development Guide](./api-development.md) diff --git a/go-b2b-starter/docs/authentication.md b/go-b2b-starter/docs/authentication.md new file mode 100644 index 0000000..e0e5990 --- /dev/null +++ b/go-b2b-starter/docs/authentication.md @@ -0,0 +1,305 @@ +# Authentication Guide + +The authentication system uses Stytch B2B for identity management with JWT verification, RBAC, and multi-tenant organization context. + +## Architecture + +**Provider**: Stytch B2B handles user authentication and sessions +**Middleware**: Verifies JWTs and resolves organization/account context +**RBAC**: Role-based access control with permissions +**Resolvers**: Bridge auth provider IDs to database IDs + +## JWT Verification + +The system uses a two-tier verification strategy: + +**1. Fast Path** - Verify JWT locally using cached public keys +**2. API Fallback** - Call Stytch API if local verification fails + +This approach balances security with performance. + +### Configuration + +```env +STYTCH_PROJECT_ID=project-test-xxx +STYTCH_SECRET=secret-test-xxx +STYTCH_ENV=test # or "live" +``` + +## Middleware + +Three middleware functions protect routes: + +### RequireAuth + +Verifies JWT and extracts identity. + +```go +router.Use(authMiddleware.RequireAuth()) +``` + +**What it does:** +- Verifies JWT from `Authorization: Bearer {token}` header +- Extracts user identity (email, roles, permissions) +- Stores `auth.Identity` in request context +- Returns 401 if auth fails + +### RequireOrganization + +Resolves organization and account IDs from auth provider. + +```go +router.Use(authMiddleware.RequireOrganization()) +``` + +**What it does:** +- Gets organization ID from Stytch → resolves to database ID +- Gets user email → resolves to account ID +- Stores `auth.RequestContext` with IDs +- Returns 401 if resolution fails + +**Note:** Always use after `RequireAuth()`. + +### RequirePermission + +Checks user has specific permission. + +```go +router.POST("/resources", + auth.RequirePermissionFunc("resource", "create"), + handler.CreateResource) +``` + +**What it does:** +- Checks if user has permission (e.g., `"resource:create"`) +- Returns 403 if permission missing + +**Note:** Use after `RequireOrganization()`. + +## Using Context in Handlers + +Access authentication info from request context: + +```go +func (h *Handler) MyHandler(c *gin.Context) { + // Get full context + reqCtx := auth.GetRequestContext(c) + orgID := reqCtx.OrganizationID // int32 + accountID := reqCtx.AccountID // int32 + email := reqCtx.Identity.Email // string + + // Or use convenience functions + orgID := auth.GetOrganizationID(c) + accountID := auth.GetAccountID(c) +} +``` + +## RBAC System + +### Roles + +Defined in `src/pkg/auth/roles.go`: + +- `RoleAdmin` - Full system access +- `RoleManager` - Organization management +- `RoleMember` - Standard user access + +### Permissions + +Format: `"{resource}:{action}"` + +**Common permissions:** +- `resource:view` - Read access +- `resource:create` - Create new items +- `resource:update` - Modify existing items +- `resource:delete` - Delete items +- `org:manage` - Organization administration + +Defined in `src/pkg/auth/permissions.go`. + +### Permission Checks + +```go +// In middleware (route-level) +router.POST("/resources", + auth.RequirePermissionFunc("resource", "create"), + handler.CreateResource) + +// In code (programmatic) +if !auth.HasPermission(identity, "resource:delete") { + return errors.New("permission denied") +} +``` + +## Resolver Pattern + +Resolvers convert auth provider IDs to database IDs. + +### Why Needed? + +- Stytch uses string UUIDs for organizations +- Database uses int32 for primary keys +- Auth package can't depend on domain modules (circular dependency) + +### How It Works + +**1. Auth package defines interfaces:** + +```go +type OrganizationResolver interface { + ResolveByProviderID(ctx context.Context, providerID string) (int32, error) +} +``` + +**2. Domain modules implement via adapters:** + +```go +type orgResolverAdapter struct { + repo domain.OrganizationRepository +} + +func (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, id string) (int32, error) { + org, err := a.repo.GetByStytchID(ctx, id) + if err != nil { + return 0, err + } + return org.ID, nil +} +``` + +**3. Wired in initialization:** + +Resolvers registered in `src/main/cmd/init_mods.go` after organization module loads. + +## Route Protection Patterns + +### Public Route (No Auth) + +```go +router.GET("/health", handler.Health) +``` + +### Authenticated Route + +```go +apiGroup := router.Group("/api") +apiGroup.Use(authMiddleware.RequireAuth()) +apiGroup.Use(authMiddleware.RequireOrganization()) +{ + apiGroup.GET("/profile", handler.GetProfile) +} +``` + +### Permission-Protected Route + +```go +apiGroup.POST("/resources", + auth.RequirePermissionFunc("resource", "create"), + handler.CreateResource) + +apiGroup.DELETE("/resources/:id", + auth.RequirePermissionFunc("resource", "delete"), + handler.DeleteResource) +``` + +### Role-Protected Route + +```go +adminGroup := router.Group("/admin") +adminGroup.Use(authMiddleware.RequireRole(auth.RoleAdmin)) +{ + adminGroup.GET("/users", handler.ListUsers) +} +``` + +## Adding New Permissions + +**1. Define permission constant** in `src/pkg/auth/permissions.go`: + +```go +const PermResourceView = Permission("resource:view") +const PermResourceCreate = Permission("resource:create") +``` + +**2. Assign to roles** in `src/pkg/auth/rbac.go`: + +```go +{ + RoleMember: { + PermResourceView, + // ... other permissions + }, + RoleManager: { + PermResourceView, + PermResourceCreate, + // ... other permissions + }, +} +``` + +**3. Protect routes**: + +```go +router.POST("/resources", + auth.RequirePermissionFunc("resource", "create"), + handler.CreateResource) +``` + +## Common Patterns + +### Check Organization Ownership + +```go +func (h *Handler) GetResource(c *gin.Context) { + orgID := auth.GetOrganizationID(c) + resourceID := parseID(c.Param("id")) + + resource, err := h.service.GetResource(c.Request.Context(), resourceID) + if err != nil { + c.JSON(500, gin.H{"error": "failed to get resource"}) + return + } + + // Verify resource belongs to user's organization + if resource.OrganizationID != orgID { + c.JSON(403, gin.H{"error": "access denied"}) + return + } + + c.JSON(200, resource) +} +``` + +### Optional Authentication + +```go +func (h *Handler) PublicResource(c *gin.Context) { + // Try to get org ID (may be 0 if not authenticated) + orgID := auth.GetOrganizationID(c) + + if orgID != 0 { + // User is authenticated, show personalized data + } else { + // User is not authenticated, show public data + } +} +``` + +## File Locations + +| Component | Path | +|-----------|------| +| Auth provider interface | `src/pkg/auth/auth.go` | +| Middleware | `src/pkg/auth/middleware.go` | +| Context helpers | `src/pkg/auth/context.go` | +| RBAC definitions | `src/pkg/auth/rbac.go` | +| Roles | `src/pkg/auth/roles.go` | +| Permissions | `src/pkg/auth/permissions.go` | +| Resolvers | `src/pkg/auth/resolvers.go` | +| Stytch adapter | `src/pkg/auth/adapters/stytch/` | + +## Next Steps + +- **Database operations**: See [Database Guide](./database.md) +- **Building APIs**: See [API Development Guide](./api-development.md) +- **Stytch documentation**: https://stytch.com/docs/b2b diff --git a/go-b2b-starter/docs/billing.md b/go-b2b-starter/docs/billing.md new file mode 100644 index 0000000..1bc3eb5 --- /dev/null +++ b/go-b2b-starter/docs/billing.md @@ -0,0 +1,251 @@ +# Billing Guide + +The billing system integrates with Polar.sh for subscription management, usage-based billing, and payment processing with a hybrid sync strategy. + +## Architecture + +**Polar.sh**: Payment provider for subscriptions and metering +**Hybrid Sync**: Webhooks + on-demand fetching +**Paywall Middleware**: Protects routes based on subscription status +**Quota Tracking**: Usage-based billing with meters + +## Core Concepts + +### Subscriptions + +Managed in Polar.sh, synced to local database. + +**Subscription states:** +- `active` - Valid subscription +- `incomplete` - Payment pending +- `cancelled` - Subscription cancelled +- `unpaid` - Payment failed + +### Quota Tracking + +Track usage for metered billing. + +**How it works:** +1. User performs action (API call, file upload, etc.) +2. System increments local quota counter +3. Periodically sync usage to Polar meters +4. Polar charges based on usage + +### Billing Status + +Represents organization's billing state: + +- **Subscription**: Active subscription details +- **Quota Usage**: Current usage vs limits +- **Payment Status**: Last payment result +- **Metering**: Usage meters for billing + +## Hybrid Sync Strategy + +Combines webhooks with on-demand fetching for reliability. + +### Webhook Path (Real-time) + +``` +Polar Event → Webhook → Update Database +``` + +**Handles:** +- Subscription created/updated/cancelled +- Payment succeeded/failed +- Customer created/updated + +### Lazy Guarding (On-demand) + +``` +API Request → Check Subscription → Fetch if stale → Update Database +``` + +**When used:** +- Webhook delivery failed +- Data drift detected +- Initial subscription fetch + +**Benefits:** +- Self-healing system +- No critical webhook dependency +- Always up-to-date data + +## Paywall Middleware + +Protects routes based on subscription requirements. + +### Basic Usage + +```go +router.POST("/premium-feature", + paywallMiddleware.RequireActiveSubscription(), + handler.PremiumFeature) +``` + +### Quota-Based Protection + +```go +router.POST("/api-call", + paywallMiddleware.RequireQuota("api_calls", 1), + handler.APICall) +``` + +**What it does:** +1. Checks organization has active subscription +2. Verifies quota available +3. Increments usage counter +4. Returns 402 (Payment Required) if quota exceeded + +### Feature-Based Protection + +```go +router.POST("/advanced-feature", + paywallMiddleware.RequireFeature("advanced_analytics"), + handler.AdvancedFeature) +``` + +Checks if subscription plan includes specific feature. + +## Webhook Processing + +Polar sends webhooks for billing events. + +### Webhook Handler + +Located in `src/api/webhooks/polar_handler.go`. + +**Events handled:** +- `subscription.created` +- `subscription.updated` +- `subscription.canceled` +- `checkout.created` +- `checkout.updated` + +### Verification + +Webhooks are verified using Polar webhook secret: + +```env +POLAR_WEBHOOK_SECRET=whsec_xxx +``` + +Invalid signatures are rejected. + +## Usage Tracking + +Track resource usage for billing. + +### Recording Usage + +```go +func (s *service) ProcessAction(ctx context.Context, orgID int32) error { + // Perform action + result, err := s.doAction(ctx) + if err != nil { + return err + } + + // Record usage + err = s.billingService.IncrementQuota(ctx, orgID, "actions", 1) + if err != nil { + // Log error but don't fail the operation + log.Error("failed to record usage", zap.Error(err)) + } + + return nil +} +``` + +### Meter Ingestion + +Usage synced to Polar periodically: + +1. Accumulate usage locally +2. Batch send to Polar meters API +3. Polar charges based on metered usage + +Configured in `src/app/billing/app/services/metering_service.go`. + +## Configuration + +```env +# Polar.sh +POLAR_ACCESS_TOKEN=polar_xxx +POLAR_WEBHOOK_SECRET=whsec_xxx +POLAR_ORGANIZATION_ID=org_xxx +``` + +## Common Patterns + +### Check Subscription Status + +```go +func (h *Handler) GetFeature(c *gin.Context) { + orgID := auth.GetOrganizationID(c) + + status, err := h.billingService.GetBillingStatus(ctx, orgID) + if err != nil { + c.JSON(500, gin.H{"error": "failed to get billing status"}) + return + } + + if status.Subscription == nil || !status.Subscription.IsActive() { + c.JSON(402, gin.H{"error": "active subscription required"}) + return + } + + // Proceed with feature +} +``` + +### Track Usage + +```go +func (s *service) ProcessFile(ctx context.Context, orgID int32, file *File) error { + // Process file + err := s.processor.Process(file) + if err != nil { + return err + } + + // Record usage + s.billingService.IncrementQuota(ctx, orgID, "files_processed", 1) + + return nil +} +``` + +### Handle Payment Failures + +```go +func (h *WebhookHandler) HandlePaymentFailed(ctx context.Context, event *Event) error { + // Update subscription status + err := h.billingService.UpdateSubscriptionStatus(ctx, event.SubscriptionID, "unpaid") + if err != nil { + return err + } + + // Notify organization + h.notificationService.SendPaymentFailure(ctx, event.OrganizationID) + + return nil +} +``` + +## File Locations + +| Component | Path | +|-----------|------| +| Billing domain | `src/app/billing/domain/` | +| Billing service | `src/app/billing/app/services/` | +| Polar adapter | `src/app/billing/infra/adapters/polar/` | +| Paywall middleware | `src/pkg/paywall/` | +| Polar client | `src/pkg/polar/` | +| Webhook handlers | `src/api/webhooks/` | + +## Next Steps + +- **API protection**: Use paywall middleware in routes +- **Usage tracking**: Implement quota consumption +- **Polar documentation**: https://docs.polar.sh/ diff --git a/go-b2b-starter/docs/database.md b/go-b2b-starter/docs/database.md new file mode 100644 index 0000000..3f78db4 --- /dev/null +++ b/go-b2b-starter/docs/database.md @@ -0,0 +1,319 @@ +# Database Guide + +The database layer uses PostgreSQL with SQLC for type-safe SQL operations and the Adapter Pattern to keep SQLC isolated from business logic. + +## Architecture + +The database layer has three components: + +**1. Store Interfaces** (`src/pkg/db/adapters/`) - Contracts for database operations +**2. Store Adapters** (`src/pkg/db/postgres/adapter_impl/`) - Implement interfaces using SQLC +**3. SQLC Generated Code** (`src/pkg/db/postgres/sqlc/gen/`) - Auto-generated from SQL queries + +### Why Use Adapters? + +- External modules depend on **interfaces**, not SQLC directly +- Easy to mock for testing +- Can swap database implementations +- SQLC internals stay contained + +## SQLC Workflow + +### 1. Write SQL Query + +Create queries in `src/pkg/db/postgres/sqlc/query/{domain}.sql`: + +```sql +-- name: GetResourceByID :one +SELECT * FROM resources WHERE id = $1; + +-- name: CreateResource :one +INSERT INTO resources (name, status) +VALUES ($1, $2) +RETURNING *; + +-- name: ListResources :many +SELECT * FROM resources +ORDER BY created_at DESC +LIMIT $1 OFFSET $2; +``` + +**SQLC Annotations:** +- `:one` - Returns single row +- `:many` - Returns slice of rows +- `:exec` - Returns error only (no data) + +### 2. Generate Code + +```bash +make sqlc +``` + +Generates Go code in `src/pkg/db/postgres/sqlc/gen/`. + +**Never edit generated files** - they are regenerated on every run. + +### 3. Create Store Interface + +Define interface in `src/pkg/db/adapters/resource_store.go`: + +```go +type ResourceStore interface { + GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) + CreateResource(ctx context.Context, arg sqlc.CreateResourceParams) (sqlc.Resource, error) + ListResources(ctx context.Context, arg sqlc.ListResourcesParams) ([]sqlc.Resource, error) +} +``` + +### 4. Implement Adapter + +Create adapter in `src/pkg/db/postgres/adapter_impl/resource_store.go`: + +```go +type resourceStore struct { + store sqlc.Store +} + +func NewResourceStore(store sqlc.Store) adapters.ResourceStore { + return &resourceStore{store: store} +} + +func (s *resourceStore) GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) { + return s.store.GetResourceByID(ctx, id) +} +``` + +### 5. Register in DI + +Add to `src/pkg/db/inject.go`: + +```go +container.Provide(func(sqlcStore sqlc.Store) adapters.ResourceStore { + return adapter_impl.NewResourceStore(sqlcStore) +}) +``` + +## Database Migrations + +### File Structure + +Migrations live in `src/pkg/db/postgres/sqlc/migrations/`: + +``` +000001_create_schema.up.sql +000001_create_schema.down.sql +000002_add_indexes.up.sql +000002_add_indexes.down.sql +``` + +### Naming Convention + +Format: `{6-digit-number}_{description}.{up|down}.sql` + +- `.up.sql` - Apply the migration +- `.down.sql` - Rollback the migration + +### Example Migration + +**Up migration** (`000005_create_resources.up.sql`): + +```sql +CREATE SCHEMA IF NOT EXISTS app; + +CREATE TABLE app.resources ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_resources_status ON app.resources(status); +``` + +**Down migration** (`000005_create_resources.down.sql`): + +```sql +DROP TABLE IF EXISTS app.resources; +DROP SCHEMA IF EXISTS app; +``` + +### Running Migrations + +```bash +make migrateup # Apply all pending migrations +make migratedown # Rollback last migration +``` + +## Type Conversions + +PostgreSQL types need conversion to Go types. + +### Nullable Fields + +SQLC uses `pgtype` for nullable fields: + +```go +// Convert pgtype.Text to string +str := postgres.StringFromPgText(dbRecord.NullableField) + +// Convert string to pgtype.Text +pgText := postgres.ToPgText(str) + +// Convert pgtype.Int4 to int32 +num := postgres.Int32FromPgInt4(dbRecord.NullableInt) +``` + +Helper functions in `src/pkg/db/postgres/types_transform.go`. + +### JSONB Fields + +```go +// Convert map to JSONB +jsonbData := postgres.ToJSONB(map[string]any{"key": "value"}) + +// Convert JSONB to map +data := postgres.JSONBToMap(dbRecord.Metadata) +``` + +## Error Handling + +The database layer provides specific error types in `src/pkg/db/core/errors.go`: + +**Common Errors:** +- `ErrNoRows` - Query returned no results +- `ErrTxClosed` - Transaction already committed/rolled back +- `ErrTimeout` - Operation exceeded timeout +- `ErrPoolClosed` - Connection pool is closed + +**Helper Functions:** + +```go +if core.IsNoRowsError(err) { + return domain.ErrResourceNotFound +} + +if core.IsConstraintError(err, "unique_name") { + return domain.ErrResourceAlreadyExists +} + +if core.IsTimeoutError(err) { + return domain.ErrDatabaseTimeout +} +``` + +## Transactions + +Use transactions for multi-step operations that must be atomic. + +### Basic Transaction + +```go +func (r *repository) CreateWithRelation(ctx context.Context, resource *domain.Resource) error { + return r.db.WithTx(ctx, func(tx core.Transaction) error { + // Step 1: Create resource + created, err := tx.CreateResource(ctx, params) + if err != nil { + return err + } + + // Step 2: Create relation + _, err = tx.CreateRelation(ctx, relationParams) + if err != nil { + return err // Transaction auto-rolls back on error + } + + return nil // Transaction commits on success + }) +} +``` + +### Transaction Options + +```go +// Read-only transaction +err := r.db.WithTxOptions(ctx, &sql.TxOptions{ReadOnly: true}, func(tx core.Transaction) error { + // Read operations only +}) + +// Custom isolation level +err := r.db.WithTxOptions(ctx, &sql.TxOptions{ + Isolation: sql.LevelSerializable, +}, func(tx core.Transaction) error { + // Operations +}) +``` + +## Best Practices + +### Always Use Context + +```go +// ✅ Good +func (r *repository) GetResource(ctx context.Context, id int32) (*Resource, error) + +// ❌ Bad +func (r *repository) GetResource(id int32) (*Resource, error) +``` + +### Handle Errors Appropriately + +```go +// ✅ Convert database errors to domain errors +resource, err := r.store.GetResourceByID(ctx, id) +if err != nil { + if core.IsNoRowsError(err) { + return nil, domain.ErrResourceNotFound + } + return nil, fmt.Errorf("failed to get resource: %w", err) +} +``` + +### Use Prepared Statements + +SQLC automatically creates prepared statements. Never concatenate SQL strings. + +```go +// ✅ Good (SQLC handles this) +SELECT * FROM resources WHERE name = $1 + +// ❌ Bad (SQL injection risk) +query := fmt.Sprintf("SELECT * FROM resources WHERE name = '%s'", name) +``` + +### Indexes for Performance + +Add indexes for commonly queried fields: + +```sql +-- Foreign keys +CREATE INDEX idx_resources_org_id ON resources(organization_id); + +-- Status fields +CREATE INDEX idx_resources_status ON resources(status); + +-- Timestamps for sorting +CREATE INDEX idx_resources_created_at ON resources(created_at DESC); + +-- Composite indexes for multi-column queries +CREATE INDEX idx_resources_org_status ON resources(organization_id, status); +``` + +## File Locations + +| Component | Path | +|-----------|------| +| Store interfaces | `src/pkg/db/adapters/` | +| Store implementations | `src/pkg/db/postgres/adapter_impl/` | +| SQL queries | `src/pkg/db/postgres/sqlc/query/` | +| Migrations | `src/pkg/db/postgres/sqlc/migrations/` | +| Generated code | `src/pkg/db/postgres/sqlc/gen/` | +| Type helpers | `src/pkg/db/postgres/types_transform.go` | +| Error types | `src/pkg/db/core/errors.go` | +| DI setup | `src/pkg/db/inject.go` | + +## Next Steps + +- **Using in repositories**: See [Architecture Guide](./architecture.md) +- **Building APIs**: See [API Development Guide](./api-development.md) +- **SQLC documentation**: https://docs.sqlc.dev/ diff --git a/go-b2b-starter/docs/event-bus.md b/go-b2b-starter/docs/event-bus.md new file mode 100644 index 0000000..d873f24 --- /dev/null +++ b/go-b2b-starter/docs/event-bus.md @@ -0,0 +1,199 @@ +# Event Bus Guide + +The event bus enables event-driven architecture for loose coupling between modules using an in-memory publish-subscribe pattern. + +## Architecture + +**In-memory event bus** - Simple, fast, synchronous +**Publisher-subscriber pattern** - Decouple event producers from consumers +**Type-safe events** - Events are Go structs implementing Event interface + +## Core Concepts + +### Events + +Events represent things that have happened in the system. + +**Naming**: Past tense (ResourceCreated, ResourceUpdated, ResourceDeleted) + +```go +type ResourceCreatedEvent struct { + BaseEvent + ResourceID int32 `json:"resource_id"` + Name string `json:"name"` + CreatedBy int32 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} +``` + +### Event Interface + +All events implement the Event interface: + +```go +type Event interface { + EventName() string + EventID() string + OccurredAt() time.Time +} +``` + +### BaseEvent + +Provides common event fields: + +```go +type BaseEvent struct { + ID string `json:"id"` + Name string `json:"name"` + Timestamp time.Time `json:"timestamp"` +} +``` + +## Publishing Events + +Emit events when something happens: + +```go +func (s *service) CreateResource(ctx context.Context, req *Request) (*Resource, error) { + // Create resource + resource, err := s.repo.Create(ctx, req) + if err != nil { + return nil, err + } + + // Publish event + event := &ResourceCreatedEvent{ + ResourceID: resource.ID, + Name: resource.Name, + CreatedBy: req.UserID, + CreatedAt: resource.CreatedAt, + } + s.eventBus.Publish(ctx, event) + + return resource, nil +} +``` + +**Note**: Publish is fire-and-forget. Failures don't block the operation. + +## Subscribing to Events + +Listen for events and react: + +```go +func (l *ResourceListener) Init(eventBus eventbus.EventBus) { + // Subscribe to events + eventBus.Subscribe("resource.created", l.HandleResourceCreated) + eventBus.Subscribe("resource.updated", l.HandleResourceUpdated) +} + +func (l *ResourceListener) HandleResourceCreated(ctx context.Context, event eventbus.Event) error { + resourceEvent := event.(*ResourceCreatedEvent) + + // React to event + log.Info("Resource created", zap.Int32("id", resourceEvent.ResourceID)) + + // Trigger other actions + return l.notificationService.NotifyResourceCreated(ctx, resourceEvent.ResourceID) +} +``` + +## Event Flow + +``` +Service → Publish Event → Event Bus → Notify Subscribers → Execute Handlers +``` + +**Synchronous**: Subscribers execute in the same request context +**Ordered**: Subscribers execute in registration order +**Error handling**: Subscriber errors are logged but don't fail the operation + +## Common Patterns + +### Cross-Module Communication + +Module A publishes events, Module B subscribes: + +```go +// Module A (Resources) +func (s *resourceService) Delete(ctx context.Context, id int32) error { + err := s.repo.Delete(ctx, id) + if err != nil { + return err + } + + s.eventBus.Publish(ctx, &ResourceDeletedEvent{ResourceID: id}) + return nil +} + +// Module B (Analytics) +func (l *analyticsListener) HandleResourceDeleted(ctx context.Context, event eventbus.Event) error { + evt := event.(*ResourceDeletedEvent) + return l.analyticsService.RecordDeletion(ctx, evt.ResourceID) +} +``` + +### Audit Logging + +Subscribe to all events for audit trail: + +```go +func (l *auditListener) Init(eventBus eventbus.EventBus) { + eventBus.Subscribe("*.created", l.HandleCreated) + eventBus.Subscribe("*.updated", l.HandleUpdated) + eventBus.Subscribe("*.deleted", l.HandleDeleted) +} + +func (l *auditListener) HandleCreated(ctx context.Context, event eventbus.Event) error { + return l.auditService.Log(ctx, "created", event) +} +``` + +### Async Processing + +Trigger background jobs from events: + +```go +func (l *processingListener) HandleFileUploaded(ctx context.Context, event eventbus.Event) error { + evt := event.(*FileUploadedEvent) + + // Queue async job + return l.jobQueue.Enqueue(ctx, &ProcessFileJob{ + FileID: evt.FileID, + }) +} +``` + +## Registration + +Register listeners during module initialization: + +```go +// src/app/resources/cmd/init.go +func Init(container *dig.Container) error { + return container.Invoke(func( + eventBus eventbus.EventBus, + listener *listeners.ResourceListener, + ) { + listener.Init(eventBus) + }) +} +``` + +## File Locations + +| Component | Path | +|-----------|------| +| Event bus interface | `src/pkg/eventbus/eventbus.go` | +| Event interface | `src/pkg/eventbus/event.go` | +| Base event | `src/pkg/eventbus/base_event.go` | +| Implementation | `src/pkg/eventbus/memory_eventbus.go` | +| Domain events | `src/app/*/domain/events/` | +| Event listeners | `src/app/*/domain/listeners/` | + +## Next Steps + +- **Define events**: Create event structs in `domain/events/` +- **Implement listeners**: Handle events in `domain/listeners/` +- **Publish events**: Emit events in service layer diff --git a/go-b2b-starter/docs/file-manager.md b/go-b2b-starter/docs/file-manager.md new file mode 100644 index 0000000..41d8fd8 --- /dev/null +++ b/go-b2b-starter/docs/file-manager.md @@ -0,0 +1,222 @@ +# File Manager Guide + +The file manager provides file storage using Cloudflare R2 (object storage) with PostgreSQL for searchable metadata. + +## Architecture + +**Dual-layer design:** + +**R2 Storage** - Stores actual file content +**PostgreSQL** - Stores searchable metadata + +This separation enables fast querying while leveraging object storage scalability. + +## Components + +**FileRepository**: Combined operations (upload, download, delete, search) +**R2Repository**: R2 object storage operations +**FileMetadataRepository**: Database metadata operations +**FileService**: Business logic with validation + +## File Upload + +### Basic Upload + +```go +req := &domain.FileUploadRequest{ + Filename: "document.pdf", + ContentType: "application/pdf", + Context: file_manager.ContextDocument, +} + +file, err := fileService.UploadFile(ctx, req, fileReader) +``` + +### Upload with Entity Linking + +Link files to domain entities (like resources, users, etc.): + +```go +req := &domain.FileUploadRequest{ + Filename: "profile.jpg", + ContentType: "image/jpeg", + Context: file_manager.ContextProfile, +} + +file := &domain.FileAsset{ + EntityType: "user", + EntityID: userID, +} + +uploadedFile, err := fileService.UploadFile(ctx, req, fileReader) +``` + +### Upload Flow + +1. Validate file (size, type, magic bytes) +2. Save metadata to database (get ID) +3. Upload content to R2 (using database ID in key) +4. Update metadata with storage path +5. Rollback on failure (atomic operation) + +## File Download + +### Get Presigned URL + +Generate temporary download link: + +```go +url, err := fileService.GetPresignedURL(ctx, fileID, 15*time.Minute) +``` + +Returns a time-limited URL for direct download from R2. + +### Download File Content + +```go +content, err := fileService.DownloadFile(ctx, fileID) +``` + +Returns `io.ReadCloser` with file content. + +## File Search + +### By Entity + +Get all files for a specific entity: + +```go +files, err := fileRepo.GetByEntity(ctx, "resource", resourceID) +``` + +### By Category + +Find files by category: + +```go +documents, err := fileRepo.GetByCategory(ctx, file_manager.CategoryDocument, 10, 0) +``` + +### By Context + +Search by context type: + +```go +profiles, err := fileRepo.GetByContext(ctx, file_manager.ContextProfile, 20, 0) +``` + +## File Validation + +Automatic validation on upload: + +**Magic byte verification** - Validates file type matches content +**Size limits** - Configurable max file size +**Content type** - Ensures valid MIME type + +Configure in `FileService` initialization. + +## Contexts and Categories + +### Predefined Contexts + +- `ContextDocument` - General documents +- `ContextProfile` - Profile images +- `ContextAttachment` - Email/message attachments +- `ContextThumbnail` - Image thumbnails + +### Categories + +- `CategoryDocument` - PDFs, docs +- `CategoryImage` - Images +- `CategoryVideo` - Videos +- `CategoryArchive` - ZIP, TAR files + +Defined in `src/pkg/file_manager/domain/constants.go`. + +## Configuration + +```env +# Cloudflare R2 +R2_ACCOUNT_ID=your-account-id +R2_ACCESS_KEY_ID=your-access-key +R2_SECRET_ACCESS_KEY=your-secret-key +R2_BUCKET_NAME=files +R2_REGION=auto # Usually "auto" for R2 +``` + +## Common Patterns + +### Upload User Avatar + +```go +func (s *service) UpdateAvatar(ctx context.Context, userID int32, avatar io.Reader) error { + req := &domain.FileUploadRequest{ + Filename: fmt.Sprintf("avatar_%d.jpg", userID), + ContentType: "image/jpeg", + Context: file_manager.ContextProfile, + } + + file, err := s.fileService.UploadFile(ctx, req, avatar) + if err != nil { + return err + } + + // Link to user + return s.userRepo.UpdateAvatar(ctx, userID, file.ID) +} +``` + +### Get Entity Files + +```go +func (h *Handler) GetResourceFiles(c *gin.Context) { + resourceID := parseID(c.Param("id")) + + files, err := h.fileRepo.GetByEntity(c.Request.Context(), "resource", resourceID) + if err != nil { + c.JSON(500, gin.H{"error": "failed to get files"}) + return + } + + c.JSON(200, files) +} +``` + +### Delete File + +```go +func (s *service) DeleteResource(ctx context.Context, resourceID int32) error { + // Get associated files + files, err := s.fileRepo.GetByEntity(ctx, "resource", resourceID) + if err != nil { + return err + } + + // Delete files + for _, file := range files { + err = s.fileService.DeleteFile(ctx, file.ID) + if err != nil { + return err + } + } + + // Delete resource + return s.resourceRepo.Delete(ctx, resourceID) +} +``` + +## File Locations + +| Component | Path | +|-----------|------| +| Domain entities | `src/pkg/file_manager/domain/` | +| File service | `src/pkg/file_manager/internal/app/` | +| R2 repository | `src/pkg/file_manager/internal/infra/r2/` | +| Metadata repository | `src/pkg/file_manager/internal/infra/metadata/` | +| Constants | `src/pkg/file_manager/domain/constants.go` | + +## Next Steps + +- **Upload files**: Integrate file upload in your features +- **Link entities**: Associate files with domain objects +- **R2 documentation**: https://developers.cloudflare.com/r2/ diff --git a/go-b2b-starter/example.env b/go-b2b-starter/example.env new file mode 100644 index 0000000..bed05ce --- /dev/null +++ b/go-b2b-starter/example.env @@ -0,0 +1,82 @@ +# Go B2B SaaS Starter Kit - Environment Configuration Template +# Copy this file to app.env and fill in your actual values + +# Environment +ENV=DEV +ALLOW_SELF_APPROVAL=true + +# Server +SERVER_ADDRESS=:8080 +RATE_LIMIT_PER_SECOND=100 +MAX_REQUEST_SIZE=10485760 + +# Security Settings +TLS_CERT_PATH=/path/to/cert.pem +TLS_KEY_PATH=/path/to/key.pem +TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Postgres Configuration +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=mydatabase +POSTGRES_USER=user +POSTGRES_PASSWORD=password +DB_SSL_MODE=disable +MIGRATION_URL=src/pkg/db/postgres/sqlc/migrations +SEED_URL=src/pkg/db/postgres/seed + +# Auth Configuration +ACCESS_TOKEN_DURATION=3h +REFRESH_TOKEN_DURATION=72h +TOKEN_SYMMETRIC_KEY=REPLACE_WITH_YOUR_32_CHAR_SECRET_KEY +SESSION_ENCRYPTION_KEY=REPLACE_WITH_YOUR_32_CHAR_SESSION_KEY +PASSWORD_HASH_COST=12 +MAX_LOGIN_ATTEMPTS=5 +LOCKOUT_DURATION=15m +JWT_ISSUER=go-b2b-starter + +# === Stytch B2B configuration === +STYTCH_PROJECT_ID=project-test-REPLACE_WITH_YOUR_STYTCH_PROJECT_ID +STYTCH_SECRET=secret-test-REPLACE_WITH_YOUR_STYTCH_SECRET +STYTCH_ENV=test +STYTCH_SESSION_DURATION_MINUTES=1440 +STYTCH_INVITE_REDIRECT_URL=http://localhost:3000/authenticate +STYTCH_LOGIN_REDIRECT_URL=http://localhost:3000/authenticate +STYTCH_OWNER_ROLE_SLUG=owner +STYTCH_DISABLE_SESSION_VERIFICATION=false + +# Cloudflare R2 Configuration +R2_ACCOUNT_ID=REPLACE_WITH_YOUR_R2_ACCOUNT_ID +R2_ACCESS_KEY_ID=REPLACE_WITH_YOUR_R2_ACCESS_KEY +R2_SECRET_ACCESS_KEY=REPLACE_WITH_YOUR_R2_SECRET_KEY +R2_BUCKET=uploads +R2_REGION=auto +S3_API=https://REPLACE_WITH_YOUR_R2_ACCOUNT_ID.r2.cloudflarestorage.com + +# OpenAI Configuration +OPENAI_API_KEY=sk-proj-REPLACE_WITH_YOUR_OPENAI_API_KEY +OPENAI_MODEL=gpt-4o-mini +OPENAI_MAX_TOKENS=500 +OPENAI_TEMPERATURE=0.0 +LLM_TIMEOUT_SEC=30 +LLM_MAX_RETRIES=1 +LLM_FALLBACK_ENABLED=true + +# Mistral Configuration +MISTRAL_API_KEY=REPLACE_WITH_YOUR_MISTRAL_API_KEY +OCR_DEBUG_MODE=true + +# Polar Configuration +POLAR_ACCESS_TOKEN=polar_oat_REPLACE_WITH_YOUR_POLAR_ACCESS_TOKEN +POLAR_BASE_URL=https://sandbox-api.polar.sh +POLAR_DEBUG=true +WEBHOOK_SECRET=polar_whs_REPLACE_WITH_YOUR_WEBHOOK_SECRET +NEXT_PUBLIC_POLAR_PRODUCT_ID=REPLACE_WITH_YOUR_PRODUCT_ID +NEXT_PUBLIC_POLAR_BUSINESS_PRODUCT_ID=REPLACE_WITH_YOUR_BUSINESS_PRODUCT_ID diff --git a/go-b2b-starter/go.mod b/go-b2b-starter/go.mod new file mode 100644 index 0000000..197847b --- /dev/null +++ b/go-b2b-starter/go.mod @@ -0,0 +1,7 @@ +module github.com/moasq/go-b2b-starter + +go 1.25 + +replace github.com/moasq/go-b2b-starter/pkg/eventbus => ./src/pkg/eventbus + +replace github.com/moasq/go-b2b-starter/pkg/logger => ./src/pkg/logger diff --git a/go-b2b-starter/go.sum b/go-b2b-starter/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/go-b2b-starter/go.work b/go-b2b-starter/go.work new file mode 100644 index 0000000..9a7c456 --- /dev/null +++ b/go-b2b-starter/go.work @@ -0,0 +1,39 @@ +go 1.25 + +use ./src/api + +use ./src/main + +use ./src/docs + +use ./src/pkg/api + +use ./src/pkg/common + +use ./src/pkg/db + +use ./src/pkg/eventbus + +use ./src/pkg/file_manager + +use ./src/pkg/llm + +use ./src/pkg/logger + +use ./src/pkg/server + +use ./src/pkg/redis + +use ./src/pkg/polar + +use ./src/pkg/ocr + +use ( + ./src/app/billing + ./src/app/example_cognitive + ./src/app/example_documents + ./src/app/organizations + ./src/pkg/auth + ./src/pkg/paywall + ./src/pkg/stytch +) diff --git a/go-b2b-starter/go.work.sum b/go-b2b-starter/go.work.sum new file mode 100644 index 0000000..bda83e0 --- /dev/null +++ b/go-b2b-starter/go.work.sum @@ -0,0 +1,889 @@ +ariga.io/atlas v0.32.0 h1:y+77nueMrExLiKlz1CcPKh/nU7VSlWfBbwCShsJyvCw= +ariga.io/atlas v0.32.0/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= +cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= +cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= +cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4= +cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= +cloud.google.com/go/accessapproval v1.7.5/go.mod h1:g88i1ok5dvQ9XJsxpUInWWvUBrIZhyPDPbk4T01OoJ0= +cloud.google.com/go/accesscontextmanager v1.8.5/go.mod h1:TInEhcZ7V9jptGNqN3EzZ5XMhT6ijWxTGjzyETwmL0Q= +cloud.google.com/go/aiplatform v1.60.0/go.mod h1:eTlGuHOahHprZw3Hio5VKmtThIOak5/qy6pzdsqcQnM= +cloud.google.com/go/analytics v0.23.0/go.mod h1:YPd7Bvik3WS95KBok2gPXDqQPHy08TsCQG6CdUCb+u0= +cloud.google.com/go/apigateway v1.6.5/go.mod h1:6wCwvYRckRQogyDDltpANi3zsCDl6kWi0b4Je+w2UiI= +cloud.google.com/go/apigeeconnect v1.6.5/go.mod h1:MEKm3AiT7s11PqTfKE3KZluZA9O91FNysvd3E6SJ6Ow= +cloud.google.com/go/apigeeregistry v0.8.3/go.mod h1:aInOWnqF4yMQx8kTjDqHNXjZGh/mxeNlAf52YqtASUs= +cloud.google.com/go/appengine v1.8.5/go.mod h1:uHBgNoGLTS5di7BvU25NFDuKa82v0qQLjyMJLuPQrVo= +cloud.google.com/go/area120 v0.8.5/go.mod h1:BcoFCbDLZjsfe4EkCnEq1LKvHSK0Ew/zk5UFu6GMyA0= +cloud.google.com/go/artifactregistry v1.14.7/go.mod h1:0AUKhzWQzfmeTvT4SjfI4zjot72EMfrkvL9g9aRjnnM= +cloud.google.com/go/asset v1.17.2/go.mod h1:SVbzde67ehddSoKf5uebOD1sYw8Ab/jD/9EIeWg99q4= +cloud.google.com/go/assuredworkloads v1.11.5/go.mod h1:FKJ3g3ZvkL2D7qtqIGnDufFkHxwIpNM9vtmhvt+6wqk= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/automl v1.13.5/go.mod h1:MDw3vLem3yh+SvmSgeYUmUKqyls6NzSumDm9OJ3xJ1Y= +cloud.google.com/go/baremetalsolution v1.2.4/go.mod h1:BHCmxgpevw9IEryE99HbYEfxXkAEA3hkMJbYYsHtIuY= +cloud.google.com/go/batch v1.8.0/go.mod h1:k8V7f6VE2Suc0zUM4WtoibNrA6D3dqBpB+++e3vSGYc= +cloud.google.com/go/beyondcorp v1.0.4/go.mod h1:Gx8/Rk2MxrvWfn4WIhHIG1NV7IBfg14pTKv1+EArVcc= +cloud.google.com/go/bigquery v1.59.1/go.mod h1:VP1UJYgevyTwsV7desjzNzDND5p6hZB+Z8gZJN1GQUc= +cloud.google.com/go/billing v1.18.2/go.mod h1:PPIwVsOOQ7xzbADCwNe8nvK776QpfrOAUkvKjCUcpSE= +cloud.google.com/go/binaryauthorization v1.8.1/go.mod h1:1HVRyBerREA/nhI7yLang4Zn7vfNVA3okoAR9qYQJAQ= +cloud.google.com/go/certificatemanager v1.7.5/go.mod h1:uX+v7kWqy0Y3NG/ZhNvffh0kuqkKZIXdvlZRO7z0VtM= +cloud.google.com/go/channel v1.17.5/go.mod h1:FlpaOSINDAXgEext0KMaBq/vwpLMkkPAw9b2mApQeHc= +cloud.google.com/go/cloudbuild v1.15.1/go.mod h1:gIofXZSu+XD2Uy+qkOrGKEx45zd7s28u/k8f99qKals= +cloud.google.com/go/clouddms v1.7.4/go.mod h1:RdrVqoFG9RWI5AvZ81SxJ/xvxPdtcRhFotwdE79DieY= +cloud.google.com/go/cloudtasks v1.12.6/go.mod h1:b7c7fe4+TJsFZfDyzO51F7cjq7HLUlRi/KZQLQjDsaY= +cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute v1.23.4/go.mod h1:/EJMj55asU6kAFnuZET8zqgwgJ9FvXWXOkkfQZa4ioI= +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/contactcenterinsights v1.13.0/go.mod h1:ieq5d5EtHsu8vhe2y3amtZ+BE+AQwX5qAy7cpo0POsI= +cloud.google.com/go/container v1.31.0/go.mod h1:7yABn5s3Iv3lmw7oMmyGbeV6tQj86njcTijkkGuvdZA= +cloud.google.com/go/containeranalysis v0.11.4/go.mod h1:cVZT7rXYBS9NG1rhQbWL9pWbXCKHWJPYraE8/FTSYPE= +cloud.google.com/go/datacatalog v1.19.3/go.mod h1:ra8V3UAsciBpJKQ+z9Whkxzxv7jmQg1hfODr3N3YPJ4= +cloud.google.com/go/dataflow v0.9.5/go.mod h1:udl6oi8pfUHnL0z6UN9Lf9chGqzDMVqcYTcZ1aPnCZQ= +cloud.google.com/go/dataform v0.9.2/go.mod h1:S8cQUwPNWXo7m/g3DhWHsLBoufRNn9EgFrMgne2j7cI= +cloud.google.com/go/datafusion v1.7.5/go.mod h1:bYH53Oa5UiqahfbNK9YuYKteeD4RbQSNMx7JF7peGHc= +cloud.google.com/go/datalabeling v0.8.5/go.mod h1:IABB2lxQnkdUbMnQaOl2prCOfms20mcPxDBm36lps+s= +cloud.google.com/go/dataplex v1.14.2/go.mod h1:0oGOSFlEKef1cQeAHXy4GZPB/Ife0fz/PxBf+ZymA2U= +cloud.google.com/go/dataproc/v2 v2.4.0/go.mod h1:3B1Ht2aRB8VZIteGxQS/iNSJGzt9+CA0WGnDVMEm7Z4= +cloud.google.com/go/dataqna v0.8.5/go.mod h1:vgihg1mz6n7pb5q2YJF7KlXve6tCglInd6XO0JGOlWM= +cloud.google.com/go/datastore v1.15.0/go.mod h1:GAeStMBIt9bPS7jMJA85kgkpsMkvseWWXiaHya9Jes8= +cloud.google.com/go/datastream v1.10.4/go.mod h1:7kRxPdxZxhPg3MFeCSulmAJnil8NJGGvSNdn4p1sRZo= +cloud.google.com/go/deploy v1.17.1/go.mod h1:SXQyfsXrk0fBmgBHRzBjQbZhMfKZ3hMQBw5ym7MN/50= +cloud.google.com/go/dialogflow v1.49.0/go.mod h1:dhVrXKETtdPlpPhE7+2/k4Z8FRNUp6kMV3EW3oz/fe0= +cloud.google.com/go/dlp v1.11.2/go.mod h1:9Czi+8Y/FegpWzgSfkRlyz+jwW6Te9Rv26P3UfU/h/w= +cloud.google.com/go/documentai v1.25.0/go.mod h1:ftLnzw5VcXkLItp6pw1mFic91tMRyfv6hHEY5br4KzY= +cloud.google.com/go/domains v0.9.5/go.mod h1:dBzlxgepazdFhvG7u23XMhmMKBjrkoUNaw0A8AQB55Y= +cloud.google.com/go/edgecontainer v1.1.5/go.mod h1:rgcjrba3DEDEQAidT4yuzaKWTbkTI5zAMu3yy6ZWS0M= +cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= +cloud.google.com/go/essentialcontacts v1.6.6/go.mod h1:XbqHJGaiH0v2UvtuucfOzFXN+rpL/aU5BCZLn4DYl1Q= +cloud.google.com/go/eventarc v1.13.4/go.mod h1:zV5sFVoAa9orc/52Q+OuYUG9xL2IIZTbbuTHC6JSY8s= +cloud.google.com/go/filestore v1.8.1/go.mod h1:MbN9KcaM47DRTIuLfQhJEsjaocVebNtNQhSLhKCF5GM= +cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ= +cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8= +cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk= +cloud.google.com/go/functions v1.16.0/go.mod h1:nbNpfAG7SG7Duw/o1iZ6ohvL7mc6MapWQVpqtM29n8k= +cloud.google.com/go/gkebackup v1.3.5/go.mod h1:KJ77KkNN7Wm1LdMopOelV6OodM01pMuK2/5Zt1t4Tvc= +cloud.google.com/go/gkeconnect v0.8.5/go.mod h1:LC/rS7+CuJ5fgIbXv8tCD/mdfnlAadTaUufgOkmijuk= +cloud.google.com/go/gkehub v0.14.5/go.mod h1:6bzqxM+a+vEH/h8W8ec4OJl4r36laxTs3A/fMNHJ0wA= +cloud.google.com/go/gkemulticloud v1.1.1/go.mod h1:C+a4vcHlWeEIf45IB5FFR5XGjTeYhF83+AYIpTy4i2Q= +cloud.google.com/go/gsuiteaddons v1.6.5/go.mod h1:Lo4P2IvO8uZ9W+RaC6s1JVxo42vgy+TX5a6hfBZ0ubs= +cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE= +cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= +cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= +cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/iap v1.9.4/go.mod h1:vO4mSq0xNf/Pu6E5paORLASBwEmphXEjgCFg7aeNu1w= +cloud.google.com/go/ids v1.4.5/go.mod h1:p0ZnyzjMWxww6d2DvMGnFwCsSxDJM666Iir1bK1UuBo= +cloud.google.com/go/iot v1.7.5/go.mod h1:nq3/sqTz3HGaWJi1xNiX7F41ThOzpud67vwk0YsSsqs= +cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI= +cloud.google.com/go/language v1.12.3/go.mod h1:evFX9wECX6mksEva8RbRnr/4wi/vKGYnAJrTRXU8+f8= +cloud.google.com/go/lifesciences v0.9.5/go.mod h1:OdBm0n7C0Osh5yZB7j9BXyrMnTRGBJIZonUMxo5CzPw= +cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE= +cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/longrunning v0.5.2/go.mod h1:nqo6DQbNV2pXhGDbDMoN2bWz68MjZUzqv2YttZiveCs= +cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI= +cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg= +cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/managedidentities v1.6.5/go.mod h1:fkFI2PwwyRQbjLxlm5bQ8SjtObFMW3ChBGNqaMcgZjI= +cloud.google.com/go/maps v1.6.4/go.mod h1:rhjqRy8NWmDJ53saCfsXQ0LKwBHfi6OSh5wkq6BaMhI= +cloud.google.com/go/mediatranslation v0.8.5/go.mod h1:y7kTHYIPCIfgyLbKncgqouXJtLsU+26hZhHEEy80fSs= +cloud.google.com/go/memcache v1.10.5/go.mod h1:/FcblbNd0FdMsx4natdj+2GWzTq+cjZvMa1I+9QsuMA= +cloud.google.com/go/metastore v1.13.4/go.mod h1:FMv9bvPInEfX9Ac1cVcRXp8EBBQnBcqH6gz3KvJ9BAE= +cloud.google.com/go/monitoring v1.18.0/go.mod h1:c92vVBCeq/OB4Ioyo+NbN2U7tlg5ZH41PZcdvfc+Lcg= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/networkconnectivity v1.14.4/go.mod h1:PU12q++/IMnDJAB+3r+tJtuCXCfwfN+C6Niyj6ji1Po= +cloud.google.com/go/networkmanagement v1.9.4/go.mod h1:daWJAl0KTFytFL7ar33I6R/oNBH8eEOX/rBNHrC/8TA= +cloud.google.com/go/networksecurity v0.9.5/go.mod h1:KNkjH/RsylSGyyZ8wXpue8xpCEK+bTtvof8SBfIhMG8= +cloud.google.com/go/notebooks v1.11.3/go.mod h1:0wQyI2dQC3AZyQqWnRsp+yA+kY4gC7ZIVP4Qg3AQcgo= +cloud.google.com/go/optimization v1.6.3/go.mod h1:8ve3svp3W6NFcAEFr4SfJxrldzhUl4VMUJmhrqVKtYA= +cloud.google.com/go/orchestration v1.8.5/go.mod h1:C1J7HesE96Ba8/hZ71ISTV2UAat0bwN+pi85ky38Yq8= +cloud.google.com/go/orgpolicy v1.12.1/go.mod h1:aibX78RDl5pcK3jA8ysDQCFkVxLj3aOQqrbBaUL2V5I= +cloud.google.com/go/osconfig v1.12.5/go.mod h1:D9QFdxzfjgw3h/+ZaAb5NypM8bhOMqBzgmbhzWViiW8= +cloud.google.com/go/oslogin v1.13.1/go.mod h1:vS8Sr/jR7QvPWpCjNqy6LYZr5Zs1e8ZGW/KPn9gmhws= +cloud.google.com/go/phishingprotection v0.8.5/go.mod h1:g1smd68F7mF1hgQPuYn3z8HDbNre8L6Z0b7XMYFmX7I= +cloud.google.com/go/policytroubleshooter v1.10.3/go.mod h1:+ZqG3agHT7WPb4EBIRqUv4OyIwRTZvsVDHZ8GlZaoxk= +cloud.google.com/go/privatecatalog v0.9.5/go.mod h1:fVWeBOVe7uj2n3kWRGlUQqR/pOd450J9yZoOECcQqJk= +cloud.google.com/go/pubsub v1.36.1/go.mod h1:iYjCa9EzWOoBiTdd4ps7QoMtMln5NwaZQpK1hbRfBDE= +cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0= +cloud.google.com/go/recaptchaenterprise/v2 v2.9.2/go.mod h1:trwwGkfhCmp05Ll5MSJPXY7yvnO0p4v3orGANAFHAuU= +cloud.google.com/go/recommendationengine v0.8.5/go.mod h1:A38rIXHGFvoPvmy6pZLozr0g59NRNREz4cx7F58HAsQ= +cloud.google.com/go/recommender v1.12.1/go.mod h1:gf95SInWNND5aPas3yjwl0I572dtudMhMIG4ni8nr+0= +cloud.google.com/go/redis v1.14.2/go.mod h1:g0Lu7RRRz46ENdFKQ2EcQZBAJ2PtJHJLuiiRuEXwyQw= +cloud.google.com/go/resourcemanager v1.9.5/go.mod h1:hep6KjelHA+ToEjOfO3garMKi/CLYwTqeAw7YiEI9x8= +cloud.google.com/go/resourcesettings v1.6.5/go.mod h1:WBOIWZraXZOGAgoR4ukNj0o0HiSMO62H9RpFi9WjP9I= +cloud.google.com/go/retail v1.16.0/go.mod h1:LW7tllVveZo4ReWt68VnldZFWJRzsh9np+01J9dYWzE= +cloud.google.com/go/run v1.3.4/go.mod h1:FGieuZvQ3tj1e9GnzXqrMABSuir38AJg5xhiYq+SF3o= +cloud.google.com/go/scheduler v1.10.6/go.mod h1:pe2pNCtJ+R01E06XCDOJs1XvAMbv28ZsQEbqknxGOuE= +cloud.google.com/go/secretmanager v1.11.5/go.mod h1:eAGv+DaCHkeVyQi0BeXgAHOU0RdrMeZIASKc+S7VqH4= +cloud.google.com/go/security v1.15.5/go.mod h1:KS6X2eG3ynWjqcIX976fuToN5juVkF6Ra6c7MPnldtc= +cloud.google.com/go/securitycenter v1.24.4/go.mod h1:PSccin+o1EMYKcFQzz9HMMnZ2r9+7jbc+LvPjXhpwcU= +cloud.google.com/go/servicedirectory v1.11.4/go.mod h1:Bz2T9t+/Ehg6x+Y7Ycq5xiShYLD96NfEsWNHyitj1qM= +cloud.google.com/go/shell v1.7.5/go.mod h1:hL2++7F47/IfpfTO53KYf1EC+F56k3ThfNEXd4zcuiE= +cloud.google.com/go/spanner v1.51.0 h1:l3exhhsVMKsx1E7Xd1QajYSvHmI1KZoWPW5tRxIIdvQ= +cloud.google.com/go/spanner v1.51.0/go.mod h1:c5KNo5LQ1X5tJwma9rSQZsXNBDNvj4/n8BVc3LNahq0= +cloud.google.com/go/spanner v1.56.0/go.mod h1:DndqtUKQAt3VLuV2Le+9Y3WTnq5cNKrnLb/Piqcj+h0= +cloud.google.com/go/speech v1.21.1/go.mod h1:E5GHZXYQlkqWQwY5xRSLHw2ci5NMQNG52FfMU1aZrIA= +cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= +cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= +cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg= +cloud.google.com/go/storage v1.38.0/go.mod h1:tlUADB0mAb9BgYls9lq+8MGkfzOXuLrnHXlpHmvFJoY= +cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/storagetransfer v1.10.4/go.mod h1:vef30rZKu5HSEf/x1tK3WfWrL0XVoUQN/EPDRGPzjZs= +cloud.google.com/go/talent v1.6.6/go.mod h1:y/WQDKrhVz12WagoarpAIyKKMeKGKHWPoReZ0g8tseQ= +cloud.google.com/go/texttospeech v1.7.5/go.mod h1:tzpCuNWPwrNJnEa4Pu5taALuZL4QRRLcb+K9pbhXT6M= +cloud.google.com/go/tpu v1.6.5/go.mod h1:P9DFOEBIBhuEcZhXi+wPoVy/cji+0ICFi4TtTkMHSSs= +cloud.google.com/go/trace v1.10.5/go.mod h1:9hjCV1nGBCtXbAE4YK7OqJ8pmPYSxPA0I67JwRd5s3M= +cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +cloud.google.com/go/translate v1.10.1/go.mod h1:adGZcQNom/3ogU65N9UXHOnnSvjPwA/jKQUMnsYXOyk= +cloud.google.com/go/video v1.20.4/go.mod h1:LyUVjyW+Bwj7dh3UJnUGZfyqjEto9DnrvTe1f/+QrW0= +cloud.google.com/go/videointelligence v1.11.5/go.mod h1:/PkeQjpRponmOerPeJxNPuxvi12HlW7Em0lJO14FC3I= +cloud.google.com/go/vision/v2 v2.8.0 h1:W52z1b6LdGI66MVhE70g/NFty9zCYYcjdKuycqmlhtg= +cloud.google.com/go/vision/v2 v2.8.0/go.mod h1:ocqDiA2j97pvgogdyhoxiQp2ZkDCyr0HWpicywGGRhU= +cloud.google.com/go/vision/v2 v2.9.5/go.mod h1:1SiNZPpypqZDbOzU052ZYRiyKjwOcyqgGgqQCI/nlx8= +cloud.google.com/go/vmmigration v1.7.5/go.mod h1:pkvO6huVnVWzkFioxSghZxIGcsstDvYiVCxQ9ZH3eYI= +cloud.google.com/go/vmwareengine v1.1.1/go.mod h1:nMpdsIVkUrSaX8UvmnBhzVzG7PPvNYc5BszcvIVudYs= +cloud.google.com/go/vpcaccess v1.7.5/go.mod h1:slc5ZRvvjP78c2dnL7m4l4R9GwL3wDLcpIWz6P/ziig= +cloud.google.com/go/webrisk v1.9.5/go.mod h1:aako0Fzep1Q714cPEM5E+mtYX8/jsfegAuS8aivxy3U= +cloud.google.com/go/websecurityscanner v1.6.5/go.mod h1:QR+DWaxAz2pWooylsBF854/Ijvuoa3FCyS1zBa1rAVQ= +cloud.google.com/go/workflows v1.12.4/go.mod h1:yQ7HUqOkdJK4duVtMeBCAOPiN1ZF1E9pAMX51vpwB/w= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= +github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 h1:+5VZ72z0Qan5Bog5C+ZkgSqUbeVUd9wgtHOrIKuc5b8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+2j+HXbTBwnyGpm5Nou7KhvSfxOq8JpTag= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest/adal v0.9.16 h1:P8An8Z9rH1ldbOLdFpxYorgOt2sywL9V24dAwWHPuGc= +github.com/Azure/go-autorest/autorest/adal v0.9.16/go.mod h1:tGMin8I49Yij6AQ+rvV+Xa/zwxYQB5hmsd6DkfAx2+A= +github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw= +github.com/ClickHouse/clickhouse-go v1.4.3 h1:iAFMa2UrQdR5bHJ2/yaSLffZkxpcOYQMCUuKeNXGdqc= +github.com/ClickHouse/clickhouse-go v1.4.3/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.12.0/go.mod h1:RZV12pcHCXQ42XnlQ3pz6FZfmrC1C+R4gaOHhRNML1g= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/ankane/disco-go v0.1.2 h1:Amm1UV3oLttAJM18MW7zofTDAXbuE9BEQUt3YPF6pVI= +github.com/ankane/disco-go v0.1.2/go.mod h1:nkR7DLW+KkXeRRAsWk6poMTpTOWp9/4iKYGDwg8dSS0= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI= +github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= +github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY= +github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/aws/aws-sdk-go v1.49.6 h1:yNldzF5kzLBRvKlKz1S0bkvc2+04R1kt13KfBWQBfFA= +github.com/aws/aws-sdk-go v1.49.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go-v2 v1.16.16 h1:M1fj4FE2lB4NzRb9Y0xdWsn2P0+2UHVxwKyOa4YJNjk= +github.com/aws/aws-sdk-go-v2 v1.16.16/go.mod h1:SwiyXi/1zTUZ6KIAmLK5V5ll8SiURNUYOqTerZPaF9k= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8 h1:tcFliCWne+zOuUfKNRn8JdFBuWPDuISDH08wD2ULkhk= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8/go.mod h1:JTnlBSot91steJeti4ryyu/tLd4Sk84O5W22L7O2EQU= +github.com/aws/aws-sdk-go-v2/credentials v1.12.20 h1:9+ZhlDY7N9dPnUmf7CDfW9In4sW5Ff3bh7oy4DzS1IE= +github.com/aws/aws-sdk-go-v2/credentials v1.12.20/go.mod h1:UKY5HyIux08bbNA7Blv4PcXQ8cTkGh7ghHMFklaviR4= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.33 h1:fAoVmNGhir6BR+RU0/EI+6+D7abM+MCwWf8v4ip5jNI= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.33/go.mod h1:84XgODVR8uRhmOnUkKGUZKqIMxmjmLOR8Uyp7G/TPwc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 h1:s4g/wnzMf+qepSNgTvaQQHNxyMLKSawNhKCPNy++2xY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23/go.mod h1:2DFxAQ9pfIRy0imBCJv+vZ2X6RKxves6fbnEuSry6b4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 h1:/K482T5A3623WJgWT8w1yRAFK4RzGzEl7y39yhtn9eA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17/go.mod h1:pRwaTYCJemADaqCbUAxltMoHKata7hmB5PjEXeu0kfg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14 h1:ZSIPAkAsCCjYrhqfw2+lNzWDzxzHXEckFkTePL5RSWQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14/go.mod h1:AyGgqiKv9ECM6IZeNQtdT8NnMvUb3/2wokeq2Fgryto= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9 h1:Lh1AShsuIJTwMkoxVCAYPJgNG5H+eN6SmoUn8nOZ5wE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9/go.mod h1:a9j48l6yL5XINLHLcOKInjdvknN+vWqPBxqeIDw7ktw= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18 h1:BBYoNQt2kUZUUK4bIPsKrCcjVPUMNsgQpNAwhznK/zo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18/go.mod h1:NS55eQ4YixUJPTC+INxi2/jCqe1y2Uw3rnh9wEOVJxY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 h1:Jrd/oMh0PKQc6+BowB+pLEwLIgaQF29eYbe7E1Av9Ug= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17/go.mod h1:4nYOrY41Lrbk2170/BGkcJKBhws9Pfn8MG3aGqjjeFI= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 h1:HfVVR1vItaG6le+Bpw6P4midjBDMKnjMyZnw9MXYUcE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17/go.mod h1:YqMdV+gEKCQ59NrB7rzrJdALeBIsYiVi8Inj3+KcqHI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 h1:3/gm/JTX9bX8CpzTgIlrtYpB3EVBDxyg/GY/QdcIEZw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11/go.mod h1:fmgDANqTUCxciViKl9hb/zD5LFbvPINFRgWhDbR+vZo= +github.com/aws/smithy-go v1.13.3 h1:l7LYxGuzK6/K+NzJ2mC+VvLUbae0sL3bXU//04MkmnA= +github.com/aws/smithy-go v1.13.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo= +github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg= +github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101 h1:7To3pQ+pZo0i3dsWEbinPNFs5gPSBOsJtx3wTT94VBY= +github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cockroachdb/cockroach-go/v2 v2.1.1 h1:3XzfSMuUT0wBe1a3o5C0eOTcArhmmFAg2Jzh/7hhKqo= +github.com/cockroachdb/cockroach-go/v2 v2.1.1/go.mod h1:7NtUnP6eK+l6k483WSYNrq3Kb23bWV10IRV1TyeSpwM= +github.com/containerd/containerd v1.7.14/go.mod h1:YMC9Qt5yzNqXx/fO4j/5yYVIHXSRrlB3H7sxkUTvspg= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk= +github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= +github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= +github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= +github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8= +github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/envoyproxy/go-control-plane v0.11.1 h1:wSUXTlLfiAQRWs2F+p+EKOY9rUyis1MyGqJ2DIk5HpM= +github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= +github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= +github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/form3tech-oss/jwt-go v3.2.5+incompatible h1:/l4kBbb4/vGSsdtB5nUe8L7B9mImVMaBPw9L/0TBHU8= +github.com/form3tech-oss/jwt-go v3.2.5+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsouza/fake-gcs-server v1.17.0 h1:OeH75kBZcZa3ZE+zz/mFdJ2btt9FgqfjI7gIh9+5fvk= +github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/inflect v0.21.0 h1:FoBjBTQEcbg2cJUWX6uwL9OyIW8eqc9k4KhN4lfbeYk= +github.com/go-openapi/inflect v0.21.0/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc= +github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI= +github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556 h1:N/MD/sr6o61X+iZBAT2qEUF023s4KbA8RWfKzl0L6MQ= +github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v2.0.8+incompatible h1:ivUb1cGomAB101ZM1T0nOiWz9pSrTMoa9+EiY7igmkM= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= +github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= +github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/googleapis/gax-go/v2 v2.12.1/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= +github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= +github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= +github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 h1:zC34cGQu69FG7qzJ3WiKW244WfhDC3xxYMeNOX2gtUQ= +github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= +github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/consul/api v1.28.2 h1:mXfkRHrpHN4YY3RqL09nXU1eHKLNiuAN4kHvDQ16k/8= +github.com/hashicorp/consul/api v1.28.2/go.mod h1:KyzqzgMEya+IZPcD65YFoOVAgPpbfERu4I/tzG6/ueE= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= +github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= +github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= +github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/k0kubun/pp v2.3.0+incompatible h1:EKhKbi34VQDWJtq+zpsKSEhkHHs9w2P8Izbq8IhLVSo= +github.com/k0kubun/pp v2.3.0+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/ktrysmt/go-bitbucket v0.6.4 h1:C8dUGp0qkwncKtAnozHCbbqhptefzEd1I0sfnuy9rYQ= +github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= +github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/markbates/pkger v0.15.1 h1:3MPelV53RnGSW07izx5xGxl4e/sdRD6zqseIk0rMASY= +github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microsoft/go-mssqldb v1.0.0 h1:k2p2uuG8T5T/7Hp7/e3vMGTnnR0sU4h8d1CcC71iLHU= +github.com/microsoft/go-mssqldb v1.0.0/go.mod h1:+4wZTUnz/SV6nffv+RRRB/ss8jPng5Sho2SmM1l2ts4= +github.com/microsoft/go-mssqldb v1.7.1/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/mutecomm/go-sqlcipher/v4 v4.4.0 h1:sV1tWCWGAVlPhNGT95Q+z/txFxuhAYWwHD1afF5bMZg= +github.com/mutecomm/go-sqlcipher/v4 v4.4.0/go.mod h1:PyN04SaWalavxRGH9E8ZftG6Ju7rsPrGmQRjrEaVpiY= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8 h1:P48LjvUQpTReR3TQRbxSeSBsMXzfK0uol7eRcr7VBYQ= +github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= +github.com/nats-io/nats.go v1.34.0 h1:fnxnPCNiwIG5w08rlMcEKTUw4AV/nKyGCOJE8TdhSPk= +github.com/nats-io/nats.go v1.34.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= +github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba h1:fhFP5RliM2HW/8XdcO5QngSfFli9GcRIpMXvypTQt6E= +github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU= +github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM= +github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pierrec/lz4/v4 v4.1.16 h1:kQPfno+wyx6C5572ABwV+Uo3pDFzQ7yhyGchSyRda0c= +github.com/pierrec/lz4/v4 v4.1.16/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rqlite/gorqlite v0.0.0-20230708021416-2acd02b70b79 h1:V7x0hCAgL8lNGezuex1RW1sh7VXXCqfw8nXZti66iFg= +github.com/rqlite/gorqlite v0.0.0-20230708021416-2acd02b70b79/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/crypt v0.19.0 h1:WMyLTjHBo64UvNcWqpzY3pbZTYgnemZU8FBZigKc42E= +github.com/sagikazarmark/crypt v0.19.0/go.mod h1:c6vimRziqqERhtSe0MhIvzE1w54FrCHtrXb5NH/ja78= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shirou/gopsutil/v3 v3.24.2/go.mod h1:tSg/594BcA+8UdQU2XcW803GWYgdtauFFPgJCJKZlVk= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y= +github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/snowflakedb/gosnowflake v1.6.19 h1:KSHXrQ5o7uso25hNIzi/RObXtnSGkFgie91X82KcvMY= +github.com/snowflakedb/gosnowflake v1.6.19/go.mod h1:FM1+PWUdwB9udFDsXdfD58NONC0m+MlOSmQRvimobSM= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/viper v1.20.0-alpha.1 h1:ScUDXU3yX4i/I1ovQQtaGe9kwl63fGzG3UsFr+pGtyY= +github.com/spf13/viper v1.20.0-alpha.1/go.mod h1:VJcuMrHwg6XArbUYF9KBRrFpavzx9NrQMFtdcRsZCLs= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.29.1/go.mod h1:SnKnKQav8UcgtKqjp/AD8bE1MqZm+3TDb/B8crE3XnI= +github.com/testcontainers/testcontainers-go/modules/postgres v0.29.1/go.mod h1:YsWyy+pHDgvGdi0axGOx6CGXWsE6eqSaApyd1FYYSSc= +github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= +github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= +github.com/tursodatabase/libsql-client-go v0.0.0-20240416075003-747366ff79c4/go.mod h1:2Fu26tjM011BLeR5+jwTfs6DX/fNMEWV/3CBZvggrA4= +github.com/twpayne/go-kml/v3 v3.1.1/go.mod h1:7VT0jsr6fzn5CPZ5e4OB93vhgf3fZcwflK7ydbXFVos= +github.com/twpayne/go-kml/v3 v3.2.1 h1:xkTIJ7KMnHGKpHGf30e4XS3UT8o/5jD62hmdGJPf7Io= +github.com/twpayne/go-kml/v3 v3.2.1/go.mod h1:lPWoJR3nQAdePBy3SrnniLdBLVQX0hlxrcziCx9XgT0= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/xanzy/go-gitlab v0.15.0 h1:rWtwKTgEnXyNUGrOArN7yyc3THRkpYcKXIXia9abywQ= +github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20240126124512-dbb0e1720dbf/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.55.1/go.mod h1:udNPW8eupyH/EZocecFmaSNJacKKYjzQa7cVgX5U2nc= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= +github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= +github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b h1:7gd+rd8P3bqcn/96gOZa3F5dpJr/vEiDQYlNb/y2uNs= +gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= +go.etcd.io/etcd/api/v3 v3.5.12 h1:W4sw5ZoU2Juc9gBWuLk5U6fHfNVyY1WC5g9uiXZio/c= +go.etcd.io/etcd/api/v3 v3.5.12/go.mod h1:Ot+o0SWSyT6uHhA56al1oCED0JImsRiU9Dc26+C2a+4= +go.etcd.io/etcd/client/pkg/v3 v3.5.12 h1:EYDL6pWwyOsylrQyLp2w+HkQ46ATiOvoEdMarindU2A= +go.etcd.io/etcd/client/pkg/v3 v3.5.12/go.mod h1:seTzl2d9APP8R5Y2hFL3NVlD6qC/dOT+3kvrqPyTas4= +go.etcd.io/etcd/client/v2 v2.305.12 h1:0m4ovXYo1CHaA/Mp3X/Fak5sRNIWf01wk/X1/G3sGKI= +go.etcd.io/etcd/client/v2 v2.305.12/go.mod h1:aQ/yhsxMu+Oht1FOupSr60oBvcS9cKXHrzBpDsPTf9E= +go.etcd.io/etcd/client/v3 v3.5.12 h1:v5lCPXn1pf1Uu3M4laUE2hp/geOTc5uPcYYsNe1lDxg= +go.etcd.io/etcd/client/v3 v3.5.12/go.mod h1:tSbBCakoWmmddL+BKVAJHa9km+O/E+bumDe9mSbPiqw= +go.mongodb.org/mongo-driver v1.7.5 h1:ny3p0reEpgsR2cfA5cjgwFZg3Cv/ofFh/8jbhGtz9VI= +go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0/go.mod h1:tIKj3DbO8N9Y2xo52og3irLsPI4GW02DSMtrVgNMgxg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= +go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= +go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= +go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= +go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/dig v1.18.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= +google.golang.org/api v0.150.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg= +google.golang.org/api v0.160.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw= +google.golang.org/api v0.162.0/go.mod h1:6SulDkfoBIg4NFmCuZ39XeeAgSHCPecfSUuDyYlAHs0= +google.golang.org/api v0.166.0/go.mod h1:4FcBc686KFi7QI/U51/2GKKevfZMpM17sCdibqe/bSA= +google.golang.org/api v0.167.0/go.mod h1:4FcBc686KFi7QI/U51/2GKKevfZMpM17sCdibqe/bSA= +google.golang.org/api v0.169.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg= +google.golang.org/api v0.171.0 h1:w174hnBPqut76FzW5Qaupt7zY8Kql6fiVjgys4f58sU= +google.golang.org/api v0.171.0/go.mod h1:Hnq5AHm4OTMt2BUVjael2CWZFD6vksJdWCWiUAmjC9o= +google.golang.org/api v0.246.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= +google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= +google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= +google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= +google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I= +google.golang.org/genproto/googleapis/api v0.0.0-20240221002015-b0ce06bbee7c/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20240314234333-6e1732d8331c/go.mod h1:IN9OQUXZ0xT+26MDwZL8fJcYw+y99b0eYPA2U15Jt8o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014/go.mod h1:SaPjaZGWb0lPqs6Ittu0spdfrOArqji4ZdeP5IC/9N4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240221002015-b0ce06bbee7c/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240304161311-37d4d3c04a78/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/b v1.0.0 h1:vpvqeyp17ddcQWF29Czawql4lDdABCDRbXRAS4+aF2o= +modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg= +modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg= +modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM= +modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/db v1.0.0 h1:2c6NdCfaLnshSvY7OU09cyAY0gYXUZj4lmg5ItHyucg= +modernc.org/db v1.0.0/go.mod h1:kYD/cO29L/29RM0hXYl4i3+Q5VojL31kTUVpVJDw0s8= +modernc.org/file v1.0.0 h1:9/PdvjVxd5+LcWUQIfapAWRGOkDLK90rloa8s/au06A= +modernc.org/file v1.0.0/go.mod h1:uqEokAEn1u6e+J45e54dsEA/pw4o7zLrA2GwyntZzjw= +modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= +modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= +modernc.org/golex v1.0.0 h1:wWpDlbK8ejRfSyi0frMyhilD3JBvtcx2AdGDnU+JtsE= +modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= +modernc.org/internal v1.0.0 h1:XMDsFDcBDsibbBnHB2xzljZ+B1yrOVLEFkKL2u15Glw= +modernc.org/internal v1.0.0/go.mod h1:VUD/+JAkhCpvkUitlEOnhpVxCgsBI90oTzSCRcqQVSM= +modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI= +modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= +modernc.org/lldb v1.0.0 h1:6vjDJxQEfhlOLwl4bhpwIz00uyFK4EmSYcbwqwbynsc= +modernc.org/lldb v1.0.0/go.mod h1:jcRvJGWfCGodDZz8BPwiKMJxGJngQ/5DrRapkQnLob8= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs= +modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/ql v1.0.0 h1:bIQ/trWNVjQPlinI6jdOQsi195SIturGo3mp5hsDqVU= +modernc.org/ql v1.0.0/go.mod h1:xGVyrLIatPcO2C1JvI/Co8c0sr6y91HKFNy4pt9JXEY= +modernc.org/sortutil v1.1.0 h1:oP3U4uM+NT/qBQcbg/K2iqAX0Nx7B1b6YZtq3Gk/PjM= +modernc.org/sortutil v1.1.0/go.mod h1:ZyL98OQHJgH9IEfN71VsamvJgrtRX9Dj2gX+vH86L1k= +modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8= +modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/zappy v1.0.0 h1:dPVaP+3ueIUv4guk8PuZ2wiUGcJ1WUVvIheeSSTD0yk= +modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4= +nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +nullprogram.com/x/optparse v1.0.0 h1:xGFgVi5ZaWOnYdac2foDT3vg0ZZC9ErXFV57mr4OHrI= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/go-b2b-starter/scripts/migrate_down.sh b/go-b2b-starter/scripts/migrate_down.sh new file mode 100755 index 0000000..a67b94b --- /dev/null +++ b/go-b2b-starter/scripts/migrate_down.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Load environment variables from a file if it exists +ENV_FILE="app.env" +if [ -f "$ENV_FILE" ]; then + source "$ENV_FILE" +else + echo "Environment file not found, ensure $ENV_FILE exists or set the variables manually." + exit 1 +fi + +# Define migration paths +MIGRATION_PATHS=( + "src/pkg/db/postgres/sqlc/migrations" + +) + +# Perform migrations +for path in "${MIGRATION_PATHS[@]}"; do + echo "Migrating up in $path..." + migrate -path $path -database "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable" -verbose down + if [ $? -ne 0 ]; then + echo "Migration failed for $path" + exit 1 + else + echo "Migration completed for $path" + fi +done diff --git a/go-b2b-starter/scripts/migrate_up.sh b/go-b2b-starter/scripts/migrate_up.sh new file mode 100755 index 0000000..3e24f1d --- /dev/null +++ b/go-b2b-starter/scripts/migrate_up.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Load environment variables from a file if it exists +ENV_FILE="app.env" +if [ -f "$ENV_FILE" ]; then + source "$ENV_FILE" +else + echo "Environment file not found, ensure $ENV_FILE exists or set the variables manually." + exit 1 +fi + +# Define migration paths +MIGRATION_PATHS=( + "src/pkg/db/postgres/sqlc/migrations" +) + +# Perform migrations +for path in "${MIGRATION_PATHS[@]}"; do + echo "Migrating up in $path..." + migrate -path $path -database "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable" -verbose up + if [ $? -ne 0 ]; then + echo "Migration failed for $path" + exit 1 + else + echo "Migration completed for $path" + fi +done diff --git a/go-b2b-starter/scripts/run_tests_with_coverage.sh b/go-b2b-starter/scripts/run_tests_with_coverage.sh new file mode 100755 index 0000000..754ac9d --- /dev/null +++ b/go-b2b-starter/scripts/run_tests_with_coverage.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# File: scripts/run_tests_with_coverage.sh + +echo "Running tests with coverage for all modules..." + +# Create coverage directory +mkdir -p coverage +rm -f coverage/coverage.txt + +# Find all go.mod files and run tests +find ./src -name go.mod | while read -r mod_file; do + mod_dir=$(dirname "$mod_file") + mod_name=$(basename "$mod_dir") + + echo "Testing module: $mod_name" + + ( + cd "$mod_dir" + if go test -v -coverprofile=coverage.out ./...; then + if [ -s coverage.out ]; then + echo "mode: atomic" > "../../coverage/coverage.$mod_name.txt" + tail -n +2 coverage.out >> "../../coverage/coverage.$mod_name.txt" + else + echo "No coverage data generated for $mod_name" + fi + else + echo "Tests failed for $mod_name" + fi + rm -f coverage.out + ) +done + +# Combine all coverage files +echo "mode: atomic" > coverage/coverage.txt +find coverage -name 'coverage.*.txt' -print0 | xargs -0 tail -q -n +2 >> coverage/coverage.txt + +# Remove any non-coverage lines (like file headers) +sed -i '/^[^[:space:]]*:/!d' coverage/coverage.txt + +# Generate coverage reports +if [ -s coverage/coverage.txt ]; then + go tool cover -func=coverage/coverage.txt + go tool cover -html=coverage/coverage.txt -o coverage/coverage.html + echo "Coverage report generated in coverage/coverage.html" +else + echo "No coverage data generated" +fi \ No newline at end of file diff --git a/go-b2b-starter/src/api/cmd/init.go b/go-b2b-starter/src/api/cmd/init.go new file mode 100644 index 0000000..2581c2f --- /dev/null +++ b/go-b2b-starter/src/api/cmd/init.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "go.uber.org/dig" + + api "github.com/moasq/go-b2b-starter/api" +) + +func Init(container *dig.Container) { + if err := api.Init(container); err != nil { + panic(err) + } +} \ No newline at end of file diff --git a/go-b2b-starter/src/api/example_cognitive/handler.go b/go-b2b-starter/src/api/example_cognitive/handler.go new file mode 100644 index 0000000..37aada5 --- /dev/null +++ b/go-b2b-starter/src/api/example_cognitive/handler.go @@ -0,0 +1,175 @@ +package cognitive + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/moasq/go-b2b-starter/app/example_cognitive/app/services" + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/common/errors" +) + +type Handler struct { + ragService services.RAGService + embeddingService services.EmbeddingService +} + +func NewHandler(ragService services.RAGService, embeddingService services.EmbeddingService) *Handler { + return &Handler{ + ragService: ragService, + embeddingService: embeddingService, + } +} + +// ChatRequest represents the JSON request body for chat +type ChatRequest struct { + SessionID int32 `json:"session_id,omitempty"` + Message string `json:"message" binding:"required"` + UseRAG bool `json:"use_rag,omitempty"` + MaxDocuments int `json:"max_documents,omitempty"` + ContextHistory int `json:"context_history,omitempty"` +} + +// Chat sends a message and gets a response +// @Summary Chat with AI +// @Description Sends a message to the AI and gets a response, optionally using RAG +// @Tags Cognitive +// @Accept json +// @Produce json +// @Param request body ChatRequest true "Chat request" +// @Success 200 {object} github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /example_cognitive/chat [post] +func (h *Handler) Chat(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + var req ChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_request", + "Invalid JSON format: "+err.Error(), + )) + return + } + + // Create domain request + chatReq := &domain.ChatRequest{ + SessionID: req.SessionID, + Message: req.Message, + UseRAG: req.UseRAG, + MaxDocuments: req.MaxDocuments, + ContextHistory: req.ContextHistory, + } + + response, err := h.ragService.Chat(c.Request.Context(), reqCtx.OrganizationID, reqCtx.AccountID, chatReq) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "chat_failed", + "Failed to process chat: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, response) +} + +// ListSessions lists chat sessions for the current user +// @Summary List chat sessions +// @Description Lists chat sessions for the current user with pagination +// @Tags Cognitive +// @Produce json +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} errors.HTTPError +// @Router /example_cognitive/sessions [get] +func (h *Handler) ListSessions(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Parse query parameters + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + sessions, err := h.ragService.ListSessions(c.Request.Context(), reqCtx.OrganizationID, reqCtx.AccountID, int32(limit), int32(offset)) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "list_failed", + "Failed to list sessions: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, gin.H{ + "sessions": sessions, + "limit": limit, + "offset": offset, + }) +} + +// GetSessionHistory retrieves messages for a session +// @Summary Get session history +// @Description Retrieves all messages for a chat session +// @Tags Cognitive +// @Produce json +// @Param id path int true "Session ID" +// @Success 200 {array} github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /example_cognitive/sessions/{id}/messages [get] +func (h *Handler) GetSessionHistory(c *gin.Context) { + idParam := c.Param("id") + var sessionID int32 + if _, err := fmt.Sscanf(idParam, "%d", &sessionID); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_id", + "Session ID must be a valid number", + )) + return + } + + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + messages, err := h.ragService.GetSessionHistory(c.Request.Context(), reqCtx.OrganizationID, sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "fetch_failed", + "Failed to fetch session history: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, messages) +} diff --git a/go-b2b-starter/src/api/example_cognitive/provider.go b/go-b2b-starter/src/api/example_cognitive/provider.go new file mode 100644 index 0000000..a761100 --- /dev/null +++ b/go-b2b-starter/src/api/example_cognitive/provider.go @@ -0,0 +1,27 @@ +package cognitive + +import ( + "go.uber.org/dig" +) + +type Provider struct { + container *dig.Container +} + +func NewProvider(container *dig.Container) *Provider { + return &Provider{container: container} +} + +func (p *Provider) RegisterDependencies() error { + // Register handler + if err := p.container.Provide(NewHandler); err != nil { + return err + } + + // Register routes + if err := p.container.Provide(NewRoutes); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/api/example_cognitive/routes.go b/go-b2b-starter/src/api/example_cognitive/routes.go new file mode 100644 index 0000000..73995bf --- /dev/null +++ b/go-b2b-starter/src/api/example_cognitive/routes.go @@ -0,0 +1,50 @@ +package cognitive + +import ( + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/auth" + serverDomain "github.com/moasq/go-b2b-starter/server/domain" +) + +type Routes struct { + handler *Handler +} + +func NewRoutes(handler *Handler) *Routes { + return &Routes{ + handler: handler, + } +} + +func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + cognitiveGroup := router.Group("/example_cognitive") + cognitiveGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + resolver.Get("subscription"), + ) + { + // Chat endpoint + cognitiveGroup.POST("/chat", + auth.RequirePermissionFunc("resource", "create"), + r.handler.Chat) + + // Chat sessions + sessionsGroup := cognitiveGroup.Group("/sessions") + { + sessionsGroup.GET("", + auth.RequirePermissionFunc("resource", "view"), + r.handler.ListSessions) + + sessionsGroup.GET("/:id/messages", + auth.RequirePermissionFunc("resource", "view"), + r.handler.GetSessionHistory) + } + } +} + +// Routes returns a RouteRegistrar function compatible with the server interface +func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + r.RegisterRoutes(router, resolver) +} diff --git a/go-b2b-starter/src/api/example_documents/handler.go b/go-b2b-starter/src/api/example_documents/handler.go new file mode 100644 index 0000000..155aab0 --- /dev/null +++ b/go-b2b-starter/src/api/example_documents/handler.go @@ -0,0 +1,173 @@ +package documents + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/moasq/go-b2b-starter/app/example_documents/app/services" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/common/errors" +) + +type Handler struct { + service services.DocumentService +} + +func NewHandler(service services.DocumentService) *Handler { + return &Handler{service: service} +} + +// UploadDocument uploads a new PDF document +// @Summary Upload PDF document +// @Description Uploads a PDF document, extracts text, and creates embeddings +// @Tags Documents +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "PDF file to upload" +// @Param title formData string true "Document title" +// @Success 201 {object} github_com_moasq_go-b2b-starter_app_example_documents_domain.Document +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /example_documents/upload [post] +func (h *Handler) UploadDocument(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Get uploaded file + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_file", + "Failed to read file: "+err.Error(), + )) + return + } + defer file.Close() + + // Get title from form + title := c.PostForm("title") + if title == "" { + title = header.Filename + } + + // Create upload request + req := &services.UploadDocumentRequest{ + Title: title, + FileName: header.Filename, + ContentType: header.Header.Get("Content-Type"), + FileSize: header.Size, + } + + // Upload document + document, err := h.service.UploadDocument(c.Request.Context(), reqCtx.OrganizationID, req, file) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "upload_failed", + "Failed to upload document: "+err.Error(), + )) + return + } + + c.JSON(http.StatusCreated, document) +} + +// ListDocuments lists documents with pagination +// @Summary List documents +// @Description Lists documents with optional filtering and pagination +// @Tags Documents +// @Produce json +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Param status query string false "Filter by status (pending, processing, processed, failed)" +// @Success 200 {object} github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse +// @Failure 500 {object} errors.HTTPError +// @Router /example_documents [get] +func (h *Handler) ListDocuments(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Parse query parameters + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + req := &services.ListDocumentsRequest{ + Limit: int32(limit), + Offset: int32(offset), + } + + // Optional status filter + // Note: Status filtering would need to be added if needed + + response, err := h.service.ListDocuments(c.Request.Context(), reqCtx.OrganizationID, req) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "list_failed", + "Failed to list documents: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, response) +} + +// DeleteDocument deletes a document +// @Summary Delete document +// @Description Deletes a document and its associated file +// @Tags Documents +// @Param id path int true "Document ID" +// @Success 204 +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /example_documents/{id} [delete] +func (h *Handler) DeleteDocument(c *gin.Context) { + idParam := c.Param("id") + var docID int32 + if _, err := fmt.Sscanf(idParam, "%d", &docID); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_id", + "Document ID must be a valid number", + )) + return + } + + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + if err := h.service.DeleteDocument(c.Request.Context(), reqCtx.OrganizationID, docID); err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "delete_failed", + "Failed to delete document: "+err.Error(), + )) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/go-b2b-starter/src/api/example_documents/provider.go b/go-b2b-starter/src/api/example_documents/provider.go new file mode 100644 index 0000000..6e3c526 --- /dev/null +++ b/go-b2b-starter/src/api/example_documents/provider.go @@ -0,0 +1,27 @@ +package documents + +import ( + "go.uber.org/dig" +) + +type Provider struct { + container *dig.Container +} + +func NewProvider(container *dig.Container) *Provider { + return &Provider{container: container} +} + +func (p *Provider) RegisterDependencies() error { + // Register handler + if err := p.container.Provide(NewHandler); err != nil { + return err + } + + // Register routes + if err := p.container.Provide(NewRoutes); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/api/example_documents/routes.go b/go-b2b-starter/src/api/example_documents/routes.go new file mode 100644 index 0000000..ec70411 --- /dev/null +++ b/go-b2b-starter/src/api/example_documents/routes.go @@ -0,0 +1,48 @@ +package documents + +import ( + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/auth" + serverDomain "github.com/moasq/go-b2b-starter/server/domain" +) + +type Routes struct { + handler *Handler +} + +func NewRoutes(handler *Handler) *Routes { + return &Routes{ + handler: handler, + } +} + +func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + docsGroup := router.Group("/example_documents") + docsGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + resolver.Get("subscription"), + ) + { + // Upload document + docsGroup.POST("/upload", + auth.RequirePermissionFunc("resource", "create"), + r.handler.UploadDocument) + + // List documents + docsGroup.GET("", + auth.RequirePermissionFunc("resource", "view"), + r.handler.ListDocuments) + + // Delete document + docsGroup.DELETE("/:id", + auth.RequirePermissionFunc("resource", "delete"), + r.handler.DeleteDocument) + } +} + +// Routes returns a RouteRegistrar function compatible with the server interface +func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + r.RegisterRoutes(router, resolver) +} diff --git a/go-b2b-starter/src/api/example_resource/go.mod b/go-b2b-starter/src/api/example_resource/go.mod new file mode 100644 index 0000000..7b4dd06 --- /dev/null +++ b/go-b2b-starter/src/api/example_resource/go.mod @@ -0,0 +1,20 @@ +module github.com/moasq/go-b2b-starter/api/example_resource + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/moasq/go-b2b-starter/app/example_resource v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/auth v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/common v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/server v0.0.0-00010101000000-000000000000 + go.uber.org/dig v1.18.0 +) + +replace github.com/moasq/go-b2b-starter/app/example_resource => ../../app/example_resource + +replace github.com/moasq/go-b2b-starter/pkg/auth => ../../pkg/auth + +replace github.com/moasq/go-b2b-starter/pkg/common => ../../pkg/common + +replace github.com/moasq/go-b2b-starter/server => ../../server diff --git a/go-b2b-starter/src/api/example_resource/handler.go b/go-b2b-starter/src/api/example_resource/handler.go new file mode 100644 index 0000000..b926f47 --- /dev/null +++ b/go-b2b-starter/src/api/example_resource/handler.go @@ -0,0 +1,394 @@ +package example_resource + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/moasq/go-b2b-starter/app/example_resource/app/services" + "github.com/moasq/go-b2b-starter/app/example_resource/domain" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/common/errors" +) + +type Handler struct { + service services.ResourceService +} + +func NewHandler(service services.ResourceService) *Handler { + return &Handler{service: service} +} + +// UploadAndProcessResource uploads a file and processes it with OCR/LLM +// @Summary Upload and process resource file +// @Description Uploads a file, performs OCR, LLM processing, and stores the resource +// @Tags Resources +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "File to upload" +// @Param metadata formData string false "JSON metadata object" +// @Success 201 {object} domain.Resource +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /resources/upload-and-process [post] +func (h *Handler) UploadAndProcessResource(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Get uploaded file + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_file", + "Failed to read file: "+err.Error(), + )) + return + } + defer file.Close() + + // Optional metadata (could parse JSON from form field if needed) + var metadata map[string]any + + // Call service with individual parameters (no DTO) + resource, err := h.service.UploadAndProcessResource( + c.Request.Context(), + reqCtx.OrganizationID, + reqCtx.AccountID, + header.Filename, + header.Size, + header.Header.Get("Content-Type"), + file, + metadata, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "upload_failed", + "Failed to upload and process resource: "+err.Error(), + )) + return + } + + // Return domain entity directly + c.JSON(http.StatusCreated, resource) +} + +// CreateResource creates a new resource (without file processing) +// @Summary Create a new resource +// @Description Creates a new resource without file upload +// @Tags Resources +// @Accept json +// @Produce json +// @Param resource body domain.Resource true "Resource to create" +// @Success 201 {object} domain.Resource +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /resources [post] +func (h *Handler) CreateResource(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Bind JSON directly to domain entity (no DTO) + var resource domain.Resource + if err := c.ShouldBindJSON(&resource); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_request", + "Invalid JSON format: "+err.Error(), + )) + return + } + + // Set organization and account from context + resource.OrganizationID = &reqCtx.OrganizationID + resource.CreatedByAccountID = &reqCtx.AccountID + + // Validate domain entity + if err := resource.Validate(); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "validation_failed", + err.Error(), + )) + return + } + + created, err := h.service.CreateResource(c.Request.Context(), &resource) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "create_failed", + "Failed to create resource: "+err.Error(), + )) + return + } + + c.JSON(http.StatusCreated, created) +} + +// GetResourceByID retrieves a resource by ID +// @Summary Get resource by ID +// @Description Retrieves a resource by its ID +// @Tags Resources +// @Produce json +// @Param id path int true "Resource ID" +// @Success 200 {object} domain.Resource +// @Failure 400 {object} errors.HTTPError +// @Failure 404 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /resources/{id} [get] +func (h *Handler) GetResourceByID(c *gin.Context) { + idParam := c.Param("id") + var resourceID int32 + if _, err := fmt.Sscanf(idParam, "%d", &resourceID); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_id", + "Resource ID must be a valid number", + )) + return + } + + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + resource, err := h.service.GetResourceByID(c.Request.Context(), resourceID, reqCtx.OrganizationID) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "fetch_failed", + "Failed to fetch resource: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, resource) +} + +// ListResources lists resources with pagination and filtering +// @Summary List resources +// @Description Lists resources with optional filtering and pagination +// @Tags Resources +// @Produce json +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Param status_id query int false "Filter by status ID" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} errors.HTTPError +// @Router /resources [get] +func (h *Handler) ListResources(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Parse query parameters + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + var statusID *int16 + if statusStr := c.Query("status_id"); statusStr != "" { + status, _ := strconv.Atoi(statusStr) + statusVal := int16(status) + statusID = &statusVal + } + + resources, total, err := h.service.ListResources( + c.Request.Context(), + reqCtx.OrganizationID, + int32(limit), + int32(offset), + statusID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "list_failed", + "Failed to list resources: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, gin.H{ + "resources": resources, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// UpdateResource updates an existing resource +// @Summary Update resource +// @Description Updates an existing resource +// @Tags Resources +// @Accept json +// @Produce json +// @Param id path int true "Resource ID" +// @Param resource body domain.Resource true "Resource updates" +// @Success 200 {object} domain.Resource +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /resources/{id} [put] +func (h *Handler) UpdateResource(c *gin.Context) { + idParam := c.Param("id") + var resourceID int32 + if _, err := fmt.Sscanf(idParam, "%d", &resourceID); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_id", + "Resource ID must be a valid number", + )) + return + } + + var resource domain.Resource + if err := c.ShouldBindJSON(&resource); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_request", + "Invalid JSON format: "+err.Error(), + )) + return + } + + // Set ID from path + resource.ID = &resourceID + + // Validate + if err := resource.Validate(); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "validation_failed", + err.Error(), + )) + return + } + + updated, err := h.service.UpdateResource(c.Request.Context(), &resource) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "update_failed", + "Failed to update resource: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, updated) +} + +// DeleteResource soft-deletes a resource +// @Summary Delete resource +// @Description Soft-deletes a resource +// @Tags Resources +// @Param id path int true "Resource ID" +// @Success 204 +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /resources/{id} [delete] +func (h *Handler) DeleteResource(c *gin.Context) { + idParam := c.Param("id") + var resourceID int32 + if _, err := fmt.Sscanf(idParam, "%d", &resourceID); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_id", + "Resource ID must be a valid number", + )) + return + } + + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + if err := h.service.DeleteResource(c.Request.Context(), resourceID, reqCtx.OrganizationID); err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "delete_failed", + "Failed to delete resource: "+err.Error(), + )) + return + } + + c.Status(http.StatusNoContent) +} + +// GetResourceDuplicates retrieves duplicate candidates for a resource +// @Summary Get duplicate candidates +// @Description Retrieves duplicate candidates detected for a resource +// @Tags Resources +// @Produce json +// @Param id path int true "Resource ID" +// @Success 200 {array} domain.DuplicateCandidate +// @Failure 400 {object} errors.HTTPError +// @Failure 500 {object} errors.HTTPError +// @Router /resources/{id}/duplicates [get] +func (h *Handler) GetResourceDuplicates(c *gin.Context) { + idParam := c.Param("id") + var resourceID int32 + if _, err := fmt.Sscanf(idParam, "%d", &resourceID); err != nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_id", + "Resource ID must be a valid number", + )) + return + } + + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + duplicates, err := h.service.GetResourceDuplicates(c.Request.Context(), resourceID, reqCtx.OrganizationID) + if err != nil { + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "fetch_failed", + "Failed to fetch duplicates: "+err.Error(), + )) + return + } + + c.JSON(http.StatusOK, duplicates) +} diff --git a/go-b2b-starter/src/api/example_resource/provider.go b/go-b2b-starter/src/api/example_resource/provider.go new file mode 100644 index 0000000..69b3eb3 --- /dev/null +++ b/go-b2b-starter/src/api/example_resource/provider.go @@ -0,0 +1,27 @@ +package example_resource + +import ( + "go.uber.org/dig" +) + +type Provider struct { + container *dig.Container +} + +func NewProvider(container *dig.Container) *Provider { + return &Provider{container: container} +} + +func (p *Provider) RegisterDependencies() error { + // Register handler + if err := p.container.Provide(NewHandler); err != nil { + return err + } + + // Register routes + if err := p.container.Provide(NewRoutes); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/api/example_resource/routes.go b/go-b2b-starter/src/api/example_resource/routes.go new file mode 100644 index 0000000..6e0a90a --- /dev/null +++ b/go-b2b-starter/src/api/example_resource/routes.go @@ -0,0 +1,63 @@ +package example_resource + +import ( + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/auth" + serverDomain "github.com/moasq/go-b2b-starter/server/domain" +) + +type Routes struct { + handler *Handler +} + +func NewRoutes(handler *Handler) *Routes { + return &Routes{ + handler: handler, + } +} + +func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + resourceGroup := router.Group("/resources") + resourceGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + ) + { + // Upload and process with file + resourceGroup.POST("/upload-and-process", + auth.RequirePermissionFunc("resource", "create"), + r.handler.UploadAndProcessResource) + + // CRUD operations + resourceGroup.POST("", + auth.RequirePermissionFunc("resource", "create"), + r.handler.CreateResource) + + resourceGroup.GET("/:id", + auth.RequirePermissionFunc("resource", "view"), + r.handler.GetResourceByID) + + resourceGroup.GET("", + auth.RequirePermissionFunc("resource", "view"), + r.handler.ListResources) + + resourceGroup.PUT("/:id", + auth.RequirePermissionFunc("resource", "edit"), + r.handler.UpdateResource) + + resourceGroup.DELETE("/:id", + auth.RequirePermissionFunc("resource", "delete"), + r.handler.DeleteResource) + + // Duplicate detection + resourceGroup.GET("/:id/duplicates", + auth.RequirePermissionFunc("resource", "view"), + r.handler.GetResourceDuplicates) + } +} + +// Routes returns a RouteRegistrar function compatible with the server interface +func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + r.RegisterRoutes(router, resolver) +} diff --git a/go-b2b-starter/src/api/go.mod b/go-b2b-starter/src/api/go.mod new file mode 100644 index 0000000..a681bed --- /dev/null +++ b/go-b2b-starter/src/api/go.mod @@ -0,0 +1,115 @@ +module github.com/moasq/go-b2b-starter/api + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.10.1 + github.com/moasq/go-b2b-starter/app/billing v0.0.0 + github.com/moasq/go-b2b-starter/app/example_cognitive v0.0.0 + github.com/moasq/go-b2b-starter/app/example_documents v0.0.0 + github.com/moasq/go-b2b-starter/app/organizations v0.0.0 + github.com/moasq/go-b2b-starter/pkg/api v0.0.0 + github.com/moasq/go-b2b-starter/pkg/auth v0.0.0 + github.com/moasq/go-b2b-starter/pkg/common v0.0.0 + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + github.com/moasq/go-b2b-starter/server v0.0.0-00010101000000-000000000000 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/cors v1.7.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moasq/go-b2b-starter/pkg/db v0.0.0 // indirect + github.com/moasq/go-b2b-starter/pkg/eventbus v0.0.0-00010101000000-000000000000 // indirect + github.com/moasq/go-b2b-starter/pkg/file_manager v0.0.0-00010101000000-000000000000 // indirect + github.com/moasq/go-b2b-starter/pkg/polar v0.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pgvector/pgvector-go v0.3.0 // indirect + github.com/rs/zerolog v1.33.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.8.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/app/example_cognitive => ../app/example_cognitive + +replace github.com/moasq/go-b2b-starter/app/example_documents => ../app/example_documents + +replace github.com/moasq/go-b2b-starter/app/organizations => ../app/organizations + +replace github.com/moasq/go-b2b-starter/pkg/api => ../pkg/api + +replace github.com/moasq/go-b2b-starter/pkg/common => ../pkg/common + +replace github.com/moasq/go-b2b-starter/pkg/db => ../pkg/db + +replace github.com/moasq/go-b2b-starter/pkg/eventbus => ../pkg/eventbus + +replace github.com/moasq/go-b2b-starter/pkg/file_manager => ../pkg/file_manager + +replace github.com/moasq/go-b2b-starter/pkg/llm => ../pkg/llm + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../pkg/logger + +replace github.com/moasq/go-b2b-starter/pkg/stytch => ../pkg/stytch + +replace github.com/moasq/go-b2b-starter/server => ../pkg/server + +replace github.com/moasq/go-b2b-starter/app/billing => ../app/billing + +replace github.com/moasq/go-b2b-starter/pkg/auth => ../pkg/auth + +replace github.com/moasq/go-b2b-starter/pkg/paywall => ../pkg/paywall + +replace github.com/moasq/go-b2b-starter/pkg/polar => ../pkg/polar + +replace github.com/moasq/go-b2b-starter/pkg/redis => ../pkg/redis diff --git a/go-b2b-starter/src/api/go.sum b/go-b2b-starter/src/api/go.sum new file mode 100644 index 0000000..8c45666 --- /dev/null +++ b/go-b2b-starter/src/api/go.sum @@ -0,0 +1,183 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic v1.12.5/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/api/organizations/account_handler.go b/go-b2b-starter/src/api/organizations/account_handler.go new file mode 100644 index 0000000..d93e385 --- /dev/null +++ b/go-b2b-starter/src/api/organizations/account_handler.go @@ -0,0 +1,302 @@ +package organizations + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/app/organizations/app/services" + "github.com/moasq/go-b2b-starter/app/organizations/domain" + "github.com/moasq/go-b2b-starter/pkg/api/response" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +type AccountHandler struct { + orgService services.OrganizationService + logger logger.Logger +} + +func NewAccountHandler(orgService services.OrganizationService, logger logger.Logger) *AccountHandler { + return &AccountHandler{ + orgService: orgService, + logger: logger, + } +} + +// CreateAccount creates a new account in an organization +func (h *AccountHandler) CreateAccount(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + var req services.CreateAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid request payload", err) + return + } + + domainReq := &req + account, err := h.orgService.CreateAccount(c.Request.Context(), reqCtx.OrganizationID, domainReq) + if err != nil { + if err == domain.ErrOrganizationNotFound { + response.Error(c, http.StatusNotFound, "organization not found", err) + return + } + h.logger.Error("failed to create account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to create account", err) + return + } + + response.Success(c, http.StatusCreated, account) +} + +// GetAccount gets an account by ID +func (h *AccountHandler) GetAccount(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + // Extract account_id from path parameter + accountIDParam := c.Param("id") + var accountID int32 + if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { + h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid account ID format", err) + return + } + + account, err := h.orgService.GetAccount(c.Request.Context(), reqCtx.OrganizationID, accountID) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to get account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to get account", err) + return + } + + response.Success(c, http.StatusOK, account) +} + +// GetAccountByEmail gets an account by email +func (h *AccountHandler) GetAccountByEmail(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + email := c.Query("email") + if email == "" { + response.Error(c, http.StatusBadRequest, "email query parameter is required", nil) + return + } + + account, err := h.orgService.GetAccountByEmail(c.Request.Context(), reqCtx.OrganizationID, email) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to get account by email", map[string]interface{}{"org_id": reqCtx.OrganizationID, "email": email, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to get account", err) + return + } + + response.Success(c, http.StatusOK, account) +} + +// ListAccounts lists all accounts in an organization +func (h *AccountHandler) ListAccounts(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + accounts, err := h.orgService.ListAccounts(c.Request.Context(), reqCtx.OrganizationID) + if err != nil { + if err == domain.ErrOrganizationNotFound { + response.Error(c, http.StatusNotFound, "organization not found", err) + return + } + h.logger.Error("failed to list accounts", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to list accounts", err) + return + } + + response.Success(c, http.StatusOK, accounts) +} + +// UpdateAccount updates an account +func (h *AccountHandler) UpdateAccount(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + // Extract account_id from path parameter + accountIDParam := c.Param("id") + var accountID int32 + if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { + h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid account ID format", err) + return + } + + var req services.UpdateAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid request payload", err) + return + } + + domainReq := &req + account, err := h.orgService.UpdateAccount(c.Request.Context(), reqCtx.OrganizationID, accountID, domainReq) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to update account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to update account", err) + return + } + + response.Success(c, http.StatusOK, account) +} + +// DeleteAccount deletes an account +func (h *AccountHandler) DeleteAccount(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + // Extract account_id from path parameter + accountIDParam := c.Param("id") + var accountID int32 + if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { + h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid account ID format", err) + return + } + + err := h.orgService.DeleteAccount(c.Request.Context(), reqCtx.OrganizationID, accountID) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to delete account", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to delete account", err) + return + } + + response.Success(c, http.StatusNoContent, nil) +} + +// UpdateAccountLastLogin updates account last login timestamp +func (h *AccountHandler) UpdateAccountLastLogin(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + // Extract account_id from path parameter + accountIDParam := c.Param("id") + var accountID int32 + if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { + h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid account ID format", err) + return + } + + account, err := h.orgService.UpdateAccountLastLogin(c.Request.Context(), reqCtx.OrganizationID, accountID) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to update account last login", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to update account last login", err) + return + } + + response.Success(c, http.StatusOK, account) +} + +// CheckAccountPermission checks account permissions +func (h *AccountHandler) CheckAccountPermission(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + // Extract account_id from path parameter + accountIDParam := c.Param("id") + var accountID int32 + if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { + h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid account ID format", err) + return + } + + permission, err := h.orgService.CheckAccountPermission(c.Request.Context(), reqCtx.OrganizationID, accountID) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to check account permission", map[string]interface{}{"org_id": reqCtx.OrganizationID, "account_id": accountID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to check account permission", err) + return + } + + response.Success(c, http.StatusOK, permission) +} + +// GetAccountStats gets account statistics +func (h *AccountHandler) GetAccountStats(c *gin.Context) { + // Extract account_id from path parameter + accountIDParam := c.Param("id") + var accountID int32 + if _, err := fmt.Sscanf(accountIDParam, "%d", &accountID); err != nil { + h.logger.Error("invalid account ID", map[string]interface{}{"id": accountIDParam, "error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid account ID format", err) + return + } + + stats, err := h.orgService.GetAccountStats(c.Request.Context(), accountID) + if err != nil { + if err == domain.ErrAccountNotFound { + response.Error(c, http.StatusNotFound, "account not found", err) + return + } + h.logger.Error("failed to get account stats", map[string]interface{}{"account_id": accountID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to get account stats", err) + return + } + + response.Success(c, http.StatusOK, stats) +} diff --git a/go-b2b-starter/src/api/organizations/member_handler.go b/go-b2b-starter/src/api/organizations/member_handler.go new file mode 100644 index 0000000..2118867 --- /dev/null +++ b/go-b2b-starter/src/api/organizations/member_handler.go @@ -0,0 +1,340 @@ +package organizations + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/app/organizations/app/services" + "github.com/moasq/go-b2b-starter/pkg/api/response" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +type MemberHandler struct { + memberService services.MemberService + logger logger.Logger +} + +func NewMemberHandler( + memberService services.MemberService, + logger logger.Logger, +) *MemberHandler { + return &MemberHandler{ + memberService: memberService, + logger: logger, + } +} + +// BootstrapOrganization creates a new organization with an admin member. +// @Summary Bootstrap organization +// @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. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest true "Organization bootstrap request (passwordless - no password required)" +// @Success 201 {object} github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse +// @Failure 400 {object} map[string]any "Invalid request payload" +// @Failure 500 {object} map[string]any "Failed to bootstrap organization" +// @Router /auth/signup [post] +func (h *MemberHandler) BootstrapOrganization(c *gin.Context) { + var req services.BootstrapOrganizationRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("invalid bootstrap request payload", map[string]any{ + "error": err.Error(), + }) + response.Error(c, http.StatusBadRequest, "invalid request payload", err) + return + } + + // Infrastructure layer handles slug generation and duplicate handling + result, err := h.memberService.BootstrapOrganizationWithOwner(c.Request.Context(), &req) + if err != nil { + h.logger.Error("failed to bootstrap organization", map[string]any{ + "org_name": req.OrgDisplayName, + "error": err.Error(), + }) + response.Error(c, http.StatusInternalServerError, "failed to bootstrap organization", err) + return + } + + h.logger.Info("organization bootstrapped successfully", map[string]any{ + "stytch_org_id": result.OrganizationID, + "admin_member": result.OwnerMemberID, + "magic_link": result.MagicLinkSent, + }) + + response.Success(c, http.StatusCreated, result) +} + +// AddMember adds a new member to an existing organization. +// @Summary Add member to organization +// @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"} +// @Tags auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer JWT token" +// @Param email body string true "Member email address" +// @Param name body string true "Member full name" +// @Param role_slug body string false "Role slug (defaults to 'member')" +// @Success 201 {object} github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse +// @Failure 400 {object} map[string]any "Invalid request payload or missing organization context" +// @Failure 500 {object} map[string]any "Failed to add member" +// @Router /auth/members [post] +func (h *MemberHandler) AddMember(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("request context not found", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + var req services.AddMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("invalid add member request payload", map[string]any{ + "error": err.Error(), + }) + response.Error(c, http.StatusBadRequest, "invalid request payload", err) + return + } + + req.OrgID = reqCtx.ProviderOrgID + if strings.TrimSpace(req.RoleSlug) == "" { + req.RoleSlug = "member" + } + + result, err := h.memberService.AddMemberDirect(c.Request.Context(), &req) + if err != nil { + h.logger.Error("failed to add member", map[string]any{ + "org_id": reqCtx.ProviderOrgID, + "email": req.Email, + "error": err.Error(), + }) + response.Error(c, http.StatusInternalServerError, "failed to add member", err) + return + } + + h.logger.Info("member added to organization", map[string]any{ + "org_id": result.OrgID, + "member_id": result.MemberID, + "invite_sent": result.InviteSent, + }) + + response.Success(c, http.StatusCreated, result) +} + +// ListMembers retrieves all members of the current organization. +// @Summary List organization members +// @Description Retrieves all members of the current organization. Restricted to admin role only. +// @Tags auth +// @Accept json +// @Produce json +// @Success 200 {object} github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse +// @Failure 400 {object} map[string]any "Missing organization context" +// @Failure 403 {object} map[string]any "Insufficient permissions - admin role required" +// @Failure 500 {object} map[string]any "Failed to list members" +// @Router /auth/members [get] +func (h *MemberHandler) ListMembers(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("request context not found", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + result, err := h.memberService.ListOrganizationMembers(c.Request.Context(), reqCtx.ProviderOrgID) + if err != nil { + h.logger.Error("failed to list members", map[string]any{ + "org_id": reqCtx.ProviderOrgID, + "error": err.Error(), + }) + response.Error(c, http.StatusInternalServerError, "failed to list members", err) + return + } + + h.logger.Info("members listed successfully", map[string]any{ + "org_id": reqCtx.ProviderOrgID, + "count": result.Total, + }) + + response.Success(c, http.StatusOK, result) +} + +// GetProfile retrieves the current authenticated user's profile. +// @Summary Get current user profile +// @Description Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status. +// @Tags auth +// @Accept json +// @Produce json +// @Success 200 {object} github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse +// @Failure 400 {object} map[string]any "Missing required context (organization or claims)" +// @Failure 401 {object} map[string]any "Authentication required" +// @Failure 500 {object} map[string]any "Failed to retrieve profile" +// @Router /auth/profile/me [get] +func (h *MemberHandler) GetProfile(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("request context not found", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + identity := reqCtx.Identity + if identity == nil { + h.logger.Error("identity not found in context", nil) + response.Error(c, http.StatusUnauthorized, "authentication required", nil) + return + } + + // Get profile using service + profile, err := h.memberService.GetCurrentUserProfile( + c.Request.Context(), + reqCtx.ProviderOrgID, + identity.UserID, // member_id + identity.Email, + ) + if err != nil { + h.logger.Error("failed to get user profile", map[string]any{ + "org_id": reqCtx.ProviderOrgID, + "member_id": identity.UserID, + "email": identity.Email, + "error": err.Error(), + }) + response.Error(c, http.StatusInternalServerError, "failed to retrieve profile", err) + return + } + + // Add computed permissions from identity (derived from Stytch RBAC policy) + profile.Permissions = auth.PermissionsToStrings(identity.Permissions) + + h.logger.Info("profile retrieved successfully", map[string]any{ + "member_id": identity.UserID, + "org_id": reqCtx.ProviderOrgID, + "email": identity.Email, + "permissions_count": len(profile.Permissions), + }) + + response.Success(c, http.StatusOK, profile) +} + +// DeleteMember deletes an organization member. +// @Summary Delete organization member +// @Description Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members. +// @Tags auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer JWT token" +// @Param member_id path string true "Member ID to delete" +// @Success 204 {object} map[string]any "Member deleted successfully" +// @Failure 400 {object} map[string]any "Invalid member ID or missing organization context" +// @Failure 403 {object} map[string]any "Insufficient permissions - admin role required" +// @Failure 404 {object} map[string]any "Member not found" +// @Failure 500 {object} map[string]any "Failed to delete member" +// @Router /auth/members/{member_id} [delete] +func (h *MemberHandler) DeleteMember(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("request context not found", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + identity := reqCtx.Identity + if identity == nil { + h.logger.Error("identity not found in context", nil) + response.Error(c, http.StatusUnauthorized, "authentication required", nil) + return + } + + // Extract member_id from path parameter + memberID := c.Param("member_id") + if memberID == "" { + h.logger.Error("member_id path parameter is missing", nil) + response.Error(c, http.StatusBadRequest, "member_id is required", nil) + return + } + + // Business rule: Cannot delete yourself + if memberID == identity.UserID { + h.logger.Warn("user attempted to delete themselves", map[string]any{ + "member_id": memberID, + "current_user": identity.UserID, + "org_id": reqCtx.ProviderOrgID, + }) + response.Error(c, http.StatusForbidden, "cannot delete yourself", nil) + return + } + + // Delete member using service + err := h.memberService.DeleteOrganizationMember(c.Request.Context(), reqCtx.ProviderOrgID, memberID) + if err != nil { + h.logger.Error("failed to delete member", map[string]any{ + "org_id": reqCtx.ProviderOrgID, + "member_id": memberID, + "error": err.Error(), + }) + response.Error(c, http.StatusInternalServerError, "failed to delete member", err) + return + } + + h.logger.Info("member deleted successfully", map[string]any{ + "member_id": memberID, + "org_id": reqCtx.ProviderOrgID, + "deleted_by": identity.UserID, + }) + + response.Success(c, http.StatusNoContent, nil) +} + +// CheckEmail checks if an email exists in the system +// @Summary Check if email exists +// @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. +// @Tags auth +// @Accept json +// @Produce json +// @Param email query string true "Email address to check" +// @Success 200 "Email exists" +// @Failure 400 {object} map[string]any "Invalid email format" +// @Failure 404 {object} map[string]any "Email not found" +// @Failure 500 {object} map[string]any "Internal server error" +// @Router /auth/check-email [get] +func (h *MemberHandler) CheckEmail(c *gin.Context) { + // Extract and validate email from query parameter + email := strings.TrimSpace(c.Query("email")) + if email == "" { + h.logger.Warn("email parameter is missing", nil) + response.Error(c, http.StatusBadRequest, "email parameter is required", nil) + return + } + + h.logger.Debug("checking email existence", map[string]any{ + "email": email, + }) + + // Check if email exists using service + exists, err := h.memberService.CheckEmailExists(c.Request.Context(), email) + if err != nil { + h.logger.Error("failed to check email existence", map[string]any{ + "email": email, + "error": err.Error(), + }) + response.Error(c, http.StatusInternalServerError, "failed to check email existence", err) + return + } + + // Return 404 if email doesn't exist + if !exists { + h.logger.Debug("email not found", map[string]any{ + "email": email, + }) + response.Error(c, http.StatusNotFound, "email not found", nil) + return + } + + // Return 200 OK with empty response if email exists + h.logger.Debug("email exists", map[string]any{ + "email": email, + }) + response.Success(c, http.StatusOK, gin.H{}) +} diff --git a/go-b2b-starter/src/api/organizations/organization_handler.go b/go-b2b-starter/src/api/organizations/organization_handler.go new file mode 100644 index 0000000..2792b0b --- /dev/null +++ b/go-b2b-starter/src/api/organizations/organization_handler.go @@ -0,0 +1,166 @@ +package organizations + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/app/organizations/app/services" + "github.com/moasq/go-b2b-starter/app/organizations/domain" + "github.com/moasq/go-b2b-starter/pkg/api/response" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +type OrganizationHandler struct { + orgService services.OrganizationService + logger logger.Logger +} + +func NewOrganizationHandler(orgService services.OrganizationService, logger logger.Logger) *OrganizationHandler { + return &OrganizationHandler{ + orgService: orgService, + logger: logger, + } +} + +// CreateOrganization creates a new organization +func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { + var req services.CreateOrganizationRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid request payload", err) + return + } + + org, err := h.orgService.CreateOrganization(c.Request.Context(), &req) + if err != nil { + h.logger.Error("failed to create organization", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to create organization", err) + return + } + + response.Success(c, http.StatusCreated, org) +} + +// GetOrganization gets the current organization (from context) +func (h *OrganizationHandler) GetOrganization(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + org, err := h.orgService.GetOrganization(c.Request.Context(), reqCtx.OrganizationID) + if err != nil { + if err == domain.ErrOrganizationNotFound { + response.Error(c, http.StatusNotFound, "organization not found", err) + return + } + h.logger.Error("failed to get organization", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to get organization", err) + return + } + + response.Success(c, http.StatusOK, org) +} + +// GetOrganizationBySlug gets an organization by slug +func (h *OrganizationHandler) GetOrganizationBySlug(c *gin.Context) { + slug := c.Param("slug") + if slug == "" { + response.Error(c, http.StatusBadRequest, "slug is required", nil) + return + } + + org, err := h.orgService.GetOrganizationBySlug(c.Request.Context(), slug) + if err != nil { + if err == domain.ErrOrganizationNotFound { + response.Error(c, http.StatusNotFound, "organization not found", err) + return + } + h.logger.Error("failed to get organization by slug", map[string]interface{}{"slug": slug, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to get organization", err) + return + } + + response.Success(c, http.StatusOK, org) +} + +// UpdateOrganization updates the current organization (from context) +func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + var req services.UpdateOrganizationRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("invalid request payload", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid request payload", err) + return + } + + org, err := h.orgService.UpdateOrganization(c.Request.Context(), reqCtx.OrganizationID, &req) + if err != nil { + if err == domain.ErrOrganizationNotFound { + response.Error(c, http.StatusNotFound, "organization not found", err) + return + } + h.logger.Error("failed to update organization", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to update organization", err) + return + } + + response.Success(c, http.StatusOK, org) +} + +// ListOrganizations lists organizations with pagination +func (h *OrganizationHandler) ListOrganizations(c *gin.Context) { + var req services.ListOrganizationsRequest + if err := c.ShouldBindQuery(&req); err != nil { + h.logger.Error("invalid query parameters", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusBadRequest, "invalid query parameters", err) + return + } + + // Set defaults + if req.Limit == 0 { + req.Limit = 10 + } + + orgResponse, err := h.orgService.ListOrganizations(c.Request.Context(), &req) + if err != nil { + h.logger.Error("failed to list organizations", map[string]interface{}{"error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to list organizations", err) + return + } + + response.Success(c, http.StatusOK, orgResponse) +} + +// GetOrganizationStats gets statistics for the current organization (from context) +func (h *OrganizationHandler) GetOrganizationStats(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + h.logger.Error("missing request context", nil) + response.Error(c, http.StatusBadRequest, "organization context is required", nil) + return + } + + stats, err := h.orgService.GetOrganizationStats(c.Request.Context(), reqCtx.OrganizationID) + if err != nil { + if err == domain.ErrOrganizationNotFound { + response.Error(c, http.StatusNotFound, "organization not found", err) + return + } + h.logger.Error("failed to get organization stats", map[string]interface{}{"org_id": reqCtx.OrganizationID, "error": err.Error()}) + response.Error(c, http.StatusInternalServerError, "failed to get organization stats", err) + return + } + + response.Success(c, http.StatusOK, stats) +} diff --git a/go-b2b-starter/src/api/organizations/provider.go b/go-b2b-starter/src/api/organizations/provider.go new file mode 100644 index 0000000..ab52e6d --- /dev/null +++ b/go-b2b-starter/src/api/organizations/provider.go @@ -0,0 +1,65 @@ +package organizations + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/organizations/app/services" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +// Provider provides organization API dependencies +type Provider struct { + container *dig.Container +} + +// NewProvider creates a new organization API provider +func NewProvider(container *dig.Container) *Provider { + return &Provider{ + container: container, + } +} + +// RegisterDependencies registers organization API dependencies +func (p *Provider) RegisterDependencies() error { + // Register handlers + if err := p.container.Provide(func( + orgService services.OrganizationService, + logger logger.Logger, + ) *OrganizationHandler { + return NewOrganizationHandler(orgService, logger) + }); err != nil { + return err + } + + if err := p.container.Provide(func( + orgService services.OrganizationService, + logger logger.Logger, + ) *AccountHandler { + return NewAccountHandler(orgService, logger) + }); err != nil { + return err + } + + // Register member handler (for auth/member routes) + if err := p.container.Provide(func( + memberService services.MemberService, + logger logger.Logger, + ) *MemberHandler { + return NewMemberHandler(memberService, logger) + }); err != nil { + return err + } + + // Register routes + if err := p.container.Provide(func( + organizationHandler *OrganizationHandler, + accountHandler *AccountHandler, + memberHandler *MemberHandler, + ) *Routes { + return NewRoutes(organizationHandler, accountHandler, memberHandler) + }); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/api/organizations/routes.go b/go-b2b-starter/src/api/organizations/routes.go new file mode 100644 index 0000000..b44ce92 --- /dev/null +++ b/go-b2b-starter/src/api/organizations/routes.go @@ -0,0 +1,102 @@ +package organizations + +import ( + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/auth" + serverDomain "github.com/moasq/go-b2b-starter/server/domain" +) + +type Routes struct { + organizationHandler *OrganizationHandler + accountHandler *AccountHandler + memberHandler *MemberHandler +} + +func NewRoutes( + organizationHandler *OrganizationHandler, + accountHandler *AccountHandler, + memberHandler *MemberHandler, +) *Routes { + return &Routes{ + organizationHandler: organizationHandler, + accountHandler: accountHandler, + memberHandler: memberHandler, + } +} + +// RegisterRoutes registers organization, account, and auth member management routes +func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + // Auth routes - member management and authentication + authGroup := router.Group("/auth") + { + // Public endpoint - Organization signup (no authentication required) + authGroup.POST("/signup", r.memberHandler.BootstrapOrganization) + + // Public endpoint - Check if email exists (no authentication required) + authGroup.GET("/check-email", r.memberHandler.CheckEmail) + + // Protected endpoint - Add member (requires JWT authentication) + authGroup.POST("/members", + resolver.Get("auth"), + resolver.Get("org_context"), + r.memberHandler.AddMember) + + // Protected endpoint - List members (requires JWT authentication and org:manage permission) + authGroup.GET("/members", + resolver.Get("auth"), + resolver.Get("org_context"), + auth.RequirePermissionFunc("org", "manage"), + r.memberHandler.ListMembers) + + // Protected endpoint - Get current user profile (requires JWT authentication only) + authGroup.GET("/profile/me", + resolver.Get("auth"), + resolver.Get("org_context"), + r.memberHandler.GetProfile) + + // Protected endpoint - Delete organization member (requires JWT authentication and org:manage permission) + authGroup.DELETE("/members/:member_id", + resolver.Get("auth"), + resolver.Get("org_context"), + auth.RequirePermissionFunc("org", "manage"), + r.memberHandler.DeleteMember) + } + + // Organization routes - require JWT authentication + orgGroup := router.Group("/organizations") + orgGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + ) + { + // Current organization endpoints + orgGroup.GET("", auth.RequirePermissionFunc("org", "view"), r.organizationHandler.GetOrganization) + orgGroup.PUT("", auth.RequirePermissionFunc("org", "manage"), r.organizationHandler.UpdateOrganization) + orgGroup.GET("/stats", auth.RequirePermissionFunc("org", "view"), r.organizationHandler.GetOrganizationStats) + } + + // Account routes - require JWT authentication + accountGroup := router.Group("/accounts") + accountGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + ) + { + // Account management + accountGroup.POST("", auth.RequirePermissionFunc("org", "manage"), r.accountHandler.CreateAccount) + accountGroup.GET("", auth.RequirePermissionFunc("org", "view"), r.accountHandler.ListAccounts) + accountGroup.GET("/by-email", auth.RequirePermissionFunc("org", "view"), r.accountHandler.GetAccountByEmail) + accountGroup.GET("/:id", auth.RequirePermissionFunc("org", "view"), r.accountHandler.GetAccount) + accountGroup.PUT("/:id", auth.RequirePermissionFunc("org", "manage"), r.accountHandler.UpdateAccount) + accountGroup.DELETE("/:id", auth.RequirePermissionFunc("org", "manage"), r.accountHandler.DeleteAccount) + accountGroup.POST("/:id/last-login", auth.RequirePermissionFunc("org", "view"), r.accountHandler.UpdateAccountLastLogin) + accountGroup.GET("/:id/permissions", auth.RequirePermissionFunc("org", "view"), r.accountHandler.CheckAccountPermission) + accountGroup.GET("/:id/stats", auth.RequirePermissionFunc("org", "view"), r.accountHandler.GetAccountStats) + } +} + +// Routes returns a RouteRegistrar function compatible with the server interface +func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + r.RegisterRoutes(router, resolver) +} diff --git a/go-b2b-starter/src/api/provider.go b/go-b2b-starter/src/api/provider.go new file mode 100644 index 0000000..ca4df89 --- /dev/null +++ b/go-b2b-starter/src/api/provider.go @@ -0,0 +1,113 @@ +package api + +import ( + cognitiveAPI "github.com/moasq/go-b2b-starter/api/example_cognitive" + documentsAPI "github.com/moasq/go-b2b-starter/api/example_documents" + organizations "github.com/moasq/go-b2b-starter/api/organizations" + rbacAPI "github.com/moasq/go-b2b-starter/api/rbac" + subscriptionsAPI "github.com/moasq/go-b2b-starter/api/subscriptions" + server "github.com/moasq/go-b2b-starter/server/domain" + "go.uber.org/dig" +) + +// moduleRoutes holds handlers for all API modules +// 1. OrganizationRoutes - Handles organization, account, and member management routes (includes /auth routes) +// 2. RbacRoutes - Handles RBAC role and permission routes +// 3. BillingHandler - Handles billing status and subscription routes (uses app/billing module) +// 4. DocumentsRoutes - Handles PDF document upload and management routes +// 5. CognitiveRoutes - Handles AI/RAG chat and document search routes +type moduleRoutes struct { + OrganizationRoutes *organizations.Routes + RbacRoutes *rbacAPI.Routes + SubscriptionHandler *subscriptionsAPI.Handler + DocumentsRoutes *documentsAPI.Routes + CognitiveRoutes *cognitiveAPI.Routes +} + +// Init initializes the API by setting up dependencies and registering routes +// 1. Sets up all module dependencies +// 2. Registers API routes and handlers +// 3. Registers tools routes and handlers +// 4. Registers exchange rates routes and handlers +// 5. Registers visa routes and handlers + +func Init(container *dig.Container) error { + if err := setupDependencies(container); err != nil { + return err + } + + if err := registerAPI(container); err != nil { + return err + } + return nil +} + +// registerAPI registers all module handlers and routes +// 1. Provides moduleRoutes struct with all handlers +// 2. Invokes route registration for each module +func registerAPI(container *dig.Container) error { + if err := container.Provide(func( + organizationRoutes *organizations.Routes, + rbacRoutes *rbacAPI.Routes, + subscriptionHandler *subscriptionsAPI.Handler, + documentsRoutes *documentsAPI.Routes, + cognitiveRoutes *cognitiveAPI.Routes, + ) *moduleRoutes { + return &moduleRoutes{ + OrganizationRoutes: organizationRoutes, + RbacRoutes: rbacRoutes, + SubscriptionHandler: subscriptionHandler, + DocumentsRoutes: documentsRoutes, + CognitiveRoutes: cognitiveRoutes, + } + }); err != nil { + return err + } + + return container.Invoke(func( + srv server.Server, + modules *moduleRoutes, + ) { + // Register each module's routes + // Note: OrganizationRoutes now includes /auth routes for member management + srv.RegisterRoutes(modules.OrganizationRoutes.Routes, server.ApiPrefix) + srv.RegisterRoutes(modules.RbacRoutes.Routes, server.ApiPrefix) + srv.RegisterRoutes(modules.SubscriptionHandler.Routes, server.ApiPrefix) + srv.RegisterRoutes(modules.DocumentsRoutes.Routes, server.ApiPrefix) + srv.RegisterRoutes(modules.CognitiveRoutes.Routes, server.ApiPrefix) + }) +} + +// setupDependencies initializes all module dependencies +// 1. Organizations API - multi-tenant organization management (includes auth/member routes) +// 2. RBAC API - role-based access control +// 3. Billing API - subscription and billing management (handler uses app/billing module) +// 4. Documents API - PDF document upload and management +// 5. Cognitive API - AI/RAG chat and document search +func setupDependencies(container *dig.Container) error { + if err := organizations.NewProvider(container).RegisterDependencies(); err != nil { + return err + } + + // Initialize RBAC API (role and permission discovery) + if err := rbacAPI.NewProvider(container).RegisterDependencies(); err != nil { + return err + } + + // Initialize billing API (subscription and billing status) + if err := subscriptionsAPI.RegisterHandlers(container); err != nil { + return err + } + + // Initialize documents API (PDF upload and management) + if err := documentsAPI.NewProvider(container).RegisterDependencies(); err != nil { + return err + } + + // Initialize cognitive API (AI/RAG chat and document search) + if err := cognitiveAPI.NewProvider(container).RegisterDependencies(); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/api/rbac/handler.go b/go-b2b-starter/src/api/rbac/handler.go new file mode 100644 index 0000000..95f00f6 --- /dev/null +++ b/go-b2b-starter/src/api/rbac/handler.go @@ -0,0 +1,164 @@ +package rbac + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/api/response" + "github.com/moasq/go-b2b-starter/pkg/auth" +) + +// Handler handles RBAC API endpoints +type Handler struct { + service auth.RBACService +} + +// NewHandler creates a new RBAC handler +func NewHandler(service auth.RBACService) *Handler { + return &Handler{ + service: service, + } +} + +// GetRoles godoc +// @Summary Get all roles with permissions +// @Description Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery. +// @Tags RBAC +// @Produce json +// @Success 200 {object} github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse "Roles with permissions" +// @Failure 500 {object} map[string]string "Internal error" +// @Router /rbac/roles [get] +func (h *Handler) GetRoles(c *gin.Context) { + roles := h.service.GetAllRoles() + + roleDTOs := make([]auth.RoleDTO, len(roles)) + for i, role := range roles { + roleDTOs[i] = auth.NewRoleDTO(role) + } + + response.Success(c, http.StatusOK, auth.RolesResponse{ + Roles: roleDTOs, + }) +} + +// GetPermissions godoc +// @Summary Get all permissions +// @Description Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering. +// @Tags RBAC +// @Produce json +// @Success 200 {object} github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse "All permissions" +// @Failure 500 {object} map[string]string "Internal error" +// @Router /rbac/permissions [get] +func (h *Handler) GetPermissions(c *gin.Context) { + permissions := h.service.GetAllPermissions() + + fmt.Printf("[DEBUG] API Handler - Returning %d permissions\n", len(permissions)) + + permDTOs := make([]auth.PermissionDTO, len(permissions)) + for i, perm := range permissions { + permDTOs[i] = auth.NewPermissionDTO(perm) + } + + response.Success(c, http.StatusOK, auth.PermissionsResponse{ + Permissions: permDTOs, + }) +} + +// GetPermissionsByCategory godoc +// @Summary Get permissions grouped by category +// @Description Returns all permissions organized by their category for better UI organization. +// @Tags RBAC +// @Produce json +// @Success 200 {object} github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse "Permissions by category" +// @Failure 500 {object} map[string]string "Internal error" +// @Router /rbac/permissions/by-category [get] +func (h *Handler) GetPermissionsByCategory(c *gin.Context) { + categoriesMap := h.service.GetPermissionsByCategory() + + // Convert to DTO format + result := make(map[string][]auth.PermissionDTO) + for category, perms := range categoriesMap { + permDTOs := make([]auth.PermissionDTO, len(perms)) + for i, perm := range perms { + permDTOs[i] = auth.NewPermissionDTO(perm) + } + result[category] = permDTOs + } + + response.Success(c, http.StatusOK, auth.PermissionsByCategoryResponse{ + Categories: result, + }) +} + +// GetRoleDetails godoc +// @Summary Get detailed information about a specific role +// @Description Returns comprehensive information about a role including permissions, statistics, and restrictions. +// @Tags RBAC +// @Produce json +// @Param role_id path string true "Role ID (member, approver, admin)" +// @Success 200 {object} github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse "Role details with statistics" +// @Failure 400 {object} map[string]string "Invalid role ID" +// @Failure 404 {object} map[string]string "Role not found" +// @Router /rbac/roles/{role_id} [get] +func (h *Handler) GetRoleDetails(c *gin.Context) { + roleID := c.Param("role_id") + + if roleID == "" { + response.Error(c, http.StatusBadRequest, "role_id_required", nil) + return + } + + roleResp := auth.NewRolePermissionsResponse(roleID) + if roleResp == nil { + response.Error(c, http.StatusNotFound, "role_not_found", nil) + return + } + + response.Success(c, http.StatusOK, roleResp) +} + +// CheckPermission godoc +// @Summary Check if a role has a specific permission +// @Description Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering. +// @Tags RBAC +// @Accept json +// @Produce json +// @Param body body github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest true "Role and permission to check" +// @Success 200 {object} github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse "Permission check result" +// @Failure 400 {object} map[string]string "Invalid request" +// @Router /rbac/check-permission [post] +func (h *Handler) CheckPermission(c *gin.Context) { + var req auth.PermissionCheckRequest + + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid_request", err) + return + } + + if req.RoleID == "" || req.PermissionID == "" { + response.Error(c, http.StatusBadRequest, "missing_parameters", nil) + return + } + + hasPermission := h.service.HasPermission(req.RoleID, req.PermissionID) + + response.Success(c, http.StatusOK, auth.PermissionCheckResponse{ + RoleID: req.RoleID, + PermissionID: req.PermissionID, + HasPermission: hasPermission, + }) +} + +// GetMetadata godoc +// @Summary Get RBAC system metadata +// @Description Returns summary information about the RBAC system including total roles, permissions, and categories. +// @Tags RBAC +// @Produce json +// @Success 200 {object} github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata "RBAC system metadata" +// @Router /rbac/metadata [get] +func (h *Handler) GetMetadata(c *gin.Context) { + metadata := h.service.GetRBACMetadata() + response.Success(c, http.StatusOK, metadata) +} diff --git a/go-b2b-starter/src/api/rbac/provider.go b/go-b2b-starter/src/api/rbac/provider.go new file mode 100644 index 0000000..28f94c7 --- /dev/null +++ b/go-b2b-starter/src/api/rbac/provider.go @@ -0,0 +1,46 @@ +package rbac + +import ( + "fmt" + + "github.com/moasq/go-b2b-starter/pkg/auth" + "go.uber.org/dig" +) + +// Provider handles dependency injection for the RBAC module +type Provider struct { + container *dig.Container +} + +// NewProvider creates a new RBAC provider +func NewProvider(container *dig.Container) *Provider { + return &Provider{ + container: container, + } +} + +// RegisterDependencies registers all RBAC dependencies in the container +func (p *Provider) RegisterDependencies() error { + // Provide RBAC Service + if err := p.container.Provide(func() auth.RBACService { + return auth.NewRBACService() + }); err != nil { + return fmt.Errorf("failed to provide rbac service: %w", err) + } + + // Provide RBAC Handler + if err := p.container.Provide(func(service auth.RBACService) *Handler { + return NewHandler(service) + }); err != nil { + return fmt.Errorf("failed to provide rbac handler: %w", err) + } + + // Provide RBAC Routes + if err := p.container.Provide(func(handler *Handler) *Routes { + return NewRoutes(handler) + }); err != nil { + return fmt.Errorf("failed to provide rbac routes: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/api/rbac/routes.go b/go-b2b-starter/src/api/rbac/routes.go new file mode 100644 index 0000000..0e62df7 --- /dev/null +++ b/go-b2b-starter/src/api/rbac/routes.go @@ -0,0 +1,64 @@ +package rbac + +import ( + "github.com/gin-gonic/gin" + + serverDomain "github.com/moasq/go-b2b-starter/server/domain" +) + +// Routes handles RBAC API routes registration +type Routes struct { + handler *Handler +} + +// NewRoutes creates a new Routes instance +func NewRoutes(handler *Handler) *Routes { + return &Routes{ + handler: handler, + } +} + +// RegisterRoutes registers RBAC routes on the router +// Note: RBAC endpoints are public and do NOT require authentication +// These endpoints are used by frontend for role/permission discovery +func (r *Routes) RegisterRoutes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + // RBAC info endpoints - NO authentication required for role/permission discovery + rbacGroup := router.Group("/rbac") + { + // Get all roles with their permissions - single source of truth for frontend + // GET /api/rbac/roles + rbacGroup.GET("/roles", + r.handler.GetRoles) + + // Get all permissions - useful for permission checkers + // GET /api/rbac/permissions + rbacGroup.GET("/permissions", + r.handler.GetPermissions) + + // Get permissions organized by category - for structured UI display + // GET /api/rbac/permissions/by-category + rbacGroup.GET("/permissions/by-category", + r.handler.GetPermissionsByCategory) + + // Get detailed information about a specific role with statistics + // GET /api/rbac/roles/{role_id} + rbacGroup.GET("/roles/:role_id", + r.handler.GetRoleDetails) + + // Check if a role has a specific permission - for conditional UI rendering + // POST /api/rbac/check-permission + rbacGroup.POST("/check-permission", + r.handler.CheckPermission) + + // Get RBAC system metadata + // GET /api/rbac/metadata + rbacGroup.GET("/metadata", + r.handler.GetMetadata) + } +} + +// Routes satisfies the RouteRegistrar interface +// This allows the routes to be registered by the server +func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + r.RegisterRoutes(router, resolver) +} diff --git a/go-b2b-starter/src/api/subscriptions/handler.go b/go-b2b-starter/src/api/subscriptions/handler.go new file mode 100644 index 0000000..bc64489 --- /dev/null +++ b/go-b2b-starter/src/api/subscriptions/handler.go @@ -0,0 +1,189 @@ +package subscriptions + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + billingServices "github.com/moasq/go-b2b-starter/app/billing/app/services" + "github.com/moasq/go-b2b-starter/app/billing/domain" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/common/errors" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +type Handler struct { + billingService billingServices.BillingService + logger logger.Logger +} + +func NewHandler(billingService billingServices.BillingService, log logger.Logger) *Handler { + return &Handler{ + billingService: billingService, + logger: log, + } +} + +// GetBillingStatus godoc +// @Summary Get current billing and quota status +// @Description Retrieve the current subscription billing status and invoice quota information for the organization +// @Tags subscriptions +// @Accept json +// @Produce json +// @Success 200 {object} domain.BillingStatus "Current billing and quota status" +// @Failure 400 {object} errors.HTTPError "Invalid request parameters or missing organization context" +// @Failure 500 {object} errors.HTTPError "Internal server error" +// @Router /api/subscriptions/status [get] +func (h *Handler) GetBillingStatus(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + if reqCtx == nil { + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_context", + "Organization context is required", + )) + return + } + + // Call service layer to get billing status + billingStatus, err := h.billingService.GetBillingStatus(c.Request.Context(), reqCtx.OrganizationID) + if err != nil { + // Check if subscription not found - this is not necessarily an error + // Organization might not have a subscription yet + if err == domain.ErrSubscriptionNotFound { + // Return a response indicating no active subscription + c.JSON(http.StatusOK, domain.BillingStatus{ + OrganizationID: reqCtx.OrganizationID, + HasActiveSubscription: false, + CanProcessInvoices: false, + InvoiceCount: 0, + Reason: "No active subscription found", + CheckedAt: time.Now(), + }) + return + } + + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "billing_status_failed", + fmt.Sprintf("Failed to retrieve billing status: %v", err), + )) + return + } + + c.JSON(http.StatusOK, billingStatus) +} + +// VerifyPaymentRequest represents the request payload for verifying a payment +type VerifyPaymentRequest struct { + SessionID string `json:"session_id" binding:"required"` +} + +// VerifyPayment godoc +// @Summary Verify payment from checkout session +// @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. +// @Tags subscriptions +// @Accept json +// @Produce json +// @Param request body VerifyPaymentRequest true "Checkout session ID" +// @Success 200 {object} domain.BillingStatus "Verification result with updated billing status" +// @Failure 400 {object} errors.HTTPError "Invalid request parameters or checkout session failed" +// @Failure 404 {object} errors.HTTPError "Checkout session not found" +// @Failure 500 {object} errors.HTTPError "Internal server error" +// @Router /api/subscriptions/verify-payment [post] +func (h *Handler) VerifyPayment(c *gin.Context) { + h.logger.Info("[VerifyPayment] Starting payment verification request", nil) + + // Bind request + var req VerifyPaymentRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("[VerifyPayment] Failed to bind request JSON", map[string]any{ + "error": err.Error(), + }) + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "invalid_request", + fmt.Sprintf("Invalid request: %v", err), + )) + return + } + + h.logger.Info("[VerifyPayment] Request parsed successfully", map[string]any{ + "session_id": req.SessionID, + }) + + // Validate session_id is not empty + if req.SessionID == "" { + h.logger.Warn("[VerifyPayment] Missing session_id in request", nil) + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "missing_session_id", + "Checkout session ID is required", + )) + return + } + + h.logger.Info("[VerifyPayment] Calling billing service to verify payment", map[string]any{ + "session_id": req.SessionID, + }) + + // Call service to verify payment + billingStatus, err := h.billingService.VerifyPaymentFromCheckout(c.Request.Context(), req.SessionID) + if err != nil { + // Check if it's a checkout session not found error + if err.Error() == "checkout session not found: "+req.SessionID { + h.logger.Warn("[VerifyPayment] Checkout session not found", map[string]any{ + "session_id": req.SessionID, + }) + c.JSON(http.StatusNotFound, errors.NewHTTPError( + http.StatusNotFound, + "session_not_found", + fmt.Sprintf("Checkout session not found: %s", req.SessionID), + )) + return + } + + h.logger.Error("[VerifyPayment] Failed to verify payment", map[string]any{ + "session_id": req.SessionID, + "error": err.Error(), + }) + c.JSON(http.StatusInternalServerError, errors.NewHTTPError( + http.StatusInternalServerError, + "verification_failed", + fmt.Sprintf("Failed to verify payment: %v", err), + )) + return + } + + h.logger.Info("[VerifyPayment] Billing service returned status", map[string]any{ + "session_id": req.SessionID, + "has_active_subscription": billingStatus.HasActiveSubscription, + "can_process_invoices": billingStatus.CanProcessInvoices, + "invoice_count": billingStatus.InvoiceCount, + "reason": billingStatus.Reason, + }) + + // If checkout session is not succeeded, return 400 with reason + if !billingStatus.HasActiveSubscription && billingStatus.Reason != "Payment verified successfully" { + h.logger.Warn("[VerifyPayment] Payment not completed", map[string]any{ + "session_id": req.SessionID, + "reason": billingStatus.Reason, + }) + c.JSON(http.StatusBadRequest, errors.NewHTTPError( + http.StatusBadRequest, + "payment_not_completed", + billingStatus.Reason, + )) + return + } + + h.logger.Info("[VerifyPayment] Payment verification completed successfully", map[string]any{ + "session_id": req.SessionID, + "organization_id": billingStatus.OrganizationID, + "invoice_count": billingStatus.InvoiceCount, + }) + + c.JSON(http.StatusOK, billingStatus) +} diff --git a/go-b2b-starter/src/api/subscriptions/provider.go b/go-b2b-starter/src/api/subscriptions/provider.go new file mode 100644 index 0000000..accddd6 --- /dev/null +++ b/go-b2b-starter/src/api/subscriptions/provider.go @@ -0,0 +1,18 @@ +package subscriptions + +import ( + "go.uber.org/dig" +) + +// RegisterHandlers registers subscription API handlers in the DI container +func RegisterHandlers(container *dig.Container) error { + if err := container.Provide(NewHandler); err != nil { + return err + } + return nil +} + +// ProvideHandler is an alias for RegisterHandlers for consistency +func ProvideHandler(container *dig.Container) error { + return RegisterHandlers(container) +} diff --git a/go-b2b-starter/src/api/subscriptions/routes.go b/go-b2b-starter/src/api/subscriptions/routes.go new file mode 100644 index 0000000..63957c7 --- /dev/null +++ b/go-b2b-starter/src/api/subscriptions/routes.go @@ -0,0 +1,31 @@ +package subscriptions + +import ( + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/auth" + serverDomain "github.com/moasq/go-b2b-starter/server/domain" +) + +// Routes registers subscription endpoints +func (h *Handler) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + // Subscription endpoints + subscriptions := router.Group("/subscriptions") + subscriptions.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + ) + { + // Get billing status - requires resource:view permission + subscriptions.GET("/status", + auth.RequirePermissionFunc("resource", "view"), + h.GetBillingStatus) + } + + // Verify payment endpoint - auth only (session_id identifies org) + // This is separate from the main group to avoid requiring org_context middleware + // The session_id from the checkout contains the customer_id which maps to the org + router.POST("/subscriptions/verify-payment", + resolver.Get("auth"), + h.VerifyPayment) +} diff --git a/go-b2b-starter/src/app/billing/README.md b/go-b2b-starter/src/app/billing/README.md new file mode 100644 index 0000000..cdeaf40 --- /dev/null +++ b/go-b2b-starter/src/app/billing/README.md @@ -0,0 +1,470 @@ +# Billing Module + +Hybrid subscription lifecycle management for B2B SaaS applications. This module combines **event-driven webhooks** with **active verification** for maximum reliability. + +## Key Principle: Hybrid Synchronization Strategy + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ HYBRID BILLING SYNC (This Module) │ +│ │ +│ "Primary: Webhooks + Fallback: Active Verification + Self-Healing" │ +│ │ +│ 1. VERIFICATION ON REDIRECT (Initial Payment): │ +│ User pays → Frontend calls /verify-payment → Instant access │ +│ │ +│ 2. WEBHOOKS (Renewals): │ +│ Polar.sh sends webhook → Billing module processes → DB updated │ +│ │ +│ 3. LAZY GUARDING (Missed Webhooks): │ +│ DB says expired → Middleware checks Polar API → Self-healing │ +│ │ +│ Result: Fast, reliable, self-healing subscription management │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Architecture + +``` + EXTERNAL +┌────────────────────────────────────────────────────────────────────────────┐ +│ Polar.sh │ +│ │ +│ - Handles checkout, payment processing, subscription management │ +│ - Sends webhooks on state changes │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + │ Webhooks (subscription.created, subscription.updated, etc.) + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ BILLING MODULE │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Webhook Handler │ ──► │ BillingService │ ──► │ Repository │ │ +│ │ (API Layer) │ │ (Domain Logic) │ │ (Infra Layer) │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Quota Tracking │ │ Local DB │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + │ Reads subscription status + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ PAYWALL MIDDLEWARE (pkg/paywall) │ +│ │ +│ - Reads from local DB (fast, no external calls) │ +│ - Blocks requests if subscription inactive (402) │ +│ - Provider-agnostic access gating │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +## Synchronization Mechanisms + +### 1. Verification on Redirect (Initial Payment) + +**Use Case:** User completes payment and returns to app + +**Problem:** Webhooks may not arrive immediately (delays, failures) + +**Solution:** Frontend triggers backend verification + +``` +User Pays → Polar Redirects → Frontend → POST /verify-payment → Backend → Polar API → DB Updated → Instant Access +``` + +**Benefits:** +- ✅ Instant access (5 seconds vs. minutes) +- ✅ No webhook dependency +- ✅ User sees immediate result + +**Implementation:** +- Endpoint: `POST /api/subscriptions/verify-payment` +- Service: `src/app/billing/app/services/verify_payment_service.go` +- Adapter: `src/app/billing/infra/polar/polar_adapter.go` → `GetCheckoutSession()` + +### 2. Webhooks (Renewals & Updates) + +**Use Case:** Monthly renewals, cancellations, plan changes + +**Standard Flow:** Polar sends webhook → Backend processes → DB updated + +**Supported Events:** +- `subscription.created`, `subscription.updated`, `subscription.canceled` +- `customer.updated`, `meter.grant.updated` + +### 3. Lazy Guarding (Self-Healing) + +**Use Case:** Webhook failed or delayed for renewal + +**How It Works:** +1. User makes request +2. Middleware checks DB → Status: "expired" +3. Middleware calls Polar API to verify +4. If Polar says "active" → Grant access + Update DB +5. If Polar says "inactive" → Block access (truly expired) + +**Code (Automatic in Middleware):** +```go +if !status.IsActive && status.Status != StatusNone { + freshStatus, err := provider.RefreshSubscriptionStatus(ctx, orgID) + if err == nil && freshStatus.IsActive { + status = freshStatus // Self-healed! + } +} +``` + +**Benefits:** +- ✅ Self-healing: No manual intervention +- ✅ Fast: Only calls API in edge cases (<1% of requests) +- ✅ Reliable: Paying users never locked out + +## Why Hybrid Approach? + +| Scenario | Mechanism | Benefit | +|----------|-----------|---------| +| Initial Payment | Verification on Redirect | Instant access | +| Monthly Renewal | Webhooks | No user action needed | +| Missed Webhook | Lazy Guarding | Self-healing | +| Normal Requests | Database Read | Fast (no API calls) | + +## Module Structure + +``` +src/app/billing/ +├── domain/ +│ ├── subscription.go # Subscription entity +│ ├── quota.go # Quota tracking entity +│ ├── billing_status.go # Combined status for API responses +│ ├── repository.go # Repository interfaces +│ ├── service.go # Service interface +│ └── errors.go # Domain errors +│ +├── app/services/ +│ ├── subscription_service_dec.go # BillingService interface +│ ├── sync_service.go # Sync subscription from Polar +│ ├── webhook_service.go # Process webhook events +│ └── quota_service.go # Quota management +│ +├── infra/ +│ ├── adapters/ +│ │ └── status_provider.go # Bridge to paywall middleware +│ ├── repositories/ +│ │ ├── subscription_repository.go # Subscription DB operations +│ │ └── organization_adapter.go # Org ID lookups +│ └── polar/ +│ └── polar_adapter.go # Polar API client (webhook only) +│ +└── cmd/ + └── init.go # DI initialization +``` + +## Data Flow + +### 1. Subscription Created (Webhook) + +``` +Polar.sh Billing Module Local DB + │ │ │ + │ subscription.created │ │ + │ ────────────────────────► │ │ + │ │ UpsertSubscription() │ + │ │ ────────────────────────► │ + │ │ │ + │ │ UpsertQuota() │ + │ │ ────────────────────────► │ + │ │ │ + │ 200 OK │ │ + │ ◄──────────────────────── │ │ +``` + +### 2. User Accesses Premium Feature + +``` +User Paywall Local DB + │ │ │ + │ GET /ai/generate │ │ + │ ────────────────────────► │ │ + │ │ GetSubscriptionStatus() │ + │ │ ──────────────────────► │ + │ │ │ + │ │ {status: "active"} │ + │ │ ◄────────────────────── │ + │ │ │ + │ Pass through │ │ + │ ◄──────────────────────── │ │ +``` + +### 3. Quota Consumption (Invoice Processing) + +``` +User BillingService Local DB + │ │ │ + │ POST /invoices/process │ │ + │ ────────────────────────► │ │ + │ │ DecrementInvoiceCount() │ + │ │ ──────────────────────► │ + │ │ │ + │ │ {remaining: 42} │ + │ │ ◄────────────────────── │ + │ │ │ + │ 200 OK │ │ + │ ◄──────────────────────── │ │ +``` + +## Key Components + +### BillingService + +```go +// BillingService handles subscription management and quota verification. +// +// This service manages the billing lifecycle with Polar.sh via event-driven webhooks. +// It does NOT expose direct API calls to Polar during request handling: +// +// 1. WEBHOOK PROCESSING (async, event-driven): +// - subscription.created, subscription.updated, subscription.canceled +// - Updates local database with subscription state +// +// 2. LOCAL DB QUERIES (sync, during requests): +// - GetBillingStatus: Check subscription status from local DB +// - GetQuotaStatus: Check quota limits from local DB +// +// 3. QUOTA CONSUMPTION (sync, during requests): +// - ConsumeInvoiceQuota: Decrement invoice count in local DB +type BillingService interface { + // Webhook processing (called by webhook handler) + ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error + + // Status queries (from local DB only) + GetBillingStatus(ctx context.Context, organizationID int32) (*BillingStatus, error) + CheckQuotaAvailability(ctx context.Context, organizationID int32) (*BillingStatus, error) + + // Quota consumption (local DB update) + ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*BillingStatus, error) + + // NEW: Verification on Redirect (makes Polar API call) + VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*BillingStatus, error) + + // NEW: Lazy Guarding (makes Polar API call when DB says expired) + RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*BillingStatus, error) + + // Manual sync (for admin/debug - makes Polar API call) + SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error +} +``` + +### StatusProviderAdapter + +Bridges the billing module to the paywall middleware: + +```go +// In app/billing/infra/adapters/status_provider.go +type StatusProviderAdapter struct { + service services.BillingService +} + +// Implements paywall.SubscriptionStatusProvider +func (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) { + billingStatus, err := a.service.GetBillingStatus(ctx, orgID) + if err != nil { + return nil, err + } + + return &paywall.SubscriptionStatus{ + OrganizationID: orgID, + Status: billingStatus.SubscriptionStatus, + IsActive: billingStatus.HasActiveSubscription, + // Maps billing status to access status + }, nil +} + +// NEW: Implements lazy guarding - refreshes from Polar API when DB says expired +func (a *StatusProviderAdapter) RefreshSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) { + billingStatus, err := a.service.RefreshSubscriptionStatus(ctx, orgID) + if err != nil { + return nil, err + } + + return &paywall.SubscriptionStatus{ + OrganizationID: orgID, + Status: billingStatus.SubscriptionStatus, + IsActive: billingStatus.HasActiveSubscription, + }, nil +} +``` + +### Webhook Events + +Supported Polar.sh webhook events: + +| Event | Description | Action | +|-------|-------------|--------| +| `subscription.created` | New subscription | Create/update subscription + quota | +| `subscription.updated` | Status change | Update subscription status | +| `subscription.canceled` | Subscription canceled | Mark as canceled | +| `checkout.completed` | Checkout finished | Trigger subscription sync | + +## Usage + +### Webhook Handler (API Layer) + +```go +// POST /api/webhooks/polar +func (h *Handler) HandlePolarWebhook(c *gin.Context) { + var event domain.WebhookEvent + if err := c.ShouldBindJSON(&event); err != nil { + c.JSON(400, gin.H{"error": "invalid payload"}) + return + } + + if err := h.billingService.ProcessSubscriptionWebhook(c.Request.Context(), &event); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{"status": "processed"}) +} +``` + +### Getting Billing Status + +```go +// In any handler that needs billing info +func (h *Handler) GetBillingStatus(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + + status, err := h.billingService.GetBillingStatus(c.Request.Context(), reqCtx.OrganizationID) + if err != nil { + if err == domain.ErrSubscriptionNotFound { + // No subscription yet - return appropriate response + c.JSON(200, domain.BillingStatus{ + HasActiveSubscription: false, + CanProcessInvoices: false, + Reason: "No active subscription", + }) + return + } + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, status) +} +``` + +### Consuming Quota + +```go +// Before processing an invoice +func (h *Handler) ProcessInvoice(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + + // Check quota + quotaStatus, err := h.billingService.GetQuotaStatus(c.Request.Context(), reqCtx.OrganizationID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + if !quotaStatus.CanProcessInvoice { + c.JSON(402, gin.H{ + "error": "quota_exceeded", + "message": "Invoice processing quota exhausted", + "upgrade_url": "/billing", + }) + return + } + + // Process the invoice... + + // Consume quota + if err := h.billingService.ConsumeInvoiceQuota(c.Request.Context(), reqCtx.OrganizationID); err != nil { + // Log but don't fail - invoice was processed + log.Printf("failed to consume quota: %v", err) + } +} +``` + +## Configuration + +Environment variables for Polar.sh integration: + +```env +POLAR_API_KEY=your_polar_api_key +POLAR_WEBHOOK_SECRET=your_webhook_secret +POLAR_ORGANIZATION_ID=your_polar_org_id +``` + +## Database Schema + +```sql +-- Subscription tracking +CREATE TABLE subscription_billing.subscriptions ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id), + external_customer_id TEXT NOT NULL, -- Polar customer ID + subscription_id TEXT NOT NULL, -- Polar subscription ID + subscription_status TEXT NOT NULL, -- active, trialing, past_due, canceled, unpaid + product_id TEXT NOT NULL, + product_name TEXT, + plan_name TEXT, + current_period_start TIMESTAMP, + current_period_end TIMESTAMP, + cancel_at_period_end BOOLEAN DEFAULT FALSE, + canceled_at TIMESTAMP, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Quota tracking +CREATE TABLE subscription_billing.quota_tracking ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id), + invoice_count INTEGER DEFAULT 0, -- Remaining invoices + max_seats INTEGER, -- Seat limit + period_start TIMESTAMP, + period_end TIMESTAMP, + last_synced_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +## Related Modules + +- **pkg/paywall**: Access gating middleware (reads from this module's DB) +- **pkg/polar**: Polar.sh API client (used for webhook validation) +- **app/organizations**: Organization management (links subscription to org) + +## Testing + +```go +// Mock the billing service for unit tests +type MockBillingService struct { + mock.Mock +} + +func (m *MockBillingService) GetBillingStatus(ctx context.Context, orgID int32) (*domain.BillingStatus, error) { + args := m.Called(ctx, orgID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.BillingStatus), args.Error(1) +} + +// In your test +func TestHandler_RequiresActiveSubscription(t *testing.T) { + mockService := new(MockBillingService) + mockService.On("GetBillingStatus", mock.Anything, int32(1)).Return(&domain.BillingStatus{ + HasActiveSubscription: false, + }, nil) + + // Test that handler returns 402 +} +``` diff --git a/go-b2b-starter/src/app/billing/app/services/check_quota_availability_service.go b/go-b2b-starter/src/app/billing/app/services/check_quota_availability_service.go new file mode 100644 index 0000000..43e8a11 --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/check_quota_availability_service.go @@ -0,0 +1,71 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +// CheckQuotaAvailability performs a read-only verification of quota availability +// This method does NOT consume quota - it only checks if processing is allowed +// Use ConsumeInvoiceQuota after successful invoice processing to actually decrement the quota +func (s *billingService) CheckQuotaAvailability(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { + // Step 1: Check database quota status (read-only) + quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) + if err != nil { + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: false, + CanProcessInvoices: false, + Reason: "no active subscription", + CheckedAt: time.Now(), + }, domain.ErrSubscriptionNotFound + } + + // Step 2: Check if we need fallback API verification + needsFallback := s.needsFallbackVerification(quotaStatus) + if needsFallback { + s.logger.Info("Quota near limit or stale, performing fallback API verification", map[string]any{ + "organization_id": organizationID, + "invoice_count": quotaStatus.InvoiceCount, + }) + + // Sync from Polar and re-check + if err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil { + s.logger.Error("Fallback sync failed, using database data", map[string]any{ + "organization_id": organizationID, + "error": err.Error(), + }) + } else { + // Re-fetch quota status after sync + quotaStatus, err = s.repo.GetQuotaStatus(ctx, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get quota after sync: %w", err) + } + } + } + + // Step 3: Verify quota is available (NO consumption here) + if !quotaStatus.CanProcessInvoice { + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", + CanProcessInvoices: false, + InvoiceCount: quotaStatus.InvoiceCount, + Reason: "quota exceeded or subscription inactive", + CheckedAt: time.Now(), + }, domain.ErrQuotaExceeded + } + + // Step 4: Return success status (quota NOT consumed yet) + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: true, + CanProcessInvoices: true, + InvoiceCount: quotaStatus.InvoiceCount, // Current count, NOT decremented + Reason: "quota available", + CheckedAt: time.Now(), + }, nil +} diff --git a/go-b2b-starter/src/app/billing/app/services/consume_invoice_quota_service.go b/go-b2b-starter/src/app/billing/app/services/consume_invoice_quota_service.go new file mode 100644 index 0000000..95aff0c --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/consume_invoice_quota_service.go @@ -0,0 +1,102 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +// ConsumeInvoiceQuota explicitly consumes one invoice quota after successful processing +// This should be called after the invoice has been successfully processed +// Can be safely called in a background goroutine for better performance +func (s *billingService) ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { + s.logger.Info("Consuming invoice quota for organization", map[string]any{ + "organization_id": organizationID, + }) + + // Step 1: Get current quota status before consumption + quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) + if err != nil { + s.logger.Error("Failed to get quota status before consumption", map[string]any{ + "organization_id": organizationID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get quota status: %w", err) + } + + // Step 2: Decrement quota count (atomic database operation) + updatedQuota, err := s.repo.DecrementInvoiceCount(ctx, organizationID) + if err != nil { + s.logger.Error("Failed to decrement invoice count", map[string]any{ + "organization_id": organizationID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to decrement invoice count: %w", err) + } + + s.logger.Info("Successfully consumed invoice quota locally", map[string]any{ + "organization_id": organizationID, + "previous_count": quotaStatus.InvoiceCount, + "new_count": updatedQuota.InvoiceCount, + "remaining_invoices": updatedQuota.InvoiceCount, + }) + + // Step 3: Ingest meter event to Polar to consume credits (best-effort) + // This notifies Polar about the invoice processing usage + // Local tracking is maintained for fast quota checks, Polar tracks actual billing + go s.ingestMeterEventToPolar(context.Background(), organizationID) + + // Step 4: Return updated billing status + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", + CanProcessInvoices: updatedQuota.InvoiceCount > 0, + InvoiceCount: updatedQuota.InvoiceCount, + Reason: "quota consumed successfully", + CheckedAt: time.Now(), + }, nil +} + +// ingestMeterEventToPolar ingests a meter event to Polar for usage-based billing +// This runs in a background goroutine and uses best-effort approach +// Failures are logged but don't affect the main operation since local tracking is maintained +func (s *billingService) ingestMeterEventToPolar(ctx context.Context, organizationID int32) { + // Use background context with timeout (independent of request context) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // Get organization's external customer ID (Stytch org ID) + externalID, err := s.orgAdapter.GetStytchOrgID(ctx, organizationID) + if err != nil { + s.logger.Error("Failed to get external customer ID for Polar meter event", map[string]any{ + "organization_id": organizationID, + "error": err.Error(), + }) + return + } + + // Ingest meter event to Polar + // Meter: "Invoice Processing" (configured in Polar dashboard) + // Filter: name equals "invoice.processed" + // Amount: 1 (one invoice processed) + meterSlug := invoicesProcessedMeterSlug // Event name MUST match meter filter exactly (with dot) + if err := s.polarAdapter.IngestMeterEvent(ctx, externalID, meterSlug, 1); err != nil { + s.logger.Error("Failed to ingest meter event to Polar", map[string]any{ + "organization_id": organizationID, + "external_id": externalID, + "meter_slug": meterSlug, + "error": err.Error(), + }) + return + } + + // Log success + s.logger.Info("Successfully ingested event to Polar", map[string]any{ + "organization_id": organizationID, + "external_id": externalID, + "event_name": meterSlug, + "amount": 1, + }) +} diff --git a/go-b2b-starter/src/app/billing/app/services/get_billing_status_service.go b/go-b2b-starter/src/app/billing/app/services/get_billing_status_service.go new file mode 100644 index 0000000..90ea5cc --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/get_billing_status_service.go @@ -0,0 +1,45 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +func (s *billingService) GetBillingStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { + // Get quota status from database + quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) + if err != nil { + // No subscription found + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: false, + CanProcessInvoices: false, + InvoiceCount: 0, + Reason: "no active subscription found", + CheckedAt: time.Now(), + }, nil + } + + // Build billing status from quota status + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", + CanProcessInvoices: quotaStatus.CanProcessInvoice, + InvoiceCount: quotaStatus.InvoiceCount, + Reason: s.buildStatusReason(quotaStatus), + CheckedAt: time.Now(), + }, nil +} + +func (s *billingService) buildStatusReason(status *domain.QuotaStatus) string { + if !status.CanProcessInvoice { + if status.SubscriptionStatus != "active" { + return fmt.Sprintf("subscription status: %s", status.SubscriptionStatus) + } + return "invoice quota exceeded" + } + return "ok" +} diff --git a/go-b2b-starter/src/app/billing/app/services/module.go b/go-b2b-starter/src/app/billing/app/services/module.go new file mode 100644 index 0000000..b3f0c63 --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/module.go @@ -0,0 +1,58 @@ +package services + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/billing/domain" + "github.com/moasq/go-b2b-starter/app/billing/infra/polar" + "github.com/moasq/go-b2b-starter/app/billing/infra/repositories" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + logger "github.com/moasq/go-b2b-starter/pkg/logger/domain" + polarpkg "github.com/moasq/go-b2b-starter/pkg/polar" +) + +// Module handles dependency injection for billing services +type Module struct{} + +// NewModule creates a new services module +func NewModule() *Module { + return &Module{} +} + +// Configure registers all services in the dependency container +func (m *Module) Configure(container *dig.Container) error { + // Register SubscriptionRepository + if err := container.Provide(func(store adapters.SubscriptionStore) domain.SubscriptionRepository { + return repositories.NewSubscriptionRepository(store) + }); err != nil { + return err + } + + // Register OrganizationAdapter + if err := container.Provide(func(orgStore adapters.OrganizationStore) domain.OrganizationAdapter { + return repositories.NewOrganizationAdapter(orgStore) + }); err != nil { + return err + } + + // Register PolarAdapter + if err := container.Provide(func(client *polarpkg.Client) PolarAdapter { + return polar.NewPolarAdapter(client) + }); err != nil { + return err + } + + // Register BillingService + if err := container.Provide(func( + repo domain.SubscriptionRepository, + orgAdapter domain.OrganizationAdapter, + polarAdapter PolarAdapter, + logger logger.Logger, + ) BillingService { + return NewBillingService(repo, orgAdapter, polarAdapter, logger) + }); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/app/billing/app/services/process_webhook_event_service.go b/go-b2b-starter/src/app/billing/app/services/process_webhook_event_service.go new file mode 100644 index 0000000..b45b8b7 --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/process_webhook_event_service.go @@ -0,0 +1,950 @@ +package services + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +const invoicesProcessedMeterSlug = "invoice.processed" + +func (s *billingService) ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error { + s.logger.Info("Processing webhook event", map[string]any{ + "event_type": eventType, + "payload_keys": mapKeys(payload), + }) + + // Update subscription based on event type + switch eventType { + case "subscription.created", "subscription.updated": + eventData, err := s.parseSubscriptionWebhookPayload(payload) + if err != nil { + return fmt.Errorf("failed to parse subscription webhook payload: %w", err) + } + return s.handleSubscriptionUpsert(ctx, eventData) + case "subscription.canceled": + eventData, err := s.parseSubscriptionWebhookPayload(payload) + if err != nil { + return fmt.Errorf("failed to parse subscription webhook payload: %w", err) + } + return s.handleSubscriptionCanceled(ctx, eventData) + case "customer.updated": + eventData, err := s.parseSubscriptionWebhookPayload(payload) + if err != nil { + return fmt.Errorf("failed to parse subscription webhook payload: %w", err) + } + return s.handleCustomerUpdated(ctx, eventData) + case "meter.grant.updated", "meter.grant.created", "entitlement.grant.updated": + if err := s.handleMeterGrantEvent(ctx, payload); err != nil { + return fmt.Errorf("failed to handle meter grant webhook: %w", err) + } + return nil + default: + s.logger.Warn("Unhandled webhook event type", map[string]any{ + "event_type": eventType, + }) + return nil // Don't fail on unknown events + } +} + +func (s *billingService) parseSubscriptionWebhookPayload(payload map[string]any) (*domain.SubscriptionEventData, error) { + normalized := normalizePolarObject(payload) + if normalized == nil { + return nil, fmt.Errorf("webhook payload missing subscription object") + } + + data := &domain.SubscriptionEventData{} + + if subID, ok := normalized["id"].(string); ok { + data.SubscriptionID = subID + } else if subID, ok := normalized["subscription_id"].(string); ok { + data.SubscriptionID = subID + } + + if status, ok := normalized["status"].(string); ok { + data.Status = status + } + + if t, ok := parseISOTime(normalized["current_period_start"]); ok { + data.CurrentPeriodStart = t + } else if t, ok := parseISOTime(normalized["current_period_start_at"]); ok { + data.CurrentPeriodStart = t + } + + if t, ok := parseISOTime(normalized["current_period_end"]); ok { + data.CurrentPeriodEnd = t + } else if t, ok := parseISOTime(normalized["current_period_end_at"]); ok { + data.CurrentPeriodEnd = t + } + + if value, exists := normalized["cancel_at_period_end"]; exists { + if v, ok := toBool(value); ok { + data.CancelAtPeriodEnd = v + } + } + + if value, exists := normalized["canceled_at"]; exists { + if t, ok := parseISOTime(value); ok { + data.CanceledAt = &t + } + } + + product := extractProductMap(normalized) + if product == nil { + product = extractProductMap(payload) + } + + if product != nil { + if productID, ok := product["id"].(string); ok && data.ProductID == "" { + data.ProductID = productID + } + if productName, ok := product["name"].(string); ok && data.ProductName == "" { + data.ProductName = productName + } + if metadata := stringMapFrom(product["metadata"]); len(metadata) > 0 { + data.ProductMetadata = metadata + } + } + + if data.ProductID == "" { + if productID, ok := normalized["product_id"].(string); ok { + data.ProductID = productID + } else if productID, ok := payload["product_id"].(string); ok { + data.ProductID = productID + } + } + + if data.ProductName == "" { + if productName, ok := normalized["product_name"].(string); ok { + data.ProductName = productName + } + } + + if len(data.ProductMetadata) == 0 { + if metadata := stringMapFrom(normalized["product_metadata"]); len(metadata) > 0 { + data.ProductMetadata = metadata + } else if metadata := stringMapFrom(payload["product_metadata"]); len(metadata) > 0 { + data.ProductMetadata = metadata + } + } + + if product != nil { + if invoiceCount := extractInvoiceCountFromProduct(product); invoiceCount != "" { + if data.ProductMetadata == nil { + data.ProductMetadata = make(map[string]string) + } + if existing, ok := data.ProductMetadata["invoice_count"]; !ok || existing == "" { + data.ProductMetadata["invoice_count"] = invoiceCount + } + } + } + + if metadata := stringMapFrom(normalized["metadata"]); len(metadata) > 0 { + data.CustomerMetadata = metadata + } + + if len(data.CustomerMetadata) == 0 { + if customer, ok := normalized["customer"].(map[string]any); ok { + if metadata := stringMapFrom(customer["metadata"]); len(metadata) > 0 { + data.CustomerMetadata = metadata + } + + if data.ExternalCustomerID == "" { + if externalID, ok := customer["external_id"].(string); ok && externalID != "" { + data.ExternalCustomerID = externalID + } else if externalID, ok := customer["id"].(string); ok && externalID != "" { + data.ExternalCustomerID = externalID + } + } + } + } + + if len(data.CustomerMetadata) == 0 { + if metadata := stringMapFrom(payload["metadata"]); len(metadata) > 0 { + data.CustomerMetadata = metadata + } + } + + if data.ExternalCustomerID == "" { + if externalID, ok := normalized["customer_external_id"].(string); ok && externalID != "" { + data.ExternalCustomerID = externalID + } else if externalID, ok := normalized["external_customer_id"].(string); ok && externalID != "" { + data.ExternalCustomerID = externalID + } else if externalID, ok := payload["customer_external_id"].(string); ok && externalID != "" { + data.ExternalCustomerID = externalID + } + } + + if data.ExternalCustomerID == "" && len(data.CustomerMetadata) > 0 { + if externalID, ok := data.CustomerMetadata["organization_id"]; ok && externalID != "" { + data.ExternalCustomerID = externalID + } else if externalID, ok := data.CustomerMetadata["external_customer_id"]; ok && externalID != "" { + data.ExternalCustomerID = externalID + } + } + + if data.ExternalCustomerID == "" { + s.logger.Warn("Subscription webhook payload missing external customer identifier", map[string]any{ + "payload_keys": mapKeys(normalized), + }) + return nil, fmt.Errorf("webhook payload missing organization_id") + } + + s.logger.Info("Parsed subscription webhook payload", map[string]any{ + "subscription_id": data.SubscriptionID, + "external_customer_id": data.ExternalCustomerID, + "status": data.Status, + "product_id": data.ProductID, + "product_metadata_keys": len(data.ProductMetadata), + "customer_metadata_keys": len(data.CustomerMetadata), + }) + + return data, nil +} + +func (s *billingService) handleSubscriptionUpsert(ctx context.Context, eventData *domain.SubscriptionEventData) error { + // Step 1: Map Polar organization_id to internal organization ID + organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) + if err != nil { + return fmt.Errorf("failed to map organization: %w", err) + } + + s.logger.Info("Mapped organization", map[string]any{ + "external_customer_id": eventData.ExternalCustomerID, + "organization_id": organizationID, + }) + + // Step 2: Parse quota limits from product metadata (remaining invoices) + var invoiceCount int32 = 0 + if val, ok := eventData.ProductMetadata["invoice_count"]; ok { + if count, err := strconv.ParseInt(val, 10, 32); err == nil { + invoiceCount = int32(count) + } else { + s.logger.Warn("Failed to parse invoice_count from product metadata", map[string]any{ + "value": val, + "error": err.Error(), + }) + } + } else { + s.logger.Warn("invoice_count not found in product metadata", map[string]any{ + "product_metadata": eventData.ProductMetadata, + }) + } + + var maxSeats int32 = 0 + if val, ok := eventData.ProductMetadata["max_seats"]; ok { + if count, err := strconv.ParseInt(val, 10, 32); err == nil { + maxSeats = int32(count) + } + } + + s.logger.Info("Parsed quota limits from metadata", map[string]any{ + "invoice_count": invoiceCount, + "max_seats": maxSeats, + }) + + // Step 4: Create subscription domain object + subscription := &domain.Subscription{ + OrganizationID: organizationID, + ExternalCustomerID: eventData.ExternalCustomerID, + SubscriptionID: eventData.SubscriptionID, + SubscriptionStatus: eventData.Status, + ProductID: eventData.ProductID, + ProductName: eventData.ProductName, + CurrentPeriodStart: eventData.CurrentPeriodStart, + CurrentPeriodEnd: eventData.CurrentPeriodEnd, + CancelAtPeriodEnd: eventData.CancelAtPeriodEnd, + CanceledAt: eventData.CanceledAt, + } + + // Step 5: Upsert subscription to database + _, err = s.repo.UpsertSubscription(ctx, subscription) + if err != nil { + return fmt.Errorf("failed to upsert subscription: %w", err) + } + + s.logger.Info("Upserted subscription", map[string]any{ + "organization_id": organizationID, + "subscription_id": eventData.SubscriptionID, + "status": eventData.Status, + }) + + // Step 6: Create quota tracking domain object + now := time.Now() + quota := &domain.QuotaTracking{ + OrganizationID: organizationID, + InvoiceCount: invoiceCount, + MaxSeats: maxSeats, + PeriodStart: eventData.CurrentPeriodStart, + PeriodEnd: eventData.CurrentPeriodEnd, + LastSyncedAt: &now, + } + + // Step 7: Upsert quota tracking to database + _, err = s.repo.UpsertQuota(ctx, quota) + if err != nil { + return fmt.Errorf("failed to upsert quota: %w", err) + } + + s.logger.Info("Upserted quota tracking", map[string]any{ + "organization_id": organizationID, + "invoice_count": invoiceCount, + "max_seats": maxSeats, + }) + + return nil +} + +func (s *billingService) handleSubscriptionCanceled(ctx context.Context, eventData *domain.SubscriptionEventData) error { + // Step 1: Map Polar organization_id to internal organization ID + organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) + if err != nil { + return fmt.Errorf("failed to map organization: %w", err) + } + + s.logger.Info("Processing subscription cancellation", map[string]any{ + "organization_id": organizationID, + "subscription_id": eventData.SubscriptionID, + }) + + // Step 2: Create subscription object with canceled status + now := time.Now() + subscription := &domain.Subscription{ + OrganizationID: organizationID, + ExternalCustomerID: eventData.ExternalCustomerID, + SubscriptionID: eventData.SubscriptionID, + SubscriptionStatus: "canceled", + ProductID: eventData.ProductID, + ProductName: eventData.ProductName, + CurrentPeriodStart: eventData.CurrentPeriodStart, + CurrentPeriodEnd: eventData.CurrentPeriodEnd, + CancelAtPeriodEnd: false, // Already canceled + CanceledAt: &now, + } + + // If webhook includes canceled_at timestamp, use it + if eventData.CanceledAt != nil { + subscription.CanceledAt = eventData.CanceledAt + } + + // Step 3: Upsert subscription with canceled status + _, err = s.repo.UpsertSubscription(ctx, subscription) + if err != nil { + return fmt.Errorf("failed to update subscription to canceled: %w", err) + } + + s.logger.Info("Subscription marked as canceled", map[string]any{ + "organization_id": organizationID, + "subscription_id": eventData.SubscriptionID, + "canceled_at": subscription.CanceledAt, + }) + + return nil +} + +func (s *billingService) handleCustomerUpdated(ctx context.Context, eventData *domain.SubscriptionEventData) error { + // Step 1: Map Polar organization_id to internal organization ID + organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) + if err != nil { + return fmt.Errorf("failed to map organization: %w", err) + } + + s.logger.Info("Processing customer update", map[string]any{ + "organization_id": organizationID, + "metadata_keys": len(eventData.CustomerMetadata), + }) + + // Step 2: Parse invoice count from customer metadata (remaining count) + var invoiceCount int32 = 0 + if val, ok := eventData.CustomerMetadata["invoice_count"]; ok { + if count, err := strconv.ParseInt(val, 10, 32); err == nil { + invoiceCount = int32(count) + } + } + + // Step 3: Get existing quota to preserve other fields + existingQuota, err := s.repo.GetQuotaByOrgID(ctx, organizationID) + if err != nil { + // If no quota exists, create a minimal one with just the invoice count + s.logger.Warn("No existing quota found, creating new quota entry", map[string]any{ + "organization_id": organizationID, + }) + + now := time.Now() + quota := &domain.QuotaTracking{ + OrganizationID: organizationID, + InvoiceCount: invoiceCount, + MaxSeats: 0, + PeriodStart: now, + PeriodEnd: now, + LastSyncedAt: &now, + } + + _, err = s.repo.UpsertQuota(ctx, quota) + if err != nil { + return fmt.Errorf("failed to create quota: %w", err) + } + + s.logger.Info("Created new quota with invoice count", map[string]any{ + "organization_id": organizationID, + "invoice_count": invoiceCount, + }) + + return nil + } + + // Step 4: Update existing quota with new invoice count + now := time.Now() + _ = existingQuota.InvoiceCount + existingQuota.InvoiceCount = invoiceCount + existingQuota.LastSyncedAt = &now + + _, err = s.repo.UpsertQuota(ctx, existingQuota) + if err != nil { + return fmt.Errorf("failed to update quota: %w", err) + } + + s.logger.Info("Updated quota with invoice count from customer metadata", map[string]any{ + "organization_id": organizationID, + "invoice_count": invoiceCount, + }) + + return nil +} + +func (s *billingService) handleMeterGrantEvent(ctx context.Context, payload map[string]any) error { + eventData, err := s.parseMeterGrantPayload(payload) + if err != nil { + return fmt.Errorf("failed to parse meter grant payload: %w", err) + } + + if !strings.EqualFold(eventData.MeterSlug, invoicesProcessedMeterSlug) { + s.logger.Info("Ignoring meter grant event for unrelated meter", map[string]any{ + "meter_slug": eventData.MeterSlug, + }) + return nil + } + + organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, eventData.ExternalCustomerID) + if err != nil { + return fmt.Errorf("failed to map organization for meter grant: %w", err) + } + + now := time.Now() + quota, err := s.repo.GetQuotaByOrgID(ctx, organizationID) + if err != nil { + if errors.Is(err, domain.ErrQuotaNotFound) { + newQuota := &domain.QuotaTracking{ + OrganizationID: organizationID, + InvoiceCount: eventData.AvailableCredits, + MaxSeats: 0, + PeriodStart: now, + PeriodEnd: now, + LastSyncedAt: &now, + } + + if _, err := s.repo.UpsertQuota(ctx, newQuota); err != nil { + return fmt.Errorf("failed to create quota from meter grant: %w", err) + } + + s.logger.Info("Created quota from meter grant event", map[string]any{ + "organization_id": organizationID, + "meter_slug": eventData.MeterSlug, + "invoice_count": eventData.AvailableCredits, + }) + + return nil + } + + return fmt.Errorf("failed to get quota for meter grant: %w", err) + } + + previous := quota.InvoiceCount + quota.InvoiceCount = eventData.AvailableCredits + quota.LastSyncedAt = &now + + if _, err := s.repo.UpsertQuota(ctx, quota); err != nil { + return fmt.Errorf("failed to update quota from meter grant: %w", err) + } + + s.logger.Info("Updated quota from meter grant event", map[string]any{ + "organization_id": organizationID, + "meter_slug": eventData.MeterSlug, + "invoice_count": quota.InvoiceCount, + "previous_count": previous, + }) + + return nil +} + +func (s *billingService) parseMeterGrantPayload(payload map[string]any) (*domain.MeterGrantEventData, error) { + normalized := normalizePolarObject(payload) + if normalized == nil { + return nil, fmt.Errorf("meter grant payload missing object") + } + + data := &domain.MeterGrantEventData{} + + if slug, ok := toString(normalized["meter_slug"]); ok { + data.MeterSlug = strings.TrimSpace(slug) + } + if data.MeterSlug == "" { + if slug, ok := toString(normalized["slug"]); ok { + data.MeterSlug = strings.TrimSpace(slug) + } + } + if data.MeterSlug == "" { + if meter, ok := normalized["meter"].(map[string]any); ok { + if slug, ok := toString(meter["slug"]); ok { + data.MeterSlug = strings.TrimSpace(slug) + } else if slug, ok := toString(meter["meter_slug"]); ok { + data.MeterSlug = strings.TrimSpace(slug) + } else if slug, ok := toString(meter["name"]); ok { + data.MeterSlug = strings.TrimSpace(slug) + } + } + } + + if externalID, ok := toString(normalized["external_customer_id"]); ok && strings.TrimSpace(externalID) != "" { + data.ExternalCustomerID = strings.TrimSpace(externalID) + } + if data.ExternalCustomerID == "" { + if externalID, ok := toString(normalized["customer_external_id"]); ok && strings.TrimSpace(externalID) != "" { + data.ExternalCustomerID = strings.TrimSpace(externalID) + } + } + if data.ExternalCustomerID == "" { + if customer, ok := normalized["customer"].(map[string]any); ok { + if externalID, ok := toString(customer["external_id"]); ok && strings.TrimSpace(externalID) != "" { + data.ExternalCustomerID = strings.TrimSpace(externalID) + } else if externalID, ok := toString(customer["id"]); ok && strings.TrimSpace(externalID) != "" { + data.ExternalCustomerID = strings.TrimSpace(externalID) + } else if metadata := stringMapFrom(customer["metadata"]); len(metadata) > 0 { + if externalID := strings.TrimSpace(metadata["organization_id"]); externalID != "" { + data.ExternalCustomerID = externalID + } + } + } + } + if data.ExternalCustomerID == "" { + if metadata := stringMapFrom(normalized["metadata"]); len(metadata) > 0 { + if externalID := strings.TrimSpace(metadata["organization_id"]); externalID != "" { + data.ExternalCustomerID = externalID + } + } + } + + var ( + available int32 + hasBalance bool + ) + + if balanceMap, ok := normalized["balance"].(map[string]any); ok { + for _, key := range []string{"available", "remaining", "quantity", "value"} { + if value, exists := balanceMap[key]; exists { + if count, ok := toInt32(value); ok { + available = count + hasBalance = true + break + } + } + } + } + + if !hasBalance { + if creditBalance, ok := normalized["credit_balance"].(map[string]any); ok { + for _, key := range []string{"available", "remaining", "quantity"} { + if value, exists := creditBalance[key]; exists { + if count, ok := toInt32(value); ok { + available = count + hasBalance = true + break + } + } + } + } + } + + if !hasBalance { + for _, key := range []string{"available", "remaining", "balance", "quantity"} { + if value, exists := normalized[key]; exists { + if count, ok := toInt32(value); ok { + available = count + hasBalance = true + break + } + } + } + } + + if !hasBalance { + s.logger.Warn("Meter grant payload missing available balance", map[string]any{ + "payload_keys": mapKeys(normalized), + }) + return nil, fmt.Errorf("meter grant payload missing available balance") + } + + data.AvailableCredits = available + + if data.MeterSlug == "" { + s.logger.Warn("Meter grant payload missing meter slug", map[string]any{ + "payload_keys": mapKeys(normalized), + }) + return nil, fmt.Errorf("meter grant payload missing meter slug") + } + + if data.ExternalCustomerID == "" { + s.logger.Warn("Meter grant payload missing external customer identifier", map[string]any{ + "payload_keys": mapKeys(normalized), + }) + return nil, fmt.Errorf("meter grant payload missing external customer id") + } + + s.logger.Info("Parsed meter grant payload", map[string]any{ + "meter_slug": data.MeterSlug, + "external_customer_id": data.ExternalCustomerID, + "available_invoice_cnt": data.AvailableCredits, + }) + + return data, nil +} + +func normalizePolarObject(payload map[string]any) map[string]any { + if payload == nil { + return nil + } + + if object, ok := payload["object"].(map[string]any); ok && len(object) > 0 { + return object + } + + if data, ok := payload["data"].(map[string]any); ok { + if object, ok := data["object"].(map[string]any); ok && len(object) > 0 { + return object + } + } + + if dataSlice, ok := payload["data"].([]any); ok && len(dataSlice) > 0 { + for _, item := range dataSlice { + if itemMap, ok := item.(map[string]any); ok { + if object, ok := itemMap["object"].(map[string]any); ok && len(object) > 0 { + return object + } + } + } + } + + return payload +} + +func extractProductMap(input map[string]any) map[string]any { + if input == nil { + return nil + } + + if product, ok := input["product"].(map[string]any); ok { + return product + } + + if price, ok := input["price"].(map[string]any); ok { + if product, ok := price["product"].(map[string]any); ok { + return product + } + } + + if plan, ok := input["plan"].(map[string]any); ok { + if product, ok := plan["product"].(map[string]any); ok { + return product + } + } + + if itemsMap := firstMapFromSlice(input["items"]); itemsMap != nil { + if product, ok := itemsMap["product"].(map[string]any); ok { + return product + } + if price, ok := itemsMap["price"].(map[string]any); ok { + if product, ok := price["product"].(map[string]any); ok { + return product + } + } + } + + return nil +} + +func firstMapFromSlice(value any) map[string]any { + items, ok := value.([]any) + if !ok { + return nil + } + + for _, item := range items { + if itemMap, ok := item.(map[string]any); ok { + return itemMap + } + } + + return nil +} + +func stringMapFrom(value any) map[string]string { + source, ok := value.(map[string]any) + if !ok || len(source) == 0 { + return nil + } + + result := toStringMap(source) + if len(result) == 0 { + return nil + } + + return result +} + +func toStringMap(input map[string]any) map[string]string { + result := make(map[string]string, len(input)) + for key, value := range input { + if str, ok := toString(value); ok { + result[key] = str + } + } + return result +} + +func toString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case fmt.Stringer: + return v.String(), true + case bool: + return strconv.FormatBool(v), true + case int: + if v > math.MaxInt32 || v < math.MinInt32 { + return "", false + } + return strconv.Itoa(v), true + case int8: + return strconv.FormatInt(int64(v), 10), true + case int16: + return strconv.FormatInt(int64(v), 10), true + case int32: + return strconv.FormatInt(int64(v), 10), true + case int64: + return strconv.FormatInt(v, 10), true + case uint: + if v > uint(math.MaxInt32) { + return "", false + } + return strconv.FormatUint(uint64(v), 10), true + case uint8: + return strconv.FormatUint(uint64(v), 10), true + case uint16: + return strconv.FormatUint(uint64(v), 10), true + case uint32: + return strconv.FormatUint(uint64(v), 10), true + case uint64: + return strconv.FormatUint(v, 10), true + case float32: + f := float64(v) + if math.Mod(f, 1) == 0 { + return strconv.FormatInt(int64(f), 10), true + } + return strconv.FormatFloat(f, 'f', -1, 32), true + case float64: + if math.Mod(v, 1) == 0 { + return strconv.FormatInt(int64(v), 10), true + } + return strconv.FormatFloat(v, 'f', -1, 64), true + default: + return "", false + } +} + +func toInt32(value any) (int32, bool) { + switch v := value.(type) { + case int: + if v > math.MaxInt32 || v < math.MinInt32 { + return 0, false + } + return int32(v), true + case int8: + return int32(v), true + case int16: + return int32(v), true + case int32: + return v, true + case int64: + if v > int64(math.MaxInt32) || v < int64(math.MinInt32) { + return 0, false + } + return int32(v), true + case uint: + if v > uint(math.MaxInt32) { + return 0, false + } + return int32(v), true + case uint8: + return int32(v), true + case uint16: + return int32(v), true + case uint32: + if v > uint32(math.MaxInt32) { + return 0, false + } + return int32(v), true + case uint64: + if v > uint64(math.MaxInt32) { + return 0, false + } + return int32(v), true + case float32: + f := float64(v) + if math.Mod(f, 1) != 0 { + return 0, false + } + if f > float64(math.MaxInt32) || f < float64(math.MinInt32) { + return 0, false + } + return int32(f), true + case float64: + if math.Mod(v, 1) != 0 { + return 0, false + } + if v > float64(math.MaxInt32) || v < float64(math.MinInt32) { + return 0, false + } + return int32(v), true + case string: + if strings.TrimSpace(v) == "" { + return 0, false + } + if strings.Contains(v, ".") { + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return 0, false + } + if math.Mod(f, 1) != 0 { + return 0, false + } + if f > float64(math.MaxInt32) || f < float64(math.MinInt32) { + return 0, false + } + return int32(f), true + } + i, err := strconv.ParseInt(v, 10, 32) + if err != nil { + return 0, false + } + return int32(i), true + default: + return 0, false + } +} + +func parseISOTime(value any) (time.Time, bool) { + switch v := value.(type) { + case string: + if strings.TrimSpace(v) == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return time.Time{}, false + } + return t, true + case time.Time: + return v, true + default: + return time.Time{}, false + } +} + +func toBool(value any) (bool, bool) { + switch v := value.(type) { + case bool: + return v, true + case string: + if strings.TrimSpace(v) == "" { + return false, false + } + parsed, err := strconv.ParseBool(v) + if err != nil { + return false, false + } + return parsed, true + case int: + return v != 0, true + case int32: + return v != 0, true + case int64: + return v != 0, true + case float32: + return v != 0, true + case float64: + return v != 0, true + default: + return false, false + } +} + +func mapKeys(m map[string]any) []string { + if m == nil { + return nil + } + + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + return keys +} + +func extractInvoiceCountFromProduct(product map[string]any) string { + if product == nil { + return "" + } + + if metadata := stringMapFrom(product["metadata"]); len(metadata) > 0 { + if value := strings.TrimSpace(metadata["invoice_count"]); value != "" { + return value + } + } + + benefits, ok := product["benefits"].([]any) + if !ok || len(benefits) == 0 { + return "" + } + + for _, item := range benefits { + benefit, ok := item.(map[string]any) + if !ok { + continue + } + + benefitType, _ := toString(benefit["type"]) + if !strings.EqualFold(strings.TrimSpace(benefitType), "meter_credit") { + continue + } + + if properties, ok := benefit["properties"].(map[string]any); ok { + if count, ok := toInt32(properties["units"]); ok && count > 0 { + return strconv.FormatInt(int64(count), 10) + } + } + + if metadata := stringMapFrom(benefit["metadata"]); len(metadata) > 0 { + if value := strings.TrimSpace(metadata["units"]); value != "" { + return value + } + } + } + + return "" +} diff --git a/go-b2b-starter/src/app/billing/app/services/refresh_subscription_status_service.go b/go-b2b-starter/src/app/billing/app/services/refresh_subscription_status_service.go new file mode 100644 index 0000000..1b35efa --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/refresh_subscription_status_service.go @@ -0,0 +1,56 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +// RefreshSubscriptionStatus forces a sync with Polar API and returns updated status. +// This is the lazy guarding mechanism - used when DB says expired but we want +// to double-check with the provider in case we missed a webhook. +func (s *billingService) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { + // Step 1: Check if subscription exists in database + _, err := s.repo.GetSubscriptionByOrgID(ctx, organizationID) + if err != nil { + // No subscription exists - don't call Polar API + s.logger.Info("No subscription found for refresh", map[string]any{ + "organization_id": organizationID, + }) + + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: false, + CanProcessInvoices: false, + InvoiceCount: 0, + Reason: "no active subscription found", + CheckedAt: time.Now(), + }, nil + } + + // Step 2: Sync subscription from Polar API + if err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil { + // Sync failed - return error + return nil, fmt.Errorf("failed to refresh subscription from Polar: %w", err) + } + + // Step 3: Get fresh billing status from database (after sync) + billingStatus, err := s.GetBillingStatus(ctx, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get billing status after refresh: %w", err) + } + + s.logger.Info("Subscription status refreshed", map[string]any{ + "organization_id": organizationID, + "has_active_subscription": billingStatus.HasActiveSubscription, + "invoice_count": billingStatus.InvoiceCount, + }) + + // Console log for refresh completion + fmt.Printf("🔄 SUBSCRIPTION REFRESHED - Org: %d | Active: %v | Invoice Count: %d | Reason: %s\n", + organizationID, billingStatus.HasActiveSubscription, billingStatus.InvoiceCount, billingStatus.Reason) + + return billingStatus, nil +} diff --git a/go-b2b-starter/src/app/billing/app/services/subscription_service_dec.go b/go-b2b-starter/src/app/billing/app/services/subscription_service_dec.go new file mode 100644 index 0000000..631b3a4 --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/subscription_service_dec.go @@ -0,0 +1,101 @@ +package services + +import ( + "context" + + "github.com/moasq/go-b2b-starter/app/billing/domain" + logger "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +// BillingService handles subscription management and quota verification. +// +// This service manages the billing lifecycle with Polar.sh via event-driven webhooks. +// It does NOT expose direct API calls to Polar during request handling - instead, +// subscription state is synced via webhooks and stored locally for fast reads. +// +// Architecture: +// +// ┌───────────────┐ webhooks ┌─────────────────┐ reads ┌─────────────┐ +// │ Polar.sh │ ─────────────► │ BillingService │ ──────────► │ Local DB │ +// └───────────────┘ └─────────────────┘ └─────────────┘ +// │ +// ▼ +// ┌─────────────────┐ +// │ Paywall reads │ +// │ from local DB │ +// └─────────────────┘ +type BillingService interface { + // ProcessWebhookEvent processes a Polar webhook event and updates local database + // Handles: subscription.created, subscription.updated, subscription.canceled, customer.updated + ProcessWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error + + // GetBillingStatus retrieves the current billing and quota status for an organization + // This is a read-only operation from the local database + GetBillingStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) + + // CheckQuotaAvailability performs a read-only check of quota availability + // Does NOT consume quota - use ConsumeInvoiceQuota after successful processing + // Performs database-first check with fallback to Polar API if needed + // Returns BillingStatus indicating if invoice processing is allowed + CheckQuotaAvailability(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) + + // ConsumeInvoiceQuota explicitly consumes one invoice quota after successful processing + // Should be called after invoice has been successfully processed + // Can be called asynchronously in background for better performance + // Returns updated quota status + ConsumeInvoiceQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) + + // VerifyAndConsumeQuota verifies quota availability and consumes one invoice quota + // Performs database-first check with fallback to Polar API if needed + // Returns BillingStatus with detailed verification result + // Automatically increments quota count on success + // DEPRECATED: Use CheckQuotaAvailability + ConsumeInvoiceQuota pattern for better control + VerifyAndConsumeQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) + + // SyncSubscriptionFromPolar forces a sync of subscription data from Polar API + // Used as fallback when webhook data is missing or stale + // TODO: Implement periodic background sync job for all subscriptions + SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error + + // VerifyPaymentFromCheckout verifies a payment by checking the Polar checkout session + // This is the primary mechanism for "Verification on Redirect" pattern + // Called when user returns from payment page with session_id + // Returns BillingStatus after updating database with latest subscription info + VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*domain.BillingStatus, error) + + // RefreshSubscriptionStatus forces a sync with Polar API and returns updated status + // This is the lazy guarding mechanism - used when DB says expired but we want + // to double-check with the provider in case we missed a webhook + // Returns updated BillingStatus after syncing with provider + RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) +} + +type billingService struct { + repo domain.SubscriptionRepository + orgAdapter domain.OrganizationAdapter + polarAdapter PolarAdapter + logger logger.Logger +} + +// NewBillingService creates a new billing service +func NewBillingService( + repo domain.SubscriptionRepository, + orgAdapter domain.OrganizationAdapter, + polarAdapter PolarAdapter, + logger logger.Logger, +) BillingService { + return &billingService{ + repo: repo, + orgAdapter: orgAdapter, + polarAdapter: polarAdapter, + logger: logger, + } +} + +// PolarAdapter defines the interface for Polar API operations +type PolarAdapter interface { + GetSubscription(ctx context.Context, externalCustomerID string) (*domain.Subscription, error) + GetCheckoutSession(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) + GetCheckoutSessionWithPolling(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) + IngestMeterEvent(ctx context.Context, externalCustomerID string, meterSlug string, amount int32) error +} diff --git a/go-b2b-starter/src/app/billing/app/services/sync_subscription_service.go b/go-b2b-starter/src/app/billing/app/services/sync_subscription_service.go new file mode 100644 index 0000000..2dc2d00 --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/sync_subscription_service.go @@ -0,0 +1,69 @@ +package services + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +func (s *billingService) SyncSubscriptionFromPolar(ctx context.Context, organizationID int32) error { + // Get organization's external customer ID + externalID, err := s.orgAdapter.GetStytchOrgID(ctx, organizationID) + if err != nil { + return fmt.Errorf("failed to get organization external ID: %w", err) + } + + // Fetch subscription from Polar + subscription, err := s.polarAdapter.GetSubscription(ctx, externalID) + if err != nil { + return fmt.Errorf("failed to fetch subscription from Polar: %w", err) + } + + // Upsert subscription to database + subscription.OrganizationID = organizationID + _, err = s.repo.UpsertSubscription(ctx, subscription) + if err != nil { + return fmt.Errorf("failed to save subscription: %w", err) + } + + // Extract and upsert quota information + invoiceCountMax := int32(0) + if metadata, ok := subscription.Metadata["invoice_count_max"].(int32); ok { + invoiceCountMax = metadata + } else if val, ok := subscription.Metadata["invoice_count_max"].(string); ok { + if count, err := strconv.ParseInt(val, 10, 32); err == nil { + invoiceCountMax = int32(count) + } + } + + // Create or update quota tracking with synced data + quota := &domain.QuotaTracking{ + OrganizationID: organizationID, + InvoiceCount: invoiceCountMax, + PeriodStart: subscription.CurrentPeriodStart, + PeriodEnd: subscription.CurrentPeriodEnd, + LastSyncedAt: &time.Time{}, + } + *quota.LastSyncedAt = time.Now() + + _, err = s.repo.UpsertQuota(ctx, quota) + if err != nil { + return fmt.Errorf("failed to save quota: %w", err) + } + + s.logger.Info("Synced subscription and quota from Polar", map[string]any{ + "organization_id": organizationID, + "subscription_id": subscription.SubscriptionID, + "invoice_count": invoiceCountMax, + "synced_at": quota.LastSyncedAt, + }) + + // Console log for sync completion + fmt.Printf("🔄 SYNC COMPLETED - Org: %d | Subscription: %s | Invoice Count: %d | Status: %s | Synced at: %s\n", + organizationID, subscription.SubscriptionID, invoiceCountMax, subscription.SubscriptionStatus, quota.LastSyncedAt.Format(time.RFC3339)) + + return nil +} diff --git a/go-b2b-starter/src/app/billing/app/services/verify_and_consume_quota_service.go b/go-b2b-starter/src/app/billing/app/services/verify_and_consume_quota_service.go new file mode 100644 index 0000000..252b18c --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/verify_and_consume_quota_service.go @@ -0,0 +1,83 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +func (s *billingService) VerifyAndConsumeQuota(ctx context.Context, organizationID int32) (*domain.BillingStatus, error) { + // Step 1: Check database quota status + quotaStatus, err := s.repo.GetQuotaStatus(ctx, organizationID) + if err != nil { + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: false, + CanProcessInvoices: false, + Reason: "no active subscription", + CheckedAt: time.Now(), + }, domain.ErrSubscriptionNotFound + } + + // Step 2: Check if we need fallback API verification + needsFallback := s.needsFallbackVerification(quotaStatus) + + if needsFallback { + s.logger.Info("Quota near limit or stale, performing fallback API verification", map[string]any{ + "organization_id": organizationID, + "invoice_count": quotaStatus.InvoiceCount, + }) + + // Sync from Polar and re-check + if err := s.SyncSubscriptionFromPolar(ctx, organizationID); err != nil { + s.logger.Error("Fallback sync failed, using database data", map[string]any{ + "organization_id": organizationID, + "error": err.Error(), + }) + } else { + // Re-fetch quota status after sync + quotaStatus, err = s.repo.GetQuotaStatus(ctx, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get quota after sync: %w", err) + } + } + } + + // Step 3: Verify quota is available + if !quotaStatus.CanProcessInvoice { + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: quotaStatus.SubscriptionStatus == "active", + CanProcessInvoices: false, + InvoiceCount: quotaStatus.InvoiceCount, + Reason: "quota exceeded or subscription inactive", + CheckedAt: time.Now(), + }, domain.ErrQuotaExceeded + } + + // Step 4: Decrement quota count (consume one invoice) + _, err = s.repo.DecrementInvoiceCount(ctx, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to decrement invoice count: %w", err) + } + + // Step 5: Return success status + return &domain.BillingStatus{ + OrganizationID: organizationID, + HasActiveSubscription: true, + CanProcessInvoices: true, + InvoiceCount: quotaStatus.InvoiceCount - 1, // Already decremented + Reason: "quota verified and consumed", + CheckedAt: time.Now(), + }, nil +} + +func (s *billingService) needsFallbackVerification(status *domain.QuotaStatus) bool { + // Perform fallback if: + // 1. Very few invoices remaining (< 10) + // 2. Subscription is inactive but we're checking + + return status.InvoiceCount < 10 || status.SubscriptionStatus != "active" +} diff --git a/go-b2b-starter/src/app/billing/app/services/verify_payment_service.go b/go-b2b-starter/src/app/billing/app/services/verify_payment_service.go new file mode 100644 index 0000000..b852cd8 --- /dev/null +++ b/go-b2b-starter/src/app/billing/app/services/verify_payment_service.go @@ -0,0 +1,103 @@ +package services + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" +) + +func (s *billingService) VerifyPaymentFromCheckout(ctx context.Context, sessionID string) (*domain.BillingStatus, error) { + // Step 1: Get checkout session from Polar with polling + checkoutSession, err := s.polarAdapter.GetCheckoutSessionWithPolling(ctx, sessionID) + if err != nil { + fmt.Printf("❌ [VerifyPayment] Failed to verify checkout session %s: %v\n", sessionID, err) + return nil, fmt.Errorf("failed to get checkout session: %w", err) + } + + fmt.Printf("✅ [VerifyPayment] Checkout session %s verified with status: %s\n", sessionID, checkoutSession.Status) + + // Step 2: Verify checkout status + if checkoutSession.Status != "succeeded" { + return &domain.BillingStatus{ + HasActiveSubscription: false, + CanProcessInvoices: false, + Reason: fmt.Sprintf("checkout session status is %s (expected: succeeded)", checkoutSession.Status), + CheckedAt: time.Now(), + }, nil + } + + // Step 3: Extract customer ID (this is the Stytch org ID) + externalCustomerID := checkoutSession.CustomerID + if externalCustomerID == "" { + return nil, fmt.Errorf("checkout session has no customer_id") + } + + // Step 4: Map external customer ID to internal organization ID + organizationID, err := s.orgAdapter.GetOrganizationIDByStytchOrgID(ctx, externalCustomerID) + if err != nil { + return nil, fmt.Errorf("failed to map customer ID to organization: %w", err) + } + + // Step 5: Fetch full subscription details from Polar + subscription, err := s.polarAdapter.GetSubscription(ctx, externalCustomerID) + if err != nil { + return nil, fmt.Errorf("failed to fetch subscription from Polar: %w", err) + } + + // Step 6: Upsert subscription to database + subscription.OrganizationID = organizationID + _, err = s.repo.UpsertSubscription(ctx, subscription) + if err != nil { + return nil, fmt.Errorf("failed to save subscription: %w", err) + } + + // Step 7: Extract and upsert quota information + invoiceCountMax := int32(0) + if metadata, ok := subscription.Metadata["invoice_count_max"].(int32); ok { + invoiceCountMax = metadata + } else if val, ok := subscription.Metadata["invoice_count_max"].(string); ok { + if count, err := strconv.ParseInt(val, 10, 32); err == nil { + invoiceCountMax = int32(count) + } + } + + // Create or update quota tracking + quota := &domain.QuotaTracking{ + OrganizationID: organizationID, + InvoiceCount: invoiceCountMax, + PeriodStart: subscription.CurrentPeriodStart, + PeriodEnd: subscription.CurrentPeriodEnd, + LastSyncedAt: &time.Time{}, + } + *quota.LastSyncedAt = time.Now() + + _, err = s.repo.UpsertQuota(ctx, quota) + if err != nil { + return nil, fmt.Errorf("failed to save quota: %w", err) + } + + s.logger.Info("Payment verified from checkout session", map[string]any{ + "session_id": sessionID, + "organization_id": organizationID, + "subscription_id": subscription.SubscriptionID, + "invoice_count": invoiceCountMax, + }) + + // Console log for verification completion + fmt.Printf("✅ PAYMENT VERIFIED - Session: %s | Org: %d | Subscription: %s | Invoice Count: %d | Status: %s\n", + sessionID, organizationID, subscription.SubscriptionID, invoiceCountMax, subscription.SubscriptionStatus) + + // Step 8: Return billing status + return &domain.BillingStatus{ + OrganizationID: organizationID, + ExternalID: externalCustomerID, + HasActiveSubscription: subscription.SubscriptionStatus == "active" || subscription.SubscriptionStatus == "trialing", + CanProcessInvoices: (subscription.SubscriptionStatus == "active" || subscription.SubscriptionStatus == "trialing") && invoiceCountMax > 0, + InvoiceCount: invoiceCountMax, + Reason: "Payment verified successfully", + CheckedAt: time.Now(), + }, nil +} diff --git a/go-b2b-starter/src/app/billing/cmd/init.go b/go-b2b-starter/src/app/billing/cmd/init.go new file mode 100644 index 0000000..1b8b250 --- /dev/null +++ b/go-b2b-starter/src/app/billing/cmd/init.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "go.uber.org/dig" +) + +// Init initializes the billing module. +// +// The billing module handles subscription lifecycle management with Polar.sh: +// - Webhook processing for subscription events +// - Quota tracking and consumption +// - Billing status queries +// +// Communication is event-driven: +// - Polar sends webhook → billing processes event → updates local DB +// - Paywall middleware reads from local DB (no external API calls) +func Init(container *dig.Container) error { + // Register all dependencies + if err := ProvideDependencies(container); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/app/billing/cmd/provider.go b/go-b2b-starter/src/app/billing/cmd/provider.go new file mode 100644 index 0000000..7c280c9 --- /dev/null +++ b/go-b2b-starter/src/app/billing/cmd/provider.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/billing/app/services" + "github.com/moasq/go-b2b-starter/app/billing/infra/adapters" + "github.com/moasq/go-b2b-starter/pkg/paywall" +) + +// ProvideDependencies registers all billing module dependencies +func ProvideDependencies(container *dig.Container) error { + // Use the services module for dependency injection + servicesModule := services.NewModule() + if err := servicesModule.Configure(container); err != nil { + return fmt.Errorf("failed to configure billing services: %w", err) + } + + // Register SubscriptionStatusProvider for the paywall middleware + // This adapter bridges the billing module to the pkg/paywall middleware + // Communication is event-driven: webhooks → billing → DB → paywall reads + if err := container.Provide(func(svc services.BillingService) paywall.SubscriptionStatusProvider { + return adapters.NewStatusProviderAdapter(svc) + }); err != nil { + return fmt.Errorf("failed to provide subscription status provider: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/app/billing/domain/errors.go b/go-b2b-starter/src/app/billing/domain/errors.go new file mode 100644 index 0000000..5e94aab --- /dev/null +++ b/go-b2b-starter/src/app/billing/domain/errors.go @@ -0,0 +1,26 @@ +package domain + +import "errors" + +var ( + // ErrSubscriptionNotFound is returned when a subscription cannot be found + ErrSubscriptionNotFound = errors.New("subscription not found") + + // ErrSubscriptionNotActive is returned when a subscription exists but is not active + ErrSubscriptionNotActive = errors.New("subscription is not active") + + // ErrQuotaNotFound is returned when quota tracking record cannot be found + ErrQuotaNotFound = errors.New("quota not found") + + // ErrQuotaExceeded is returned when invoice quota has been exceeded + ErrQuotaExceeded = errors.New("invoice quota exceeded") + + // ErrInvalidWebhookPayload is returned when webhook payload cannot be parsed + ErrInvalidWebhookPayload = errors.New("invalid webhook payload") + + // ErrWebhookSignatureInvalid is returned when webhook signature verification fails + ErrWebhookSignatureInvalid = errors.New("webhook signature invalid") + + // ErrQuotaDataStale is returned when quota data hasn't been synced recently + ErrQuotaDataStale = errors.New("quota data is stale") +) diff --git a/go-b2b-starter/src/app/billing/domain/repository.go b/go-b2b-starter/src/app/billing/domain/repository.go new file mode 100644 index 0000000..1f57de9 --- /dev/null +++ b/go-b2b-starter/src/app/billing/domain/repository.go @@ -0,0 +1,25 @@ +package domain + +import "context" + +// SubscriptionRepository provides database operations for subscriptions and quotas +type SubscriptionRepository interface { + // Subscription operations + GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*Subscription, error) + UpsertSubscription(ctx context.Context, subscription *Subscription) (*Subscription, error) + DeleteSubscription(ctx context.Context, organizationID int32) error + + // Quota operations + GetQuotaByOrgID(ctx context.Context, organizationID int32) (*QuotaTracking, error) + UpsertQuota(ctx context.Context, quota *QuotaTracking) (*QuotaTracking, error) + DecrementInvoiceCount(ctx context.Context, organizationID int32) (*QuotaTracking, error) + + // Combined operations + GetQuotaStatus(ctx context.Context, organizationID int32) (*QuotaStatus, error) +} + +// OrganizationAdapter provides access to organization data +type OrganizationAdapter interface { + GetStytchOrgID(ctx context.Context, organizationID int32) (string, error) + GetOrganizationIDByStytchOrgID(ctx context.Context, stytchOrgID string) (int32, error) +} diff --git a/go-b2b-starter/src/app/billing/domain/types.go b/go-b2b-starter/src/app/billing/domain/types.go new file mode 100644 index 0000000..2a5d8b2 --- /dev/null +++ b/go-b2b-starter/src/app/billing/domain/types.go @@ -0,0 +1,97 @@ +package domain + +import "time" + +// Subscription represents a billing subscription from Polar +type Subscription struct { + ID int32 + OrganizationID int32 + ExternalCustomerID string + SubscriptionID string + SubscriptionStatus string + ProductID string + ProductName string + PlanName string + CurrentPeriodStart time.Time + CurrentPeriodEnd time.Time + CancelAtPeriodEnd bool + CanceledAt *time.Time + Metadata map[string]any + CreatedAt time.Time + UpdatedAt time.Time +} + +// QuotaTracking represents usage quota tracking for an organization +type QuotaTracking struct { + ID int32 + OrganizationID int32 + InvoiceCount int32 // Remaining invoices (decremented on use) + MaxSeats int32 + PeriodStart time.Time + PeriodEnd time.Time + LastSyncedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// QuotaStatus represents the combined subscription and quota status +// This is returned from the GetQuotaStatus database query +type QuotaStatus struct { + SubscriptionStatus string + CurrentPeriodStart time.Time + CurrentPeriodEnd time.Time + CancelAtPeriodEnd bool + InvoiceCount int32 // Remaining invoices + MaxSeats int32 + CanProcessInvoice bool +} + +// BillingStatus represents the overall billing status for quota verification +type BillingStatus struct { + OrganizationID int32 + ExternalID string + HasActiveSubscription bool + CanProcessInvoices bool + InvoiceCount int32 // Remaining invoices + Reason string + CheckedAt time.Time +} + +// WebhookEvent represents a Polar webhook event +type WebhookEvent struct { + EventType string + Payload map[string]any +} + +// SubscriptionEventData represents parsed subscription data from webhook +type SubscriptionEventData struct { + SubscriptionID string + ExternalCustomerID string + ProductID string + ProductName string + Status string + CurrentPeriodStart time.Time + CurrentPeriodEnd time.Time + CancelAtPeriodEnd bool + CanceledAt *time.Time + ProductMetadata map[string]string + CustomerMetadata map[string]string +} + +// MeterGrantEventData represents meter grant payload details from Polar webhooks +type MeterGrantEventData struct { + MeterSlug string + ExternalCustomerID string + AvailableCredits int32 +} + +// CheckoutSessionResponse represents a Polar checkout session +type CheckoutSessionResponse struct { + ID string + Status string // "succeeded", "pending", "expired", "failed" + CustomerID string + SubscriptionID string + ProductID string + Amount int64 + CreatedAt time.Time +} diff --git a/go-b2b-starter/src/app/billing/go.mod b/go-b2b-starter/src/app/billing/go.mod new file mode 100644 index 0000000..3f5644c --- /dev/null +++ b/go-b2b-starter/src/app/billing/go.mod @@ -0,0 +1,76 @@ +module github.com/moasq/go-b2b-starter/app/billing + +go 1.25 + + +replace ( + github.com/moasq/go-b2b-starter/pkg/auth => ../../pkg/auth + github.com/moasq/go-b2b-starter/pkg/db => ../../pkg/db + github.com/moasq/go-b2b-starter/pkg/logger => ../../pkg/logger + github.com/moasq/go-b2b-starter/pkg/polar => ../../pkg/polar + github.com/moasq/go-b2b-starter/pkg/paywall => ../../pkg/paywall +) + +require ( + github.com/jackc/pgx/v5 v5.7.2 + github.com/moasq/go-b2b-starter/pkg/db v0.0.0 + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + github.com/moasq/go-b2b-starter/pkg/polar v0.0.0 + github.com/moasq/go-b2b-starter/pkg/paywall v0.0.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moasq/go-b2b-starter/pkg/auth v0.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pgvector/pgvector-go v0.3.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/app/billing/go.sum b/go-b2b-starter/src/app/billing/go.sum new file mode 100644 index 0000000..74236ec --- /dev/null +++ b/go-b2b-starter/src/app/billing/go.sum @@ -0,0 +1,198 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic v1.12.5/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/app/billing/infra/adapters/status_provider.go b/go-b2b-starter/src/app/billing/infra/adapters/status_provider.go new file mode 100644 index 0000000..e15841a --- /dev/null +++ b/go-b2b-starter/src/app/billing/infra/adapters/status_provider.go @@ -0,0 +1,117 @@ +// Package adapters provides adapter implementations for external interfaces. +package adapters + +import ( + "context" + + "github.com/moasq/go-b2b-starter/app/billing/app/services" + "github.com/moasq/go-b2b-starter/pkg/paywall" +) + +// StatusProviderAdapter adapts the BillingService to the SubscriptionStatusProvider interface. +// +// This adapter allows the paywall middleware to check subscription status +// without depending directly on the billing service implementation. +// Communication is event-driven: Polar webhooks → billing module → local DB → paywall reads. +type StatusProviderAdapter struct { + service services.BillingService +} + +// NewStatusProviderAdapter creates a new StatusProviderAdapter. +func NewStatusProviderAdapter(service services.BillingService) paywall.SubscriptionStatusProvider { + return &StatusProviderAdapter{service: service} +} + +// GetSubscriptionStatus implements paywall.SubscriptionStatusProvider. +// +// It delegates to the BillingService.GetBillingStatus method and converts +// the BillingStatus to a SubscriptionStatus for the middleware to use. +func (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, organizationID int32) (*paywall.SubscriptionStatus, error) { + billingStatus, err := a.service.GetBillingStatus(ctx, organizationID) + if err != nil { + return nil, err + } + + // Map BillingStatus to SubscriptionStatus + status := &paywall.SubscriptionStatus{ + OrganizationID: billingStatus.OrganizationID, + IsActive: billingStatus.HasActiveSubscription, + Reason: billingStatus.Reason, + } + + // Determine status string from reason + if billingStatus.HasActiveSubscription { + status.Status = paywall.StatusActive + } else if billingStatus.Reason == "no active subscription found" { + status.Status = paywall.StatusNone + } else { + // Parse status from reason if available, otherwise default to inactive + status.Status = parseStatusFromReason(billingStatus.Reason) + } + + return status, nil +} + +// RefreshSubscriptionStatus implements paywall.SubscriptionStatusProvider. +// +// It forces a sync with the payment provider API and returns the updated status. +// This is the lazy guarding mechanism - used when DB says expired but we want +// to double-check with the provider in case we missed a webhook. +func (a *StatusProviderAdapter) RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*paywall.SubscriptionStatus, error) { + // Delegate to the BillingService.RefreshSubscriptionStatus method + billingStatus, err := a.service.RefreshSubscriptionStatus(ctx, organizationID) + if err != nil { + return nil, err + } + + // Map BillingStatus to SubscriptionStatus + status := &paywall.SubscriptionStatus{ + OrganizationID: billingStatus.OrganizationID, + IsActive: billingStatus.HasActiveSubscription, + Reason: billingStatus.Reason, + } + + // Determine status string from reason + if billingStatus.HasActiveSubscription { + status.Status = paywall.StatusActive + } else if billingStatus.Reason == "no active subscription found" { + status.Status = paywall.StatusNone + } else { + // Parse status from reason if available, otherwise default to inactive + status.Status = parseStatusFromReason(billingStatus.Reason) + } + + return status, nil +} + +// parseStatusFromReason attempts to extract a subscription status from the reason string. +func parseStatusFromReason(reason string) string { + // Check for common status patterns in reason + switch { + case containsStatus(reason, "past_due"): + return paywall.StatusPastDue + case containsStatus(reason, "canceled"): + return paywall.StatusCanceled + case containsStatus(reason, "unpaid"): + return paywall.StatusUnpaid + case containsStatus(reason, "trialing"): + return paywall.StatusTrialing + default: + return paywall.StatusNone + } +} + +// containsStatus checks if the reason contains a specific status. +func containsStatus(reason, status string) bool { + return len(reason) >= len(status) && contains(reason, status) +} + +// contains is a simple substring check. +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/go-b2b-starter/src/app/billing/infra/polar/polar_adapter.go b/go-b2b-starter/src/app/billing/infra/polar/polar_adapter.go new file mode 100644 index 0000000..1eb9d78 --- /dev/null +++ b/go-b2b-starter/src/app/billing/infra/polar/polar_adapter.go @@ -0,0 +1,333 @@ +package polar + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/app/billing/domain" + polarpkg "github.com/moasq/go-b2b-starter/pkg/polar" +) + +type polarAdapter struct { + client *polarpkg.Client +} + +// NewPolarAdapter creates a new Polar API adapter +func NewPolarAdapter(client *polarpkg.Client) *polarAdapter { + return &polarAdapter{ + client: client, + } +} + +func (p *polarAdapter) GetSubscription(ctx context.Context, externalCustomerID string) (*domain.Subscription, error) { + // Call Polar API to get subscription by customer external ID + endpoint := fmt.Sprintf("/v1/subscriptions?customer_external_id=%s", externalCustomerID) + + resp, err := p.client.Get(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to call Polar API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("polar API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response + var result struct { + Items []struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + ProductID string `json:"product_id"` + Status string `json:"status"` + CurrentPeriodStart string `json:"current_period_start"` + CurrentPeriodEnd string `json:"current_period_end"` + CanceledAt *string `json:"canceled_at"` + Customer struct { + ID string `json:"id"` + Metadata map[string]string `json:"metadata"` + } `json:"customer"` + Product struct { + ID string `json:"id"` + Name string `json:"name"` + Metadata map[string]string `json:"metadata"` + } `json:"product"` + } `json:"items"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(result.Items) == 0 { + return nil, domain.ErrSubscriptionNotFound + } + + polarSub := result.Items[0] + + // Parse timestamps + currentPeriodStart, _ := parseTime(polarSub.CurrentPeriodStart) + currentPeriodEnd, _ := parseTime(polarSub.CurrentPeriodEnd) + + var canceledAt *time.Time + if polarSub.CanceledAt != nil { + t, _ := parseTime(*polarSub.CanceledAt) + canceledAt = &t + } + + // Parse quota limit from product metadata + invoiceCountMax := int32(0) + if val, ok := polarSub.Product.Metadata["invoice_count"]; ok { + if count, err := strconv.ParseInt(val, 10, 32); err == nil { + invoiceCountMax = int32(count) + } + } + + // Console log for Polar API response + fmt.Printf("🌐 POLAR API SYNC - Customer: %s | Subscription: %s | Invoice Count: %d | Status: %s | Product: %s\n", + externalCustomerID, polarSub.ID, invoiceCountMax, polarSub.Status, polarSub.Product.Name) + + // Create domain subscription (organizationID will be set by caller) + subscription := &domain.Subscription{ + ExternalCustomerID: externalCustomerID, + SubscriptionID: polarSub.ID, + SubscriptionStatus: polarSub.Status, + ProductID: polarSub.ProductID, + ProductName: polarSub.Product.Name, + CurrentPeriodStart: currentPeriodStart, + CurrentPeriodEnd: currentPeriodEnd, + CanceledAt: canceledAt, + Metadata: map[string]any{ + "invoice_count_max": invoiceCountMax, + "product_metadata": polarSub.Product.Metadata, + "customer_metadata": polarSub.Customer.Metadata, + }, + } + + return subscription, nil +} + +// GetCheckoutSession retrieves checkout session details from Polar +func (p *polarAdapter) GetCheckoutSession(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) { + // Call Polar API to get checkout session details + endpoint := fmt.Sprintf("/v1/checkouts/custom/%s", sessionID) + + resp, err := p.client.Get(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to call Polar checkout API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, fmt.Errorf("checkout session not found: %s", sessionID) + } + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("polar checkout API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response - Polar returns customer_external_id at root level + var result struct { + ID string `json:"id"` + Status string `json:"status"` + Amount int64 `json:"amount"` + CustomerExternalID string `json:"customer_external_id"` // The Stytch org ID we passed during checkout + CustomerID string `json:"customer_id"` // Polar internal customer ID + Product struct { + ID string `json:"id"` + } `json:"product"` + Customer struct { + ID string `json:"id"` + ExternalID string `json:"external_id"` // Also available in nested customer object + } `json:"customer"` + Subscription struct { + ID string `json:"id"` + } `json:"subscription"` + CreatedAt string `json:"created_at"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode checkout response: %w", err) + } + + // Parse timestamp + createdAt, _ := parseTime(result.CreatedAt) + + // Resolve external customer ID - try multiple fields + externalCustomerID := result.CustomerExternalID + if externalCustomerID == "" { + externalCustomerID = result.Customer.ExternalID + } + + // Console log for checkout session retrieval + fmt.Printf("🔍 POLAR CHECKOUT SESSION - ID: %s | Status: %s | ExternalCustomerID: %s | CustomerID: %s | Subscription: %s\n", + result.ID, result.Status, externalCustomerID, result.CustomerID, result.Subscription.ID) + + // Create domain checkout session response + checkoutSession := &domain.CheckoutSessionResponse{ + ID: result.ID, + Status: result.Status, + CustomerID: externalCustomerID, // Use external customer ID (Stytch org ID) + SubscriptionID: result.Subscription.ID, + ProductID: result.Product.ID, + Amount: result.Amount, + CreatedAt: createdAt, + } + + return checkoutSession, nil +} + +// GetCheckoutSessionWithPolling retrieves checkout session with polling and retry logic +// Polls every 2 seconds for up to 10 seconds (5 attempts total) +// Continues polling when status is "pending" or on transient errors +// Returns immediately on "succeeded" status or non-retryable errors +func (p *polarAdapter) GetCheckoutSessionWithPolling(ctx context.Context, sessionID string) (*domain.CheckoutSessionResponse, error) { + const ( + pollInterval = 2 * time.Second // Poll every 2 seconds + maxDuration = 10 * time.Second // Total timeout: 10 seconds + ) + + deadline := time.Now().Add(maxDuration) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + // First attempt (immediate) + session, err := p.GetCheckoutSession(ctx, sessionID) + if err == nil && session.Status == "succeeded" { + return session, nil + } + + // Log initial status + if err == nil { + fmt.Printf("🔄 [Polar Polling] Initial status: %s, will poll for %v\n", session.Status, maxDuration) + } else if !isRetryableError(err) { + // Non-retryable error (e.g., 404) - fail immediately + return nil, err + } else { + fmt.Printf("⚠️ [Polar Polling] Initial attempt failed: %v, will retry\n", err) + } + + // Polling loop + attemptCount := 1 + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + attemptCount++ + session, err := p.GetCheckoutSession(ctx, sessionID) + + if err == nil { + fmt.Printf("🔄 [Polar Polling] Attempt %d: status=%s\n", attemptCount, session.Status) + if session.Status == "succeeded" { + fmt.Printf("✅ [Polar Polling] Success after %d attempts\n", attemptCount) + return session, nil + } + // Continue polling for "pending", "processing", etc. + continue + } + + // Check if error is retryable + if !isRetryableError(err) { + fmt.Printf("❌ [Polar Polling] Non-retryable error: %v\n", err) + return nil, err + } + + fmt.Printf("⚠️ [Polar Polling] Attempt %d failed (retryable): %v\n", attemptCount, err) + } + } + + // Timeout reached - get last known status + lastStatus := "unknown" + if session != nil { + lastStatus = session.Status + } + fmt.Printf("⏱️ [Polar Polling] Timeout after %d attempts (10s), last status: %s\n", attemptCount, lastStatus) + return nil, fmt.Errorf("checkout verification timed out after 10 seconds (last status: %s)", lastStatus) +} + +// isRetryableError determines if an error should trigger a retry +func isRetryableError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + + // Don't retry 404 (session not found) + if strings.Contains(errStr, "checkout session not found") || strings.Contains(errStr, "404") { + return false + } + + // Don't retry 4xx client errors (except 429) + if strings.Contains(errStr, "returned status 400") || + strings.Contains(errStr, "returned status 401") || + strings.Contains(errStr, "returned status 403") { + return false + } + + // Retry on: + // - Network errors + // - 5xx server errors + // - 429 rate limit errors + // - Timeout errors + // - Connection errors + return true +} + +// IngestMeterEvent ingests a meter event to Polar for usage-based billing +// This notifies Polar about invoice processing to consume meter credits +// Meter: "Invoice Processing" +func (p *polarAdapter) IngestMeterEvent(ctx context.Context, externalCustomerID string, meterSlug string, amount int32) error { + // Call Polar API to ingest meter event + // POST /v1/events/ingest endpoint for event ingestion + endpoint := "/v1/events/ingest" + + // Prepare request body for event ingestion + // Events must be wrapped in "events" array + // Meter will automatically aggregate events and decrement credits + body := map[string]any{ + "events": []map[string]any{ + { + "name": meterSlug, + "external_customer_id": externalCustomerID, + "metadata": map[string]any{ + "count": amount, + }, + }, + }, + } + + // Log the exact payload being sent to Polar for debugging + bodyJSON, _ := json.Marshal(body) + fmt.Printf("📤 SENDING TO POLAR - POST %s\nPayload: %s\n", endpoint, string(bodyJSON)) + + resp, err := p.client.Post(ctx, endpoint, body) + if err != nil { + return fmt.Errorf("failed to call Polar events API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("polar events API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + // Console log for successful event ingestion + fmt.Printf("✅ EVENT INGESTED - Customer: %s | Event: %s | Amount: %d | Polar meters will aggregate\n", + externalCustomerID, meterSlug, amount) + + return nil +} + +func parseTime(s string) (time.Time, error) { + // Parse ISO 8601 timestamp + return time.Parse(time.RFC3339, s) +} diff --git a/go-b2b-starter/src/app/billing/infra/repositories/organization_adapter.go b/go-b2b-starter/src/app/billing/infra/repositories/organization_adapter.go new file mode 100644 index 0000000..454c0d9 --- /dev/null +++ b/go-b2b-starter/src/app/billing/infra/repositories/organization_adapter.go @@ -0,0 +1,48 @@ +package repositories + +import ( + "context" + "fmt" + + "github.com/moasq/go-b2b-starter/app/billing/domain" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + "github.com/jackc/pgx/v5/pgtype" +) + +type organizationAdapter struct { + orgStore adapters.OrganizationStore +} + +// NewOrganizationAdapter creates a new organization adapter +func NewOrganizationAdapter(orgStore adapters.OrganizationStore) domain.OrganizationAdapter { + return &organizationAdapter{ + orgStore: orgStore, + } +} + +func (a *organizationAdapter) GetStytchOrgID(ctx context.Context, organizationID int32) (string, error) { + org, err := a.orgStore.GetOrganizationByID(ctx, organizationID) + if err != nil { + return "", fmt.Errorf("failed to get organization: %w", err) + } + + if !org.StytchOrgID.Valid || org.StytchOrgID.String == "" { + return "", fmt.Errorf("organization has no Stytch org ID") + } + + return org.StytchOrgID.String, nil +} + +func (a *organizationAdapter) GetOrganizationIDByStytchOrgID(ctx context.Context, stytchOrgID string) (int32, error) { + stytchOrgIDText := pgtype.Text{ + String: stytchOrgID, + Valid: true, + } + + org, err := a.orgStore.GetOrganizationByStytchID(ctx, stytchOrgIDText) + if err != nil { + return 0, fmt.Errorf("failed to get organization by Stytch org ID: %w", err) + } + + return org.ID, nil +} diff --git a/go-b2b-starter/src/app/billing/infra/repositories/subscription_repository.go b/go-b2b-starter/src/app/billing/infra/repositories/subscription_repository.go new file mode 100644 index 0000000..e1987f8 --- /dev/null +++ b/go-b2b-starter/src/app/billing/infra/repositories/subscription_repository.go @@ -0,0 +1,199 @@ +package repositories + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/moasq/go-b2b-starter/app/billing/domain" + "github.com/moasq/go-b2b-starter/pkg/db/postgres" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" +) + +type subscriptionRepository struct { + store adapters.SubscriptionStore +} + +// NewSubscriptionRepository creates a new subscription repository +func NewSubscriptionRepository(store adapters.SubscriptionStore) domain.SubscriptionRepository { + return &subscriptionRepository{ + store: store, + } +} + +func (r *subscriptionRepository) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*domain.Subscription, error) { + result, err := r.store.GetSubscriptionByOrgID(ctx, organizationID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrSubscriptionNotFound + } + return nil, fmt.Errorf("failed to get subscription: %w", err) + } + + return r.mapToDomainSubscription(&result), nil +} + +func (r *subscriptionRepository) UpsertSubscription(ctx context.Context, subscription *domain.Subscription) (*domain.Subscription, error) { + // Marshal metadata to JSONB + metadataJSON, err := json.Marshal(subscription.Metadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal metadata: %w", err) + } + + params := sqlc.UpsertSubscriptionParams{ + OrganizationID: subscription.OrganizationID, + ExternalCustomerID: subscription.ExternalCustomerID, + SubscriptionID: subscription.SubscriptionID, + SubscriptionStatus: subscription.SubscriptionStatus, + ProductID: subscription.ProductID, + ProductName: postgres.PgText(&subscription.ProductName), + PlanName: postgres.PgText(&subscription.PlanName), + CurrentPeriodStart: postgres.PgTimestamp(&subscription.CurrentPeriodStart), + CurrentPeriodEnd: postgres.PgTimestamp(&subscription.CurrentPeriodEnd), + CancelAtPeriodEnd: postgres.PgBool(&subscription.CancelAtPeriodEnd), + CanceledAt: postgres.PgTimestamp(subscription.CanceledAt), + Metadata: metadataJSON, + } + + result, err := r.store.UpsertSubscription(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to upsert subscription: %w", err) + } + + return r.mapToDomainSubscription(&result), nil +} + +func (r *subscriptionRepository) DeleteSubscription(ctx context.Context, organizationID int32) error { + if err := r.store.DeleteSubscription(ctx, organizationID); err != nil { + return fmt.Errorf("failed to delete subscription: %w", err) + } + return nil +} + +func (r *subscriptionRepository) GetQuotaByOrgID(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) { + result, err := r.store.GetQuotaByOrgID(ctx, organizationID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrQuotaNotFound + } + return nil, fmt.Errorf("failed to get quota: %w", err) + } + + return r.mapToDomainQuota(&result), nil +} + +func (r *subscriptionRepository) UpsertQuota(ctx context.Context, quota *domain.QuotaTracking) (*domain.QuotaTracking, error) { + params := sqlc.UpsertQuotaParams{ + OrganizationID: quota.OrganizationID, + InvoiceCount: quota.InvoiceCount, + MaxSeats: postgres.PgInt4("a.MaxSeats), + PeriodStart: postgres.PgTimestamp("a.PeriodStart), + PeriodEnd: postgres.PgTimestamp("a.PeriodEnd), + } + + result, err := r.store.UpsertQuota(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to upsert quota: %w", err) + } + + return r.mapToDomainQuota(&result), nil +} + +func (r *subscriptionRepository) DecrementInvoiceCount(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) { + result, err := r.store.DecrementInvoiceCount(ctx, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to decrement invoice count: %w", err) + } + + return r.mapToDomainQuota(&result), nil +} + +func (r *subscriptionRepository) GetQuotaStatus(ctx context.Context, organizationID int32) (*domain.QuotaStatus, error) { + result, err := r.store.GetQuotaStatus(ctx, organizationID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrSubscriptionNotFound + } + return nil, fmt.Errorf("failed to get quota status: %w", err) + } + + return r.mapToDomainQuotaStatus(&result), nil +} + +// Mapping functions + +func (r *subscriptionRepository) mapToDomainSubscription(s *sqlc.SubscriptionBillingSubscription) *domain.Subscription { + var metadata map[string]any + if len(s.Metadata) > 0 { + json.Unmarshal(s.Metadata, &metadata) + } + + subscription := &domain.Subscription{ + ID: s.ID, + OrganizationID: s.OrganizationID, + ExternalCustomerID: s.ExternalCustomerID, + SubscriptionID: s.SubscriptionID, + SubscriptionStatus: s.SubscriptionStatus, + ProductID: s.ProductID, + ProductName: postgres.StringFromPgText(s.ProductName), + PlanName: postgres.StringFromPgText(s.PlanName), + CurrentPeriodStart: s.CurrentPeriodStart.Time, + CurrentPeriodEnd: s.CurrentPeriodEnd.Time, + Metadata: metadata, + CreatedAt: s.CreatedAt.Time, + UpdatedAt: s.UpdatedAt.Time, + } + + // Handle nullable fields + if s.CancelAtPeriodEnd.Valid { + subscription.CancelAtPeriodEnd = s.CancelAtPeriodEnd.Bool + } + if s.CanceledAt.Valid { + subscription.CanceledAt = &s.CanceledAt.Time + } + + return subscription +} + +func (r *subscriptionRepository) mapToDomainQuota(q *sqlc.SubscriptionBillingQuotaTracking) *domain.QuotaTracking { + quota := &domain.QuotaTracking{ + ID: q.ID, + OrganizationID: q.OrganizationID, + InvoiceCount: q.InvoiceCount, + MaxSeats: postgres.Int32FromPgInt4(q.MaxSeats), + PeriodStart: q.PeriodStart.Time, + PeriodEnd: q.PeriodEnd.Time, + CreatedAt: q.CreatedAt.Time, + UpdatedAt: q.UpdatedAt.Time, + } + + // Handle nullable LastSyncedAt + if q.LastSyncedAt.Valid { + quota.LastSyncedAt = &q.LastSyncedAt.Time + } + + return quota +} + +func (r *subscriptionRepository) mapToDomainQuotaStatus(qs *sqlc.GetQuotaStatusRow) *domain.QuotaStatus { + status := &domain.QuotaStatus{ + SubscriptionStatus: qs.SubscriptionStatus, + CurrentPeriodStart: qs.CurrentPeriodStart.Time, + CurrentPeriodEnd: qs.CurrentPeriodEnd.Time, + InvoiceCount: qs.InvoiceCount, + CanProcessInvoice: qs.CanProcessInvoice, + } + + // Handle nullable fields + if qs.CancelAtPeriodEnd.Valid { + status.CancelAtPeriodEnd = qs.CancelAtPeriodEnd.Bool + } + if qs.MaxSeats.Valid { + status.MaxSeats = qs.MaxSeats.Int32 + } + + return status +} diff --git a/go-b2b-starter/src/app/example_cognitive/app/services/document_listener.go b/go-b2b-starter/src/app/example_cognitive/app/services/document_listener.go new file mode 100644 index 0000000..c943f94 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/app/services/document_listener.go @@ -0,0 +1,34 @@ +package services + +import ( + "context" + "fmt" +) + +type documentListener struct { + embeddingService EmbeddingService +} + +// NewDocumentListener creates a new document listener +func NewDocumentListener( + embeddingService EmbeddingService, +) DocumentListener { + return &documentListener{ + embeddingService: embeddingService, + } +} + +func (l *documentListener) HandleDocumentUploaded(ctx context.Context, documentID, orgID int32, text string) error { + // Skip if no text to embed + if text == "" { + return nil + } + + // Create embedding for the document + _, err := l.embeddingService.EmbedDocument(ctx, orgID, documentID, text) + if err != nil { + return fmt.Errorf("failed to embed document: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/app/example_cognitive/app/services/embedding_service.go b/go-b2b-starter/src/app/example_cognitive/app/services/embedding_service.go new file mode 100644 index 0000000..7c175ca --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/app/services/embedding_service.go @@ -0,0 +1,108 @@ +package services + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" +) + +const ( + // MaxChunkSize is the maximum number of characters per chunk + MaxChunkSize = 8000 + // ContentPreviewLength is the length of content preview to store + ContentPreviewLength = 500 +) + +type embeddingService struct { + embeddingRepo domain.EmbeddingRepository + textVectorizer domain.TextVectorizer +} + +// NewEmbeddingService creates a new embedding service +func NewEmbeddingService( + embeddingRepo domain.EmbeddingRepository, + textVectorizer domain.TextVectorizer, +) EmbeddingService { + return &embeddingService{ + embeddingRepo: embeddingRepo, + textVectorizer: textVectorizer, + } +} + +func (s *embeddingService) EmbedDocument(ctx context.Context, orgID, documentID int32, text string) (*domain.DocumentEmbedding, error) { + // Generate embedding using text vectorizer + embedding, err := s.textVectorizer.Vectorize(ctx, text) + if err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrEmbeddingGenerationFailed, err) + } + + // Create content hash for deduplication + contentHash := s.hashContent(text) + + // Create content preview + contentPreview := text + if len(contentPreview) > ContentPreviewLength { + contentPreview = contentPreview[:ContentPreviewLength] + } + + // Create embedding record + docEmbedding := &domain.DocumentEmbedding{ + DocumentID: documentID, + OrganizationID: orgID, + Embedding: embedding, + ContentHash: contentHash, + ContentPreview: contentPreview, + ChunkIndex: 0, // Single chunk for now + } + + result, err := s.embeddingRepo.Create(ctx, docEmbedding) + if err != nil { + return nil, fmt.Errorf("failed to store embedding: %w", err) + } + + return result, nil +} + +func (s *embeddingService) GetDocumentEmbeddings(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) { + return s.embeddingRepo.GetByDocumentID(ctx, orgID, documentID) +} + +func (s *embeddingService) SearchSimilarDocuments(ctx context.Context, orgID int32, text string, limit int32) ([]*domain.SimilarDocument, error) { + // Generate embedding for the search query + embedding, err := s.textVectorizer.Vectorize(ctx, text) + if err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrEmbeddingGenerationFailed, err) + } + + // Search for similar documents + return s.embeddingRepo.SearchSimilar(ctx, orgID, embedding, limit) +} + +func (s *embeddingService) DeleteDocumentEmbeddings(ctx context.Context, orgID, documentID int32) error { + if err := s.embeddingRepo.Delete(ctx, orgID, documentID); err != nil { + return fmt.Errorf("failed to delete embeddings: %w", err) + } + + return nil +} + +func (s *embeddingService) GetStats(ctx context.Context, orgID int32) (*domain.EmbeddingStats, error) { + count, err := s.embeddingRepo.Count(ctx, orgID) + if err != nil { + return nil, fmt.Errorf("failed to get embedding count: %w", err) + } + + return &domain.EmbeddingStats{ + TotalEmbeddings: count, + TotalDocuments: count, // For now, 1:1 relationship + }, nil +} + +// hashContent creates a SHA-256 hash of the content for deduplication +func (s *embeddingService) hashContent(content string) string { + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]) +} diff --git a/go-b2b-starter/src/app/example_cognitive/app/services/interface.go b/go-b2b-starter/src/app/example_cognitive/app/services/interface.go new file mode 100644 index 0000000..78910dc --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/app/services/interface.go @@ -0,0 +1,52 @@ +package services + +import ( + "context" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" +) + +// EmbeddingService defines the interface for embedding operations +type EmbeddingService interface { + // EmbedDocument generates and stores embeddings for a document + EmbedDocument(ctx context.Context, orgID, documentID int32, text string) (*domain.DocumentEmbedding, error) + + // GetDocumentEmbeddings retrieves embeddings for a document + GetDocumentEmbeddings(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) + + // SearchSimilarDocuments finds documents similar to the given text + SearchSimilarDocuments(ctx context.Context, orgID int32, text string, limit int32) ([]*domain.SimilarDocument, error) + + // DeleteDocumentEmbeddings removes embeddings for a document + DeleteDocumentEmbeddings(ctx context.Context, orgID, documentID int32) error + + // GetStats retrieves embedding statistics + GetStats(ctx context.Context, orgID int32) (*domain.EmbeddingStats, error) +} + +// RAGService defines the interface for RAG (Retrieval-Augmented Generation) operations +type RAGService interface { + // Chat sends a message and gets a response, optionally using RAG + Chat(ctx context.Context, orgID, accountID int32, req *domain.ChatRequest) (*domain.ChatResponse, error) + + // GetSession retrieves a chat session + GetSession(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) + + // ListSessions lists chat sessions for an account + ListSessions(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) + + // DeleteSession deletes a chat session + DeleteSession(ctx context.Context, orgID, sessionID int32) error + + // GetSessionHistory retrieves messages for a session + GetSessionHistory(ctx context.Context, orgID, sessionID int32) ([]*domain.ChatMessage, error) + + // UpdateSessionTitle updates the title of a chat session + UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) +} + +// DocumentListener handles document events from the documents module +type DocumentListener interface { + // HandleDocumentUploaded processes the DocumentUploaded event + HandleDocumentUploaded(ctx context.Context, documentID, orgID int32, text string) error +} diff --git a/go-b2b-starter/src/app/example_cognitive/app/services/rag_service.go b/go-b2b-starter/src/app/example_cognitive/app/services/rag_service.go new file mode 100644 index 0000000..3b46477 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/app/services/rag_service.go @@ -0,0 +1,235 @@ +package services + +import ( + "context" + "fmt" + "strings" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" +) + +const ( + // DefaultMaxDocuments is the default number of documents to retrieve for RAG + DefaultMaxDocuments = 3 + // DefaultContextHistory is the default number of messages to include in context + DefaultContextHistory = 10 + // SystemPrompt is the default system prompt for RAG + SystemPrompt = `You are a helpful assistant that answers questions based on the provided context. +If the context doesn't contain relevant information, say so clearly. +Always cite which documents you used to answer the question.` +) + +type ragService struct { + chatRepo domain.ChatRepository + embeddingRepo domain.EmbeddingRepository + textVectorizer domain.TextVectorizer + assistantProvider domain.AssistantProvider +} + +// NewRAGService creates a new RAG service +func NewRAGService( + chatRepo domain.ChatRepository, + embeddingRepo domain.EmbeddingRepository, + textVectorizer domain.TextVectorizer, + assistantProvider domain.AssistantProvider, +) RAGService { + return &ragService{ + chatRepo: chatRepo, + embeddingRepo: embeddingRepo, + textVectorizer: textVectorizer, + assistantProvider: assistantProvider, + } +} + +func (s *ragService) Chat(ctx context.Context, orgID, accountID int32, req *domain.ChatRequest) (*domain.ChatResponse, error) { + var session *domain.ChatSession + var err error + + // Get or create session + if req.SessionID > 0 { + session, err = s.chatRepo.GetSessionByID(ctx, orgID, req.SessionID) + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + } else { + // Create new session + session = &domain.ChatSession{ + OrganizationID: orgID, + AccountID: accountID, + Title: generateSessionTitle(req.Message), + } + session, err = s.chatRepo.CreateSession(ctx, session) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + } + + // Save user message + userMessage := &domain.ChatMessage{ + SessionID: session.ID, + Role: domain.ChatRoleUser, + Content: req.Message, + } + userMessage, err = s.chatRepo.CreateMessage(ctx, userMessage) + if err != nil { + return nil, fmt.Errorf("failed to save user message: %w", err) + } + + // Build context and generate response + var referencedDocs []*domain.SimilarDocument + var prompt string + + if req.UseRAG { + // Search for similar documents + maxDocs := req.MaxDocuments + if maxDocs <= 0 { + maxDocs = DefaultMaxDocuments + } + + // Generate embedding for the query and search + embedding, err := s.textVectorizer.Vectorize(ctx, req.Message) + if err == nil { + docs, err := s.embeddingRepo.SearchSimilar(ctx, orgID, embedding, int32(maxDocs)) + if err == nil { + referencedDocs = docs + } + } + + // Build RAG prompt + prompt = s.buildRAGPrompt(req.Message, referencedDocs) + } else { + prompt = req.Message + } + + // Get conversation history for context + contextHistory := req.ContextHistory + if contextHistory <= 0 { + contextHistory = DefaultContextHistory + } + + history, _ := s.chatRepo.GetRecentMessages(ctx, session.ID, int32(contextHistory)) + + // Build full prompt with history + fullPrompt := s.buildPromptWithHistory(prompt, history) + + // Generate response using AI assistant + response, err := s.assistantProvider.GenerateResponse(ctx, fullPrompt) + if err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrRAGCompletionFailed, err) + } + + // Extract document IDs from referenced docs + var docIDs []int32 + for _, doc := range referencedDocs { + docIDs = append(docIDs, doc.DocumentID) + } + + // Save assistant response + assistantMessage := &domain.ChatMessage{ + SessionID: session.ID, + Role: domain.ChatRoleAssistant, + Content: response.Content, + ReferencedDocs: docIDs, + TokensUsed: int32(response.TokensUsed), + } + assistantMessage, err = s.chatRepo.CreateMessage(ctx, assistantMessage) + if err != nil { + return nil, fmt.Errorf("failed to save assistant message: %w", err) + } + + // Convert []*SimilarDocument to []SimilarDocument + var docs []domain.SimilarDocument + for _, doc := range referencedDocs { + if doc != nil { + docs = append(docs, *doc) + } + } + + return &domain.ChatResponse{ + SessionID: session.ID, + Message: assistantMessage, + ReferencedDocs: docs, + TokensUsed: int32(response.TokensUsed), + }, nil +} + +func (s *ragService) GetSession(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) { + return s.chatRepo.GetSessionByID(ctx, orgID, sessionID) +} + +func (s *ragService) ListSessions(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) { + return s.chatRepo.ListSessionsByAccount(ctx, orgID, accountID, limit, offset) +} + +func (s *ragService) DeleteSession(ctx context.Context, orgID, sessionID int32) error { + return s.chatRepo.DeleteSession(ctx, orgID, sessionID) +} + +func (s *ragService) GetSessionHistory(ctx context.Context, orgID, sessionID int32) ([]*domain.ChatMessage, error) { + // Verify session belongs to organization + _, err := s.chatRepo.GetSessionByID(ctx, orgID, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to verify session: %w", err) + } + + return s.chatRepo.GetMessagesBySession(ctx, sessionID) +} + +func (s *ragService) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) { + return s.chatRepo.UpdateSessionTitle(ctx, orgID, sessionID, title) +} + +// buildRAGPrompt builds a prompt with RAG context +func (s *ragService) buildRAGPrompt(query string, docs []*domain.SimilarDocument) string { + if len(docs) == 0 { + return fmt.Sprintf("%s\n\nUser Question: %s", SystemPrompt, query) + } + + var contextBuilder strings.Builder + contextBuilder.WriteString(SystemPrompt) + contextBuilder.WriteString("\n\n--- CONTEXT FROM DOCUMENTS ---\n") + + for i, doc := range docs { + contextBuilder.WriteString(fmt.Sprintf("\n[Document %d (similarity: %.2f)]:\n%s\n", + i+1, doc.SimilarityScore, doc.ContentPreview)) + } + + contextBuilder.WriteString("\n--- END OF CONTEXT ---\n\n") + contextBuilder.WriteString(fmt.Sprintf("User Question: %s", query)) + + return contextBuilder.String() +} + +// buildPromptWithHistory builds a prompt including conversation history +func (s *ragService) buildPromptWithHistory(prompt string, history []*domain.ChatMessage) string { + if len(history) == 0 { + return prompt + } + + var builder strings.Builder + builder.WriteString("Previous conversation:\n") + + // History is in descending order, so reverse it + for i := len(history) - 1; i >= 0; i-- { + msg := history[i] + role := "User" + if msg.Role == domain.ChatRoleAssistant { + role = "Assistant" + } + builder.WriteString(fmt.Sprintf("%s: %s\n", role, msg.Content)) + } + + builder.WriteString("\nCurrent prompt:\n") + builder.WriteString(prompt) + + return builder.String() +} + +// generateSessionTitle generates a title from the first message +func generateSessionTitle(message string) string { + // Take first 50 characters of the message as title + if len(message) <= 50 { + return message + } + return message[:50] + "..." +} diff --git a/go-b2b-starter/src/app/example_cognitive/cmd/init.go b/go-b2b-starter/src/app/example_cognitive/cmd/init.go new file mode 100644 index 0000000..b44cc89 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/cmd/init.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "context" + "fmt" + + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/example_cognitive" + "github.com/moasq/go-b2b-starter/app/example_cognitive/app/services" + docEvents "github.com/moasq/go-b2b-starter/app/example_documents/domain/events" + "github.com/moasq/go-b2b-starter/pkg/eventbus" +) + +// Init initializes the cognitive module +func Init(container *dig.Container) error { + module := cognitive.NewModule(container) + if err := module.RegisterDependencies(); err != nil { + return fmt.Errorf("failed to register cognitive dependencies: %w", err) + } + + // Wire up event listener for document uploads + if err := container.Invoke(func( + bus eventbus.EventBus, + listener services.DocumentListener, + ) error { + // Subscribe to DocumentUploaded events + return bus.Subscribe(docEvents.DocumentUploadedEventType, func(ctx context.Context, event eventbus.Event) error { + // Type assert to get the specific event + docEvent, ok := event.(*docEvents.DocumentUploaded) + if !ok { + return fmt.Errorf("unexpected event type: %T", event) + } + + // Handle the event + return listener.HandleDocumentUploaded(ctx, docEvent.DocumentID, docEvent.OrganizationID, docEvent.ExtractedText) + }) + }); err != nil { + return fmt.Errorf("failed to wire document event listener: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/app/example_cognitive/domain/ai_provider.go b/go-b2b-starter/src/app/example_cognitive/domain/ai_provider.go new file mode 100644 index 0000000..59c80f2 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/domain/ai_provider.go @@ -0,0 +1,25 @@ +package domain + +import "context" + +// TextVectorizer creates searchable vector representations of text content. +// This enables semantic document search and similarity matching. +// Implementation details (embedding models, providers) are in the infra layer. +type TextVectorizer interface { + // Vectorize converts text content into a searchable vector representation + Vectorize(ctx context.Context, text string) ([]float64, error) +} + +// AssistantProvider provides AI-powered conversational assistance. +// This enables intelligent responses based on context and user queries. +// Implementation details (LLM providers, models) are in the infra layer. +type AssistantProvider interface { + // GenerateResponse creates an AI response for the given prompt with context + GenerateResponse(ctx context.Context, prompt string) (*AssistantResponse, error) +} + +// AssistantResponse contains the result of an AI assistance request +type AssistantResponse struct { + Content string // The generated response text + TokensUsed int // Tokens consumed (for usage tracking) +} diff --git a/go-b2b-starter/src/app/example_cognitive/domain/entity.go b/go-b2b-starter/src/app/example_cognitive/domain/entity.go new file mode 100644 index 0000000..1d8f75f --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/domain/entity.go @@ -0,0 +1,134 @@ +package domain + +import ( + "time" +) + +// ChatRole represents the role of a message sender +type ChatRole string + +const ( + ChatRoleUser ChatRole = "user" + ChatRoleAssistant ChatRole = "assistant" + ChatRoleSystem ChatRole = "system" +) + +// DocumentEmbedding represents a vector embedding for a document +type DocumentEmbedding struct { + ID int32 `json:"id"` + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + Embedding []float64 `json:"embedding,omitempty"` // 1536 dimensions for OpenAI + ContentHash string `json:"content_hash,omitempty"` + ContentPreview string `json:"content_preview,omitempty"` + ChunkIndex int32 `json:"chunk_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SimilarDocument represents a document found through similarity search +type SimilarDocument struct { + DocumentEmbedding + SimilarityScore float64 `json:"similarity_score"` +} + +// ChatSession represents a conversation session +type ChatSession struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + AccountID int32 `json:"account_id"` + Title string `json:"title,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetID returns the chat session's database ID +func (s *ChatSession) GetID() int32 { + return s.ID +} + +// Validate validates the chat session entity +func (s *ChatSession) Validate() error { + if s.OrganizationID == 0 { + return ErrSessionOrganizationRequired + } + if s.AccountID == 0 { + return ErrSessionAccountRequired + } + return nil +} + +// ChatMessage represents a message within a chat session +type ChatMessage struct { + ID int32 `json:"id"` + SessionID int32 `json:"session_id"` + Role ChatRole `json:"role"` + Content string `json:"content"` + ReferencedDocs []int32 `json:"referenced_docs,omitempty"` + TokensUsed int32 `json:"tokens_used,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// GetID returns the chat message's database ID +func (m *ChatMessage) GetID() int32 { + return m.ID +} + +// Validate validates the chat message entity +func (m *ChatMessage) Validate() error { + if m.SessionID == 0 { + return ErrMessageSessionRequired + } + if m.Content == "" { + return ErrMessageContentRequired + } + if m.Role == "" { + return ErrMessageRoleRequired + } + return nil +} + +// IsUserMessage checks if the message is from the user +func (m *ChatMessage) IsUserMessage() bool { + return m.Role == ChatRoleUser +} + +// IsAssistantMessage checks if the message is from the assistant +func (m *ChatMessage) IsAssistantMessage() bool { + return m.Role == ChatRoleAssistant +} + +// RAGContext represents context retrieved for RAG +type RAGContext struct { + Documents []SimilarDocument `json:"documents"` + Query string `json:"query"` +} + +// ChatRequest represents a request to send a chat message +type ChatRequest struct { + SessionID int32 `json:"session_id,omitempty"` // Optional - create new session if not provided + Message string `json:"message"` + UseRAG bool `json:"use_rag,omitempty"` // Whether to use RAG for context + MaxDocuments int `json:"max_documents,omitempty"` + ContextHistory int `json:"context_history,omitempty"` // Number of previous messages to include +} + +// ChatResponse represents a response from the chat service +type ChatResponse struct { + SessionID int32 `json:"session_id"` + Message *ChatMessage `json:"message"` + ReferencedDocs []SimilarDocument `json:"referenced_docs,omitempty"` + TokensUsed int32 `json:"tokens_used,omitempty"` +} + +// EmbeddingStats represents embedding statistics +type EmbeddingStats struct { + TotalEmbeddings int64 `json:"total_embeddings"` + TotalDocuments int64 `json:"total_documents"` +} + +// ChatStats represents chat statistics +type ChatStats struct { + TotalSessions int64 `json:"total_sessions"` + TotalMessages int64 `json:"total_messages"` +} diff --git a/go-b2b-starter/src/app/example_cognitive/domain/errors.go b/go-b2b-starter/src/app/example_cognitive/domain/errors.go new file mode 100644 index 0000000..c3baa3a --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/domain/errors.go @@ -0,0 +1,32 @@ +package domain + +import "errors" + +// Domain errors for cognitive module +var ( + // Embedding errors + ErrEmbeddingNotFound = errors.New("embedding not found") + ErrEmbeddingGenerationFailed = errors.New("failed to generate embedding") + ErrEmbeddingAlreadyExists = errors.New("embedding already exists for this document") + + // Session errors + ErrSessionNotFound = errors.New("chat session not found") + ErrSessionOrganizationRequired = errors.New("session organization ID is required") + ErrSessionAccountRequired = errors.New("session account ID is required") + + // Message errors + ErrMessageNotFound = errors.New("chat message not found") + ErrMessageSessionRequired = errors.New("message session ID is required") + ErrMessageContentRequired = errors.New("message content is required") + ErrMessageRoleRequired = errors.New("message role is required") + + // RAG errors + ErrRAGContextEmpty = errors.New("no relevant documents found for RAG context") + ErrRAGSearchFailed = errors.New("RAG similarity search failed") + ErrRAGCompletionFailed = errors.New("RAG completion generation failed") + + // LLM errors + ErrLLMUnavailable = errors.New("LLM service is unavailable") + ErrLLMRequestFailed = errors.New("LLM request failed") + ErrLLMResponseInvalid = errors.New("LLM response is invalid") +) diff --git a/go-b2b-starter/src/app/example_cognitive/domain/repository.go b/go-b2b-starter/src/app/example_cognitive/domain/repository.go new file mode 100644 index 0000000..9c1dd3a --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/domain/repository.go @@ -0,0 +1,41 @@ +package domain + +import "context" + +// EmbeddingRepository defines the interface for embedding data operations +type EmbeddingRepository interface { + // Create creates a new document embedding + Create(ctx context.Context, embedding *DocumentEmbedding) (*DocumentEmbedding, error) + + // GetByID retrieves an embedding by ID + GetByID(ctx context.Context, orgID, embeddingID int32) (*DocumentEmbedding, error) + + // GetByDocumentID retrieves all embeddings for a document + GetByDocumentID(ctx context.Context, orgID, documentID int32) ([]*DocumentEmbedding, error) + + // SearchSimilar finds similar documents using vector similarity + SearchSimilar(ctx context.Context, orgID int32, embedding []float64, limit int32) ([]*SimilarDocument, error) + + // Delete removes embeddings for a document + Delete(ctx context.Context, orgID, documentID int32) error + + // Count returns the total count of embeddings for an organization + Count(ctx context.Context, orgID int32) (int64, error) +} + +// ChatRepository defines the interface for chat session and message operations +type ChatRepository interface { + // Sessions + CreateSession(ctx context.Context, session *ChatSession) (*ChatSession, error) + GetSessionByID(ctx context.Context, orgID, sessionID int32) (*ChatSession, error) + ListSessionsByAccount(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*ChatSession, error) + UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*ChatSession, error) + DeleteSession(ctx context.Context, orgID, sessionID int32) error + + // Messages + CreateMessage(ctx context.Context, message *ChatMessage) (*ChatMessage, error) + GetMessagesBySession(ctx context.Context, sessionID int32) ([]*ChatMessage, error) + GetRecentMessages(ctx context.Context, sessionID int32, limit int32) ([]*ChatMessage, error) + CountMessagesBySession(ctx context.Context, sessionID int32) (int64, error) + DeleteMessage(ctx context.Context, messageID int32) error +} diff --git a/go-b2b-starter/src/app/example_cognitive/go.mod b/go-b2b-starter/src/app/example_cognitive/go.mod new file mode 100644 index 0000000..9a75a2b --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/go.mod @@ -0,0 +1,35 @@ +module github.com/moasq/go-b2b-starter/app/example_cognitive + +go 1.25 + +require ( + github.com/jackc/pgx/v5 v5.7.2 + github.com/moasq/go-b2b-starter/app/example_documents v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/db v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/eventbus v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/llm v0.0.0-00010101000000-000000000000 + github.com/pgvector/pgvector-go v0.3.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/text v0.23.0 // indirect +) + +replace github.com/moasq/go-b2b-starter/app/example_documents => ../example_documents + +replace github.com/moasq/go-b2b-starter/pkg/db => ../../pkg/db + +replace github.com/moasq/go-b2b-starter/pkg/eventbus => ../../pkg/eventbus + +replace github.com/moasq/go-b2b-starter/pkg/llm => ../../pkg/llm + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../../pkg/logger diff --git a/go-b2b-starter/src/app/example_cognitive/go.sum b/go-b2b-starter/src/app/example_cognitive/go.sum new file mode 100644 index 0000000..0625cb1 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/go.sum @@ -0,0 +1,77 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/go-b2b-starter/src/app/example_cognitive/infra/ai/assistant_provider.go b/go-b2b-starter/src/app/example_cognitive/infra/ai/assistant_provider.go new file mode 100644 index 0000000..1122eb4 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/infra/ai/assistant_provider.go @@ -0,0 +1,29 @@ +package ai + +import ( + "context" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" + llmdomain "github.com/moasq/go-b2b-starter/pkg/llm/domain" +) + +type openAIAssistantProvider struct { + llmClient llmdomain.LLMClient +} + +// NewAssistantProvider creates an AssistantProvider backed by OpenAI +func NewAssistantProvider(llmClient llmdomain.LLMClient) domain.AssistantProvider { + return &openAIAssistantProvider{llmClient: llmClient} +} + +func (p *openAIAssistantProvider) GenerateResponse(ctx context.Context, prompt string) (*domain.AssistantResponse, error) { + req := llmdomain.CompletionRequest{Prompt: prompt} + resp, err := p.llmClient.Complete(ctx, req) + if err != nil { + return nil, err + } + return &domain.AssistantResponse{ + Content: resp.Text, + TokensUsed: resp.TokensUsed, + }, nil +} diff --git a/go-b2b-starter/src/app/example_cognitive/infra/ai/text_vectorizer.go b/go-b2b-starter/src/app/example_cognitive/infra/ai/text_vectorizer.go new file mode 100644 index 0000000..4edb769 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/infra/ai/text_vectorizer.go @@ -0,0 +1,23 @@ +package ai + +import ( + "context" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" + llmdomain "github.com/moasq/go-b2b-starter/pkg/llm/domain" +) + +const embeddingModel = "text-embedding-3-small" + +type openAITextVectorizer struct { + llmClient llmdomain.LLMClient +} + +// NewTextVectorizer creates a new TextVectorizer implementation +func NewTextVectorizer(llmClient llmdomain.LLMClient) domain.TextVectorizer { + return &openAITextVectorizer{llmClient: llmClient} +} + +func (v *openAITextVectorizer) Vectorize(ctx context.Context, text string) ([]float64, error) { + return v.llmClient.GenerateEmbedding(ctx, text, embeddingModel) +} diff --git a/go-b2b-starter/src/app/example_cognitive/infra/repositories/chat_repository.go b/go-b2b-starter/src/app/example_cognitive/infra/repositories/chat_repository.go new file mode 100644 index 0000000..06828ea --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/infra/repositories/chat_repository.go @@ -0,0 +1,193 @@ +package repositories + +import ( + "context" + "fmt" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +type chatRepository struct { + store adapters.ChatStore +} + +// NewChatRepository creates a new chat repository +func NewChatRepository(store adapters.ChatStore) domain.ChatRepository { + return &chatRepository{store: store} +} + +// Sessions + +func (r *chatRepository) CreateSession(ctx context.Context, session *domain.ChatSession) (*domain.ChatSession, error) { + params := sqlc.CreateChatSessionParams{ + OrganizationID: session.OrganizationID, + AccountID: session.AccountID, + Title: toPgText(session.Title), + } + + result, err := r.store.CreateChatSession(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create chat session: %w", err) + } + + return r.mapSessionToDomain(&result), nil +} + +func (r *chatRepository) GetSessionByID(ctx context.Context, orgID, sessionID int32) (*domain.ChatSession, error) { + params := sqlc.GetChatSessionByIDParams{ + ID: sessionID, + OrganizationID: orgID, + } + + result, err := r.store.GetChatSessionByID(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get chat session: %w", err) + } + + return r.mapSessionToDomain(&result), nil +} + +func (r *chatRepository) ListSessionsByAccount(ctx context.Context, orgID, accountID int32, limit, offset int32) ([]*domain.ChatSession, error) { + params := sqlc.ListChatSessionsByAccountParams{ + OrganizationID: orgID, + AccountID: accountID, + Limit: limit, + Offset: offset, + } + + results, err := r.store.ListChatSessionsByAccount(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to list chat sessions: %w", err) + } + + sessions := make([]*domain.ChatSession, len(results)) + for i, result := range results { + sessions[i] = r.mapSessionToDomain(&result) + } + + return sessions, nil +} + +func (r *chatRepository) UpdateSessionTitle(ctx context.Context, orgID, sessionID int32, title string) (*domain.ChatSession, error) { + params := sqlc.UpdateChatSessionTitleParams{ + ID: sessionID, + OrganizationID: orgID, + Title: toPgText(title), + } + + result, err := r.store.UpdateChatSessionTitle(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to update chat session title: %w", err) + } + + return r.mapSessionToDomain(&result), nil +} + +func (r *chatRepository) DeleteSession(ctx context.Context, orgID, sessionID int32) error { + params := sqlc.DeleteChatSessionParams{ + ID: sessionID, + OrganizationID: orgID, + } + + if err := r.store.DeleteChatSession(ctx, params); err != nil { + return fmt.Errorf("failed to delete chat session: %w", err) + } + + return nil +} + +// Messages + +func (r *chatRepository) CreateMessage(ctx context.Context, message *domain.ChatMessage) (*domain.ChatMessage, error) { + params := sqlc.CreateChatMessageParams{ + SessionID: message.SessionID, + Role: string(message.Role), + Content: message.Content, + ReferencedDocs: message.ReferencedDocs, + TokensUsed: toPgInt4(message.TokensUsed), + } + + result, err := r.store.CreateChatMessage(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create chat message: %w", err) + } + + return r.mapMessageToDomain(&result), nil +} + +func (r *chatRepository) GetMessagesBySession(ctx context.Context, sessionID int32) ([]*domain.ChatMessage, error) { + results, err := r.store.GetChatMessagesBySession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get chat messages: %w", err) + } + + messages := make([]*domain.ChatMessage, len(results)) + for i, result := range results { + messages[i] = r.mapMessageToDomain(&result) + } + + return messages, nil +} + +func (r *chatRepository) GetRecentMessages(ctx context.Context, sessionID int32, limit int32) ([]*domain.ChatMessage, error) { + params := sqlc.GetRecentChatMessagesParams{ + SessionID: sessionID, + Limit: limit, + } + + results, err := r.store.GetRecentChatMessages(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get recent chat messages: %w", err) + } + + messages := make([]*domain.ChatMessage, len(results)) + for i, result := range results { + messages[i] = r.mapMessageToDomain(&result) + } + + return messages, nil +} + +func (r *chatRepository) CountMessagesBySession(ctx context.Context, sessionID int32) (int64, error) { + count, err := r.store.CountChatMessagesBySession(ctx, sessionID) + if err != nil { + return 0, fmt.Errorf("failed to count chat messages: %w", err) + } + + return count, nil +} + +func (r *chatRepository) DeleteMessage(ctx context.Context, messageID int32) error { + if err := r.store.DeleteChatMessage(ctx, messageID); err != nil { + return fmt.Errorf("failed to delete chat message: %w", err) + } + + return nil +} + +// mapSessionToDomain maps a database session to a domain session +func (r *chatRepository) mapSessionToDomain(s *sqlc.CognitiveChatSession) *domain.ChatSession { + return &domain.ChatSession{ + ID: s.ID, + OrganizationID: s.OrganizationID, + AccountID: s.AccountID, + Title: fromPgText(s.Title), + CreatedAt: s.CreatedAt.Time, + UpdatedAt: s.UpdatedAt.Time, + } +} + +// mapMessageToDomain maps a database message to a domain message +func (r *chatRepository) mapMessageToDomain(m *sqlc.CognitiveChatMessage) *domain.ChatMessage { + return &domain.ChatMessage{ + ID: m.ID, + SessionID: m.SessionID, + Role: domain.ChatRole(m.Role), + Content: m.Content, + ReferencedDocs: m.ReferencedDocs, + TokensUsed: fromPgInt4(m.TokensUsed), + CreatedAt: m.CreatedAt.Time, + } +} diff --git a/go-b2b-starter/src/app/example_cognitive/infra/repositories/embedding_repository.go b/go-b2b-starter/src/app/example_cognitive/infra/repositories/embedding_repository.go new file mode 100644 index 0000000..2e2fec2 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/infra/repositories/embedding_repository.go @@ -0,0 +1,158 @@ +package repositories + +import ( + "context" + "fmt" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/pgvector/pgvector-go" +) + +type embeddingRepository struct { + store adapters.EmbeddingStore +} + +// NewEmbeddingRepository creates a new embedding repository +func NewEmbeddingRepository(store adapters.EmbeddingStore) domain.EmbeddingRepository { + return &embeddingRepository{store: store} +} + +func (r *embeddingRepository) Create(ctx context.Context, embedding *domain.DocumentEmbedding) (*domain.DocumentEmbedding, error) { + params := sqlc.CreateDocumentEmbeddingParams{ + DocumentID: embedding.DocumentID, + OrganizationID: embedding.OrganizationID, + Embedding: toVector(embedding.Embedding), + ContentHash: toPgText(embedding.ContentHash), + ContentPreview: toPgText(embedding.ContentPreview), + ChunkIndex: toPgInt4(embedding.ChunkIndex), + } + + result, err := r.store.CreateDocumentEmbedding(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create document embedding: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *embeddingRepository) GetByID(ctx context.Context, orgID, embeddingID int32) (*domain.DocumentEmbedding, error) { + params := sqlc.GetDocumentEmbeddingByIDParams{ + ID: embeddingID, + OrganizationID: orgID, + } + + result, err := r.store.GetDocumentEmbeddingByID(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get document embedding: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *embeddingRepository) GetByDocumentID(ctx context.Context, orgID, documentID int32) ([]*domain.DocumentEmbedding, error) { + params := sqlc.GetDocumentEmbeddingsByDocumentIDParams{ + DocumentID: documentID, + OrganizationID: orgID, + } + + results, err := r.store.GetDocumentEmbeddingsByDocumentID(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get document embeddings: %w", err) + } + + embeddings := make([]*domain.DocumentEmbedding, len(results)) + for i, result := range results { + embeddings[i] = r.mapToDomain(&result) + } + + return embeddings, nil +} + +func (r *embeddingRepository) SearchSimilar(ctx context.Context, orgID int32, embedding []float64, limit int32) ([]*domain.SimilarDocument, error) { + params := sqlc.SearchSimilarDocumentsParams{ + Column1: toVector(embedding), + OrganizationID: orgID, + Limit: limit, + } + + results, err := r.store.SearchSimilarDocuments(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to search similar documents: %w", err) + } + + docs := make([]*domain.SimilarDocument, len(results)) + for i, result := range results { + docs[i] = &domain.SimilarDocument{ + DocumentEmbedding: domain.DocumentEmbedding{ + ID: result.ID, + DocumentID: result.DocumentID, + OrganizationID: result.OrganizationID, + ContentHash: fromPgText(result.ContentHash), + ContentPreview: fromPgText(result.ContentPreview), + ChunkIndex: fromPgInt4(result.ChunkIndex), + CreatedAt: result.CreatedAt.Time, + UpdatedAt: result.UpdatedAt.Time, + }, + SimilarityScore: result.SimilarityScore, + } + } + + return docs, nil +} + +func (r *embeddingRepository) Delete(ctx context.Context, orgID, documentID int32) error { + params := sqlc.DeleteDocumentEmbeddingsParams{ + DocumentID: documentID, + OrganizationID: orgID, + } + + if err := r.store.DeleteDocumentEmbeddings(ctx, params); err != nil { + return fmt.Errorf("failed to delete document embeddings: %w", err) + } + + return nil +} + +func (r *embeddingRepository) Count(ctx context.Context, orgID int32) (int64, error) { + count, err := r.store.CountDocumentEmbeddingsByOrganization(ctx, orgID) + if err != nil { + return 0, fmt.Errorf("failed to count document embeddings: %w", err) + } + + return count, nil +} + +// mapToDomain maps a database embedding to a domain embedding +func (r *embeddingRepository) mapToDomain(e *sqlc.CognitiveDocumentEmbedding) *domain.DocumentEmbedding { + return &domain.DocumentEmbedding{ + ID: e.ID, + DocumentID: e.DocumentID, + OrganizationID: e.OrganizationID, + Embedding: fromVector(e.Embedding), + ContentHash: fromPgText(e.ContentHash), + ContentPreview: fromPgText(e.ContentPreview), + ChunkIndex: fromPgInt4(e.ChunkIndex), + CreatedAt: e.CreatedAt.Time, + UpdatedAt: e.UpdatedAt.Time, + } +} + +// Vector conversion helpers +func toVector(embedding []float64) pgvector.Vector { + floats := make([]float32, len(embedding)) + for i, v := range embedding { + floats[i] = float32(v) + } + return pgvector.NewVector(floats) +} + +func fromVector(v pgvector.Vector) []float64 { + slice := v.Slice() + floats := make([]float64, len(slice)) + for i, f := range slice { + floats[i] = float64(f) + } + return floats +} diff --git a/go-b2b-starter/src/app/example_cognitive/infra/repositories/helpers.go b/go-b2b-starter/src/app/example_cognitive/infra/repositories/helpers.go new file mode 100644 index 0000000..cf9e707 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/infra/repositories/helpers.go @@ -0,0 +1,33 @@ +package repositories + +import "github.com/jackc/pgx/v5/pgtype" + +// Helper functions for type conversion + +func toPgText(s string) pgtype.Text { + if s == "" { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: s, Valid: true} +} + +func fromPgText(t pgtype.Text) string { + if !t.Valid { + return "" + } + return t.String +} + +func toPgInt4(i int32) pgtype.Int4 { + if i == 0 { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: i, Valid: true} +} + +func fromPgInt4(i pgtype.Int4) int32 { + if !i.Valid { + return 0 + } + return i.Int32 +} diff --git a/go-b2b-starter/src/app/example_cognitive/module.go b/go-b2b-starter/src/app/example_cognitive/module.go new file mode 100644 index 0000000..2421895 --- /dev/null +++ b/go-b2b-starter/src/app/example_cognitive/module.go @@ -0,0 +1,95 @@ +package cognitive + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/example_cognitive/app/services" + "github.com/moasq/go-b2b-starter/app/example_cognitive/domain" + "github.com/moasq/go-b2b-starter/app/example_cognitive/infra/ai" + "github.com/moasq/go-b2b-starter/app/example_cognitive/infra/repositories" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + llmdomain "github.com/moasq/go-b2b-starter/pkg/llm/domain" +) + +// Module provides cognitive module dependencies +type Module struct { + container *dig.Container +} + +// NewModule creates a new cognitive module +func NewModule(container *dig.Container) *Module { + return &Module{ + container: container, + } +} + +// RegisterDependencies registers all cognitive module dependencies +func (m *Module) RegisterDependencies() error { + // Register embedding repository + if err := m.container.Provide(func( + embeddingStore adapters.EmbeddingStore, + ) domain.EmbeddingRepository { + return repositories.NewEmbeddingRepository(embeddingStore) + }); err != nil { + return err + } + + // Register chat repository + if err := m.container.Provide(func( + chatStore adapters.ChatStore, + ) domain.ChatRepository { + return repositories.NewChatRepository(chatStore) + }); err != nil { + return err + } + + // Register AI adapters (infra layer) + if err := m.container.Provide(func( + llmClient llmdomain.LLMClient, + ) domain.TextVectorizer { + return ai.NewTextVectorizer(llmClient) + }); err != nil { + return err + } + + if err := m.container.Provide(func( + llmClient llmdomain.LLMClient, + ) domain.AssistantProvider { + return ai.NewAssistantProvider(llmClient) + }); err != nil { + return err + } + + // Register embedding service + if err := m.container.Provide(func( + embeddingRepo domain.EmbeddingRepository, + textVectorizer domain.TextVectorizer, + ) services.EmbeddingService { + return services.NewEmbeddingService(embeddingRepo, textVectorizer) + }); err != nil { + return err + } + + // Register RAG service + if err := m.container.Provide(func( + chatRepo domain.ChatRepository, + embeddingRepo domain.EmbeddingRepository, + textVectorizer domain.TextVectorizer, + assistantProvider domain.AssistantProvider, + ) services.RAGService { + return services.NewRAGService(chatRepo, embeddingRepo, textVectorizer, assistantProvider) + }); err != nil { + return err + } + + // Register document listener + if err := m.container.Provide(func( + embeddingService services.EmbeddingService, + ) services.DocumentListener { + return services.NewDocumentListener(embeddingService) + }); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/app/example_documents/app/services/document_service.go b/go-b2b-starter/src/app/example_documents/app/services/document_service.go new file mode 100644 index 0000000..f1d5b1f --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/app/services/document_service.go @@ -0,0 +1,289 @@ +package services + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "strings" + + "github.com/moasq/go-b2b-starter/app/example_documents/domain" + "github.com/moasq/go-b2b-starter/app/example_documents/domain/events" + "github.com/moasq/go-b2b-starter/pkg/eventbus" + filemanager "github.com/moasq/go-b2b-starter/pkg/file_manager" + filedomain "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" + "github.com/moasq/go-b2b-starter/pkg/logger" + loggerdomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" + ocrdomain "github.com/moasq/go-b2b-starter/pkg/ocr/domain" +) + +type documentService struct { + docRepo domain.DocumentRepository + fileService filedomain.FileService + ocrService ocrdomain.OCRService + eventBus eventbus.EventBus + logger logger.Logger +} + +// NewDocumentService creates a new document service +func NewDocumentService( + docRepo domain.DocumentRepository, + fileService filedomain.FileService, + ocrService ocrdomain.OCRService, + eventBus eventbus.EventBus, + logger logger.Logger, +) DocumentService { + return &documentService{ + docRepo: docRepo, + fileService: fileService, + ocrService: ocrService, + eventBus: eventBus, + logger: logger, + } +} + +func (s *documentService) UploadDocument(ctx context.Context, orgID int32, req *UploadDocumentRequest, content io.Reader) (*domain.Document, error) { + // Validate content type (only PDFs allowed) + if !strings.Contains(strings.ToLower(req.ContentType), "pdf") { + return nil, domain.ErrInvalidFileType + } + + // Upload file using file manager + fileReq := &filedomain.FileUploadRequest{ + Filename: req.FileName, + Size: req.FileSize, + ContentType: req.ContentType, + Context: filemanager.ContextGeneral, + Metadata: req.Metadata, + } + + fileAsset, err := s.fileService.UploadFile(ctx, fileReq, content) + if err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrFileUploadFailed, err) + } + + // Create document record + doc := &domain.Document{ + OrganizationID: orgID, + FileAssetID: fileAsset.ID, + Title: req.Title, + FileName: req.FileName, + ContentType: req.ContentType, + FileSize: req.FileSize, + Status: domain.DocumentStatusPending, + Metadata: req.Metadata, + } + + createdDoc, err := s.docRepo.Create(ctx, doc) + if err != nil { + return nil, fmt.Errorf("failed to create document: %w", err) + } + + // Process document asynchronously (extract text) + go func() { + processCtx := context.Background() + s.ProcessDocument(processCtx, orgID, createdDoc.ID) + }() + + return createdDoc, nil +} + +func (s *documentService) GetDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) { + doc, err := s.docRepo.GetByID(ctx, orgID, docID) + if err != nil { + return nil, fmt.Errorf("failed to get document: %w", err) + } + + return doc, nil +} + +func (s *documentService) ListDocuments(ctx context.Context, orgID int32, req *ListDocumentsRequest) (*ListDocumentsResponse, error) { + var docs []*domain.Document + var total int64 + var err error + + if req.Status != nil { + docs, err = s.docRepo.ListByStatus(ctx, orgID, *req.Status, req.Limit, req.Offset) + if err != nil { + return nil, fmt.Errorf("failed to list documents by status: %w", err) + } + total, err = s.docRepo.CountByStatus(ctx, orgID, *req.Status) + } else { + docs, err = s.docRepo.List(ctx, orgID, req.Limit, req.Offset) + if err != nil { + return nil, fmt.Errorf("failed to list documents: %w", err) + } + total, err = s.docRepo.Count(ctx, orgID) + } + + if err != nil { + return nil, fmt.Errorf("failed to count documents: %w", err) + } + + return &ListDocumentsResponse{ + Documents: docs, + Total: total, + Limit: req.Limit, + Offset: req.Offset, + }, nil +} + +func (s *documentService) UpdateDocument(ctx context.Context, orgID, docID int32, req *UpdateDocumentRequest) (*domain.Document, error) { + // Get existing document + doc, err := s.docRepo.GetByID(ctx, orgID, docID) + if err != nil { + return nil, fmt.Errorf("failed to get document: %w", err) + } + + // Update fields + if req.Title != "" { + doc.Title = req.Title + } + if req.Metadata != nil { + doc.Metadata = req.Metadata + } + + updatedDoc, err := s.docRepo.Update(ctx, doc) + if err != nil { + return nil, fmt.Errorf("failed to update document: %w", err) + } + + return updatedDoc, nil +} + +func (s *documentService) DeleteDocument(ctx context.Context, orgID, docID int32) error { + // Get document to verify it exists + doc, err := s.docRepo.GetByID(ctx, orgID, docID) + if err != nil { + return fmt.Errorf("failed to get document: %w", err) + } + + // Delete the file asset + if err := s.fileService.DeleteFile(ctx, doc.FileAssetID); err != nil { + // Continue with document deletion even if file deletion fails + } + + // Delete the document record + if err := s.docRepo.Delete(ctx, orgID, docID); err != nil { + return fmt.Errorf("failed to delete document: %w", err) + } + + return nil +} + +func (s *documentService) GetDocumentStats(ctx context.Context, orgID int32) (*domain.DocumentStats, error) { + total, err := s.docRepo.Count(ctx, orgID) + if err != nil { + return nil, fmt.Errorf("failed to count documents: %w", err) + } + + pending, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusPending) + if err != nil { + return nil, fmt.Errorf("failed to count pending documents: %w", err) + } + + processed, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusProcessed) + if err != nil { + return nil, fmt.Errorf("failed to count processed documents: %w", err) + } + + failed, err := s.docRepo.CountByStatus(ctx, orgID, domain.DocumentStatusFailed) + if err != nil { + return nil, fmt.Errorf("failed to count failed documents: %w", err) + } + + return &domain.DocumentStats{ + TotalCount: total, + PendingCount: pending, + ProcessedCount: processed, + FailedCount: failed, + }, nil +} + +func (s *documentService) ProcessDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) { + // Update status to processing + doc, err := s.docRepo.UpdateStatus(ctx, orgID, docID, domain.DocumentStatusProcessing) + if err != nil { + return nil, fmt.Errorf("failed to update document status: %w", err) + } + + // Download file content + content, _, err := s.fileService.DownloadFile(ctx, doc.FileAssetID) + if err != nil { + s.markDocumentFailed(ctx, orgID, docID, err.Error()) + return nil, fmt.Errorf("%w: %v", domain.ErrFileDownloadFailed, err) + } + defer content.Close() + + // Extract text from PDF + extractedText, err := s.extractTextFromPDF(content) + if err != nil { + s.markDocumentFailed(ctx, orgID, docID, err.Error()) + return nil, fmt.Errorf("%w: %v", domain.ErrTextExtractionFailed, err) + } + + // Update document with extracted text + doc, err = s.docRepo.UpdateExtractedText(ctx, orgID, docID, extractedText) + if err != nil { + s.markDocumentFailed(ctx, orgID, docID, err.Error()) + return nil, fmt.Errorf("failed to update extracted text: %w", err) + } + + // Publish event for cognitive module to pick up + event := events.NewDocumentUploaded(docID, orgID, doc.FileAssetID, doc.Title, extractedText) + if err := s.eventBus.Publish(ctx, event); err != nil { + // Don't fail the operation just because event publishing failed + } + + return doc, nil +} + +// markDocumentFailed marks a document as failed and publishes failure event +func (s *documentService) markDocumentFailed(ctx context.Context, orgID, docID int32, errMsg string) { + s.docRepo.UpdateStatus(ctx, orgID, docID, domain.DocumentStatusFailed) + + // Publish failure event + event := events.NewDocumentFailed(docID, orgID, errMsg) + s.eventBus.Publish(ctx, event) +} + +// extractTextFromPDF extracts text from a PDF file using OCR service +func (s *documentService) extractTextFromPDF(content io.Reader) (string, error) { + // Read all content into memory + data, err := io.ReadAll(content) + if err != nil { + return "", fmt.Errorf("failed to read PDF content: %w", err) + } + + // Encode to base64 for OCR service + base64Data := base64.StdEncoding.EncodeToString(data) + + // Call OCR service + ctx := context.Background() + ocrResult, err := s.ocrService.ExtractText(ctx, base64Data, "application/pdf") + if err != nil { + s.logger.Error("OCR extraction failed", loggerdomain.Fields{"error": err.Error()}) + return "", fmt.Errorf("OCR extraction failed: %w", err) + } + + // Check confidence score + const MinOCRConfidence = 0.7 + if ocrResult.Confidence < MinOCRConfidence { + s.logger.Warn("OCR confidence below threshold", loggerdomain.Fields{ + "confidence": ocrResult.Confidence, + "pages": ocrResult.Pages, + "min_threshold": MinOCRConfidence, + }) + // Still proceed but log the warning + } + + // Log success + s.logger.Info("Successfully extracted PDF text via OCR", loggerdomain.Fields{ + "pages": ocrResult.Pages, + "chars": len(ocrResult.Text), + "confidence": ocrResult.Confidence, + }) + + // Return extracted text (already in markdown format from Mistral) + return ocrResult.Text, nil +} diff --git a/go-b2b-starter/src/app/example_documents/app/services/interface.go b/go-b2b-starter/src/app/example_documents/app/services/interface.go new file mode 100644 index 0000000..f5d0ce7 --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/app/services/interface.go @@ -0,0 +1,62 @@ +package services + +import ( + "context" + "io" + + "github.com/moasq/go-b2b-starter/app/example_documents/domain" +) + +// DocumentService defines the interface for document operations +type DocumentService interface { + // UploadDocument uploads a new document and extracts text from it + UploadDocument(ctx context.Context, orgID int32, req *UploadDocumentRequest, content io.Reader) (*domain.Document, error) + + // GetDocument retrieves a document by ID + GetDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) + + // ListDocuments lists documents with pagination + ListDocuments(ctx context.Context, orgID int32, req *ListDocumentsRequest) (*ListDocumentsResponse, error) + + // UpdateDocument updates document metadata + UpdateDocument(ctx context.Context, orgID, docID int32, req *UpdateDocumentRequest) (*domain.Document, error) + + // DeleteDocument deletes a document + DeleteDocument(ctx context.Context, orgID, docID int32) error + + // GetDocumentStats retrieves document statistics + GetDocumentStats(ctx context.Context, orgID int32) (*domain.DocumentStats, error) + + // ProcessDocument processes a document (extract text, etc.) + ProcessDocument(ctx context.Context, orgID, docID int32) (*domain.Document, error) +} + +// UploadDocumentRequest represents a request to upload a document +type UploadDocumentRequest struct { + Title string `json:"title"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + FileSize int64 `json:"file_size"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ListDocumentsRequest represents a request to list documents +type ListDocumentsRequest struct { + Status *domain.DocumentStatus `json:"status,omitempty"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +// ListDocumentsResponse represents the response for listing documents +type ListDocumentsResponse struct { + Documents []*domain.Document `json:"documents"` + Total int64 `json:"total"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +// UpdateDocumentRequest represents a request to update a document +type UpdateDocumentRequest struct { + Title string `json:"title,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} diff --git a/go-b2b-starter/src/app/example_documents/cmd/init.go b/go-b2b-starter/src/app/example_documents/cmd/init.go new file mode 100644 index 0000000..91bc6de --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/cmd/init.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/example_documents" +) + +// Init initializes the documents module +func Init(container *dig.Container) error { + module := documents.NewModule(container) + return module.RegisterDependencies() +} diff --git a/go-b2b-starter/src/app/example_documents/domain/entity.go b/go-b2b-starter/src/app/example_documents/domain/entity.go new file mode 100644 index 0000000..16de9d1 --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/domain/entity.go @@ -0,0 +1,91 @@ +package domain + +import ( + "time" +) + +// DocumentStatus represents the processing status of a document +type DocumentStatus string + +const ( + DocumentStatusPending DocumentStatus = "pending" + DocumentStatusProcessing DocumentStatus = "processing" + DocumentStatusProcessed DocumentStatus = "processed" + DocumentStatusFailed DocumentStatus = "failed" +) + +// Document represents an uploaded document (PDF) +type Document struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + FileAssetID int32 `json:"file_asset_id"` + Title string `json:"title"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + FileSize int64 `json:"file_size"` + ExtractedText string `json:"extracted_text,omitempty"` + Status DocumentStatus `json:"status"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetID returns the document's database ID +func (d *Document) GetID() int32 { + return d.ID +} + +// Validate validates the document entity +func (d *Document) Validate() error { + if d.OrganizationID == 0 { + return ErrDocumentOrganizationRequired + } + if d.Title == "" { + return ErrDocumentTitleRequired + } + if d.FileName == "" { + return ErrDocumentFileNameRequired + } + if d.FileAssetID == 0 { + return ErrDocumentFileAssetRequired + } + return nil +} + +// IsProcessed checks if the document has been processed +func (d *Document) IsProcessed() bool { + return d.Status == DocumentStatusProcessed +} + +// IsPending checks if the document is pending processing +func (d *Document) IsPending() bool { + return d.Status == DocumentStatusPending +} + +// HasText checks if the document has extracted text +func (d *Document) HasText() bool { + return d.ExtractedText != "" +} + +// DocumentUploadRequest represents a request to upload a new document +type DocumentUploadRequest struct { + OrganizationID int32 `json:"organization_id"` + Title string `json:"title"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + FileSize int64 `json:"file_size"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// DocumentFilter represents filter options for listing documents +type DocumentFilter struct { + Status *DocumentStatus `json:"status,omitempty"` +} + +// DocumentStats represents document statistics +type DocumentStats struct { + TotalCount int64 `json:"total_count"` + PendingCount int64 `json:"pending_count"` + ProcessedCount int64 `json:"processed_count"` + FailedCount int64 `json:"failed_count"` +} diff --git a/go-b2b-starter/src/app/example_documents/domain/errors.go b/go-b2b-starter/src/app/example_documents/domain/errors.go new file mode 100644 index 0000000..c4edd18 --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/domain/errors.go @@ -0,0 +1,26 @@ +package domain + +import "errors" + +// Domain errors for documents +var ( + // Validation errors + ErrDocumentOrganizationRequired = errors.New("document organization ID is required") + ErrDocumentTitleRequired = errors.New("document title is required") + ErrDocumentFileNameRequired = errors.New("document file name is required") + ErrDocumentFileAssetRequired = errors.New("document file asset ID is required") + + // Not found errors + ErrDocumentNotFound = errors.New("document not found") + + // Processing errors + ErrDocumentAlreadyProcessed = errors.New("document has already been processed") + ErrDocumentProcessingFailed = errors.New("document processing failed") + ErrTextExtractionFailed = errors.New("text extraction from document failed") + + // File errors + ErrInvalidFileType = errors.New("invalid file type: only PDF files are allowed") + ErrFileTooLarge = errors.New("file size exceeds maximum allowed limit") + ErrFileUploadFailed = errors.New("failed to upload file") + ErrFileDownloadFailed = errors.New("failed to download file") +) diff --git a/go-b2b-starter/src/app/example_documents/domain/events/document_events.go b/go-b2b-starter/src/app/example_documents/domain/events/document_events.go new file mode 100644 index 0000000..95228c4 --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/domain/events/document_events.go @@ -0,0 +1,87 @@ +package events + +import ( + "time" + + "github.com/google/uuid" + "github.com/moasq/go-b2b-starter/pkg/eventbus" +) + +const ( + DocumentUploadedEventType = "document.uploaded" + DocumentProcessedEventType = "document.processed" + DocumentFailedEventType = "document.failed" +) + +// DocumentUploaded is published when a document has been uploaded and text extracted +type DocumentUploaded struct { + eventbus.BaseEvent + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + FileAssetID int32 `json:"file_asset_id"` + Title string `json:"title"` + ExtractedText string `json:"extracted_text"` +} + +// NewDocumentUploaded creates a new DocumentUploaded event +func NewDocumentUploaded(documentID, organizationID, fileAssetID int32, title, extractedText string) *DocumentUploaded { + return &DocumentUploaded{ + BaseEvent: eventbus.BaseEvent{ + ID: uuid.New().String(), + Name: DocumentUploadedEventType, + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + DocumentID: documentID, + OrganizationID: organizationID, + FileAssetID: fileAssetID, + Title: title, + ExtractedText: extractedText, + } +} + +// DocumentProcessed is published when a document embedding has been created +type DocumentProcessed struct { + eventbus.BaseEvent + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + EmbeddingID int32 `json:"embedding_id"` +} + +// NewDocumentProcessed creates a new DocumentProcessed event +func NewDocumentProcessed(documentID, organizationID, embeddingID int32) *DocumentProcessed { + return &DocumentProcessed{ + BaseEvent: eventbus.BaseEvent{ + ID: uuid.New().String(), + Name: DocumentProcessedEventType, + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + DocumentID: documentID, + OrganizationID: organizationID, + EmbeddingID: embeddingID, + } +} + +// DocumentFailed is published when document processing fails +type DocumentFailed struct { + eventbus.BaseEvent + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + Error string `json:"error"` +} + +// NewDocumentFailed creates a new DocumentFailed event +func NewDocumentFailed(documentID, organizationID int32, err string) *DocumentFailed { + return &DocumentFailed{ + BaseEvent: eventbus.BaseEvent{ + ID: uuid.New().String(), + Name: DocumentFailedEventType, + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + DocumentID: documentID, + OrganizationID: organizationID, + Error: err, + } +} diff --git a/go-b2b-starter/src/app/example_documents/domain/repository.go b/go-b2b-starter/src/app/example_documents/domain/repository.go new file mode 100644 index 0000000..68ee81b --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/domain/repository.go @@ -0,0 +1,39 @@ +package domain + +import "context" + +// DocumentRepository defines the interface for document data operations +type DocumentRepository interface { + // Create creates a new document + Create(ctx context.Context, doc *Document) (*Document, error) + + // GetByID retrieves a document by ID + GetByID(ctx context.Context, orgID, docID int32) (*Document, error) + + // GetByFileAssetID retrieves a document by file asset ID + GetByFileAssetID(ctx context.Context, orgID, fileAssetID int32) (*Document, error) + + // List retrieves documents with pagination + List(ctx context.Context, orgID int32, limit, offset int32) ([]*Document, error) + + // ListByStatus retrieves documents by status with pagination + ListByStatus(ctx context.Context, orgID int32, status DocumentStatus, limit, offset int32) ([]*Document, error) + + // UpdateStatus updates the document status + UpdateStatus(ctx context.Context, orgID, docID int32, status DocumentStatus) (*Document, error) + + // UpdateExtractedText updates the extracted text and sets status to processed + UpdateExtractedText(ctx context.Context, orgID, docID int32, text string) (*Document, error) + + // Update updates document metadata + Update(ctx context.Context, doc *Document) (*Document, error) + + // Delete removes a document + Delete(ctx context.Context, orgID, docID int32) error + + // Count returns the total count of documents for an organization + Count(ctx context.Context, orgID int32) (int64, error) + + // CountByStatus returns the count of documents with a specific status + CountByStatus(ctx context.Context, orgID int32, status DocumentStatus) (int64, error) +} diff --git a/go-b2b-starter/src/app/example_documents/go.mod b/go-b2b-starter/src/app/example_documents/go.mod new file mode 100644 index 0000000..c751fbe --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/go.mod @@ -0,0 +1,41 @@ +module github.com/moasq/go-b2b-starter/app/example_documents + +go 1.25 + +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/moasq/go-b2b-starter/pkg/db v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/eventbus v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/file_manager v0.0.0-00010101000000-000000000000 + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + github.com/moasq/go-b2b-starter/pkg/ocr v0.0.0-00010101000000-000000000000 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pgvector/pgvector-go v0.3.0 // indirect + github.com/rs/zerolog v1.33.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/pkg/db => ../../pkg/db + +replace github.com/moasq/go-b2b-starter/pkg/eventbus => ../../pkg/eventbus + +replace github.com/moasq/go-b2b-starter/pkg/file_manager => ../../pkg/file_manager + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../../pkg/logger + +replace github.com/moasq/go-b2b-starter/pkg/ocr => ../../pkg/ocr diff --git a/go-b2b-starter/src/app/example_documents/go.sum b/go-b2b-starter/src/app/example_documents/go.sum new file mode 100644 index 0000000..0d709db --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/go.sum @@ -0,0 +1,96 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/go-b2b-starter/src/app/example_documents/infra/repositories/document_repository.go b/go-b2b-starter/src/app/example_documents/infra/repositories/document_repository.go new file mode 100644 index 0000000..2682564 --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/infra/repositories/document_repository.go @@ -0,0 +1,241 @@ +package repositories + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/moasq/go-b2b-starter/app/example_documents/domain" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +type documentRepository struct { + store adapters.DocumentStore +} + +// NewDocumentRepository creates a new document repository +func NewDocumentRepository(store adapters.DocumentStore) domain.DocumentRepository { + return &documentRepository{store: store} +} + +func (r *documentRepository) Create(ctx context.Context, doc *domain.Document) (*domain.Document, error) { + params := sqlc.CreateDocumentParams{ + OrganizationID: doc.OrganizationID, + FileAssetID: doc.FileAssetID, + Title: doc.Title, + FileName: doc.FileName, + ContentType: doc.ContentType, + FileSize: doc.FileSize, + ExtractedText: toPgText(doc.ExtractedText), + Status: string(doc.Status), + Metadata: toJSONB(doc.Metadata), + } + + result, err := r.store.CreateDocument(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create document: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *documentRepository) GetByID(ctx context.Context, orgID, docID int32) (*domain.Document, error) { + params := sqlc.GetDocumentByIDParams{ + ID: docID, + OrganizationID: orgID, + } + + result, err := r.store.GetDocumentByID(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get document: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *documentRepository) GetByFileAssetID(ctx context.Context, orgID, fileAssetID int32) (*domain.Document, error) { + params := sqlc.GetDocumentByFileAssetIDParams{ + FileAssetID: fileAssetID, + OrganizationID: orgID, + } + + result, err := r.store.GetDocumentByFileAssetID(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get document by file asset: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *documentRepository) List(ctx context.Context, orgID int32, limit, offset int32) ([]*domain.Document, error) { + params := sqlc.ListDocumentsByOrganizationParams{ + OrganizationID: orgID, + Limit: limit, + Offset: offset, + } + + results, err := r.store.ListDocumentsByOrganization(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to list documents: %w", err) + } + + docs := make([]*domain.Document, len(results)) + for i, result := range results { + docs[i] = r.mapToDomain(&result) + } + + return docs, nil +} + +func (r *documentRepository) ListByStatus(ctx context.Context, orgID int32, status domain.DocumentStatus, limit, offset int32) ([]*domain.Document, error) { + params := sqlc.ListDocumentsByStatusParams{ + OrganizationID: orgID, + Status: string(status), + Limit: limit, + Offset: offset, + } + + results, err := r.store.ListDocumentsByStatus(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to list documents by status: %w", err) + } + + docs := make([]*domain.Document, len(results)) + for i, result := range results { + docs[i] = r.mapToDomain(&result) + } + + return docs, nil +} + +func (r *documentRepository) UpdateStatus(ctx context.Context, orgID, docID int32, status domain.DocumentStatus) (*domain.Document, error) { + params := sqlc.UpdateDocumentStatusParams{ + ID: docID, + OrganizationID: orgID, + Status: string(status), + } + + result, err := r.store.UpdateDocumentStatus(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to update document status: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *documentRepository) UpdateExtractedText(ctx context.Context, orgID, docID int32, text string) (*domain.Document, error) { + params := sqlc.UpdateDocumentExtractedTextParams{ + ID: docID, + OrganizationID: orgID, + ExtractedText: toPgText(text), + } + + result, err := r.store.UpdateDocumentExtractedText(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to update extracted text: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *documentRepository) Update(ctx context.Context, doc *domain.Document) (*domain.Document, error) { + params := sqlc.UpdateDocumentParams{ + ID: doc.ID, + OrganizationID: doc.OrganizationID, + Title: doc.Title, + Metadata: toJSONB(doc.Metadata), + } + + result, err := r.store.UpdateDocument(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to update document: %w", err) + } + + return r.mapToDomain(&result), nil +} + +func (r *documentRepository) Delete(ctx context.Context, orgID, docID int32) error { + params := sqlc.DeleteDocumentParams{ + ID: docID, + OrganizationID: orgID, + } + + if err := r.store.DeleteDocument(ctx, params); err != nil { + return fmt.Errorf("failed to delete document: %w", err) + } + + return nil +} + +func (r *documentRepository) Count(ctx context.Context, orgID int32) (int64, error) { + count, err := r.store.CountDocumentsByOrganization(ctx, orgID) + if err != nil { + return 0, fmt.Errorf("failed to count documents: %w", err) + } + + return count, nil +} + +func (r *documentRepository) CountByStatus(ctx context.Context, orgID int32, status domain.DocumentStatus) (int64, error) { + params := sqlc.CountDocumentsByStatusParams{ + OrganizationID: orgID, + Status: string(status), + } + + count, err := r.store.CountDocumentsByStatus(ctx, params) + if err != nil { + return 0, fmt.Errorf("failed to count documents by status: %w", err) + } + + return count, nil +} + +// mapToDomain maps a database document to a domain document +func (r *documentRepository) mapToDomain(doc *sqlc.DocumentsDocument) *domain.Document { + return &domain.Document{ + ID: doc.ID, + OrganizationID: doc.OrganizationID, + FileAssetID: doc.FileAssetID, + Title: doc.Title, + FileName: doc.FileName, + ContentType: doc.ContentType, + FileSize: doc.FileSize, + ExtractedText: fromPgText(doc.ExtractedText), + Status: domain.DocumentStatus(doc.Status), + Metadata: fromJSONB(doc.Metadata), + CreatedAt: doc.CreatedAt.Time, + UpdatedAt: doc.UpdatedAt.Time, + } +} + +// Helper functions for type conversion +func toPgText(s string) pgtype.Text { + if s == "" { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: s, Valid: true} +} + +func fromPgText(t pgtype.Text) string { + if !t.Valid { + return "" + } + return t.String +} + +func toJSONB(m map[string]interface{}) []byte { + if m == nil { + return []byte("{}") + } + // In production, use proper JSON marshaling + return []byte("{}") +} + +func fromJSONB(b []byte) map[string]interface{} { + if b == nil { + return nil + } + // In production, use proper JSON unmarshaling + return nil +} diff --git a/go-b2b-starter/src/app/example_documents/module.go b/go-b2b-starter/src/app/example_documents/module.go new file mode 100644 index 0000000..90a0533 --- /dev/null +++ b/go-b2b-starter/src/app/example_documents/module.go @@ -0,0 +1,53 @@ +package documents + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/example_documents/app/services" + "github.com/moasq/go-b2b-starter/app/example_documents/domain" + "github.com/moasq/go-b2b-starter/app/example_documents/infra/repositories" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + "github.com/moasq/go-b2b-starter/pkg/eventbus" + filedomain "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" + "github.com/moasq/go-b2b-starter/pkg/logger" + ocrdomain "github.com/moasq/go-b2b-starter/pkg/ocr/domain" +) + +// Module provides documents module dependencies +type Module struct { + container *dig.Container +} + +// NewModule creates a new documents module +func NewModule(container *dig.Container) *Module { + return &Module{ + container: container, + } +} + +// RegisterDependencies registers all documents module dependencies +func (m *Module) RegisterDependencies() error { + // Register document repository + if err := m.container.Provide(func( + docStore adapters.DocumentStore, + ) domain.DocumentRepository { + return repositories.NewDocumentRepository(docStore) + }); err != nil { + return err + } + + // Register document service + if err := m.container.Provide(func( + docRepo domain.DocumentRepository, + fileService filedomain.FileService, + ocrService ocrdomain.OCRService, + eventBus eventbus.EventBus, + logger logger.Logger, + ) services.DocumentService { + return services.NewDocumentService(docRepo, fileService, ocrService, eventBus, logger) + }); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/app/organizations/app/services/member_service.go b/go-b2b-starter/src/app/organizations/app/services/member_service.go new file mode 100644 index 0000000..49cec7f --- /dev/null +++ b/go-b2b-starter/src/app/organizations/app/services/member_service.go @@ -0,0 +1,160 @@ +package services + +import ( + "context" + "fmt" + "strings" +) + +// MemberService defines the core authentication and member management operations +// This interface focuses on organization bootstrap and member operations +type MemberService interface { + // BootstrapOrganizationWithOwner creates a new organization with an initial owner user + // This is the primary signup flow for new organizations + BootstrapOrganizationWithOwner(ctx context.Context, req *BootstrapOrganizationRequest) (*BootstrapOrganizationResponse, error) + + // AddMemberDirect adds a new member to an existing organization without invitation + // Creates the user if they don't exist, then adds them to the organization with specified roles + AddMemberDirect(ctx context.Context, req *AddMemberRequest) (*AddMemberResponse, error) + + // ListOrganizationMembers retrieves all members of an organization + // Returns a list of members with their details including roles and status + ListOrganizationMembers(ctx context.Context, orgID string) (*ListMembersResponse, error) + + // GetCurrentUserProfile retrieves the current authenticated user's profile + // Returns comprehensive profile information including member, organization, and account details + GetCurrentUserProfile(ctx context.Context, orgID, memberID, email string) (*ProfileResponse, error) + + // DeleteOrganizationMember removes a member from the organization (admin only) + // Deletes from both auth provider and internal database + DeleteOrganizationMember(ctx context.Context, orgID, memberID string) error + + // CheckEmailExists checks if an email exists in the system + // Returns true if email is found, false otherwise + // Used for login flow to verify if user has an account + CheckEmailExists(ctx context.Context, email string) (bool, error) +} + +// BootstrapOrganizationRequest represents the request to create a new organization with an owner +type BootstrapOrganizationRequest struct { + // Organization details + OrgDisplayName string `json:"org_display_name" binding:"required"` + + // Owner member details + OwnerEmail string `json:"owner_email" binding:"required,email"` + OwnerName string `json:"owner_name" binding:"required"` +} + +// Validate performs business validation on the bootstrap request +func (r *BootstrapOrganizationRequest) Validate() error { + if strings.TrimSpace(r.OrgDisplayName) == "" { + return fmt.Errorf("organization display name cannot be empty") + } + if strings.TrimSpace(r.OwnerEmail) == "" { + return fmt.Errorf("owner email cannot be empty") + } + if strings.TrimSpace(r.OwnerName) == "" { + return fmt.Errorf("owner name cannot be empty") + } + return nil +} + +// BootstrapOrganizationResponse represents the response after organization bootstrap +type BootstrapOrganizationResponse struct { + OrganizationID string `json:"organization_id"` + OrgSlug string `json:"org_slug"` + DisplayName string `json:"display_name"` + OwnerMemberID string `json:"owner_member_id"` + OwnerEmail string `json:"owner_email"` + OwnerName string `json:"owner_name"` + InviteSent bool `json:"invite_sent"` + MagicLinkSent bool `json:"magic_link_sent"` +} + +// AddMemberRequest represents the request to add a member to an organization +type AddMemberRequest struct { + // Organization context (populated by handler from JWT middleware, not from request body) + OrgID string `json:"-"` + + // Member user details + Email string `json:"email" binding:"required,email"` + Name string `json:"name" binding:"required"` + + // Role assignment (single role per member) + RoleSlug string `json:"role_slug"` +} + +// Validate performs business validation on the add member request +func (r *AddMemberRequest) Validate() error { + if strings.TrimSpace(r.Email) == "" { + return fmt.Errorf("email cannot be empty") + } + if strings.TrimSpace(r.Name) == "" { + return fmt.Errorf("name cannot be empty") + } + // Note: OrgID is validated by handler (extracted from JWT middleware) + return nil +} + +// AddMemberResponse represents the response after adding a member +type AddMemberResponse struct { + MemberID string `json:"member_id"` + Email string `json:"email"` + Name string `json:"name"` + OrgID string `json:"org_id"` + RoleSlug string `json:"role_slug"` + InviteSent bool `json:"invite_sent"` +} + +// MemberInfo represents a member in the list response +type MemberInfo struct { + MemberID string `json:"member_id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListMembersResponse represents the response for listing organization members +type ListMembersResponse struct { + Members []*MemberInfo `json:"members"` + Total int `json:"total"` +} + +// ProfileResponse represents the current user's profile information +// This is a composite response combining auth provider member data + internal account + organization +type ProfileResponse struct { + // Auth provider member details + MemberID string `json:"member_id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + EmailVerified bool `json:"email_verified"` + Status string `json:"status"` + + // Organization details + Organization ProfileOrganization `json:"organization"` + + // Internal account details + AccountID int32 `json:"account_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ProfileOrganization represents organization info in profile response +type ProfileOrganization struct { + OrganizationID string `json:"organization_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` +} + +// CheckEmailRequest represents the request to check if an email exists +// Used for login flow to verify if user has an account +type CheckEmailRequest struct { + Email string `form:"email" binding:"required,email"` +} diff --git a/go-b2b-starter/src/app/organizations/app/services/member_service_impl.go b/go-b2b-starter/src/app/organizations/app/services/member_service_impl.go new file mode 100644 index 0000000..6ad04f6 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/app/services/member_service_impl.go @@ -0,0 +1,590 @@ +package services + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger" +) + +// rollbackFunc represents a function that can rollback a created resource +type rollbackFunc func(context.Context) error + +// rollbackStack manages rollback functions in LIFO order +type rollbackStack []rollbackFunc + +// add appends a rollback function to the stack +func (rs *rollbackStack) add(fn rollbackFunc) { + *rs = append(*rs, fn) +} + +// execute runs all rollback functions in reverse order (LIFO) +func (rs rollbackStack) execute(ctx context.Context, logger loggerDomain.Logger) { + // Execute in reverse order (LIFO - Last In First Out) + for i := len(rs) - 1; i >= 0; i-- { + if err := rs[i](ctx); err != nil { + logger.Error("rollback operation failed", loggerDomain.Fields{ + "step": i, + "error": err.Error(), + }) + // Continue with remaining rollbacks even if one fails + } + } +} + +type memberService struct { + authOrgRepo domain.AuthOrganizationRepository + authMemberRepo domain.AuthMemberRepository + authRoleRepo domain.AuthRoleRepository + localOrgRepo domain.OrganizationRepository + localAccountRepo domain.AccountRepository + logger loggerDomain.Logger +} + +// NewMemberService creates a new instance of the member service. +func NewMemberService( + authOrgRepo domain.AuthOrganizationRepository, + authMemberRepo domain.AuthMemberRepository, + authRoleRepo domain.AuthRoleRepository, + localOrgRepo domain.OrganizationRepository, + localAccountRepo domain.AccountRepository, + logger loggerDomain.Logger, +) MemberService { + return &memberService{ + authOrgRepo: authOrgRepo, + authMemberRepo: authMemberRepo, + authRoleRepo: authRoleRepo, + localOrgRepo: localOrgRepo, + localAccountRepo: localAccountRepo, + logger: logger, + } +} + +// BootstrapOrganizationWithOwner creates a new organization with an initial owner member. +// If any step fails, all previously created resources are automatically rolled back. +func (s *memberService) BootstrapOrganizationWithOwner( + ctx context.Context, + req *BootstrapOrganizationRequest, +) (*BootstrapOrganizationResponse, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid bootstrap request: %w", err) + } + + // Always use "admin" role for bootstrap (primary admin user) + ownerRoleSlug := "admin" + + // Initialize rollback stack for transaction-like behavior + var rollbacks rollbackStack + shouldRollback := true + + // Defer rollback execution - runs on function exit if shouldRollback is true + defer func() { + if shouldRollback { + s.logger.Warn("bootstrap failed, executing rollback", loggerDomain.Fields{ + "org_name": req.OrgDisplayName, + "rollback_steps": len(rollbacks), + }) + rollbacks.execute(context.Background(), s.logger) + } + }() + + s.logger.Info("starting organization bootstrap", loggerDomain.Fields{ + "org_name": req.OrgDisplayName, + "owner_email": req.OwnerEmail, + }) + + // Step 1: Create organization in auth provider + // Infrastructure layer handles slug generation and duplicate retry logic + authOrg, err := s.authOrgRepo.CreateOrganization(ctx, &domain.CreateAuthOrganizationRequest{ + DisplayName: req.OrgDisplayName, + EmailInvitesAllowed: true, + }) + if err != nil { + fmt.Println(err) + s.logger.Error("failed to create auth organization", loggerDomain.Fields{ + "org_name": req.OrgDisplayName, + "error": err.Error(), + }) + return nil, err + } + + // Track auth org creation for rollback + rollbacks.add(func(ctx context.Context) error { + s.logger.Info("rolling back auth organization", loggerDomain.Fields{ + "auth_org_id": authOrg.OrganizationID, + }) + return s.authOrgRepo.DeleteOrganization(ctx, authOrg.OrganizationID) + }) + + s.logger.Info("organization created in auth provider", loggerDomain.Fields{ + "auth_org_id": authOrg.OrganizationID, + "org_slug": authOrg.Slug, + }) + + // Step 2: Create local organization record. + s.logger.Info("creating local organization", loggerDomain.Fields{ + "slug": authOrg.Slug, + "display_name": authOrg.DisplayName, + }) + + localOrg, err := s.localOrgRepo.Create(ctx, &domain.Organization{ + Slug: authOrg.Slug, + Name: authOrg.DisplayName, + Status: "active", + }) + if err != nil { + s.logger.Error("failed to create local organization", loggerDomain.Fields{ + "org_slug": authOrg.Slug, + "display_name": authOrg.DisplayName, + "status": "active", + "error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + }) + return nil, fmt.Errorf("failed to create local organization: %w", err) + } + + // Track local org creation for rollback + rollbacks.add(func(ctx context.Context) error { + s.logger.Info("rolling back local organization", loggerDomain.Fields{ + "local_org_id": localOrg.ID, + }) + return s.localOrgRepo.Delete(ctx, localOrg.ID) + }) + + s.logger.Info("local organization created successfully", loggerDomain.Fields{ + "local_org_id": localOrg.ID, + "slug": localOrg.Slug, + }) + + if _, err := s.localOrgRepo.UpdateStytchInfo(ctx, localOrg.ID, authOrg.OrganizationID, "", ""); err != nil { + s.logger.Error("failed to map auth organization locally", loggerDomain.Fields{ + "local_org_id": localOrg.ID, + "auth_org_id": authOrg.OrganizationID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to map auth organization: %w", err) + } + + // Step 3: Create owner member (no invite). + createMemberReq := &domain.CreateAuthMemberRequest{ + OrganizationID: authOrg.OrganizationID, + Email: req.OwnerEmail, + Name: req.OwnerName, + SendInvite: false, // No magic link invite + } + + member, err := s.authMemberRepo.CreateMember(ctx, createMemberReq) + if err != nil { + return nil, fmt.Errorf("failed to create owner member: %w", err) + } + + // Track auth member creation for rollback + rollbacks.add(func(ctx context.Context) error { + s.logger.Info("rolling back auth member", loggerDomain.Fields{ + "member_id": member.MemberID, + "auth_org_id": authOrg.OrganizationID, + }) + return s.authMemberRepo.RemoveMembers(ctx, &domain.RemoveAuthMembersRequest{ + OrganizationID: authOrg.OrganizationID, + MemberIDs: []string{member.MemberID}, + }) + }) + + // Step 4: Assign admin role in auth provider. + if err := s.authMemberRepo.AssignRoles(ctx, &domain.AssignAuthRolesRequest{ + OrganizationID: authOrg.OrganizationID, + MemberID: member.MemberID, + Roles: []string{ownerRoleSlug}, + }); err != nil { + return nil, fmt.Errorf("failed to assign admin role: %w", err) + } + + role, err := s.authRoleRepo.GetRoleBySlug(ctx, ownerRoleSlug) + if err != nil { + return nil, fmt.Errorf("failed to fetch admin role metadata: %w", err) + } + + // Step 5: Create local account record. + localAccount, err := s.localAccountRepo.Create(ctx, &domain.Account{ + OrganizationID: localOrg.ID, + Email: member.Email, + FullName: member.Name, + Role: mapRoleSlugToAccountRole(ownerRoleSlug), + Status: "active", + }) + if err != nil { + return nil, fmt.Errorf("failed to create local account: %w", err) + } + + // Track local account creation for rollback + rollbacks.add(func(ctx context.Context) error { + s.logger.Info("rolling back local account", loggerDomain.Fields{ + "account_id": localAccount.ID, + "local_org_id": localOrg.ID, + }) + return s.localAccountRepo.Delete(ctx, localOrg.ID, localAccount.ID) + }) + + if _, err := s.localAccountRepo.UpdateStytchInfo( + ctx, + localOrg.ID, + localAccount.ID, + member.MemberID, + role.RoleID, + ownerRoleSlug, + member.EmailVerified, + ); err != nil { + return nil, fmt.Errorf("failed to map auth member locally: %w", err) + } + + // Success! Disable rollback + shouldRollback = false + + s.logger.Info("organization bootstrap completed", loggerDomain.Fields{ + "stytch_org_id": authOrg.OrganizationID, + "owner_member": member.MemberID, + }) + + return &BootstrapOrganizationResponse{ + OrganizationID: authOrg.OrganizationID, + OrgSlug: authOrg.Slug, + DisplayName: authOrg.DisplayName, + OwnerMemberID: member.MemberID, + OwnerEmail: member.Email, + OwnerName: member.Name, + InviteSent: false, // No invite sent + MagicLinkSent: false, // No magic link sent + }, nil +} + +// AddMemberDirect adds a new member to an existing organization without invitation workflows. +func (s *memberService) AddMemberDirect( + ctx context.Context, + req *AddMemberRequest, +) (*AddMemberResponse, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid add member request: %w", err) + } + + roleSlug := strings.ToLower(strings.TrimSpace(req.RoleSlug)) + if roleSlug == "" { + roleSlug = "member" + } + + orgID := req.OrgID + if orgID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + + localOrgID, err := s.resolveLocalOrganizationID(ctx, orgID) + if err != nil { + return nil, err + } + + if existingAccount, err := s.localAccountRepo.GetByEmail(ctx, localOrgID, req.Email); err == nil { + s.logger.Warn("member email already exists locally", loggerDomain.Fields{ + "org_id": localOrgID, + "email": req.Email, + "status": existingAccount.Status, + }) + return nil, domain.ErrAuthMemberAlreadyExists + } else if !errors.Is(err, domain.ErrAccountNotFound) { + return nil, fmt.Errorf("failed to check existing account: %w", err) + } + + createReq := &domain.CreateAuthMemberRequest{ + OrganizationID: orgID, + Email: req.Email, + Name: req.Name, + SendInvite: false, // No magic link invite + } + + member, err := s.authMemberRepo.CreateMember(ctx, createReq) + if err != nil { + return nil, fmt.Errorf("failed to create member: %w", err) + } + + if err := s.authMemberRepo.AssignRoles(ctx, &domain.AssignAuthRolesRequest{ + OrganizationID: orgID, + MemberID: member.MemberID, + Roles: []string{roleSlug}, + }); err != nil { + return nil, fmt.Errorf("failed to assign member role: %w", err) + } + + role, err := s.authRoleRepo.GetRoleBySlug(ctx, roleSlug) + if err != nil { + return nil, fmt.Errorf("failed to fetch role metadata: %w", err) + } + + localAccount, err := s.localAccountRepo.Create(ctx, &domain.Account{ + OrganizationID: localOrgID, + Email: member.Email, + FullName: member.Name, + Role: mapRoleSlugToAccountRole(roleSlug), + Status: "active", + }) + if err != nil { + return nil, fmt.Errorf("failed to create local account: %w", err) + } + + if _, err := s.localAccountRepo.UpdateStytchInfo( + ctx, + localOrgID, + localAccount.ID, + member.MemberID, + role.RoleID, + roleSlug, + member.EmailVerified, + ); err != nil { + return nil, fmt.Errorf("failed to map auth member locally: %w", err) + } + + s.logger.Info("member added successfully", loggerDomain.Fields{ + "org_id": orgID, + "member_id": member.MemberID, + "invite_sent": true, + }) + + return &AddMemberResponse{ + MemberID: member.MemberID, + Email: member.Email, + Name: member.Name, + OrgID: orgID, + RoleSlug: roleSlug, + InviteSent: true, // Always true (passwordless) + }, nil +} + +// ListOrganizationMembers retrieves all members of an organization. +func (s *memberService) ListOrganizationMembers( + ctx context.Context, + orgID string, +) (*ListMembersResponse, error) { + if orgID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + + // Retrieve members from repository (no pagination limit) + members, err := s.authMemberRepo.ListMembers(ctx, orgID, 0, 0) + if err != nil { + s.logger.Error("failed to list organization members", loggerDomain.Fields{ + "org_id": orgID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to list members: %w", err) + } + + // Convert domain members to response info + memberInfos := make([]*MemberInfo, 0, len(members)) + for _, member := range members { + memberInfos = append(memberInfos, &MemberInfo{ + MemberID: member.MemberID, + Email: member.Email, + Name: member.Name, + Roles: member.Roles, + Status: member.Status, + EmailVerified: member.EmailVerified, + CreatedAt: member.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: member.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + + s.logger.Info("members listed successfully", loggerDomain.Fields{ + "org_id": orgID, + "count": len(memberInfos), + }) + + return &ListMembersResponse{ + Members: memberInfos, + Total: len(memberInfos), + }, nil +} + +// GetCurrentUserProfile retrieves the current authenticated user's profile. +func (s *memberService) GetCurrentUserProfile( + ctx context.Context, + orgID, memberID, email string, +) (*ProfileResponse, error) { + // Validate required parameters + if orgID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + if memberID == "" { + return nil, domain.ErrAuthMemberIDRequired + } + if email == "" { + return nil, domain.ErrAuthEmailRequired + } + + // Get member details from auth provider + member, err := s.authMemberRepo.GetMember(ctx, orgID, memberID) + if err != nil { + s.logger.Error("failed to get member details", loggerDomain.Fields{ + "org_id": orgID, + "member_id": memberID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get member details: %w", err) + } + + // Get organization details from auth provider + organization, err := s.authOrgRepo.GetOrganization(ctx, orgID) + if err != nil { + s.logger.Error("failed to get organization details", loggerDomain.Fields{ + "org_id": orgID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get organization details: %w", err) + } + + // Get local organization details (for database ID) + localOrg, err := s.localOrgRepo.GetByStytchID(ctx, orgID) + if err != nil { + s.logger.Error("failed to get local organization", loggerDomain.Fields{ + "auth_org_id": orgID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get local organization: %w", err) + } + + // Get local account details (for database ID) + localAccount, err := s.localAccountRepo.GetByEmail(ctx, localOrg.ID, email) + if err != nil { + s.logger.Error("failed to get local account", loggerDomain.Fields{ + "org_id": localOrg.ID, + "email": email, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get local account: %w", err) + } + + // Build profile response + profile := &ProfileResponse{ + MemberID: member.MemberID, + Email: member.Email, + Name: member.Name, + Roles: member.Roles, + EmailVerified: member.EmailVerified, + Status: member.Status, + Organization: ProfileOrganization{ + OrganizationID: organization.OrganizationID, + Slug: organization.Slug, + Name: organization.DisplayName, + Status: organization.Status, + }, + AccountID: localAccount.ID, + CreatedAt: member.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: member.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + s.logger.Info("profile retrieved successfully", loggerDomain.Fields{ + "member_id": memberID, + "org_id": orgID, + "email": email, + }) + + return profile, nil +} + +// DeleteOrganizationMember removes a member from the organization +// This deletes from both auth provider and the internal database +// Admin-only operation (permission check done at handler level) +func (s *memberService) DeleteOrganizationMember( + ctx context.Context, + orgID, memberID string, +) error { + if orgID == "" || memberID == "" { + return fmt.Errorf("organization ID and member ID are required") + } + + s.logger.Info("deleting organization member", map[string]interface{}{ + "org_id": orgID, + "member_id": memberID, + }) + + // Create remove members request + req := &domain.RemoveAuthMembersRequest{ + OrganizationID: orgID, + MemberIDs: []string{memberID}, + } + + // Remove from auth organization + err := s.authMemberRepo.RemoveMembers(ctx, req) + if err != nil { + s.logger.Error("failed to remove member from auth organization", map[string]interface{}{ + "org_id": orgID, + "member_id": memberID, + "error": err.Error(), + }) + return fmt.Errorf("failed to remove member: %w", err) + } + + s.logger.Info("member successfully deleted from organization", map[string]interface{}{ + "org_id": orgID, + "member_id": memberID, + }) + + return nil +} + +// CheckEmailExists checks if an email exists in the system +// Returns true if email is found in any organization, false otherwise +func (s *memberService) CheckEmailExists(ctx context.Context, email string) (bool, error) { + // Validate email format + email = strings.TrimSpace(email) + if email == "" { + return false, fmt.Errorf("email cannot be empty") + } + + s.logger.Info("checking email existence", loggerDomain.Fields{ + "email": email, + }) + + // Check using organization repository + exists, err := s.authOrgRepo.CheckEmailExists(ctx, email) + if err != nil { + s.logger.Error("failed to check email existence", loggerDomain.Fields{ + "email": email, + "error": err.Error(), + }) + return false, fmt.Errorf("failed to check email existence: %w", err) + } + + s.logger.Info("email existence check completed", loggerDomain.Fields{ + "email": email, + "exists": exists, + }) + + return exists, nil +} + +func (s *memberService) resolveLocalOrganizationID(ctx context.Context, authOrgID string) (int32, error) { + org, err := s.localOrgRepo.GetByStytchID(ctx, authOrgID) + if err != nil { + return 0, fmt.Errorf("failed to resolve local organization: %w", err) + } + return org.ID, nil +} + +func mapRoleSlugToAccountRole(slug string) string { + switch strings.ToLower(strings.TrimSpace(slug)) { + case "owner": + // Legacy: map owner to admin + return "admin" + case "admin": + return "admin" + case "approver": + return "approver" + case "reviewer": + // Legacy: map reviewer to approver + return "approver" + case "employee", "member": + return "member" + default: + return slug + } +} diff --git a/go-b2b-starter/src/app/organizations/app/services/organization_service.go b/go-b2b-starter/src/app/organizations/app/services/organization_service.go new file mode 100644 index 0000000..36a1360 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/app/services/organization_service.go @@ -0,0 +1,205 @@ +package services + +import ( + "context" + "fmt" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" +) + +type organizationService struct { + orgRepo domain.OrganizationRepository + accountRepo domain.AccountRepository +} + +// NewOrganizationService creates a new organization service +func NewOrganizationService(orgRepo domain.OrganizationRepository, accountRepo domain.AccountRepository) OrganizationService { + return &organizationService{ + orgRepo: orgRepo, + accountRepo: accountRepo, + } +} + +func (s *organizationService) CreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*domain.Organization, error) { + // Create organization + org := &domain.Organization{ + Slug: req.Slug, + Name: req.Name, + Status: "active", + StytchOrgID: req.StytchOrgID, + StytchConnectionID: req.StytchConnectionID, + StytchConnectionName: req.StytchConnectionName, + } + + createdOrg, err := s.orgRepo.Create(ctx, org) + if err != nil { + return nil, fmt.Errorf("failed to create organization: %w", err) + } + + if req.StytchOrgID != "" || req.StytchConnectionID != "" || req.StytchConnectionName != "" { + createdOrg.StytchOrgID = req.StytchOrgID + createdOrg.StytchConnectionID = req.StytchConnectionID + createdOrg.StytchConnectionName = req.StytchConnectionName + createdOrg, err = s.orgRepo.Update(ctx, createdOrg) + if err != nil { + return nil, fmt.Errorf("failed to persist organization Stytch metadata: %w", err) + } + } + + // Create admin account (primary admin user) + adminAccount := &domain.Account{ + OrganizationID: createdOrg.ID, + Email: req.OwnerEmail, + FullName: req.OwnerName, + Role: "admin", + Status: "active", + } + + _, err = s.accountRepo.Create(ctx, adminAccount) + if err != nil { + return nil, fmt.Errorf("failed to create admin account: %w", err) + } + + return createdOrg, nil +} + +func (s *organizationService) GetOrganization(ctx context.Context, orgID int32) (*domain.Organization, error) { + return s.orgRepo.GetByID(ctx, orgID) +} + +func (s *organizationService) GetOrganizationBySlug(ctx context.Context, slug string) (*domain.Organization, error) { + return s.orgRepo.GetBySlug(ctx, slug) +} + +func (s *organizationService) GetOrganizationByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) { + return s.orgRepo.GetByStytchID(ctx, stytchOrgID) +} + +func (s *organizationService) GetOrganizationByUserEmail(ctx context.Context, email string) (*domain.Organization, error) { + return s.orgRepo.GetByUserEmail(ctx, email) +} + +func (s *organizationService) UpdateOrganization(ctx context.Context, orgID int32, req *UpdateOrganizationRequest) (*domain.Organization, error) { + // Get existing organization + org, err := s.orgRepo.GetByID(ctx, orgID) + if err != nil { + return nil, err + } + + // Update fields + org.Name = req.Name + org.Status = req.Status + if req.StytchOrgID != "" { + org.StytchOrgID = req.StytchOrgID + } + if req.StytchConnectionID != "" { + org.StytchConnectionID = req.StytchConnectionID + } + if req.StytchConnectionName != "" { + org.StytchConnectionName = req.StytchConnectionName + } + + return s.orgRepo.Update(ctx, org) +} + +func (s *organizationService) ListOrganizations(ctx context.Context, req *ListOrganizationsRequest) (*ListOrganizationsResponse, error) { + organizations, err := s.orgRepo.List(ctx, req.Limit, req.Offset) + if err != nil { + return nil, err + } + + // For simplicity, we're not implementing total count yet + // In production, you'd want a separate query for total count + total := int32(len(organizations)) + + return &ListOrganizationsResponse{ + Organizations: organizations, + Total: total, + Limit: req.Limit, + Offset: req.Offset, + }, nil +} + +func (s *organizationService) GetOrganizationStats(ctx context.Context, orgID int32) (*domain.OrganizationStats, error) { + return s.orgRepo.GetStats(ctx, orgID) +} + +func (s *organizationService) CreateAccount(ctx context.Context, orgID int32, req *CreateAccountRequest) (*domain.Account, error) { + // Verify organization exists + _, err := s.orgRepo.GetByID(ctx, orgID) + if err != nil { + return nil, err + } + + account := &domain.Account{ + OrganizationID: orgID, + Email: req.Email, + FullName: req.FullName, + StytchMemberID: req.StytchMemberID, + StytchRoleID: req.StytchRoleID, + StytchRoleSlug: req.StytchRoleSlug, + StytchEmailVerified: req.StytchEmailVerified, + Role: req.Role, + Status: "active", + } + + return s.accountRepo.Create(ctx, account) +} + +func (s *organizationService) GetAccount(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { + return s.accountRepo.GetByID(ctx, orgID, accountID) +} + +func (s *organizationService) GetAccountByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) { + return s.accountRepo.GetByEmail(ctx, orgID, email) +} + +func (s *organizationService) ListAccounts(ctx context.Context, orgID int32) ([]*domain.Account, error) { + // Verify organization exists + _, err := s.orgRepo.GetByID(ctx, orgID) + if err != nil { + return nil, err + } + + return s.accountRepo.ListByOrganization(ctx, orgID) +} + +func (s *organizationService) UpdateAccount(ctx context.Context, orgID, accountID int32, req *UpdateAccountRequest) (*domain.Account, error) { + // Get existing account + account, err := s.accountRepo.GetByID(ctx, orgID, accountID) + if err != nil { + return nil, err + } + + // Update fields + account.FullName = req.FullName + account.Role = req.Role + account.Status = req.Status + if req.StytchRoleID != "" { + account.StytchRoleID = req.StytchRoleID + } + if req.StytchRoleSlug != "" { + account.StytchRoleSlug = req.StytchRoleSlug + } + if req.StytchEmailVerified != nil { + account.StytchEmailVerified = *req.StytchEmailVerified + } + + return s.accountRepo.Update(ctx, account) +} + +func (s *organizationService) DeleteAccount(ctx context.Context, orgID, accountID int32) error { + return s.accountRepo.Delete(ctx, orgID, accountID) +} + +func (s *organizationService) UpdateAccountLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { + return s.accountRepo.UpdateLastLogin(ctx, orgID, accountID) +} + +func (s *organizationService) CheckAccountPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) { + return s.accountRepo.CheckPermission(ctx, orgID, accountID) +} + +func (s *organizationService) GetAccountStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) { + return s.accountRepo.GetStats(ctx, accountID) +} diff --git a/go-b2b-starter/src/app/organizations/app/services/organization_service_interface.go b/go-b2b-starter/src/app/organizations/app/services/organization_service_interface.go new file mode 100644 index 0000000..1c7aec9 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/app/services/organization_service_interface.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" +) + +// OrganizationService defines the interface for organization business operations +type OrganizationService interface { + // Organization operations + CreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*domain.Organization, error) + GetOrganization(ctx context.Context, orgID int32) (*domain.Organization, error) + GetOrganizationBySlug(ctx context.Context, slug string) (*domain.Organization, error) + GetOrganizationByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) + GetOrganizationByUserEmail(ctx context.Context, email string) (*domain.Organization, error) + UpdateOrganization(ctx context.Context, orgID int32, req *UpdateOrganizationRequest) (*domain.Organization, error) + ListOrganizations(ctx context.Context, req *ListOrganizationsRequest) (*ListOrganizationsResponse, error) + GetOrganizationStats(ctx context.Context, orgID int32) (*domain.OrganizationStats, error) + + // Account operations + CreateAccount(ctx context.Context, orgID int32, req *CreateAccountRequest) (*domain.Account, error) + GetAccount(ctx context.Context, orgID, accountID int32) (*domain.Account, error) + GetAccountByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) + ListAccounts(ctx context.Context, orgID int32) ([]*domain.Account, error) + UpdateAccount(ctx context.Context, orgID, accountID int32, req *UpdateAccountRequest) (*domain.Account, error) + DeleteAccount(ctx context.Context, orgID, accountID int32) error + UpdateAccountLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) + + // Utility operations + CheckAccountPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) + GetAccountStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) +} + +// CreateOrganizationRequest represents data needed to create an organization +type CreateOrganizationRequest struct { + Slug string `json:"slug" binding:"required,min=3"` + Name string `json:"name" binding:"required"` + OwnerEmail string `json:"owner_email" binding:"required,email"` + OwnerName string `json:"owner_name" binding:"required"` + StytchOrgID string `json:"stytch_org_id"` + StytchConnectionID string `json:"stytch_connection_id"` + StytchConnectionName string `json:"stytch_connection_name"` +} + +// UpdateOrganizationRequest represents data needed to update an organization +type UpdateOrganizationRequest struct { + Name string `json:"name" binding:"required"` + Status string `json:"status" binding:"required,oneof=active suspended"` + StytchOrgID string `json:"stytch_org_id"` + StytchConnectionID string `json:"stytch_connection_id"` + StytchConnectionName string `json:"stytch_connection_name"` +} + +// CreateAccountRequest represents data needed to create an account +type CreateAccountRequest struct { + Email string `json:"email" binding:"required,email"` + FullName string `json:"full_name" binding:"required"` + Role string `json:"role" binding:"required,oneof=admin approver member"` + StytchMemberID string `json:"stytch_member_id"` + StytchRoleID string `json:"stytch_role_id"` + StytchRoleSlug string `json:"stytch_role_slug"` + StytchEmailVerified bool `json:"stytch_email_verified"` +} + +// UpdateAccountRequest represents data needed to update an account +type UpdateAccountRequest struct { + FullName string `json:"full_name" binding:"required"` + Role string `json:"role" binding:"required,oneof=admin approver member"` + Status string `json:"status" binding:"required,oneof=active inactive suspended"` + StytchRoleID string `json:"stytch_role_id"` + StytchRoleSlug string `json:"stytch_role_slug"` + StytchEmailVerified *bool `json:"stytch_email_verified"` +} + +// ListOrganizationsRequest represents parameters for listing organizations +type ListOrganizationsRequest struct { + Limit int32 `json:"limit" binding:"min=1,max=100"` + Offset int32 `json:"offset" binding:"min=0"` +} + +// ListOrganizationsResponse represents the response for listing organizations +type ListOrganizationsResponse struct { + Organizations []*domain.Organization `json:"organizations"` + Total int32 `json:"total"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} diff --git a/go-b2b-starter/src/app/organizations/cmd/init.go b/go-b2b-starter/src/app/organizations/cmd/init.go new file mode 100644 index 0000000..86117bc --- /dev/null +++ b/go-b2b-starter/src/app/organizations/cmd/init.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/organizations" +) + +// Init initializes the organizations module +func Init(container *dig.Container) error { + module := organizations.NewModule(container) + return module.RegisterDependencies() +} \ No newline at end of file diff --git a/go-b2b-starter/src/app/organizations/domain/auth_provider.go b/go-b2b-starter/src/app/organizations/domain/auth_provider.go new file mode 100644 index 0000000..cfaf5eb --- /dev/null +++ b/go-b2b-starter/src/app/organizations/domain/auth_provider.go @@ -0,0 +1,190 @@ +package domain + +import ( + "context" + "net/mail" + "time" +) + +// AuthMember represents an authenticated member from the auth provider. +type AuthMember struct { + MemberID string `json:"member_id"` + OrganizationID string `json:"organization_id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AuthOrganization represents an organization (tenant) from the auth provider. +type AuthOrganization struct { + OrganizationID string `json:"organization_id"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AuthRole represents an RBAC role from the auth provider. +type AuthRole struct { + RoleID string `json:"role_id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` +} + +// CreateAuthMemberRequest represents the data needed to create a member in the auth provider. +type CreateAuthMemberRequest struct { + OrganizationID string `json:"organization_id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + SendInvite bool `json:"send_invite"` + Password string `json:"password"` +} + +// UpdateAuthMemberRequest represents member profile updates in the auth provider. +type UpdateAuthMemberRequest struct { + OrganizationID string `json:"organization_id"` + MemberID string `json:"member_id"` + Name *string `json:"name,omitempty"` + Roles []string `json:"roles,omitempty"` + TrustedMeta map[string]any `json:"trusted_metadata,omitempty"` + UntrustedMeta map[string]any `json:"untrusted_metadata,omitempty"` +} + +// CreateAuthOrganizationRequest represents the data needed to create an organization in the auth provider. +type CreateAuthOrganizationRequest struct { + DisplayName string `json:"display_name"` + EmailInvitesAllowed bool `json:"email_invites_allowed"` +} + +// AssignAuthRolesRequest represents assigning roles to a member in the auth provider. +type AssignAuthRolesRequest struct { + OrganizationID string `json:"organization_id"` + MemberID string `json:"member_id"` + Roles []string `json:"roles"` +} + +// RemoveAuthMembersRequest represents removing members from an organization in the auth provider. +type RemoveAuthMembersRequest struct { + OrganizationID string `json:"organization_id"` + MemberIDs []string `json:"member_ids"` +} + +// SendMagicLinkRequest represents the payload required to email a login magic link. +type SendMagicLinkRequest struct { + OrganizationID string `json:"organization_id"` + Email string `json:"email"` + LoginRedirectURL string `json:"login_redirect_url"` + SignupRedirectURL string `json:"signup_redirect_url"` +} + +// Validate validates the CreateAuthMemberRequest. +func (r *CreateAuthMemberRequest) Validate() error { + if r.OrganizationID == "" { + return ErrAuthOrganizationIDRequired + } + if r.Email == "" { + return ErrAuthEmailRequired + } + if _, err := mail.ParseAddress(r.Email); err != nil { + return ErrAuthInvalidEmail + } + if r.Name == "" { + return ErrAuthNameRequired + } + return nil +} + +// Validate ensures the SendMagicLinkRequest contains core identifiers. +func (r *SendMagicLinkRequest) Validate() error { + if r.OrganizationID == "" { + return ErrAuthOrganizationIDRequired + } + if r.Email == "" { + return ErrAuthEmailRequired + } + if _, err := mail.ParseAddress(r.Email); err != nil { + return ErrAuthInvalidEmail + } + return nil +} + +// Validate validates the UpdateAuthMemberRequest. +func (r *UpdateAuthMemberRequest) Validate() error { + if r.OrganizationID == "" { + return ErrAuthOrganizationIDRequired + } + if r.MemberID == "" { + return ErrAuthMemberIDRequired + } + return nil +} + +// Validate validates the CreateAuthOrganizationRequest. +func (r *CreateAuthOrganizationRequest) Validate() error { + if r.DisplayName == "" { + return ErrAuthOrganizationDisplayNameRequired + } + if len(r.DisplayName) < 2 { + return ErrAuthOrganizationNameTooShort + } + return nil +} + +// Validate validates the AssignAuthRolesRequest. +func (r *AssignAuthRolesRequest) Validate() error { + if r.OrganizationID == "" { + return ErrAuthOrganizationIDRequired + } + if r.MemberID == "" { + return ErrAuthMemberIDRequired + } + if len(r.Roles) == 0 { + return ErrAuthRoleIDsRequired + } + return nil +} + +// Validate validates the RemoveAuthMembersRequest. +func (r *RemoveAuthMembersRequest) Validate() error { + if r.OrganizationID == "" { + return ErrAuthOrganizationIDRequired + } + if len(r.MemberIDs) == 0 { + return ErrAuthMemberIDsRequired + } + return nil +} + +// AuthOrganizationRepository defines auth provider organization operations. +type AuthOrganizationRepository interface { + CreateOrganization(ctx context.Context, req *CreateAuthOrganizationRequest) (*AuthOrganization, error) + GetOrganization(ctx context.Context, organizationID string) (*AuthOrganization, error) + DeleteOrganization(ctx context.Context, organizationID string) error + CheckEmailExists(ctx context.Context, email string) (bool, error) +} + +// AuthMemberRepository defines auth provider member operations. +type AuthMemberRepository interface { + CreateMember(ctx context.Context, req *CreateAuthMemberRequest) (*AuthMember, error) + UpdateMember(ctx context.Context, req *UpdateAuthMemberRequest) (*AuthMember, error) + GetMember(ctx context.Context, organizationID, memberID string) (*AuthMember, error) + GetMemberByEmail(ctx context.Context, organizationID, email string) (*AuthMember, error) + ListMembers(ctx context.Context, organizationID string, limit, offset int) ([]*AuthMember, error) + RemoveMembers(ctx context.Context, req *RemoveAuthMembersRequest) error + AssignRoles(ctx context.Context, req *AssignAuthRolesRequest) error + SendMagicLink(ctx context.Context, req *SendMagicLinkRequest) error +} + +// AuthRoleRepository defines auth provider RBAC operations. +type AuthRoleRepository interface { + GetRoleByID(ctx context.Context, roleID string) (*AuthRole, error) + GetRoleBySlug(ctx context.Context, slug string) (*AuthRole, error) + ListRoles(ctx context.Context, limit, offset int) ([]*AuthRole, error) +} diff --git a/go-b2b-starter/src/app/organizations/domain/entity.go b/go-b2b-starter/src/app/organizations/domain/entity.go new file mode 100644 index 0000000..66c6092 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/domain/entity.go @@ -0,0 +1,95 @@ +package domain + +import "time" + +// Organization represents an organization (tenant) in the system +type Organization struct { + ID int32 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + StytchOrgID string `json:"stytch_org_id"` + StytchConnectionID string `json:"stytch_connection_id"` + StytchConnectionName string `json:"stytch_connection_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Account represents a user account within an organization +type Account struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + Email string `json:"email"` + FullName string `json:"full_name"` + StytchMemberID string `json:"stytch_member_id"` + StytchRoleID string `json:"stytch_role_id"` + StytchRoleSlug string `json:"stytch_role_slug"` + StytchEmailVerified bool `json:"stytch_email_verified"` + Role string `json:"role"` + Status string `json:"status"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OrganizationContext provides context for operations within an organization +type OrganizationContext struct { + OrganizationID int32 `json:"organization_id"` + AccountID int32 `json:"account_id"` + AccountRole string `json:"account_role"` +} + +// GetID returns the organization's database ID. +// Implements auth.OrganizationEntity interface. +func (o *Organization) GetID() int32 { + return o.ID +} + +// Validate validates the organization entity +func (o *Organization) Validate() error { + if o.Name == "" { + return ErrOrganizationNameRequired + } + if o.Slug == "" { + return ErrOrganizationSlugRequired + } + if len(o.Slug) < 3 { + return ErrOrganizationSlugTooShort + } + return nil +} + +// GetID returns the account's database ID. +// Implements auth.AccountEntity interface. +func (a *Account) GetID() int32 { + return a.ID +} + +// Validate validates the account entity +func (a *Account) Validate() error { + if a.Email == "" { + return ErrAccountEmailRequired + } + if a.FullName == "" { + return ErrAccountFullNameRequired + } + if a.OrganizationID == 0 { + return ErrAccountOrganizationRequired + } + return nil +} + +// IsOwner checks if the account has admin role (legacy function name, kept for compatibility) +func (a *Account) IsOwner() bool { + return a.Role == "admin" +} + +// IsAdmin checks if the account has admin role +func (a *Account) IsAdmin() bool { + return a.Role == "admin" +} + +// CanManageAccounts checks if the account can manage other accounts +func (a *Account) CanManageAccounts() bool { + return a.IsAdmin() +} diff --git a/go-b2b-starter/src/app/organizations/domain/errors.go b/go-b2b-starter/src/app/organizations/domain/errors.go new file mode 100644 index 0000000..507fe82 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/domain/errors.go @@ -0,0 +1,120 @@ +package domain + +import "errors" + +// Organization errors +var ( + ErrOrganizationNotFound = errors.New("organization not found") + ErrOrganizationNameRequired = errors.New("organization name is required") + ErrOrganizationSlugRequired = errors.New("organization slug is required") + ErrOrganizationSlugTooShort = errors.New("organization slug must be at least 3 characters") + ErrOrganizationSlugTaken = errors.New("organization slug is already taken") + ErrOrganizationInactive = errors.New("organization is inactive") +) + +// Account errors +var ( + ErrAccountNotFound = errors.New("account not found") + ErrAccountEmailRequired = errors.New("account email is required") + ErrAccountFullNameRequired = errors.New("account full name is required") + ErrAccountOrganizationRequired = errors.New("account organization is required") + ErrAccountEmailTaken = errors.New("account email is already taken") + ErrAccountInactive = errors.New("account is inactive") + ErrAccountInsufficientRole = errors.New("account does not have sufficient permissions") +) + +// Permission errors +var ( + ErrPermissionDenied = errors.New("permission denied") + ErrInvalidRole = errors.New("invalid role") +) + +// Auth provider member-related errors +var ( + ErrAuthMemberNotFound = errors.New("auth member not found") + ErrAuthMemberAlreadyExists = errors.New("auth member already exists") + ErrAuthEmailRequired = errors.New("email is required") + ErrAuthInvalidEmail = errors.New("invalid email format") + ErrAuthPasswordRequired = errors.New("password is required") + ErrAuthNameRequired = errors.New("name is required") + ErrAuthMemberIDRequired = errors.New("member ID is required") + ErrAuthMemberIDsRequired = errors.New("member IDs are required") +) + +// Auth provider organization-related errors +var ( + ErrAuthOrganizationNotFound = errors.New("auth organization not found") + ErrAuthOrganizationAlreadyExists = errors.New("auth organization already exists") + ErrAuthOrganizationNameRequired = errors.New("auth organization name is required") + ErrAuthOrganizationDisplayNameRequired = errors.New("auth organization display name is required") + ErrAuthOrganizationNameTooShort = errors.New("auth organization name must be at least 2 characters") + ErrAuthOrganizationIDRequired = errors.New("auth organization ID is required") +) + +// Auth provider role-related errors +var ( + ErrAuthRoleNotFound = errors.New("auth role not found") + ErrAuthRoleIDsRequired = errors.New("auth role IDs are required") +) + +// Auth provider integration errors +var ( + ErrAuthConnection = errors.New("failed to connect to auth provider") + ErrAuthOperation = errors.New("auth provider operation failed") + ErrAuthUnauthorized = errors.New("unauthorized auth operation") + ErrAuthRateLimit = errors.New("auth provider rate limit exceeded") +) + +// OrganizationError represents a domain-specific organization error +type OrganizationError struct { + Type string `json:"type"` + Message string `json:"message"` + OrganizationID *int32 `json:"organization_id,omitempty"` + Cause error `json:"-"` +} + +func (e *OrganizationError) Error() string { + return e.Message +} + +func (e *OrganizationError) Unwrap() error { + return e.Cause +} + +// NewOrganizationError creates a new organization error +func NewOrganizationError(errorType, message string, orgID *int32, cause error) *OrganizationError { + return &OrganizationError{ + Type: errorType, + Message: message, + OrganizationID: orgID, + Cause: cause, + } +} + +// AccountError represents a domain-specific account error +type AccountError struct { + Type string `json:"type"` + Message string `json:"message"` + AccountID *int32 `json:"account_id,omitempty"` + OrganizationID *int32 `json:"organization_id,omitempty"` + Cause error `json:"-"` +} + +func (e *AccountError) Error() string { + return e.Message +} + +func (e *AccountError) Unwrap() error { + return e.Cause +} + +// NewAccountError creates a new account error +func NewAccountError(errorType, message string, accountID, orgID *int32, cause error) *AccountError { + return &AccountError{ + Type: errorType, + Message: message, + AccountID: accountID, + OrganizationID: orgID, + Cause: cause, + } +} \ No newline at end of file diff --git a/go-b2b-starter/src/app/organizations/domain/events/organization_events.go b/go-b2b-starter/src/app/organizations/domain/events/organization_events.go new file mode 100644 index 0000000..5c29a7e --- /dev/null +++ b/go-b2b-starter/src/app/organizations/domain/events/organization_events.go @@ -0,0 +1,68 @@ +package events + +import ( + "time" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" +) + +const ( + OrganizationCreatedEventType = "organization.created" + OrganizationUpdatedEventType = "organization.updated" + AccountCreatedEventType = "account.created" + AccountUpdatedEventType = "account.updated" + AccountDeletedEventType = "account.deleted" + AccountLoginEventType = "account.login" +) + +type OrganizationCreatedEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + Organization *domain.Organization `json:"organization"` + OwnerAccount *domain.Account `json:"owner_account"` +} + +type OrganizationUpdatedEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + Organization *domain.Organization `json:"organization"` + PreviousName string `json:"previous_name"` +} + +type AccountCreatedEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + Account *domain.Account `json:"account"` + OrganizationID int32 `json:"organization_id"` +} + +type AccountUpdatedEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + Account *domain.Account `json:"account"` + OrganizationID int32 `json:"organization_id"` + PreviousRole string `json:"previous_role"` + PreviousStatus string `json:"previous_status"` +} + +type AccountDeletedEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + AccountID int32 `json:"account_id"` + OrganizationID int32 `json:"organization_id"` + Email string `json:"email"` +} + +type AccountLoginEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Timestamp time.Time `json:"timestamp"` + AccountID int32 `json:"account_id"` + OrganizationID int32 `json:"organization_id"` + Email string `json:"email"` +} \ No newline at end of file diff --git a/go-b2b-starter/src/app/organizations/domain/repository.go b/go-b2b-starter/src/app/organizations/domain/repository.go new file mode 100644 index 0000000..721f0c1 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/domain/repository.go @@ -0,0 +1,54 @@ +package domain + +import "context" + +// OrganizationRepository defines the interface for organization data operations +type OrganizationRepository interface { + Create(ctx context.Context, org *Organization) (*Organization, error) + GetByID(ctx context.Context, id int32) (*Organization, error) + GetBySlug(ctx context.Context, slug string) (*Organization, error) + GetByStytchID(ctx context.Context, stytchOrgID string) (*Organization, error) + GetByUserEmail(ctx context.Context, email string) (*Organization, error) + Update(ctx context.Context, org *Organization) (*Organization, error) + UpdateStytchInfo(ctx context.Context, id int32, stytchOrgID, stytchConnectionID, stytchConnectionName string) (*Organization, error) + Delete(ctx context.Context, id int32) error + List(ctx context.Context, limit, offset int32) ([]*Organization, error) + GetStats(ctx context.Context, id int32) (*OrganizationStats, error) +} + +// AccountRepository defines the interface for account data operations +type AccountRepository interface { + Create(ctx context.Context, account *Account) (*Account, error) + GetByID(ctx context.Context, orgID, accountID int32) (*Account, error) + GetByEmail(ctx context.Context, orgID int32, email string) (*Account, error) + ListByOrganization(ctx context.Context, orgID int32) ([]*Account, error) + Update(ctx context.Context, account *Account) (*Account, error) + UpdateStytchInfo(ctx context.Context, orgID, accountID int32, stytchMemberID, stytchRoleID, stytchRoleSlug string, stytchEmailVerified bool) (*Account, error) + UpdateLastLogin(ctx context.Context, orgID, accountID int32) (*Account, error) + Delete(ctx context.Context, orgID, accountID int32) error + GetOrganization(ctx context.Context, accountID int32) (*Organization, error) + CheckPermission(ctx context.Context, orgID, accountID int32) (*AccountPermission, error) + GetStats(ctx context.Context, accountID int32) (*AccountStats, error) +} + +// OrganizationStats represents organization statistics +type OrganizationStats struct { + Organization *Organization `json:"organization"` + AccountCount int64 `json:"account_count"` + ActiveAccountCount int64 `json:"active_account_count"` +} + +// AccountStats represents account statistics with organization info +type AccountStats struct { + Account *Account `json:"account"` + OrganizationName string `json:"organization_name"` + OrganizationSlug string `json:"organization_slug"` +} + +// AccountPermission represents account permission check result +type AccountPermission struct { + AccountID int32 `json:"account_id"` + Role string `json:"role"` + Status string `json:"status"` + OrgStatus string `json:"org_status"` +} diff --git a/go-b2b-starter/src/app/organizations/go.mod b/go-b2b-starter/src/app/organizations/go.mod new file mode 100644 index 0000000..3c8c344 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/go.mod @@ -0,0 +1,48 @@ +module github.com/moasq/go-b2b-starter/app/organizations + +go 1.25 + +require ( + github.com/moasq/go-b2b-starter/pkg/db v0.0.0-00010101000000-000000000000 + github.com/stytchauth/stytch-go/v16 v16.40.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pgvector/pgvector-go v0.3.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/pkg/db => ../../pkg/db + +replace github.com/moasq/go-b2b-starter/pkg/eventbus => ../../pkg/eventbus + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../../pkg/logger diff --git a/go-b2b-starter/src/app/organizations/go.sum b/go-b2b-starter/src/app/organizations/go.sum new file mode 100644 index 0000000..38039dc --- /dev/null +++ b/go-b2b-starter/src/app/organizations/go.sum @@ -0,0 +1,85 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stytchauth/stytch-go/v16 v16.40.0 h1:xT9QyPtWi4j6rJPhkROfGCDzDeVBqvS2KQge1dv8rfs= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= diff --git a/go-b2b-starter/src/app/organizations/infra/repositories/account_repository.go b/go-b2b-starter/src/app/organizations/infra/repositories/account_repository.go new file mode 100644 index 0000000..b90a2ba --- /dev/null +++ b/go-b2b-starter/src/app/organizations/infra/repositories/account_repository.go @@ -0,0 +1,277 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" + "github.com/moasq/go-b2b-starter/pkg/db/postgres" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" +) + +type accountRepository struct { + accountStore adapters.AccountStore + orgStore adapters.OrganizationStore +} + +// NewAccountRepository creates a new account repository +func NewAccountRepository(accountStore adapters.AccountStore, orgStore adapters.OrganizationStore) domain.AccountRepository { + return &accountRepository{ + accountStore: accountStore, + orgStore: orgStore, + } +} + +func (r *accountRepository) Create(ctx context.Context, account *domain.Account) (*domain.Account, error) { + params := sqlc.CreateAccountParams{ + OrganizationID: account.OrganizationID, + Email: account.Email, + FullName: account.FullName, + StytchMemberID: postgres.PgText(&account.StytchMemberID), + StytchRoleID: postgres.PgText(&account.StytchRoleID), + StytchRoleSlug: postgres.PgText(&account.StytchRoleSlug), + StytchEmailVerified: account.StytchEmailVerified, + Role: account.Role, + Status: account.Status, + } + + result, err := r.accountStore.CreateAccount(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create account: %w", err) + } + + return r.mapToDomainAccount(&result), nil +} + +func (r *accountRepository) GetByID(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { + params := sqlc.GetAccountByIDParams{ + ID: accountID, + OrganizationID: orgID, + } + + result, err := r.accountStore.GetAccountByID(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to get account by ID: %w", err) + } + + return r.mapToDomainAccount(&result), nil +} + +func (r *accountRepository) GetByEmail(ctx context.Context, orgID int32, email string) (*domain.Account, error) { + params := sqlc.GetAccountByEmailParams{ + Email: email, + OrganizationID: orgID, + } + + result, err := r.accountStore.GetAccountByEmail(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to get account by email: %w", err) + } + + return r.mapToDomainAccount(&result), nil +} + +func (r *accountRepository) ListByOrganization(ctx context.Context, orgID int32) ([]*domain.Account, error) { + results, err := r.accountStore.ListAccountsByOrganization(ctx, orgID) + if err != nil { + return nil, fmt.Errorf("failed to list accounts by organization: %w", err) + } + + accounts := make([]*domain.Account, len(results)) + for i, result := range results { + accounts[i] = r.mapToDomainAccount(&result) + } + + return accounts, nil +} + +func (r *accountRepository) Update(ctx context.Context, account *domain.Account) (*domain.Account, error) { + params := sqlc.UpdateAccountParams{ + ID: account.ID, + OrganizationID: account.OrganizationID, + FullName: account.FullName, + StytchRoleID: postgres.PgText(&account.StytchRoleID), + StytchRoleSlug: postgres.PgText(&account.StytchRoleSlug), + StytchEmailVerified: account.StytchEmailVerified, + Role: account.Role, + Status: account.Status, + } + + result, err := r.accountStore.UpdateAccount(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to update account: %w", err) + } + + return r.mapToDomainAccount(&result), nil +} + +func (r *accountRepository) UpdateStytchInfo(ctx context.Context, orgID, accountID int32, stytchMemberID, stytchRoleID, stytchRoleSlug string, stytchEmailVerified bool) (*domain.Account, error) { + params := sqlc.UpdateAccountStytchInfoParams{ + ID: accountID, + OrganizationID: orgID, + StytchMemberID: postgres.PgText(&stytchMemberID), + StytchRoleID: postgres.PgText(&stytchRoleID), + StytchRoleSlug: postgres.PgText(&stytchRoleSlug), + StytchEmailVerified: stytchEmailVerified, + } + + result, err := r.accountStore.UpdateAccountStytchInfo(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to update account Stytch info: %w", err) + } + + return r.mapToDomainAccount(&result), nil +} + +func (r *accountRepository) UpdateLastLogin(ctx context.Context, orgID, accountID int32) (*domain.Account, error) { + params := sqlc.UpdateAccountLastLoginParams{ + ID: accountID, + OrganizationID: orgID, + } + + result, err := r.accountStore.UpdateAccountLastLogin(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to update account last login: %w", err) + } + + return r.mapToDomainAccount(&result), nil +} + +func (r *accountRepository) Delete(ctx context.Context, orgID, accountID int32) error { + params := sqlc.DeleteAccountParams{ + ID: accountID, + OrganizationID: orgID, + } + + err := r.accountStore.DeleteAccount(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.ErrAccountNotFound + } + return fmt.Errorf("failed to delete account: %w", err) + } + + return nil +} + +func (r *accountRepository) GetOrganization(ctx context.Context, accountID int32) (*domain.Organization, error) { + result, err := r.accountStore.GetAccountOrganization(ctx, accountID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to get account organization: %w", err) + } + + return &domain.Organization{ + ID: result.ID, + Slug: result.Slug, + Name: result.Name, + Status: result.Status, + CreatedAt: result.CreatedAt.Time, + UpdatedAt: result.UpdatedAt.Time, + }, nil +} + +func (r *accountRepository) CheckPermission(ctx context.Context, orgID, accountID int32) (*domain.AccountPermission, error) { + params := sqlc.CheckAccountPermissionParams{ + ID: accountID, + OrganizationID: orgID, + } + + result, err := r.accountStore.CheckAccountPermission(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to check account permission: %w", err) + } + + return &domain.AccountPermission{ + AccountID: result.ID, + Role: result.Role, + Status: result.Status, + OrgStatus: result.OrgStatus, + }, nil +} + +func (r *accountRepository) GetStats(ctx context.Context, accountID int32) (*domain.AccountStats, error) { + result, err := r.accountStore.GetAccountStats(ctx, accountID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("failed to get account stats: %w", err) + } + + // Map the result to domain stats + account := &domain.Account{ + ID: result.ID, + OrganizationID: result.OrganizationID, + Email: result.Email, + FullName: result.FullName, + StytchMemberID: postgres.StringFromPgText(result.StytchMemberID), + StytchRoleID: postgres.StringFromPgText(result.StytchRoleID), + StytchRoleSlug: postgres.StringFromPgText(result.StytchRoleSlug), + StytchEmailVerified: result.StytchEmailVerified, + Role: result.Role, + Status: result.Status, + CreatedAt: result.CreatedAt.Time, + UpdatedAt: result.UpdatedAt.Time, + } + + if result.LastLoginAt.Valid { + account.LastLoginAt = &result.LastLoginAt.Time + } + + stats := &domain.AccountStats{ + Account: account, + OrganizationName: result.OrganizationName, + OrganizationSlug: result.OrganizationSlug, + } + + return stats, nil +} + +// mapToDomainAccount maps SQLC account to domain account +func (r *accountRepository) mapToDomainAccount(sqlcAccount *sqlc.OrganizationsAccount) *domain.Account { + account := &domain.Account{ + ID: sqlcAccount.ID, + OrganizationID: sqlcAccount.OrganizationID, + Email: sqlcAccount.Email, + FullName: sqlcAccount.FullName, + StytchMemberID: postgres.StringFromPgText(sqlcAccount.StytchMemberID), + StytchRoleID: postgres.StringFromPgText(sqlcAccount.StytchRoleID), + StytchRoleSlug: postgres.StringFromPgText(sqlcAccount.StytchRoleSlug), + StytchEmailVerified: sqlcAccount.StytchEmailVerified, + Role: sqlcAccount.Role, + Status: sqlcAccount.Status, + CreatedAt: sqlcAccount.CreatedAt.Time, + UpdatedAt: sqlcAccount.UpdatedAt.Time, + } + + // Handle nullable LastLoginAt + if sqlcAccount.LastLoginAt.Valid { + account.LastLoginAt = &sqlcAccount.LastLoginAt.Time + } + + return account +} diff --git a/go-b2b-starter/src/app/organizations/infra/repositories/organization_repository.go b/go-b2b-starter/src/app/organizations/infra/repositories/organization_repository.go new file mode 100644 index 0000000..0be4d87 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/infra/repositories/organization_repository.go @@ -0,0 +1,209 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" + "github.com/moasq/go-b2b-starter/pkg/db/postgres" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" +) + +type organizationRepository struct { + orgStore adapters.OrganizationStore +} + +// NewOrganizationRepository creates a new organization repository +func NewOrganizationRepository(orgStore adapters.OrganizationStore) domain.OrganizationRepository { + return &organizationRepository{ + orgStore: orgStore, + } +} + +func (r *organizationRepository) Create(ctx context.Context, org *domain.Organization) (*domain.Organization, error) { + params := sqlc.CreateOrganizationParams{ + Slug: org.Slug, + Name: org.Name, + Status: org.Status, + } + + result, err := r.orgStore.CreateOrganization(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create organization: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) GetByID(ctx context.Context, id int32) (*domain.Organization, error) { + result, err := r.orgStore.GetOrganizationByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to get organization by ID: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*domain.Organization, error) { + result, err := r.orgStore.GetOrganizationBySlug(ctx, slug) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to get organization by slug: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) GetByStytchID(ctx context.Context, stytchOrgID string) (*domain.Organization, error) { + result, err := r.orgStore.GetOrganizationByStytchID(ctx, postgres.PgText(&stytchOrgID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to get organization by Stytch ID: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) GetByUserEmail(ctx context.Context, email string) (*domain.Organization, error) { + result, err := r.orgStore.GetOrganizationByUserEmail(ctx, email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to get organization by user email: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) Update(ctx context.Context, org *domain.Organization) (*domain.Organization, error) { + params := sqlc.UpdateOrganizationParams{ + ID: org.ID, + Name: org.Name, + Status: org.Status, + StytchOrgID: postgres.PgText(&org.StytchOrgID), + StytchConnectionID: postgres.PgText(&org.StytchConnectionID), + StytchConnectionName: postgres.PgText(&org.StytchConnectionName), + } + + result, err := r.orgStore.UpdateOrganization(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to update organization: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) UpdateStytchInfo(ctx context.Context, id int32, stytchOrgID, stytchConnectionID, stytchConnectionName string) (*domain.Organization, error) { + params := sqlc.UpdateOrganizationStytchInfoParams{ + ID: id, + StytchOrgID: postgres.PgText(&stytchOrgID), + StytchConnectionID: postgres.PgText(&stytchConnectionID), + StytchConnectionName: postgres.PgText(&stytchConnectionName), + } + + result, err := r.orgStore.UpdateOrganizationStytchInfo(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to update organization Stytch info: %w", err) + } + + return r.mapToDomainOrganization(&result), nil +} + +func (r *organizationRepository) List(ctx context.Context, limit, offset int32) ([]*domain.Organization, error) { + params := sqlc.ListOrganizationsParams{ + Limit: limit, + Offset: offset, + } + + results, err := r.orgStore.ListOrganizations(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to list organizations: %w", err) + } + + organizations := make([]*domain.Organization, len(results)) + for i, result := range results { + organizations[i] = r.mapToDomainOrganization(&result) + } + + return organizations, nil +} + +func (r *organizationRepository) Delete(ctx context.Context, id int32) error { + if err := r.orgStore.DeleteOrganization(ctx, id); err != nil { + return fmt.Errorf("failed to delete organization: %w", err) + } + return nil +} + +func (r *organizationRepository) GetStats(ctx context.Context, id int32) (*domain.OrganizationStats, error) { + result, err := r.orgStore.GetOrganizationStats(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrOrganizationNotFound + } + return nil, fmt.Errorf("failed to get organization stats: %w", err) + } + + // Map the result to domain stats + org := &domain.Organization{ + ID: result.ID, + Slug: result.Slug, + Name: result.Name, + Status: result.Status, + StytchOrgID: postgres.StringFromPgText(result.StytchOrgID), + StytchConnectionID: postgres.StringFromPgText(result.StytchConnectionID), + StytchConnectionName: postgres.StringFromPgText(result.StytchConnectionName), + CreatedAt: result.CreatedAt.Time, + UpdatedAt: result.UpdatedAt.Time, + } + + stats := &domain.OrganizationStats{ + Organization: org, + AccountCount: result.AccountCount, + ActiveAccountCount: result.ActiveAccountCount, + } + + return stats, nil +} + +// mapToDomainOrganization maps SQLC OrganizationsOrganization to domain organization +func (r *organizationRepository) mapToDomainOrganization(sqlcOrg *sqlc.OrganizationsOrganization) *domain.Organization { + org := &domain.Organization{ + ID: sqlcOrg.ID, + Slug: sqlcOrg.Slug, + Name: sqlcOrg.Name, + Status: sqlcOrg.Status, + CreatedAt: sqlcOrg.CreatedAt.Time, + UpdatedAt: sqlcOrg.UpdatedAt.Time, + } + + // Map Stytch fields + if sqlcOrg.StytchOrgID.Valid { + org.StytchOrgID = sqlcOrg.StytchOrgID.String + } + if sqlcOrg.StytchConnectionID.Valid { + org.StytchConnectionID = sqlcOrg.StytchConnectionID.String + } + if sqlcOrg.StytchConnectionName.Valid { + org.StytchConnectionName = sqlcOrg.StytchConnectionName.String + } + + return org +} diff --git a/go-b2b-starter/src/app/organizations/infra/repositories/slug_generator.go b/go-b2b-starter/src/app/organizations/infra/repositories/slug_generator.go new file mode 100644 index 0000000..d02dcd7 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/infra/repositories/slug_generator.go @@ -0,0 +1,60 @@ +package repositories + +import ( + "fmt" + "regexp" + "strings" +) + +// generateSlug creates a URL-safe slug from an organization name +// following Stytch requirements: 2-128 chars, alphanumeric + -._~ +func generateSlug(name string) string { + // Convert to lowercase + slug := strings.ToLower(name) + + // Replace spaces with hyphens + slug = strings.ReplaceAll(slug, " ", "-") + + // Remove characters not allowed by Stytch (keep alphanumeric and -._~) + reg := regexp.MustCompile(`[^a-z0-9\-._~]+`) + slug = reg.ReplaceAllString(slug, "") + + // Remove leading and trailing hyphens, dots, underscores, tildes + slug = strings.Trim(slug, "-._~") + + // Ensure minimum length of 2 characters + if len(slug) < 2 { + slug = "org-" + slug + } + + // Ensure maximum length of 128 characters + if len(slug) > 128 { + slug = slug[:128] + // Re-trim in case we cut off in middle of separator + slug = strings.TrimRight(slug, "-._~") + } + + return slug +} + +// generateSlugWithSuffix generates a slug with numeric suffix for retry attempts +// attempt 1 returns base slug, attempt 2+ adds suffix +func generateSlugWithSuffix(baseSlug string, attempt int) string { + if attempt <= 1 { + return baseSlug + } + + suffix := fmt.Sprintf("-%d", attempt) + slug := baseSlug + suffix + + // Ensure we don't exceed 128 character limit + if len(slug) > 128 { + // Truncate base slug to make room for suffix + maxBaseLength := 128 - len(suffix) + slug = baseSlug[:maxBaseLength] + suffix + // Re-trim in case we cut off in middle of separator + slug = strings.TrimRight(slug[:len(slug)-len(suffix)], "-._~") + suffix + } + + return slug +} diff --git a/go-b2b-starter/src/app/organizations/infra/repositories/stytch_member_repository.go b/go-b2b-starter/src/app/organizations/infra/repositories/stytch_member_repository.go new file mode 100644 index 0000000..e153fef --- /dev/null +++ b/go-b2b-starter/src/app/organizations/infra/repositories/stytch_member_repository.go @@ -0,0 +1,275 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger" + stytchcfg "github.com/moasq/go-b2b-starter/pkg/stytch" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/magiclinks/email" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations/members" +) + +type stytchMemberRepository struct { + client *stytchcfg.Client + config stytchcfg.Config + logger loggerDomain.Logger +} + +// NewStytchMemberRepository creates a Stytch-backed member repository. +func NewStytchMemberRepository(client *stytchcfg.Client, cfg stytchcfg.Config, logger loggerDomain.Logger) domain.AuthMemberRepository { + return &stytchMemberRepository{ + client: client, + config: cfg, + logger: logger, + } +} + +func (r *stytchMemberRepository) CreateMember(ctx context.Context, req *domain.CreateAuthMemberRequest) (*domain.AuthMember, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid create member request: %w", err) + } + + stytchReq := &members.CreateParams{ + OrganizationID: req.OrganizationID, + EmailAddress: req.Email, + Name: req.Name, + } + + resp, err := r.client.API().Organizations.Members.Create(ctx, stytchReq) + if err != nil { + r.logger.Error("failed to create member in Stytch", loggerDomain.Fields{ + "org_id": req.OrganizationID, + "email": req.Email, + "error": err.Error(), + }) + return nil, fmt.Errorf("stytch create member: %w", stytchcfg.MapError(err)) + } + + member := mapToAuthMember(resp.Member) + + return member, nil +} + +func (r *stytchMemberRepository) UpdateMember(ctx context.Context, req *domain.UpdateAuthMemberRequest) (*domain.AuthMember, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid update member request: %w", err) + } + + params := &members.UpdateParams{ + OrganizationID: req.OrganizationID, + MemberID: req.MemberID, + } + + if req.Name != nil { + params.Name = *req.Name + } + if len(req.Roles) > 0 { + rolesCopy := append([]string(nil), req.Roles...) + params.Roles = &rolesCopy + } + if req.TrustedMeta != nil { + params.TrustedMetadata = req.TrustedMeta + } + if req.UntrustedMeta != nil { + params.UntrustedMetadata = req.UntrustedMeta + } + + resp, err := r.client.API().Organizations.Members.Update(ctx, params) + if err != nil { + return nil, fmt.Errorf("stytch update member: %w", stytchcfg.MapError(err)) + } + + return mapToAuthMember(resp.Member), nil +} + +func (r *stytchMemberRepository) GetMember(ctx context.Context, organizationID, memberID string) (*domain.AuthMember, error) { + if organizationID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + if memberID == "" { + return nil, domain.ErrAuthMemberIDRequired + } + + resp, err := r.client.API().Organizations.Members.Get(ctx, &members.GetParams{ + OrganizationID: organizationID, + MemberID: memberID, + }) + if err != nil { + return nil, fmt.Errorf("stytch get member: %w", stytchcfg.MapError(err)) + } + + return mapToAuthMember(resp.Member), nil +} + +func (r *stytchMemberRepository) GetMemberByEmail(ctx context.Context, organizationID, emailAddr string) (*domain.AuthMember, error) { + if organizationID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + if emailAddr == "" { + return nil, domain.ErrAuthEmailRequired + } + + resp, err := r.client.API().Organizations.Members.Get(ctx, &members.GetParams{ + OrganizationID: organizationID, + EmailAddress: emailAddr, + }) + if err != nil { + return nil, fmt.Errorf("stytch get member by email: %w", stytchcfg.MapError(err)) + } + + return mapToAuthMember(resp.Member), nil +} + +func (r *stytchMemberRepository) ListMembers(ctx context.Context, organizationID string, limit, offset int) ([]*domain.AuthMember, error) { + if organizationID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + + params := &members.SearchParams{ + OrganizationIds: []string{organizationID}, + } + + requested := 0 + if limit > 0 { + requested = limit + } + if offset > 0 { + requested += offset + } + if requested > 0 { + params.Limit = uint32(requested) + } + + resp, err := r.client.API().Organizations.Members.Search(ctx, params) + if err != nil { + return nil, fmt.Errorf("stytch search members: %w", stytchcfg.MapError(err)) + } + + membersList := resp.Members + if offset > 0 { + if offset >= len(membersList) { + membersList = nil + } else { + membersList = membersList[offset:] + } + } + if limit > 0 && limit < len(membersList) { + membersList = membersList[:limit] + } + + results := make([]*domain.AuthMember, 0, len(membersList)) + for _, m := range membersList { + results = append(results, mapToAuthMember(m)) + } + + return results, nil +} + +func (r *stytchMemberRepository) RemoveMembers(ctx context.Context, req *domain.RemoveAuthMembersRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid remove members request: %w", err) + } + + for _, memberID := range req.MemberIDs { + _, err := r.client.API().Organizations.Members.Delete(ctx, &members.DeleteParams{ + OrganizationID: req.OrganizationID, + MemberID: memberID, + }) + if err != nil { + return fmt.Errorf("stytch delete member %s: %w", memberID, stytchcfg.MapError(err)) + } + } + + return nil +} + +func (r *stytchMemberRepository) AssignRoles(ctx context.Context, req *domain.AssignAuthRolesRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid assign roles request: %w", err) + } + + updateParams := &members.UpdateParams{ + OrganizationID: req.OrganizationID, + MemberID: req.MemberID, + } + if len(req.Roles) > 0 { + rolesCopy := append([]string(nil), req.Roles...) + updateParams.Roles = &rolesCopy + } + + _, err := r.client.API().Organizations.Members.Update(ctx, updateParams) + if err != nil { + return fmt.Errorf("stytch update member roles: %w", stytchcfg.MapError(err)) + } + return nil +} + +func (r *stytchMemberRepository) SendMagicLink(ctx context.Context, req *domain.SendMagicLinkRequest) error { + if err := req.Validate(); err != nil { + return fmt.Errorf("invalid magic link request: %w", err) + } + + params := &email.LoginOrSignupParams{ + OrganizationID: req.OrganizationID, + EmailAddress: req.Email, + } + + loginRedirect := strings.TrimSpace(req.LoginRedirectURL) + if loginRedirect == "" { + loginRedirect = strings.TrimSpace(r.config.LoginRedirectURL) + } + if loginRedirect != "" { + params.LoginRedirectURL = loginRedirect + } + + signupRedirect := strings.TrimSpace(req.SignupRedirectURL) + if signupRedirect == "" { + signupRedirect = strings.TrimSpace(r.config.InviteRedirectURL) + if signupRedirect == "" { + signupRedirect = loginRedirect + } + } + if signupRedirect != "" { + params.SignupRedirectURL = signupRedirect + } + + if _, err := r.client.API().MagicLinks.Email.LoginOrSignup(ctx, params); err != nil { + return fmt.Errorf("stytch send magic link: %w", stytchcfg.MapError(err)) + } + + return nil +} + +func mapToAuthMember(src organizations.Member) *domain.AuthMember { + var createdAt, updatedAt time.Time + if src.CreatedAt != nil { + createdAt = src.CreatedAt.UTC() + } + if src.UpdatedAt != nil { + updatedAt = src.UpdatedAt.UTC() + } + + roleIDs := make([]string, 0, len(src.Roles)) + for _, role := range src.Roles { + if role.RoleID != "" { + roleIDs = append(roleIDs, role.RoleID) + } + } + + return &domain.AuthMember{ + MemberID: src.MemberID, + OrganizationID: src.OrganizationID, + Email: src.EmailAddress, + Name: src.Name, + Roles: roleIDs, + Status: src.Status, + EmailVerified: src.EmailAddressVerified, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} diff --git a/go-b2b-starter/src/app/organizations/infra/repositories/stytch_organization_repository.go b/go-b2b-starter/src/app/organizations/infra/repositories/stytch_organization_repository.go new file mode 100644 index 0000000..133f931 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/infra/repositories/stytch_organization_repository.go @@ -0,0 +1,183 @@ +package repositories + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" + stytchcfg "github.com/moasq/go-b2b-starter/pkg/stytch" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/organizations" +) + +type stytchOrganizationRepository struct { + client *stytchcfg.Client + logger loggerDomain.Logger + localOrgRepo domain.OrganizationRepository +} + +// NewStytchOrganizationRepository creates a Stytch-backed organization repository. +func NewStytchOrganizationRepository( + client *stytchcfg.Client, + logger loggerDomain.Logger, + localOrgRepo domain.OrganizationRepository, +) domain.AuthOrganizationRepository { + return &stytchOrganizationRepository{ + client: client, + logger: logger, + localOrgRepo: localOrgRepo, + } +} + +func (r *stytchOrganizationRepository) CreateOrganization(ctx context.Context, req *domain.CreateAuthOrganizationRequest) (*domain.AuthOrganization, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid create organization request: %w", err) + } + + // Generate base slug from display name (infrastructure concern) + baseSlug := generateSlug(req.DisplayName) + + // Prepare email invites parameter + emailInvites := "NOT_ALLOWED" + if req.EmailInvitesAllowed { + emailInvites = "ALL_ALLOWED" + } + + // Retry loop for duplicate slug handling (infrastructure concern) + const maxAttempts = 5 + var lastErr error + + for attempt := 1; attempt <= maxAttempts; attempt++ { + // Generate slug with suffix if needed + slug := generateSlugWithSuffix(baseSlug, attempt) + + r.logger.Debug("attempting to create organization", loggerDomain.Fields{ + "display_name": req.DisplayName, + "slug": slug, + "attempt": attempt, + }) + + // Try to create in Stytch + params := &organizations.CreateParams{ + OrganizationSlug: slug, + OrganizationName: req.DisplayName, + EmailInvites: emailInvites, + } + + resp, err := r.client.API().Organizations.Create(ctx, params) + + // Success - return immediately + if err == nil { + if attempt > 1 { + r.logger.Info("created organization with retry", loggerDomain.Fields{ + "display_name": req.DisplayName, + "final_slug": slug, + "attempts": attempt, + }) + } + return mapToAuthOrganization(resp.Organization), nil + } + + // Check if duplicate slug error - retry + if stytchcfg.IsDuplicateSlugError(err) { + r.logger.Debug("slug already exists, retrying", loggerDomain.Fields{ + "attempted_slug": slug, + "attempt": attempt, + "max_attempts": maxAttempts, + }) + lastErr = err + continue // Try next suffix + } + + // Other error - fail immediately + return nil, fmt.Errorf("stytch create organization: %w", stytchcfg.MapError(err)) + } + + // All attempts exhausted + r.logger.Error("failed to create organization after retries", loggerDomain.Fields{ + "display_name": req.DisplayName, + "base_slug": baseSlug, + "attempts": maxAttempts, + }) + return nil, fmt.Errorf("failed to create organization after %d attempts, slug conflicts: %w", + maxAttempts, stytchcfg.MapError(lastErr)) +} + +func (r *stytchOrganizationRepository) GetOrganization(ctx context.Context, organizationID string) (*domain.AuthOrganization, error) { + if organizationID == "" { + return nil, domain.ErrAuthOrganizationIDRequired + } + + resp, err := r.client.API().Organizations.Get(ctx, &organizations.GetParams{OrganizationID: organizationID}) + if err != nil { + return nil, fmt.Errorf("stytch get organization: %w", stytchcfg.MapError(err)) + } + + return mapToAuthOrganization(resp.Organization), nil +} + +func (r *stytchOrganizationRepository) DeleteOrganization(ctx context.Context, organizationID string) error { + if organizationID == "" { + return domain.ErrAuthOrganizationIDRequired + } + + _, err := r.client.API().Organizations.Delete(ctx, &organizations.DeleteParams{OrganizationID: organizationID}) + if err != nil { + return fmt.Errorf("stytch delete organization: %w", stytchcfg.MapError(err)) + } + + return nil +} + +func (r *stytchOrganizationRepository) CheckEmailExists(ctx context.Context, email string) (bool, error) { + if email == "" { + return false, fmt.Errorf("email cannot be empty") + } + + r.logger.Debug("checking if email exists", loggerDomain.Fields{ + "email": email, + }) + + // Use the local organization repository to check if email exists + // GetByUserEmail returns organization if email is found (and account is active) + _, err := r.localOrgRepo.GetByUserEmail(ctx, email) + if err != nil { + // Check if it's a "not found" error + if err.Error() == "organization not found" || err.Error() == "sql: no rows in result set" { + r.logger.Debug("email not found", loggerDomain.Fields{ + "email": email, + }) + return false, nil + } + // Other errors are real failures + r.logger.Error("failed to check email existence", loggerDomain.Fields{ + "email": email, + "error": err.Error(), + }) + return false, fmt.Errorf("failed to check email existence: %w", err) + } + + r.logger.Debug("email exists", loggerDomain.Fields{ + "email": email, + }) + return true, nil +} + +func mapToAuthOrganization(src organizations.Organization) *domain.AuthOrganization { + var createdAt, updatedAt time.Time + if src.CreatedAt != nil { + createdAt = src.CreatedAt.UTC() + } + if src.UpdatedAt != nil { + updatedAt = src.UpdatedAt.UTC() + } + + return &domain.AuthOrganization{ + OrganizationID: src.OrganizationID, + Slug: src.OrganizationSlug, + DisplayName: src.OrganizationName, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} diff --git a/go-b2b-starter/src/app/organizations/infra/repositories/stytch_role_repository.go b/go-b2b-starter/src/app/organizations/infra/repositories/stytch_role_repository.go new file mode 100644 index 0000000..38cce59 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/infra/repositories/stytch_role_repository.go @@ -0,0 +1,144 @@ +package repositories + +import ( + "context" + "fmt" + + "github.com/moasq/go-b2b-starter/app/organizations/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" + stytchcfg "github.com/moasq/go-b2b-starter/pkg/stytch" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac" +) + +type stytchRoleRepository struct { + client *stytchcfg.Client + logger loggerDomain.Logger +} + +// NewStytchRoleRepository creates a Stytch-backed role repository. +func NewStytchRoleRepository(client *stytchcfg.Client, logger loggerDomain.Logger) domain.AuthRoleRepository { + return &stytchRoleRepository{ + client: client, + logger: logger, + } +} + +func (r *stytchRoleRepository) GetRoleByID(ctx context.Context, roleID string) (*domain.AuthRole, error) { + if roleID == "" { + return nil, domain.ErrAuthRoleNotFound + } + + role, err := r.findRole(ctx, func(role *rbac.PolicyRole) bool { + return role.RoleID == roleID + }) + if err != nil { + return nil, err + } + if role == nil { + return nil, domain.ErrAuthRoleNotFound + } + return role, nil +} + +func (r *stytchRoleRepository) GetRoleBySlug(ctx context.Context, slug string) (*domain.AuthRole, error) { + if slug == "" { + return nil, domain.ErrAuthRoleNotFound + } + + role, err := r.findRole(ctx, func(role *rbac.PolicyRole) bool { + return role.RoleID == slug + }) + if err != nil { + return nil, err + } + if role == nil { + return nil, domain.ErrAuthRoleNotFound + } + return role, nil +} + +func (r *stytchRoleRepository) ListRoles(ctx context.Context, limit, offset int) ([]*domain.AuthRole, error) { + policy, err := r.fetchPolicy(ctx) + if err != nil { + return nil, err + } + if policy == nil || len(policy.Roles) == 0 { + return nil, nil + } + + start := offset + if start < 0 { + start = 0 + } + if start > len(policy.Roles) { + start = len(policy.Roles) + } + + end := len(policy.Roles) + if limit > 0 && start+limit < end { + end = start + limit + } + + result := make([]*domain.AuthRole, 0, end-start) + roles := policy.Roles[start:end] + for i := range roles { + role := roles[i] + result = append(result, mapToAuthRole(&role)) + } + return result, nil +} + +func (r *stytchRoleRepository) findRole(ctx context.Context, predicate func(*rbac.PolicyRole) bool) (*domain.AuthRole, error) { + policy, err := r.fetchPolicy(ctx) + if err != nil { + return nil, err + } + if policy == nil { + return nil, nil + } + + for i := range policy.Roles { + role := policy.Roles[i] + if predicate(&role) { + return mapToAuthRole(&role), nil + } + } + return nil, nil +} + +func (r *stytchRoleRepository) fetchPolicy(ctx context.Context) (*rbac.Policy, error) { + resp, err := r.client.API().RBAC.Policy(ctx, &rbac.PolicyParams{}) + if err != nil { + return nil, fmt.Errorf("stytch fetch rbac policy: %w", stytchcfg.MapError(err)) + } + return resp.Policy, nil +} + +func mapToAuthRole(src *rbac.PolicyRole) *domain.AuthRole { + if src == nil { + return nil + } + + role := &domain.AuthRole{ + RoleID: src.RoleID, + Name: src.RoleID, + Description: src.Description, + } + + if len(src.Permissions) > 0 { + perms := make([]string, 0, len(src.Permissions)) + for _, perm := range src.Permissions { + // Actions may be empty for resource-only permissions. + if len(perm.Actions) == 0 { + perms = append(perms, perm.ResourceID) + continue + } + for _, action := range perm.Actions { + perms = append(perms, fmt.Sprintf("%s:%s", perm.ResourceID, action)) + } + } + role.Permissions = perms + } + + return role +} diff --git a/go-b2b-starter/src/app/organizations/module.go b/go-b2b-starter/src/app/organizations/module.go new file mode 100644 index 0000000..d74d799 --- /dev/null +++ b/go-b2b-starter/src/app/organizations/module.go @@ -0,0 +1,108 @@ +package organizations + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/app/organizations/app/services" + "github.com/moasq/go-b2b-starter/app/organizations/domain" + "github.com/moasq/go-b2b-starter/app/organizations/infra/repositories" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" + stytchcfg "github.com/moasq/go-b2b-starter/pkg/stytch" +) + +// Module provides organization module dependencies +type Module struct { + container *dig.Container +} + +// NewModule creates a new organization module +func NewModule(container *dig.Container) *Module { + return &Module{ + container: container, + } +} + +// RegisterDependencies registers all organization module dependencies +func (m *Module) RegisterDependencies() error { + // Register local database repositories + if err := m.container.Provide(func( + accountStore adapters.AccountStore, + orgStore adapters.OrganizationStore, + ) domain.AccountRepository { + return repositories.NewAccountRepository(accountStore, orgStore) + }); err != nil { + return err + } + + if err := m.container.Provide(func( + orgStore adapters.OrganizationStore, + ) domain.OrganizationRepository { + return repositories.NewOrganizationRepository(orgStore) + }); err != nil { + return err + } + + // Register auth provider repositories (Stytch implementation) + if err := m.container.Provide(func( + client *stytchcfg.Client, + logger loggerDomain.Logger, + localOrgRepo domain.OrganizationRepository, + ) domain.AuthOrganizationRepository { + return repositories.NewStytchOrganizationRepository(client, logger, localOrgRepo) + }); err != nil { + return err + } + + if err := m.container.Provide(func( + client *stytchcfg.Client, + cfg *stytchcfg.Config, + logger loggerDomain.Logger, + ) domain.AuthMemberRepository { + return repositories.NewStytchMemberRepository(client, *cfg, logger) + }); err != nil { + return err + } + + if err := m.container.Provide(func( + client *stytchcfg.Client, + logger loggerDomain.Logger, + ) domain.AuthRoleRepository { + return repositories.NewStytchRoleRepository(client, logger) + }); err != nil { + return err + } + + // Register organization service + if err := m.container.Provide(func( + orgRepo domain.OrganizationRepository, + accountRepo domain.AccountRepository, + ) services.OrganizationService { + return services.NewOrganizationService(orgRepo, accountRepo) + }); err != nil { + return err + } + + // Register member service (for auth member operations) + if err := m.container.Provide(func( + authOrgRepo domain.AuthOrganizationRepository, + authMemberRepo domain.AuthMemberRepository, + authRoleRepo domain.AuthRoleRepository, + localOrgRepo domain.OrganizationRepository, + localAccountRepo domain.AccountRepository, + logger loggerDomain.Logger, + ) services.MemberService { + return services.NewMemberService( + authOrgRepo, + authMemberRepo, + authRoleRepo, + localOrgRepo, + localAccountRepo, + logger, + ) + }); err != nil { + return err + } + + return nil +} diff --git a/go-b2b-starter/src/docs/api/handler.go b/go-b2b-starter/src/docs/api/handler.go new file mode 100644 index 0000000..a4d4e6c --- /dev/null +++ b/go-b2b-starter/src/docs/api/handler.go @@ -0,0 +1,8 @@ +package api + +type Handler struct { +} + +func NewHandler() *Handler { + return &Handler{} +} diff --git a/go-b2b-starter/src/docs/api/routes.go b/go-b2b-starter/src/docs/api/routes.go new file mode 100644 index 0000000..1a5ed15 --- /dev/null +++ b/go-b2b-starter/src/docs/api/routes.go @@ -0,0 +1,19 @@ +package api + +import ( + docs "github.com/moasq/go-b2b-starter/docs/gen" + "github.com/moasq/go-b2b-starter/server/domain" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +func (h *Handler) Routes(router *gin.RouterGroup, resolver domain.MiddlewareResolver) { + if gin.Mode() != gin.ReleaseMode { + docs.SwaggerInfo.Title = "API" + docs.SwaggerInfo.Description = "API" + docs.SwaggerInfo.BasePath = "/" + + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } +} diff --git a/go-b2b-starter/src/docs/cmd/init.go b/go-b2b-starter/src/docs/cmd/init.go new file mode 100644 index 0000000..af22d38 --- /dev/null +++ b/go-b2b-starter/src/docs/cmd/init.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "log" + + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/docs/api" + server "github.com/moasq/go-b2b-starter/server/domain" +) + +func Init(container *dig.Container) { + err := container.Invoke(func(srv server.Server) { + handler := api.NewHandler() + srv.RegisterRoutes(handler.Routes, "") + }) + + if err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/go-b2b-starter/src/docs/gen/docs.go b/go-b2b-starter/src/docs/gen/docs.go new file mode 100644 index 0000000..5e70c27 --- /dev/null +++ b/go-b2b-starter/src/docs/gen/docs.go @@ -0,0 +1,1547 @@ +// Package gen Code generated by swaggo/swag. DO NOT EDIT +package gen + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/subscriptions/status": { + "get": { + "description": "Retrieve the current subscription billing status and invoice quota information for the organization", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get current billing and quota status", + "responses": { + "200": { + "description": "Current billing and quota status", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus" + } + }, + "400": { + "description": "Invalid request parameters or missing organization context", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/api/subscriptions/verify-payment": { + "post": { + "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Verify payment from checkout session", + "parameters": [ + { + "description": "Checkout session ID", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_subscriptions.VerifyPaymentRequest" + } + } + ], + "responses": { + "200": { + "description": "Verification result with updated billing status", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus" + } + }, + "400": { + "description": "Invalid request parameters or checkout session failed", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "404": { + "description": "Checkout session not found", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/auth/check-email": { + "get": { + "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Check if email exists", + "parameters": [ + { + "type": "string", + "description": "Email address to check", + "name": "email", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Email exists" + }, + "400": { + "description": "Invalid email format", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Email not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/members": { + "get": { + "description": "Retrieves all members of the current organization. Restricted to admin role only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "List organization members", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse" + } + }, + "400": { + "description": "Missing organization context", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "Insufficient permissions - admin role required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to list members", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "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\"}", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Add member to organization", + "parameters": [ + { + "type": "string", + "description": "Bearer JWT token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Member email address", + "name": "email", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Member full name", + "name": "name", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Role slug (defaults to 'member')", + "name": "role_slug", + "in": "body", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse" + } + }, + "400": { + "description": "Invalid request payload or missing organization context", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to add member", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/members/{member_id}": { + "delete": { + "description": "Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Delete organization member", + "parameters": [ + { + "type": "string", + "description": "Bearer JWT token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Member ID to delete", + "name": "member_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Member deleted successfully", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid member ID or missing organization context", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "Insufficient permissions - admin role required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Member not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to delete member", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/profile/me": { + "get": { + "description": "Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse" + } + }, + "400": { + "description": "Missing required context (organization or claims)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Authentication required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to retrieve profile", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/signup": { + "post": { + "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Bootstrap organization", + "parameters": [ + { + "description": "Organization bootstrap request (passwordless - no password required)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse" + } + }, + "400": { + "description": "Invalid request payload", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to bootstrap organization", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/example_cognitive/chat": { + "post": { + "description": "Sends a message to the AI and gets a response, optionally using RAG", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Cognitive" + ], + "summary": "Chat with AI", + "parameters": [ + { + "description": "Chat request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_example_cognitive.ChatRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_cognitive/sessions": { + "get": { + "description": "Lists chat sessions for the current user with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "Cognitive" + ], + "summary": "List chat sessions", + "parameters": [ + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_cognitive/sessions/{id}/messages": { + "get": { + "description": "Retrieves all messages for a chat session", + "produces": [ + "application/json" + ], + "tags": [ + "Cognitive" + ], + "summary": "Get session history", + "parameters": [ + { + "type": "integer", + "description": "Session ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_documents": { + "get": { + "description": "Lists documents with optional filtering and pagination", + "produces": [ + "application/json" + ], + "tags": [ + "Documents" + ], + "summary": "List documents", + "parameters": [ + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status (pending, processing, processed, failed)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_documents/upload": { + "post": { + "description": "Uploads a PDF document, extracts text, and creates embeddings", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Documents" + ], + "summary": "Upload PDF document", + "parameters": [ + { + "type": "file", + "description": "PDF file to upload", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Document title", + "name": "title", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.Document" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_documents/{id}": { + "delete": { + "description": "Deletes a document and its associated file", + "tags": [ + "Documents" + ], + "summary": "Delete document", + "parameters": [ + { + "type": "integer", + "description": "Document ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/rbac/check-permission": { + "post": { + "description": "Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Check if a role has a specific permission", + "parameters": [ + { + "description": "Role and permission to check", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest" + } + } + ], + "responses": { + "200": { + "description": "Permission check result", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/metadata": { + "get": { + "description": "Returns summary information about the RBAC system including total roles, permissions, and categories.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get RBAC system metadata", + "responses": { + "200": { + "description": "RBAC system metadata", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata" + } + } + } + } + }, + "/rbac/permissions": { + "get": { + "description": "Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get all permissions", + "responses": { + "200": { + "description": "All permissions", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/permissions/by-category": { + "get": { + "description": "Returns all permissions organized by their category for better UI organization.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get permissions grouped by category", + "responses": { + "200": { + "description": "Permissions by category", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/roles": { + "get": { + "description": "Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get all roles with permissions", + "responses": { + "200": { + "description": "Roles with permissions", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/roles/{role_id}": { + "get": { + "description": "Returns comprehensive information about a role including permissions, statistics, and restrictions.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get detailed information about a specific role", + "parameters": [ + { + "type": "string", + "description": "Role ID (member, approver, admin)", + "name": "role_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Role details with statistics", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse" + } + }, + "400": { + "description": "Invalid role ID", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Role not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "api_example_cognitive.ChatRequest": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "context_history": { + "type": "integer" + }, + "max_documents": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "session_id": { + "type": "integer" + }, + "use_rag": { + "type": "boolean" + } + } + }, + "api_subscriptions.VerifyPaymentRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus": { + "type": "object", + "properties": { + "canProcessInvoices": { + "type": "boolean" + }, + "checkedAt": { + "type": "string" + }, + "externalID": { + "type": "string" + }, + "hasActiveSubscription": { + "type": "boolean" + }, + "invoiceCount": { + "description": "Remaining invoices", + "type": "integer", + "format": "int32" + }, + "organizationID": { + "type": "integer", + "format": "int32" + }, + "reason": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "referenced_docs": { + "type": "array", + "items": { + "type": "integer" + } + }, + "role": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatRole" + }, + "session_id": { + "type": "integer" + }, + "tokens_used": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage" + }, + "referenced_docs": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.SimilarDocument" + } + }, + "session_id": { + "type": "integer" + }, + "tokens_used": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatRole": { + "type": "string", + "enum": [ + "user", + "assistant", + "system" + ], + "x-enum-varnames": [ + "ChatRoleUser", + "ChatRoleAssistant", + "ChatRoleSystem" + ] + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.SimilarDocument": { + "type": "object", + "properties": { + "chunk_index": { + "type": "integer" + }, + "content_hash": { + "type": "string" + }, + "content_preview": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "document_id": { + "type": "integer" + }, + "embedding": { + "description": "1536 dimensions for OpenAI", + "type": "array", + "items": { + "type": "number" + } + }, + "id": { + "type": "integer" + }, + "organization_id": { + "type": "integer" + }, + "similarity_score": { + "type": "number" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.Document" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_documents_domain.Document": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "extracted_text": { + "type": "string" + }, + "file_asset_id": { + "type": "integer" + }, + "file_name": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "organization_id": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.DocumentStatus" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_documents_domain.DocumentStatus": { + "type": "string", + "enum": [ + "pending", + "processing", + "processed", + "failed" + ], + "x-enum-varnames": [ + "DocumentStatusPending", + "DocumentStatusProcessing", + "DocumentStatusProcessed", + "DocumentStatusFailed" + ] + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "invite_sent": { + "type": "boolean" + }, + "member_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "org_id": { + "type": "string" + }, + "role_slug": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest": { + "type": "object", + "required": [ + "org_display_name", + "owner_email", + "owner_name" + ], + "properties": { + "org_display_name": { + "description": "Organization details", + "type": "string" + }, + "owner_email": { + "description": "Owner member details", + "type": "string" + }, + "owner_name": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "invite_sent": { + "type": "boolean" + }, + "magic_link_sent": { + "type": "boolean" + }, + "org_slug": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "owner_email": { + "type": "string" + }, + "owner_member_id": { + "type": "string" + }, + "owner_name": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.MemberInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.MemberInfo": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "member_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileOrganization": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse": { + "type": "object", + "properties": { + "account_id": { + "description": "Internal account details", + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "member_id": { + "description": "Auth provider member details", + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "description": "Organization details", + "allOf": [ + { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileOrganization" + } + ] + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest": { + "type": "object", + "required": [ + "permission_id", + "role_id" + ], + "properties": { + "permission_id": { + "type": "string" + }, + "role_id": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse": { + "type": "object", + "properties": { + "has_permission": { + "type": "boolean" + }, + "permission_id": { + "type": "string" + }, + "role_id": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "resource": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse": { + "type": "object", + "properties": { + "categories": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO" + } + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO" + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "permissions_by_role": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "total_permissions": { + "type": "integer" + }, + "total_roles": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO" + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse": { + "type": "object", + "properties": { + "restrictions": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleRestrictions" + }, + "role": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO" + }, + "statistics": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleStatistics" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RoleRestrictions": { + "type": "object", + "properties": { + "cannot_do": { + "type": "array", + "items": { + "type": "string" + } + }, + "data_access_level": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RoleStatistics": { + "type": "object", + "properties": { + "can_approve": { + "type": "boolean" + }, + "can_manage_org": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "total_permissions": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse": { + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO" + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BasicAuth": { + "type": "basic" + } + }, + "externalDocs": { + "description": "OpenAPI", + "url": "https://swagger.io/resources/open-api/" + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/api", + Schemes: []string{}, + Title: "B2B SaaS Starter API", + Description: "This is the API server for B2B SaaS Starter.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/go-b2b-starter/src/docs/gen/swagger.json b/go-b2b-starter/src/docs/gen/swagger.json new file mode 100644 index 0000000..4de36ae --- /dev/null +++ b/go-b2b-starter/src/docs/gen/swagger.json @@ -0,0 +1,1523 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is the API server for B2B SaaS Starter.", + "title": "B2B SaaS Starter API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/api", + "paths": { + "/api/subscriptions/status": { + "get": { + "description": "Retrieve the current subscription billing status and invoice quota information for the organization", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Get current billing and quota status", + "responses": { + "200": { + "description": "Current billing and quota status", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus" + } + }, + "400": { + "description": "Invalid request parameters or missing organization context", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/api/subscriptions/verify-payment": { + "post": { + "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscriptions" + ], + "summary": "Verify payment from checkout session", + "parameters": [ + { + "description": "Checkout session ID", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_subscriptions.VerifyPaymentRequest" + } + } + ], + "responses": { + "200": { + "description": "Verification result with updated billing status", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus" + } + }, + "400": { + "description": "Invalid request parameters or checkout session failed", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "404": { + "description": "Checkout session not found", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/auth/check-email": { + "get": { + "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Check if email exists", + "parameters": [ + { + "type": "string", + "description": "Email address to check", + "name": "email", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Email exists" + }, + "400": { + "description": "Invalid email format", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Email not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/members": { + "get": { + "description": "Retrieves all members of the current organization. Restricted to admin role only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "List organization members", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse" + } + }, + "400": { + "description": "Missing organization context", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "Insufficient permissions - admin role required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to list members", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "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\"}", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Add member to organization", + "parameters": [ + { + "type": "string", + "description": "Bearer JWT token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Member email address", + "name": "email", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Member full name", + "name": "name", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Role slug (defaults to 'member')", + "name": "role_slug", + "in": "body", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse" + } + }, + "400": { + "description": "Invalid request payload or missing organization context", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to add member", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/members/{member_id}": { + "delete": { + "description": "Removes a member from the organization (deletes from both Stytch and internal database). Only admins can delete members.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Delete organization member", + "parameters": [ + { + "type": "string", + "description": "Bearer JWT token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Member ID to delete", + "name": "member_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Member deleted successfully", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid member ID or missing organization context", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "Insufficient permissions - admin role required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Member not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to delete member", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/profile/me": { + "get": { + "description": "Retrieves comprehensive profile information for the currently authenticated user, including member details, organization info, and account status.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse" + } + }, + "400": { + "description": "Missing required context (organization or claims)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Authentication required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to retrieve profile", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/auth/signup": { + "post": { + "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.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Bootstrap organization", + "parameters": [ + { + "description": "Organization bootstrap request (passwordless - no password required)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse" + } + }, + "400": { + "description": "Invalid request payload", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Failed to bootstrap organization", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/example_cognitive/chat": { + "post": { + "description": "Sends a message to the AI and gets a response, optionally using RAG", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Cognitive" + ], + "summary": "Chat with AI", + "parameters": [ + { + "description": "Chat request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_example_cognitive.ChatRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_cognitive/sessions": { + "get": { + "description": "Lists chat sessions for the current user with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "Cognitive" + ], + "summary": "List chat sessions", + "parameters": [ + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_cognitive/sessions/{id}/messages": { + "get": { + "description": "Retrieves all messages for a chat session", + "produces": [ + "application/json" + ], + "tags": [ + "Cognitive" + ], + "summary": "Get session history", + "parameters": [ + { + "type": "integer", + "description": "Session ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_documents": { + "get": { + "description": "Lists documents with optional filtering and pagination", + "produces": [ + "application/json" + ], + "tags": [ + "Documents" + ], + "summary": "List documents", + "parameters": [ + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status (pending, processing, processed, failed)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_documents/upload": { + "post": { + "description": "Uploads a PDF document, extracts text, and creates embeddings", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Documents" + ], + "summary": "Upload PDF document", + "parameters": [ + { + "type": "file", + "description": "PDF file to upload", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Document title", + "name": "title", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.Document" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/example_documents/{id}": { + "delete": { + "description": "Deletes a document and its associated file", + "tags": [ + "Documents" + ], + "summary": "Delete document", + "parameters": [ + { + "type": "integer", + "description": "Document ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError" + } + } + } + } + }, + "/rbac/check-permission": { + "post": { + "description": "Verifies whether a role has been granted a specific permission. Useful for conditional UI rendering.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Check if a role has a specific permission", + "parameters": [ + { + "description": "Role and permission to check", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest" + } + } + ], + "responses": { + "200": { + "description": "Permission check result", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/metadata": { + "get": { + "description": "Returns summary information about the RBAC system including total roles, permissions, and categories.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get RBAC system metadata", + "responses": { + "200": { + "description": "RBAC system metadata", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata" + } + } + } + } + }, + "/rbac/permissions": { + "get": { + "description": "Returns all available permissions in the system. Each permission includes resource, action, display name, and description for frontend rendering.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get all permissions", + "responses": { + "200": { + "description": "All permissions", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/permissions/by-category": { + "get": { + "description": "Returns all permissions organized by their category for better UI organization.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get permissions grouped by category", + "responses": { + "200": { + "description": "Permissions by category", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/roles": { + "get": { + "description": "Returns all available roles in the system with their associated permissions. This is the single source of truth for frontend role/permission discovery.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get all roles with permissions", + "responses": { + "200": { + "description": "Roles with permissions", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/rbac/roles/{role_id}": { + "get": { + "description": "Returns comprehensive information about a role including permissions, statistics, and restrictions.", + "produces": [ + "application/json" + ], + "tags": [ + "RBAC" + ], + "summary": "Get detailed information about a specific role", + "parameters": [ + { + "type": "string", + "description": "Role ID (member, approver, admin)", + "name": "role_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Role details with statistics", + "schema": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse" + } + }, + "400": { + "description": "Invalid role ID", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Role not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "api_example_cognitive.ChatRequest": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "context_history": { + "type": "integer" + }, + "max_documents": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "session_id": { + "type": "integer" + }, + "use_rag": { + "type": "boolean" + } + } + }, + "api_subscriptions.VerifyPaymentRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus": { + "type": "object", + "properties": { + "canProcessInvoices": { + "type": "boolean" + }, + "checkedAt": { + "type": "string" + }, + "externalID": { + "type": "string" + }, + "hasActiveSubscription": { + "type": "boolean" + }, + "invoiceCount": { + "description": "Remaining invoices", + "type": "integer", + "format": "int32" + }, + "organizationID": { + "type": "integer", + "format": "int32" + }, + "reason": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "referenced_docs": { + "type": "array", + "items": { + "type": "integer" + } + }, + "role": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatRole" + }, + "session_id": { + "type": "integer" + }, + "tokens_used": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage" + }, + "referenced_docs": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.SimilarDocument" + } + }, + "session_id": { + "type": "integer" + }, + "tokens_used": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatRole": { + "type": "string", + "enum": [ + "user", + "assistant", + "system" + ], + "x-enum-varnames": [ + "ChatRoleUser", + "ChatRoleAssistant", + "ChatRoleSystem" + ] + }, + "github_com_moasq_go-b2b-starter_app_example_cognitive_domain.SimilarDocument": { + "type": "object", + "properties": { + "chunk_index": { + "type": "integer" + }, + "content_hash": { + "type": "string" + }, + "content_preview": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "document_id": { + "type": "integer" + }, + "embedding": { + "description": "1536 dimensions for OpenAI", + "type": "array", + "items": { + "type": "number" + } + }, + "id": { + "type": "integer" + }, + "organization_id": { + "type": "integer" + }, + "similarity_score": { + "type": "number" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.Document" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_documents_domain.Document": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "extracted_text": { + "type": "string" + }, + "file_asset_id": { + "type": "integer" + }, + "file_name": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "organization_id": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.DocumentStatus" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_example_documents_domain.DocumentStatus": { + "type": "string", + "enum": [ + "pending", + "processing", + "processed", + "failed" + ], + "x-enum-varnames": [ + "DocumentStatusPending", + "DocumentStatusProcessing", + "DocumentStatusProcessed", + "DocumentStatusFailed" + ] + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "invite_sent": { + "type": "boolean" + }, + "member_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "org_id": { + "type": "string" + }, + "role_slug": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest": { + "type": "object", + "required": [ + "org_display_name", + "owner_email", + "owner_name" + ], + "properties": { + "org_display_name": { + "description": "Organization details", + "type": "string" + }, + "owner_email": { + "description": "Owner member details", + "type": "string" + }, + "owner_name": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "invite_sent": { + "type": "boolean" + }, + "magic_link_sent": { + "type": "boolean" + }, + "org_slug": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "owner_email": { + "type": "string" + }, + "owner_member_id": { + "type": "string" + }, + "owner_name": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.MemberInfo" + } + }, + "total": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.MemberInfo": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "member_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileOrganization": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse": { + "type": "object", + "properties": { + "account_id": { + "description": "Internal account details", + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "member_id": { + "description": "Auth provider member details", + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "description": "Organization details", + "allOf": [ + { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileOrganization" + } + ] + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest": { + "type": "object", + "required": [ + "permission_id", + "role_id" + ], + "properties": { + "permission_id": { + "type": "string" + }, + "role_id": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse": { + "type": "object", + "properties": { + "has_permission": { + "type": "boolean" + }, + "permission_id": { + "type": "string" + }, + "role_id": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "resource": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse": { + "type": "object", + "properties": { + "categories": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO" + } + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO" + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "permissions_by_role": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "total_permissions": { + "type": "integer" + }, + "total_roles": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO" + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse": { + "type": "object", + "properties": { + "restrictions": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleRestrictions" + }, + "role": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO" + }, + "statistics": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleStatistics" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RoleRestrictions": { + "type": "object", + "properties": { + "cannot_do": { + "type": "array", + "items": { + "type": "string" + } + }, + "data_access_level": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RoleStatistics": { + "type": "object", + "properties": { + "can_approve": { + "type": "boolean" + }, + "can_manage_org": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "total_permissions": { + "type": "integer" + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse": { + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO" + } + } + } + }, + "github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BasicAuth": { + "type": "basic" + } + }, + "externalDocs": { + "description": "OpenAPI", + "url": "https://swagger.io/resources/open-api/" + } +} \ No newline at end of file diff --git a/go-b2b-starter/src/docs/gen/swagger.yaml b/go-b2b-starter/src/docs/gen/swagger.yaml new file mode 100644 index 0000000..e026169 --- /dev/null +++ b/go-b2b-starter/src/docs/gen/swagger.yaml @@ -0,0 +1,1034 @@ +basePath: /api +definitions: + api_example_cognitive.ChatRequest: + properties: + context_history: + type: integer + max_documents: + type: integer + message: + type: string + session_id: + type: integer + use_rag: + type: boolean + required: + - message + type: object + api_subscriptions.VerifyPaymentRequest: + properties: + session_id: + type: string + required: + - session_id + type: object + github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus: + properties: + canProcessInvoices: + type: boolean + checkedAt: + type: string + externalID: + type: string + hasActiveSubscription: + type: boolean + invoiceCount: + description: Remaining invoices + format: int32 + type: integer + organizationID: + format: int32 + type: integer + reason: + type: string + type: object + github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage: + properties: + content: + type: string + created_at: + type: string + id: + type: integer + referenced_docs: + items: + type: integer + type: array + role: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatRole' + session_id: + type: integer + tokens_used: + type: integer + type: object + github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse: + properties: + message: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage' + referenced_docs: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.SimilarDocument' + type: array + session_id: + type: integer + tokens_used: + type: integer + type: object + github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatRole: + enum: + - user + - assistant + - system + type: string + x-enum-varnames: + - ChatRoleUser + - ChatRoleAssistant + - ChatRoleSystem + github_com_moasq_go-b2b-starter_app_example_cognitive_domain.SimilarDocument: + properties: + chunk_index: + type: integer + content_hash: + type: string + content_preview: + type: string + created_at: + type: string + document_id: + type: integer + embedding: + description: 1536 dimensions for OpenAI + items: + type: number + type: array + id: + type: integer + organization_id: + type: integer + similarity_score: + type: number + updated_at: + type: string + type: object + github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse: + properties: + documents: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.Document' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + github_com_moasq_go-b2b-starter_app_example_documents_domain.Document: + properties: + content_type: + type: string + created_at: + type: string + extracted_text: + type: string + file_asset_id: + type: integer + file_name: + type: string + file_size: + type: integer + id: + type: integer + metadata: + additionalProperties: true + type: object + organization_id: + type: integer + status: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.DocumentStatus' + title: + type: string + updated_at: + type: string + type: object + github_com_moasq_go-b2b-starter_app_example_documents_domain.DocumentStatus: + enum: + - pending + - processing + - processed + - failed + type: string + x-enum-varnames: + - DocumentStatusPending + - DocumentStatusProcessing + - DocumentStatusProcessed + - DocumentStatusFailed + github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse: + properties: + email: + type: string + invite_sent: + type: boolean + member_id: + type: string + name: + type: string + org_id: + type: string + role_slug: + type: string + type: object + github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest: + properties: + org_display_name: + description: Organization details + type: string + owner_email: + description: Owner member details + type: string + owner_name: + type: string + required: + - org_display_name + - owner_email + - owner_name + type: object + github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse: + properties: + display_name: + type: string + invite_sent: + type: boolean + magic_link_sent: + type: boolean + org_slug: + type: string + organization_id: + type: string + owner_email: + type: string + owner_member_id: + type: string + owner_name: + type: string + type: object + github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse: + properties: + members: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.MemberInfo' + type: array + total: + type: integer + type: object + github_com_moasq_go-b2b-starter_app_organizations_app_services.MemberInfo: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + member_id: + type: string + name: + type: string + roles: + items: + type: string + type: array + status: + type: string + updated_at: + type: string + type: object + github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileOrganization: + properties: + name: + type: string + organization_id: + type: string + slug: + type: string + status: + type: string + type: object + github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse: + properties: + account_id: + description: Internal account details + type: integer + created_at: + type: string + email: + type: string + email_verified: + type: boolean + member_id: + description: Auth provider member details + type: string + name: + type: string + organization: + allOf: + - $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileOrganization' + description: Organization details + permissions: + items: + type: string + type: array + roles: + items: + type: string + type: array + status: + type: string + updated_at: + type: string + type: object + github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest: + properties: + permission_id: + type: string + role_id: + type: string + required: + - permission_id + - role_id + type: object + github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse: + properties: + has_permission: + type: boolean + permission_id: + type: string + role_id: + type: string + type: object + github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO: + properties: + action: + type: string + category: + type: string + description: + type: string + display_name: + type: string + id: + type: string + resource: + type: string + type: object + github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse: + properties: + categories: + additionalProperties: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO' + type: array + type: object + type: object + github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse: + properties: + permissions: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO' + type: array + type: object + github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata: + properties: + description: + type: string + permissions_by_role: + additionalProperties: + type: integer + type: object + total_permissions: + type: integer + total_roles: + type: integer + type: object + github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO: + properties: + description: + type: string + id: + type: string + name: + type: string + permissions: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionDTO' + type: array + type: object + github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse: + properties: + restrictions: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleRestrictions' + role: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO' + statistics: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleStatistics' + type: object + github_com_moasq_go-b2b-starter_pkg_auth.RoleRestrictions: + properties: + cannot_do: + items: + type: string + type: array + data_access_level: + type: string + scope: + type: string + type: object + github_com_moasq_go-b2b-starter_pkg_auth.RoleStatistics: + properties: + can_approve: + type: boolean + can_manage_org: + type: boolean + description: + type: string + total_permissions: + type: integer + type: object + github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse: + properties: + roles: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RoleDTO' + type: array + type: object + github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError: + properties: + code: + type: string + message: + type: string + type: object +externalDocs: + description: OpenAPI + url: https://swagger.io/resources/open-api/ +host: localhost:8080 +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: This is the API server for B2B SaaS Starter. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: B2B SaaS Starter API + version: "1.0" +paths: + /api/subscriptions/status: + get: + consumes: + - application/json + description: Retrieve the current subscription billing status and invoice quota + information for the organization + produces: + - application/json + responses: + "200": + description: Current billing and quota status + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus' + "400": + description: Invalid request parameters or missing organization context + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: Get current billing and quota status + tags: + - subscriptions + /api/subscriptions/verify-payment: + post: + consumes: + - application/json + 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. + parameters: + - description: Checkout session ID + in: body + name: request + required: true + schema: + $ref: '#/definitions/api_subscriptions.VerifyPaymentRequest' + produces: + - application/json + responses: + "200": + description: Verification result with updated billing status + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_billing_domain.BillingStatus' + "400": + description: Invalid request parameters or checkout session failed + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "404": + description: Checkout session not found + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: Verify payment from checkout session + tags: + - subscriptions + /auth/check-email: + get: + consumes: + - application/json + 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. + parameters: + - description: Email address to check + in: query + name: email + required: true + type: string + produces: + - application/json + responses: + "200": + description: Email exists + "400": + description: Invalid email format + schema: + additionalProperties: true + type: object + "404": + description: Email not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Check if email exists + tags: + - auth + /auth/members: + get: + consumes: + - application/json + description: Retrieves all members of the current organization. Restricted to + admin role only. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ListMembersResponse' + "400": + description: Missing organization context + schema: + additionalProperties: true + type: object + "403": + description: Insufficient permissions - admin role required + schema: + additionalProperties: true + type: object + "500": + description: Failed to list members + schema: + additionalProperties: true + type: object + summary: List organization members + tags: + - auth + post: + consumes: + - application/json + 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"}' + parameters: + - description: Bearer JWT token + in: header + name: Authorization + required: true + type: string + - description: Member email address + in: body + name: email + required: true + schema: + type: string + - description: Member full name + in: body + name: name + required: true + schema: + type: string + - description: Role slug (defaults to 'member') + in: body + name: role_slug + schema: + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.AddMemberResponse' + "400": + description: Invalid request payload or missing organization context + schema: + additionalProperties: true + type: object + "500": + description: Failed to add member + schema: + additionalProperties: true + type: object + summary: Add member to organization + tags: + - auth + /auth/members/{member_id}: + delete: + consumes: + - application/json + description: Removes a member from the organization (deletes from both Stytch + and internal database). Only admins can delete members. + parameters: + - description: Bearer JWT token + in: header + name: Authorization + required: true + type: string + - description: Member ID to delete + in: path + name: member_id + required: true + type: string + produces: + - application/json + responses: + "204": + description: Member deleted successfully + schema: + additionalProperties: true + type: object + "400": + description: Invalid member ID or missing organization context + schema: + additionalProperties: true + type: object + "403": + description: Insufficient permissions - admin role required + schema: + additionalProperties: true + type: object + "404": + description: Member not found + schema: + additionalProperties: true + type: object + "500": + description: Failed to delete member + schema: + additionalProperties: true + type: object + summary: Delete organization member + tags: + - auth + /auth/profile/me: + get: + consumes: + - application/json + description: Retrieves comprehensive profile information for the currently authenticated + user, including member details, organization info, and account status. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.ProfileResponse' + "400": + description: Missing required context (organization or claims) + schema: + additionalProperties: true + type: object + "401": + description: Authentication required + schema: + additionalProperties: true + type: object + "500": + description: Failed to retrieve profile + schema: + additionalProperties: true + type: object + summary: Get current user profile + tags: + - auth + /auth/signup: + post: + consumes: + - application/json + 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. + parameters: + - description: Organization bootstrap request (passwordless - no password required) + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_organizations_app_services.BootstrapOrganizationResponse' + "400": + description: Invalid request payload + schema: + additionalProperties: true + type: object + "500": + description: Failed to bootstrap organization + schema: + additionalProperties: true + type: object + summary: Bootstrap organization + tags: + - auth + /example_cognitive/chat: + post: + consumes: + - application/json + description: Sends a message to the AI and gets a response, optionally using + RAG + parameters: + - description: Chat request + in: body + name: request + required: true + schema: + $ref: '#/definitions/api_example_cognitive.ChatRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: Chat with AI + tags: + - Cognitive + /example_cognitive/sessions: + get: + description: Lists chat sessions for the current user with pagination + parameters: + - default: 10 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: List chat sessions + tags: + - Cognitive + /example_cognitive/sessions/{id}/messages: + get: + description: Retrieves all messages for a chat session + parameters: + - description: Session ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_cognitive_domain.ChatMessage' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: Get session history + tags: + - Cognitive + /example_documents: + get: + description: Lists documents with optional filtering and pagination + parameters: + - default: 10 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + - description: Filter by status (pending, processing, processed, failed) + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_app_services.ListDocumentsResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: List documents + tags: + - Documents + /example_documents/{id}: + delete: + description: Deletes a document and its associated file + parameters: + - description: Document ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: Delete document + tags: + - Documents + /example_documents/upload: + post: + consumes: + - multipart/form-data + description: Uploads a PDF document, extracts text, and creates embeddings + parameters: + - description: PDF file to upload + in: formData + name: file + required: true + type: file + - description: Document title + in: formData + name: title + required: true + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_app_example_documents_domain.Document' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_common_errors.HTTPError' + summary: Upload PDF document + tags: + - Documents + /rbac/check-permission: + post: + consumes: + - application/json + description: Verifies whether a role has been granted a specific permission. + Useful for conditional UI rendering. + parameters: + - description: Role and permission to check + in: body + name: body + required: true + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckRequest' + produces: + - application/json + responses: + "200": + description: Permission check result + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionCheckResponse' + "400": + description: Invalid request + schema: + additionalProperties: + type: string + type: object + summary: Check if a role has a specific permission + tags: + - RBAC + /rbac/metadata: + get: + description: Returns summary information about the RBAC system including total + roles, permissions, and categories. + produces: + - application/json + responses: + "200": + description: RBAC system metadata + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RBACMetadata' + summary: Get RBAC system metadata + tags: + - RBAC + /rbac/permissions: + get: + description: Returns all available permissions in the system. Each permission + includes resource, action, display name, and description for frontend rendering. + produces: + - application/json + responses: + "200": + description: All permissions + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionsResponse' + "500": + description: Internal error + schema: + additionalProperties: + type: string + type: object + summary: Get all permissions + tags: + - RBAC + /rbac/permissions/by-category: + get: + description: Returns all permissions organized by their category for better + UI organization. + produces: + - application/json + responses: + "200": + description: Permissions by category + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.PermissionsByCategoryResponse' + "500": + description: Internal error + schema: + additionalProperties: + type: string + type: object + summary: Get permissions grouped by category + tags: + - RBAC + /rbac/roles: + get: + description: Returns all available roles in the system with their associated + permissions. This is the single source of truth for frontend role/permission + discovery. + produces: + - application/json + responses: + "200": + description: Roles with permissions + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RolesResponse' + "500": + description: Internal error + schema: + additionalProperties: + type: string + type: object + summary: Get all roles with permissions + tags: + - RBAC + /rbac/roles/{role_id}: + get: + description: Returns comprehensive information about a role including permissions, + statistics, and restrictions. + parameters: + - description: Role ID (member, approver, admin) + in: path + name: role_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Role details with statistics + schema: + $ref: '#/definitions/github_com_moasq_go-b2b-starter_pkg_auth.RolePermissionsResponse' + "400": + description: Invalid role ID + schema: + additionalProperties: + type: string + type: object + "404": + description: Role not found + schema: + additionalProperties: + type: string + type: object + summary: Get detailed information about a specific role + tags: + - RBAC +securityDefinitions: + BasicAuth: + type: basic +swagger: "2.0" diff --git a/go-b2b-starter/src/docs/go.mod b/go-b2b-starter/src/docs/go.mod new file mode 100644 index 0000000..36022c9 --- /dev/null +++ b/go-b2b-starter/src/docs/go.mod @@ -0,0 +1,57 @@ +module github.com/moasq/go-b2b-starter/docs + +go 1.25 + + +require ( + github.com/gin-gonic/gin v1.10.1 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.3 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.31.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/docs/go.sum b/go-b2b-starter/src/docs/go.sum new file mode 100644 index 0000000..876b9e0 --- /dev/null +++ b/go-b2b-starter/src/docs/go.sum @@ -0,0 +1,52 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-b2b-starter/src/main/cmd/init_mods.go b/go-b2b-starter/src/main/cmd/init_mods.go new file mode 100644 index 0000000..ef12c8c --- /dev/null +++ b/go-b2b-starter/src/main/cmd/init_mods.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + + "go.uber.org/dig" + + api "github.com/moasq/go-b2b-starter/api/cmd" + cognitive "github.com/moasq/go-b2b-starter/app/example_cognitive/cmd" + documents "github.com/moasq/go-b2b-starter/app/example_documents/cmd" + organizations "github.com/moasq/go-b2b-starter/app/organizations/cmd" + orgDomain "github.com/moasq/go-b2b-starter/app/organizations/domain" + billing "github.com/moasq/go-b2b-starter/app/billing/cmd" + docs "github.com/moasq/go-b2b-starter/docs/cmd" + "github.com/moasq/go-b2b-starter/pkg/auth" + authPkg "github.com/moasq/go-b2b-starter/pkg/auth/cmd" + db "github.com/moasq/go-b2b-starter/pkg/db/cmd" + eventbus "github.com/moasq/go-b2b-starter/pkg/eventbus/cmd" + file_manager "github.com/moasq/go-b2b-starter/pkg/file_manager/cmd" + llm "github.com/moasq/go-b2b-starter/pkg/llm/cmd" + logger "github.com/moasq/go-b2b-starter/pkg/logger/cmd" + ocr "github.com/moasq/go-b2b-starter/pkg/ocr/cmd" + polar "github.com/moasq/go-b2b-starter/pkg/polar/cmd" + redisCmd "github.com/moasq/go-b2b-starter/pkg/redis/cmd" + stytchCmd "github.com/moasq/go-b2b-starter/pkg/stytch/cmd" + paywall "github.com/moasq/go-b2b-starter/pkg/paywall" + server "github.com/moasq/go-b2b-starter/server/cmd" +) + +// orgLookupAdapter adapts orgDomain.OrganizationRepository to auth.OrganizationLookup +type orgLookupAdapter struct { + repo orgDomain.OrganizationRepository +} + +func (a *orgLookupAdapter) GetByStytchID(ctx context.Context, stytchOrgID string) (auth.OrganizationEntity, error) { + return a.repo.GetByStytchID(ctx, stytchOrgID) +} + +// accLookupAdapter adapts orgDomain.AccountRepository to auth.AccountLookup +type accLookupAdapter struct { + repo orgDomain.AccountRepository +} + +func (a *accLookupAdapter) GetByEmail(ctx context.Context, orgID int32, email string) (auth.AccountEntity, error) { + return a.repo.GetByEmail(ctx, orgID, email) +} + +func InitMods(container *dig.Container) { + + // pkg + server.Init(container) + logger.Init(container) + db.Init(container) + file_manager.Init(container) + if err := eventbus.Init(container); err != nil { + panic(err) + } + if err := llm.Init(container); err != nil { + panic(err) + } + + // Polar package must be initialized before payment module (payment depends on Polar client) + if err := polar.Init(container); err != nil { + panic(err) + } + + // Redis must be initialized before auth (Stytch repositories rely on Redis-backed clients upstream) + if err := redisCmd.Init(container); err != nil { + panic(err) + } + + // Stytch client package must be initialized before app/auth (for organization/member management) + // This provides: stytch.Config, stytch.Client, stytch.RBACPolicyService + if err := stytchCmd.ProvideStytchDependencies(container); err != nil { + panic(err) + } + + // Auth package (pkg/auth) must be initialized before app/auth + // This provides: auth.AuthProvider (authentication/authorization) + if err := authPkg.Init(container); err != nil { + panic(err) + } + + // docs + docs.Init(container) + + // app + if err := organizations.Init(container); err != nil { + panic(err) + } + + // Register auth resolvers (bridges organizations domain to auth package) + if err := auth.ProvideResolvers(container, + func(repo orgDomain.OrganizationRepository) auth.OrganizationResolver { + return auth.NewOrganizationResolver(&orgLookupAdapter{repo: repo}) + }, + func(repo orgDomain.AccountRepository) auth.AccountResolver { + return auth.NewAccountResolver(&accLookupAdapter{repo: repo}) + }, + ); err != nil { + panic(err) + } + + // Initialize auth middleware (requires resolvers to be registered) + if err := authPkg.InitMiddleware(container); err != nil { + panic(err) + } + + // Register auth middleware as named middlewares for use in routes + if err := auth.RegisterNamedMiddlewares(container); err != nil { + panic(err) + } + + // Billing module (subscription lifecycle, quotas, webhooks) + if err := billing.Init(container); err != nil { + panic(err) + } + + // Paywall middleware (access gating based on subscription status) + if err := paywall.SetupMiddleware(container); err != nil { + panic(err) + } + if err := paywall.RegisterNamedMiddlewares(container); err != nil { + panic(err) + } + + // OCR service (Mistral API for document text extraction) + // Must be initialized before documents module (documents depends on OCR) + if err := ocr.Init(container); err != nil { + panic(err) + } + + // Documents module (PDF upload and text extraction) + if err := documents.Init(container); err != nil { + panic(err) + } + + // Cognitive module (AI/RAG with embeddings and vector search) + // Note: This also wires the event listener for DocumentUploaded events + if err := cognitive.Init(container); err != nil { + panic(err) + } + + // api + api.Init(container) +} diff --git a/go-b2b-starter/src/main/cmd/root.go b/go-b2b-starter/src/main/cmd/root.go new file mode 100644 index 0000000..c29879e --- /dev/null +++ b/go-b2b-starter/src/main/cmd/root.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "log" + + "github.com/joho/godotenv" + "go.uber.org/dig" + + server "github.com/moasq/go-b2b-starter/server/domain" +) + +func Execute() { + // Load environment variables from app.env file + if err := godotenv.Load("app.env"); err != nil { + log.Printf("Warning: Error loading app.env file: %v", err) + } + + container := dig.New() + + InitMods(container) + + var srv server.Server + + if err := container.Invoke(func(s server.Server) { + srv = s + }); err != nil { + panic(err) + } + + srv.Start() + +} diff --git a/go-b2b-starter/src/main/go.mod b/go-b2b-starter/src/main/go.mod new file mode 100644 index 0000000..08b5671 --- /dev/null +++ b/go-b2b-starter/src/main/go.mod @@ -0,0 +1,162 @@ +module github.com/moasq/go-b2b-starter/main + +go 1.25 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/moasq/go-b2b-starter/api v0.0.0 + github.com/moasq/go-b2b-starter/app/billing v0.0.0 + github.com/moasq/go-b2b-starter/app/example_cognitive v0.0.0 + github.com/moasq/go-b2b-starter/app/example_documents v0.0.0 + github.com/moasq/go-b2b-starter/app/organizations v0.0.0 + github.com/moasq/go-b2b-starter/docs v0.0.0 + github.com/moasq/go-b2b-starter/pkg/auth v0.0.0 + github.com/moasq/go-b2b-starter/pkg/db v0.0.0 + github.com/moasq/go-b2b-starter/pkg/eventbus v0.0.0 + github.com/moasq/go-b2b-starter/pkg/file_manager v0.0.0 + github.com/moasq/go-b2b-starter/pkg/llm v0.0.0 + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + github.com/moasq/go-b2b-starter/pkg/paywall v0.0.0 + github.com/moasq/go-b2b-starter/pkg/polar v0.0.0 + github.com/moasq/go-b2b-starter/pkg/redis v0.0.0 + github.com/moasq/go-b2b-starter/pkg/stytch v0.0.0 + github.com/moasq/go-b2b-starter/server v0.0.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/MicahParks/keyfunc/v2 v2.0.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect + github.com/aws/smithy-go v1.19.0 // indirect + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/cors v1.7.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.1 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moasq/go-b2b-starter/pkg/api v0.0.0 // indirect + github.com/moasq/go-b2b-starter/pkg/common v0.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pgvector/pgvector-go v0.3.0 // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/rs/zerolog v1.33.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/stytchauth/stytch-go/v16 v16.40.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/gin-swagger v1.6.0 // indirect + github.com/swaggo/swag v1.16.3 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.8.0 // indirect + golang.org/x/tools v0.31.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/api => ../api + +replace github.com/moasq/go-b2b-starter/app/organizations => ../app/organizations + +replace github.com/moasq/go-b2b-starter/docs => ../docs + +replace github.com/moasq/go-b2b-starter/pkg/db => ../pkg/db + +replace github.com/moasq/go-b2b-starter/pkg/eventbus => ../pkg/eventbus + +replace github.com/moasq/go-b2b-starter/pkg/file_manager => ../pkg/file_manager + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../pkg/logger + +replace github.com/moasq/go-b2b-starter/pkg/llm => ../pkg/llm + +replace github.com/moasq/go-b2b-starter/pkg/common => ../pkg/common + +replace github.com/moasq/go-b2b-starter/pkg/api => ../pkg/api + +replace github.com/moasq/go-b2b-starter/server => ../pkg/server + +replace github.com/moasq/go-b2b-starter/app/billing => ../app/billing + +replace github.com/moasq/go-b2b-starter/app/example_documents => ../app/example_documents + +replace github.com/moasq/go-b2b-starter/app/example_cognitive => ../app/example_cognitive + +replace github.com/moasq/go-b2b-starter/pkg/paywall => ../pkg/paywall + +replace github.com/moasq/go-b2b-starter/pkg/auth => ../pkg/auth + +replace github.com/moasq/go-b2b-starter/pkg/stytch => ../pkg/stytch + +replace github.com/moasq/go-b2b-starter/pkg/redis => ../pkg/redis + +replace github.com/moasq/go-b2b-starter/pkg/polar => ../pkg/polar diff --git a/go-b2b-starter/src/main/go.sum b/go-b2b-starter/src/main/go.sum new file mode 100644 index 0000000..cedfb46 --- /dev/null +++ b/go-b2b-starter/src/main/go.sum @@ -0,0 +1,265 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/MicahParks/keyfunc/v2 v2.0.1 h1:6FrNNvG/20gEKkjxV+5anrkq0VOF666G2zUn8lk8dgk= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= +github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic v1.12.5/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stytchauth/stytch-go/v16 v16.40.0 h1:xT9QyPtWi4j6rJPhkROfGCDzDeVBqvS2KQge1dv8rfs= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/main/main.go b/go-b2b-starter/src/main/main.go new file mode 100644 index 0000000..e8645f3 --- /dev/null +++ b/go-b2b-starter/src/main/main.go @@ -0,0 +1,28 @@ +// Package main provides the entry point for the B2B SaaS Starter +// +// @title B2B SaaS Starter API +// @version 1.0 +// @description This is the API server for B2B SaaS Starter. +// @termsOfService http://swagger.io/terms/ +// +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io +// +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// +// @host localhost:8080 +// @BasePath /api +// +// @securityDefinitions.basic BasicAuth +// +// @externalDocs.description OpenAPI +// @externalDocs.url https://swagger.io/resources/open-api/ +package main + +import "github.com/moasq/go-b2b-starter/main/cmd" + +func main() { + cmd.Execute() +} diff --git a/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/migrations/000015_create_payment_export_schema.down.sql b/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/migrations/000015_create_payment_export_schema.down.sql new file mode 100644 index 0000000..ec78545 --- /dev/null +++ b/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/migrations/000015_create_payment_export_schema.down.sql @@ -0,0 +1,6 @@ +-- Drop tables +DROP TABLE IF EXISTS payment_export.exported_payments; +DROP TABLE IF EXISTS payment_export.payment_exports; + +-- Drop schema +DROP SCHEMA IF EXISTS payment_export; diff --git a/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/migrations/000015_create_payment_export_schema.up.sql b/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/migrations/000015_create_payment_export_schema.up.sql new file mode 100644 index 0000000..840ce0e --- /dev/null +++ b/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/migrations/000015_create_payment_export_schema.up.sql @@ -0,0 +1,39 @@ +-- Create payment_export schema for clean separation +CREATE SCHEMA IF NOT EXISTS payment_export; + +-- Create payment_exports table for tracking export operations +CREATE TABLE payment_export.payment_exports ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL, + export_type VARCHAR(20) NOT NULL CHECK (export_type IN ('QBO', 'XERO')), + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NULL, + total_payments INTEGER NOT NULL DEFAULT 0, + total_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED')), + error_message TEXT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER NOT NULL +); + +-- Create exported_payments table for tracking individual payments in exports +CREATE TABLE payment_export.exported_payments ( + id SERIAL PRIMARY KEY, + export_id INTEGER NOT NULL REFERENCES payment_export.payment_exports(id) ON DELETE CASCADE, + payment_schedule_id INTEGER NOT NULL, + amount DECIMAL(15,2) NOT NULL, + vendor_name VARCHAR(255) NOT NULL, + invoice_number VARCHAR(100) NOT NULL, + payment_date TIMESTAMP WITH TIME ZONE NOT NULL, + executed BOOLEAN NOT NULL DEFAULT FALSE, + executed_date TIMESTAMP WITH TIME ZONE NULL, + execution_reference VARCHAR(100) NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for performance +CREATE INDEX idx_payment_exports_org_created ON payment_export.payment_exports(organization_id, created_at DESC); +CREATE INDEX idx_payment_exports_status ON payment_export.payment_exports(status); +CREATE INDEX idx_exported_payments_export_id ON payment_export.exported_payments(export_id); +CREATE INDEX idx_exported_payments_schedule_id ON payment_export.exported_payments(payment_schedule_id); +CREATE INDEX idx_exported_payments_executed ON payment_export.exported_payments(executed); diff --git a/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/query/payment_export.sql b/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/query/payment_export.sql new file mode 100644 index 0000000..8ad88a4 --- /dev/null +++ b/go-b2b-starter/src/main/src/pkg/db/postgres/sqlc/query/payment_export.sql @@ -0,0 +1,68 @@ +-- ============================================================================ +-- EXPORT OPERATIONS +-- ============================================================================ + +-- name: CreateExport :one +INSERT INTO payment_export.payment_exports ( + organization_id, export_type, file_name, total_payments, total_amount, created_by +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING *; + +-- name: GetExportByID :one +SELECT * FROM payment_export.payment_exports +WHERE id = $1 AND organization_id = $2; + +-- name: UpdateExportFile :one +UPDATE payment_export.payment_exports +SET file_path = $3, status = 'COMPLETED' +WHERE id = $1 AND organization_id = $2 +RETURNING *; + +-- name: ListExports :many +SELECT * FROM payment_export.payment_exports +WHERE organization_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3; + +-- ============================================================================ +-- EXPORTED PAYMENT OPERATIONS +-- ============================================================================ + +-- name: AddExportedPayment :one +INSERT INTO payment_export.exported_payments ( + export_id, payment_schedule_id, amount, vendor_name, invoice_number, payment_date +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING *; + +-- name: GetExportedPayments :many +SELECT * FROM payment_export.exported_payments +WHERE export_id = $1 +ORDER BY created_at; + +-- name: MarkPaymentExecuted :one +UPDATE payment_export.exported_payments +SET executed = TRUE, executed_date = $3, execution_reference = $4 +WHERE export_id = $1 AND payment_schedule_id = $2 +RETURNING *; + +-- ============================================================================ +-- DATA RETRIEVAL FOR EXPORT +-- ============================================================================ + +-- name: GetExportablePaymentSchedules :many +SELECT + ps.id, + ps.scheduled_execution_date, + ps.amount, + ps.invoice_id, + i.invoice_number, + v.vendor_name +FROM payment_optimization.payment_schedules ps +JOIN invoice_management.invoices i ON ps.invoice_id = i.id +LEFT JOIN invoice_management.vendors v ON i.vendor_id = v.id +WHERE ps.id = ANY($2::int[]) +AND ps.organization_id = $1 +AND ps.status IN ('SCHEDULED', 'PENDING') +ORDER BY ps.scheduled_execution_date; diff --git a/go-b2b-starter/src/pkg/api/go.mod b/go-b2b-starter/src/pkg/api/go.mod new file mode 100644 index 0000000..aab4655 --- /dev/null +++ b/go-b2b-starter/src/pkg/api/go.mod @@ -0,0 +1,47 @@ +module github.com/moasq/go-b2b-starter/pkg/api + +go 1.25 + + +replace github.com/moasq/go-b2b-starter/pkg/common => ../common + +require ( + github.com/moasq/go-b2b-starter/pkg/common v0.0.0 + github.com/gin-gonic/gin v1.10.1 +) + +require ( + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/pkg/api/go.sum b/go-b2b-starter/src/pkg/api/go.sum new file mode 100644 index 0000000..8481660 --- /dev/null +++ b/go-b2b-starter/src/pkg/api/go.sum @@ -0,0 +1,71 @@ +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/pkg/api/response/response.go b/go-b2b-starter/src/pkg/api/response/response.go new file mode 100644 index 0000000..7286df9 --- /dev/null +++ b/go-b2b-starter/src/pkg/api/response/response.go @@ -0,0 +1,24 @@ +package response + +import ( + "github.com/gin-gonic/gin" + + "github.com/moasq/go-b2b-starter/pkg/common/errors" +) + +// Success sends a successful response +func Success(c *gin.Context, statusCode int, data interface{}) { + c.JSON(statusCode, gin.H{ + "success": true, + "data": data, + }) +} + +// Error sends an error response +func Error(c *gin.Context, statusCode int, message string, err error) { + c.JSON(statusCode, errors.NewHTTPError( + statusCode, + "error", + message, + )) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/auth/README.md b/go-b2b-starter/src/pkg/auth/README.md new file mode 100644 index 0000000..ed9fbbb --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/README.md @@ -0,0 +1,377 @@ +# Auth Package + +Provider-agnostic authentication and authorization with type-safe middleware. Supports JWT verification, RBAC permissions, and multi-tenant organization context. + +## Quick Start + +### Setup Middleware + +```go +// In server initialization +authMiddleware := auth.NewMiddleware(authProvider, orgResolver, accResolver, nil) + +// Apply to routes +router.Use(authMiddleware.RequireAuth()) // Verify JWT token +router.Use(authMiddleware.RequireOrganization()) // Resolve org/account IDs +``` + +### Protect Routes + +```go +router.GET("/invoices", + auth.RequirePermissionFunc("invoice", "view"), + handler.ListInvoices) + +router.POST("/invoices", + auth.RequirePermissionFunc("invoice", "create"), + handler.CreateInvoice) +``` + +### Get Context in Handlers + +```go +func (h *Handler) MyHandler(c *gin.Context) { + reqCtx := auth.GetRequestContext(c) + + orgID := reqCtx.OrganizationID // int32 database ID + accountID := reqCtx.AccountID // int32 database ID + email := reqCtx.Identity.Email // User's email +} +``` + +## Core Concepts + +- **Identity**: User info from auth provider (email, roles, permissions) +- **RequestContext**: Resolved database IDs (OrganizationID, AccountID) +- **Permissions**: Format `"resource:action"` (e.g., `"invoice:create"`, `"org:manage"`) + +## Common Patterns + +### Pattern 1: Public Route + +No authentication required: + +```go +router.POST("/auth/signup", handler.Signup) +router.GET("/auth/check-email", handler.CheckEmail) +``` + +### Pattern 2: Authenticated Route + +Any logged-in user can access: + +```go +router.GET("/profile/me", + authMiddleware.RequireAuth(), + handler.GetProfile) +``` + +### Pattern 3: Organization Route + +Requires organization context (most common pattern): + +```go +orgGroup := router.Group("/organizations") +orgGroup.Use( + authMiddleware.RequireAuth(), + authMiddleware.RequireOrganization(), +) +{ + orgGroup.GET("", handler.GetOrganization) + orgGroup.PUT("", handler.UpdateOrganization) + orgGroup.GET("/stats", handler.GetOrganizationStats) +} +``` + +### Pattern 4: Permission-Protected Route + +Requires specific permission: + +```go +router.POST("/invoices", + authMiddleware.RequireAuth(), + authMiddleware.RequireOrganization(), + auth.RequirePermissionFunc("invoice", "create"), + handler.CreateInvoice) + +router.DELETE("/invoices/:id", + authMiddleware.RequireAuth(), + authMiddleware.RequireOrganization(), + auth.RequirePermissionFunc("invoice", "delete"), + handler.DeleteInvoice) +``` + +### Pattern 5: Role-Protected Route + +Requires specific role: + +```go +router.DELETE("/organizations/:id", + authMiddleware.RequireAuth(), + authMiddleware.RequireRole(auth.RoleAdmin), + handler.DeleteOrganization) +``` + +## Stytch Project Setup + +### Create Stytch Account & Project + +1. Go to [https://stytch.com](https://stytch.com) and sign up +2. Create a new **B2B project** +3. Choose **"Test"** environment for development + +### Get Your Credentials + +From your Stytch project dashboard: + +```env +STYTCH_PROJECT_ID=project-test-xxx-xxx # Project Settings → Project ID +STYTCH_SECRET=secret-test-xxx # API Keys → Secret +STYTCH_ENV=test # "test" or "live" +``` + +### Configure RBAC Policies + +Go to **Dashboard → RBAC → Policies** and create these resources: + +| Resource | Actions | Description | +|----------|---------|-------------| +| `resource` | `view`, `create`, `edit`, `delete`, `approve` | Your domain entity (rename to your business) | +| `org` | `view`, `manage` | Organization settings | + +> **Tip**: Rename "resource" to your domain entity (e.g., `invoice`, `patient`, `project`). + +### Set Up Roles + +Go to **Dashboard → RBAC → Roles**: + +- **stytch_admin** (built-in): Auto-assigned to organization creator, has all permissions +- **stytch_member** (built-in): Default role for all members +- **manager** (custom): Create for elevated access + +| Role | Permissions | +|------|-------------| +| member | `resource:view`, `resource:create` | +| manager | All resource permissions + `org:view` | +| admin | All permissions | + +Assign permissions to roles based on your business needs. + +### Test Your Setup + +```bash +# Add credentials to app.env +STYTCH_PROJECT_ID=project-test-xxx-xxx +STYTCH_SECRET=secret-test-xxx +STYTCH_ENV=test +STYTCH_SESSION_DURATION_MINUTES=1440 # Optional: 24 hours + +# Run the application +make server + +# First user to sign up becomes stytch_admin +# Subsequent users get stytch_member role +``` + +## Configuration + +After completing setup above, your `app.env` should have: + +```env +STYTCH_PROJECT_ID=project-test-xxx-xxx # Required +STYTCH_SECRET=secret-test-xxx # Required +STYTCH_ENV=test # Optional: "test" or "live" +STYTCH_SESSION_DURATION_MINUTES=1440 # Optional: 24 hours (default) +STYTCH_API_TIMEOUT=15s # Optional: 15 seconds (default) +``` + +## Adding New Permissions + +**Step 1:** Define in `rbac.go` + +```go +// In the permissions section of rbac.go +var ( + // Add your new permissions + PermReportView = NewPermission("report", "view") + PermReportExport = NewPermission("report", "export") +) + +// Don't forget to add to AllPermissions +var AllPermissions = []Permission{ + // ... existing permissions + PermReportView, + PermReportExport, +} +``` + +**Step 2:** Use in routes + +```go +router.GET("/reports", + auth.RequirePermissionFunc("report", "view"), + handler.ListReports) + +router.POST("/reports/export", + auth.RequirePermissionFunc("report", "export"), + handler.ExportReport) +``` + +**Step 3:** Configure in Stytch Dashboard +- Go to Stytch Dashboard → RBAC → Policies +- Add resource: `report` +- Add actions: `view`, `export` +- Assign to roles + +## Common Handler Patterns + +### Check Permission + +```go +identity := auth.GetIdentity(c) +if identity.HasResourcePermission("invoice", "delete") { + // Show delete button +} +``` + +### Check Role + +```go +identity := auth.GetIdentity(c) +if identity.HasRole(auth.RoleAdmin) { + // Show admin panel +} +``` + +### Get Organization ID + +```go +// Safe: returns 0 if not set +orgID := auth.GetOrganizationID(c) + +// Panics if not set (use only after RequireOrganization) +reqCtx := auth.MustGetRequestContext(c) +``` + +### Get Account ID + +```go +// Safe: returns 0 if not set +accountID := auth.GetAccountID(c) +``` + +### Get Full Context + +```go +reqCtx := auth.GetRequestContext(c) +if reqCtx == nil { + // Handle missing context + return +} + +// Access all fields +orgID := reqCtx.OrganizationID +accountID := reqCtx.AccountID +email := reqCtx.Identity.Email +roles := reqCtx.Identity.Roles +permissions := reqCtx.Identity.Permissions +``` + +## Multiple Permission Checks + +### Require Any Permission + +At least one permission required: + +```go +router.GET("/reports", + auth.RequireAnyPermissionFunc( + auth.PermReportView, + auth.PermReportExport, + ), + handler.GetReports) +``` + +### Require All Permissions + +All permissions required: + +```go +router.POST("/admin/dangerous", + authMiddleware.RequireAllPermissions( + auth.NewPermission("admin", "access"), + auth.NewPermission("admin", "write"), + ), + handler.DangerousOperation) +``` + +## Troubleshooting + +**"authentication required"** +- Check `Authorization: Bearer ` header format +- Verify token not expired +- Check STYTCH_PROJECT_ID matches token issuer + +**"organization not found"** +- Organization must exist in database before authentication +- Check provider org ID is mapped to database ID + +**"insufficient permissions"** +- Verify user has required permission in Stytch RBAC dashboard +- Check permission format: `"resource:action"` (not `resource.action`) +- Check role-based permissions in `roles.go` + +## Predefined Permissions + +Generic permissions available as constants (rename "resource" to your domain): + +```go +// Resource (rename to your domain: invoice, patient, project, etc.) +auth.PermResourceView // "resource:view" +auth.PermResourceCreate // "resource:create" +auth.PermResourceEdit // "resource:edit" +auth.PermResourceDelete // "resource:delete" +auth.PermResourceApprove // "resource:approve" + +// Organization +auth.PermOrgView // "org:view" +auth.PermOrgManage // "org:manage" + +// See rbac.go for complete list and customization instructions +``` + +## Custom Middleware Example + +Create custom auth middleware for specific needs: + +```go +func RequireAdminOrOwner() gin.HandlerFunc { + return func(c *gin.Context) { + identity := auth.GetIdentity(c) + if identity == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + c.Abort() + return + } + + if !identity.HasRole(auth.RoleAdmin) && !identity.HasRole(auth.RoleOwner) { + c.JSON(http.StatusForbidden, gin.H{"error": "admin or owner required"}) + c.Abort() + return + } + + c.Next() + } +} + +// Usage +router.DELETE("/organizations/:id", RequireAdminOrOwner(), handler.Delete) +``` + +## Learn More + +- **RBAC Definitions**: See `rbac.go` for all roles, permissions, and customization instructions +- **Stytch Setup**: See `STYTCH_SETUP.md` for dashboard configuration +- **API reference**: Run `go doc github.com/moasq/go-b2b-starter/pkg/auth` +- **Examples**: See `src/api/organizations/routes.go` for real-world usage +- **Stytch B2B RBAC**: https://stytch.com/docs/b2b/guides/rbac/overview diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/adapter.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/adapter.go new file mode 100644 index 0000000..42bbcd8 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/adapter.go @@ -0,0 +1,176 @@ +// Package stytch provides Stytch B2B authentication integration. +// +// This package implements the auth.AuthProvider interface using Stytch +// as the identity provider. It handles JWT verification, JWKS caching, +// and RBAC policy management. +// +// # Architecture +// +// The adapter uses a two-tier verification strategy: +// 1. Fast Path: Local JWT verification using cached JWKS (Redis) +// 2. Slow Path: Stytch API verification (fallback) +// +// This optimization saves 300-500ms per request for the common case. +// +// # Components +// +// - StytchAuthAdapter: Main entry point implementing auth.AuthProvider +// - TokenVerifier: JWT verification with local/API fallback +// - JWKSCache: Public key caching in Redis +// - RBACPolicyService: Role permission resolution +// +// # Usage +// +// cfg, err := stytch.LoadConfig() +// if err != nil { +// log.Fatal(err) +// } +// +// adapter, err := stytch.NewStytchAuthAdapter(cfg, redisClient, logger) +// if err != nil { +// log.Fatal(err) +// } +// +// // Use as auth.AuthProvider +// identity, err := adapter.VerifyToken(ctx, token) +package stytch + +import ( + "context" + "fmt" + + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" +) + +// StytchAuthAdapter implements auth.AuthProvider using Stytch B2B. +// +// It provides authentication and authorization using Stytch's +// session management and RBAC capabilities. +type StytchAuthAdapter struct { + client *b2bstytchapi.API + tokenVerifier *TokenVerifier + policyService *RBACPolicyService + cfg *Config + logger logger.Logger +} + +// Ensure StytchAuthAdapter implements auth.AuthProvider. +var _ auth.AuthProvider = (*StytchAuthAdapter)(nil) + +// NewStytchAuthAdapter creates a new Stytch authentication adapter. +// +// It initializes the Stytch client, JWKS cache, and RBAC policy service. +// Returns an error if configuration or client initialization fails. +func NewStytchAuthAdapter( + cfg *Config, + redisClient redis.Client, + log logger.Logger, +) (*StytchAuthAdapter, error) { + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid stytch config: %w", err) + } + + // Create Stytch API client + client, err := b2bstytchapi.NewClient(cfg.ProjectID, cfg.Secret) + if err != nil { + return nil, fmt.Errorf("failed to create stytch client: %w", err) + } + + // Create JWKS cache for local JWT verification + jwksCache := NewJWKSCache(cfg.JWKSURL, redisClient, log) + + // Create RBAC policy service for permission resolution + policyService := NewRBACPolicyService(client, redisClient, log) + + // Create token verifier with two-tier strategy + tokenVerifier := NewTokenVerifier(client, jwksCache, policyService, cfg, log) + + return &StytchAuthAdapter{ + client: client, + tokenVerifier: tokenVerifier, + policyService: policyService, + cfg: cfg, + logger: log, + }, nil +} + +// NewStytchAuthAdapterWithClient creates an adapter with an existing Stytch client. +// +// This is useful for testing or when you want to reuse an existing client. +func NewStytchAuthAdapterWithClient( + client *b2bstytchapi.API, + cfg *Config, + redisClient redis.Client, + log logger.Logger, +) *StytchAuthAdapter { + jwksCache := NewJWKSCache(cfg.JWKSURL, redisClient, log) + policyService := NewRBACPolicyService(client, redisClient, log) + tokenVerifier := NewTokenVerifier(client, jwksCache, policyService, cfg, log) + + return &StytchAuthAdapter{ + client: client, + tokenVerifier: tokenVerifier, + policyService: policyService, + cfg: cfg, + logger: log, + } +} + +// VerifyToken validates the supplied session JWT and returns an Identity. +// +// This implements auth.AuthProvider.VerifyToken. +// +// The verification uses a two-tier strategy: +// 1. Fast Path: Local JWT verification using cached JWKS +// 2. Slow Path: Stytch API verification (fallback) +// +// Returns auth.ErrInvalidToken if the token is invalid. +// Returns auth.ErrTokenExpired if the token has expired. +// Returns auth.ErrEmailNotVerified if email is not verified. +func (a *StytchAuthAdapter) VerifyToken(ctx context.Context, token string) (*auth.Identity, error) { + if token == "" { + return nil, auth.ErrInvalidToken + } + + identity, err := a.tokenVerifier.Verify(ctx, token) + if err != nil { + a.logger.Debug("token verification failed", logger.Fields{ + "error": err.Error(), + }) + return nil, err + } + + a.logger.Debug("token verified successfully", logger.Fields{ + "user_id": identity.UserID, + "email": identity.Email, + "organization_id": identity.OrganizationID, + "roles_count": len(identity.Roles), + "permissions_count": len(identity.Permissions), + }) + + return identity, nil +} + +// Client returns the underlying Stytch API client. +// +// This is useful for advanced operations not covered by auth.AuthProvider, +// such as member management, organization settings, etc. +func (a *StytchAuthAdapter) Client() *b2bstytchapi.API { + return a.client +} + +// Config returns the Stytch configuration. +func (a *StytchAuthAdapter) Config() *Config { + return a.cfg +} + +// PolicyService returns the RBAC policy service. +// +// This is useful for permission queries outside the normal auth flow. +func (a *StytchAuthAdapter) PolicyService() *RBACPolicyService { + return a.policyService +} diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/config.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/config.go new file mode 100644 index 0000000..0301565 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/config.go @@ -0,0 +1,160 @@ +package stytch + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/viper" +) + +// Environment constants supported by the Stytch B2B API. +const ( + EnvTest = "test" + EnvLive = "live" +) + +// Config captures the runtime configuration for Stytch authentication. +// +// All configuration values can be set via environment variables with the +// STYTCH_ prefix (e.g., STYTCH_PROJECT_ID, STYTCH_SECRET). +type Config struct { + // ProjectID is the Stytch project identifier (required) + ProjectID string `mapstructure:"STYTCH_PROJECT_ID"` + + // Secret is the Stytch API secret (required) + Secret string `mapstructure:"STYTCH_SECRET"` + + // Env is the Stytch environment: "test" or "live" + Env string `mapstructure:"STYTCH_ENV"` + + // BaseURL is the Stytch API base URL (derived from Env if not set) + BaseURL string `mapstructure:"STYTCH_BASE_URL"` + + // CustomDomain is an optional custom domain for Stytch + CustomDomain string `mapstructure:"STYTCH_CUSTOM_DOMAIN"` + + // JWKSURL is the JWKS endpoint URL (derived from BaseURL if not set) + JWKSURL string `mapstructure:"STYTCH_JWKS_URL"` + + // SessionDurationMinutes is how long sessions should last + SessionDurationMinutes int32 `mapstructure:"STYTCH_SESSION_DURATION_MINUTES"` + + // DisableSessionVerification disables JWT signature verification (testing only!) + DisableSessionVerification bool `mapstructure:"STYTCH_DISABLE_SESSION_VERIFICATION"` + + // OwnerRoleSlug is the role slug for organization owners + OwnerRoleSlug string `mapstructure:"STYTCH_OWNER_ROLE_SLUG"` + + // InviteRedirectURL is where to redirect after invitation acceptance + InviteRedirectURL string `mapstructure:"STYTCH_INVITE_REDIRECT_URL"` + + // LoginRedirectURL is where to redirect after login + LoginRedirectURL string `mapstructure:"STYTCH_LOGIN_REDIRECT_URL"` + + // APITimeout is the timeout for Stytch API calls + APITimeout time.Duration `mapstructure:"STYTCH_API_TIMEOUT"` +} + +// LoadConfig loads the Stytch configuration from environment variables and app.env file. +// +// Configuration priority: +// 1. Environment variables (highest) +// 2. app.env file +// 3. Default values (lowest) +func LoadConfig() (*Config, error) { + v := viper.New() + v.SetConfigName("app") + v.SetConfigType("env") + v.AddConfigPath(".") + v.AutomaticEnv() + + // Set defaults + v.SetDefault("STYTCH_ENV", EnvTest) + v.SetDefault("STYTCH_SESSION_DURATION_MINUTES", 1440) // 24 hours + v.SetDefault("STYTCH_API_TIMEOUT", "15s") + v.SetDefault("STYTCH_DISABLE_SESSION_VERIFICATION", false) + + // Try to read config file (ignore if not found) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("failed to read config: %w", err) + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unable to decode stytch config: %w", err) + } + + // Normalize environment + cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env)) + if cfg.Env == "" { + cfg.Env = EnvTest + } + + // Validate required fields + if cfg.ProjectID == "" { + return nil, fmt.Errorf("stytch configuration invalid: STYTCH_PROJECT_ID is required") + } + if cfg.Secret == "" { + return nil, fmt.Errorf("stytch configuration invalid: STYTCH_SECRET is required") + } + + // Normalize timeout + if cfg.APITimeout <= 0 { + cfg.APITimeout = 15 * time.Second + } + + // Derive base URL if not set + if cfg.BaseURL == "" { + switch cfg.Env { + case EnvLive: + cfg.BaseURL = "https://api.stytch.com" + default: + cfg.BaseURL = "https://test.stytch.com" + } + } + + // Custom domain overrides base URL + if cfg.CustomDomain != "" { + cfg.BaseURL = fmt.Sprintf("https://%s", strings.TrimSuffix(cfg.CustomDomain, "/")) + } + + // Derive JWKS URL if not set + if cfg.JWKSURL == "" { + if cfg.CustomDomain != "" { + cfg.JWKSURL = fmt.Sprintf("https://%s/.well-known/jwks.json", strings.TrimSuffix(cfg.CustomDomain, "/")) + } else { + cfg.JWKSURL = fmt.Sprintf("%s/v1/b2b/sessions/jwks/%s", strings.TrimSuffix(cfg.BaseURL, "/"), cfg.ProjectID) + } + } + + return &cfg, nil +} + +// Validate checks that the configuration has all required fields. +func (c *Config) Validate() error { + if c.ProjectID == "" { + return fmt.Errorf("stytch configuration invalid: ProjectID is required") + } + if c.Secret == "" { + return fmt.Errorf("stytch configuration invalid: Secret is required") + } + return nil +} + +// NewConfigFromExisting creates a new Config from the existing stytch.Config type. +// This allows gradual migration from the old config type. +func NewConfigFromExisting(projectID, secret, env, baseURL, jwksURL string, sessionDurationMinutes int32, disableVerification bool, apiTimeout time.Duration) *Config { + return &Config{ + ProjectID: projectID, + Secret: secret, + Env: env, + BaseURL: baseURL, + JWKSURL: jwksURL, + SessionDurationMinutes: sessionDurationMinutes, + DisableSessionVerification: disableVerification, + APITimeout: apiTimeout, + } +} diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/jwks_cache.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/jwks_cache.go new file mode 100644 index 0000000..cc9f4c7 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/jwks_cache.go @@ -0,0 +1,229 @@ +package stytch + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" +) + +const ( + // Redis cache keys for JWKS + jwksCacheKeyPattern = "auth:stytch:jwks:key:%s" // Individual public key by kid + jwksCacheTTL = 24 * time.Hour // 24-hour cache +) + +// JWKSCache manages caching of JSON Web Key Sets from Stytch. +// +// It fetches JWKS from Stytch's endpoint and caches public keys in Redis. +// This enables local JWT verification without making Stytch API calls +// on every request (saving 300-500ms per request). +type JWKSCache struct { + jwksURL string + redis redis.Client + logger logger.Logger + httpClient *http.Client +} + +// JWKS represents the JSON Web Key Set structure from Stytch. +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// JWK represents a single JSON Web Key. +type JWK struct { + Kid string `json:"kid"` // Key ID + Kty string `json:"kty"` // Key type (RSA) + N string `json:"n"` // Modulus (base64url encoded) + E string `json:"e"` // Exponent (base64url encoded) + Alg string `json:"alg"` // Algorithm (RS256) + Use string `json:"use"` // Public key use (sig) +} + +// serializedPublicKey represents RSA public key components for Redis storage. +type serializedPublicKey struct { + N string `json:"n"` // Modulus (base64url encoded) + E string `json:"e"` // Exponent (base64url encoded) +} + +// NewJWKSCache creates a new JWKS cache manager. +func NewJWKSCache(jwksURL string, redisClient redis.Client, logger logger.Logger) *JWKSCache { + return &JWKSCache{ + jwksURL: jwksURL, + redis: redisClient, + logger: logger, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// GetPublicKey retrieves a public key by kid from cache or fetches from Stytch. +func (c *JWKSCache) GetPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + // Try to get from Redis cache first + cacheKey := fmt.Sprintf(jwksCacheKeyPattern, kid) + cached, err := c.redis.Get(ctx, cacheKey) + if err == nil && cached != "" { + var serialized serializedPublicKey + if err := json.Unmarshal([]byte(cached), &serialized); err == nil { + key, err := c.deserializePublicKey(&serialized) + if err == nil { + c.logger.Debug("public key fetched from Redis cache", logger.Fields{ + "kid": kid, + }) + return key, nil + } + c.logger.Warn("failed to deserialize cached public key", logger.Fields{ + "kid": kid, + "error": err.Error(), + }) + } + } + + // Cache miss - fetch JWKS from Stytch + c.logger.Info("fetching JWKS from Stytch", logger.Fields{ + "jwks_url": c.jwksURL, + "kid": kid, + }) + + jwks, err := c.fetchJWKS(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + + // Find the key with matching kid + for _, jwk := range jwks.Keys { + if jwk.Kid == kid { + publicKey, err := c.jwkToPublicKey(&jwk) + if err != nil { + return nil, fmt.Errorf("failed to convert JWK to public key: %w", err) + } + + // Cache the key + c.cachePublicKey(ctx, kid, &jwk) + + c.logger.Info("public key fetched and cached", logger.Fields{ + "kid": kid, + }) + + return publicKey, nil + } + } + + // Log available keys for debugging + availableKids := make([]string, 0, len(jwks.Keys)) + for _, jwk := range jwks.Keys { + availableKids = append(availableKids, jwk.Kid) + } + c.logger.Error("key not found in JWKS", logger.Fields{ + "kid": kid, + "available_kids": availableKids, + }) + + return nil, fmt.Errorf("key with ID %s not found in JWKS", kid) +} + +// fetchJWKS fetches the JWKS from Stytch's endpoint. +func (c *JWKSCache) fetchJWKS(ctx context.Context) (*JWKS, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.jwksURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create JWKS request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("JWKS HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + var jwks JWKS + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("failed to decode JWKS JSON: %w", err) + } + + c.logger.Debug("successfully fetched JWKS", logger.Fields{ + "keys_count": len(jwks.Keys), + }) + + return &jwks, nil +} + +// jwkToPublicKey converts a JWK to an RSA public key. +func (c *JWKSCache) jwkToPublicKey(jwk *JWK) (*rsa.PublicKey, error) { + // Decode modulus (n) + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, fmt.Errorf("failed to decode modulus: %w", err) + } + + // Decode exponent (e) + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("failed to decode exponent: %w", err) + } + + // Convert to RSA public key + n := new(big.Int).SetBytes(nBytes) + + // Convert exponent bytes to int + var e int + for i := 0; i < len(eBytes); i++ { + e = e<<8 + int(eBytes[i]) + } + + return &rsa.PublicKey{N: n, E: e}, nil +} + +// cachePublicKey stores a public key in Redis. +func (c *JWKSCache) cachePublicKey(ctx context.Context, kid string, jwk *JWK) { + serialized := &serializedPublicKey{N: jwk.N, E: jwk.E} + + data, err := json.Marshal(serialized) + if err != nil { + c.logger.Warn("failed to marshal public key for caching", logger.Fields{ + "kid": kid, + "error": err.Error(), + }) + return + } + + cacheKey := fmt.Sprintf(jwksCacheKeyPattern, kid) + if err := c.redis.Set(ctx, cacheKey, string(data), jwksCacheTTL); err != nil { + c.logger.Warn("failed to cache public key in Redis", logger.Fields{ + "kid": kid, + "error": err.Error(), + }) + } +} + +// deserializePublicKey converts serialized key components back to RSA public key. +func (c *JWKSCache) deserializePublicKey(serialized *serializedPublicKey) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(serialized.N) + if err != nil { + return nil, fmt.Errorf("failed to decode cached modulus: %w", err) + } + + eBytes, err := base64.RawURLEncoding.DecodeString(serialized.E) + if err != nil { + return nil, fmt.Errorf("failed to decode cached exponent: %w", err) + } + + n := new(big.Int).SetBytes(nBytes) + var e int + for i := 0; i < len(eBytes); i++ { + e = e<<8 + int(eBytes[i]) + } + + return &rsa.PublicKey{N: n, E: e}, nil +} diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/jwt_parser.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/jwt_parser.go new file mode 100644 index 0000000..1ae23eb --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/jwt_parser.go @@ -0,0 +1,78 @@ +package stytch + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// JWTParser provides JWT token parsing utilities. +// +// This parser can decode JWT tokens without verifying the signature, +// which is useful for extracting the key ID (kid) from the header +// before signature verification. +type JWTParser struct{} + +// NewJWTParser creates a new JWT parser. +func NewJWTParser() *JWTParser { + return &JWTParser{} +} + +// ParseWithoutVerification decodes a JWT token without verifying the signature. +// +// This extracts the header and claims from the token for inspection. +// The signature is NOT verified - use this only for extracting metadata +// like the key ID (kid) before performing proper verification. +// +// Returns: +// - header: JWT header containing algorithm (alg), key ID (kid), etc. +// - claims: JWT claims containing user information +// - err: Error if the token format is invalid +func (p *JWTParser) ParseWithoutVerification(token string) (header map[string]any, claims map[string]any, err error) { + // JWT format: header.payload.signature + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) + } + + // Decode header (first part) + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode JWT header: %w", err) + } + + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, nil, fmt.Errorf("failed to parse JWT header JSON: %w", err) + } + + // Decode payload (second part) + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode JWT payload: %w", err) + } + + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return nil, nil, fmt.Errorf("failed to parse JWT claims JSON: %w", err) + } + + return header, claims, nil +} + +// ExtractKeyID extracts the key ID (kid) from a JWT token header. +// +// This is a convenience method for getting the kid without needing +// to handle the full header map. +func (p *JWTParser) ExtractKeyID(token string) (string, error) { + header, _, err := p.ParseWithoutVerification(token) + if err != nil { + return "", err + } + + kid, ok := header["kid"].(string) + if !ok { + return "", fmt.Errorf("kid not found in JWT header") + } + + return kid, nil +} diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/mock_adapter.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/mock_adapter.go new file mode 100644 index 0000000..727936e --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/mock_adapter.go @@ -0,0 +1,85 @@ +package stytch + +import ( + "context" + "fmt" + "time" + + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +// MockAuthAdapter is a development-only auth adapter that bypasses Stytch. +// +// WARNING: This should NEVER be used in production. It accepts any token +// and returns a mock identity. It's only for local development when +// Stytch credentials are not configured. +type MockAuthAdapter struct { + logger logger.Logger +} + +// Ensure MockAuthAdapter implements auth.AuthProvider. +var _ auth.AuthProvider = (*MockAuthAdapter)(nil) + +// NewMockAuthAdapter creates a new mock auth adapter for development. +func NewMockAuthAdapter(log logger.Logger) *MockAuthAdapter { + return &MockAuthAdapter{ + logger: log, + } +} + +// VerifyToken accepts any token and returns a mock identity. +// This is for development only and should never be used in production. +func (m *MockAuthAdapter) VerifyToken(ctx context.Context, token string) (*auth.Identity, error) { + m.logger.Warn("Using mock auth adapter - accepting any token", map[string]any{ + "warning": "This is for development only. Configure real Stytch credentials for production.", + }) + + // Return a mock identity for development + return &auth.Identity{ + UserID: "mock-user-123", + Email: "dev@example.com", + EmailVerified: true, + OrganizationID: "mock-org-stytch-id", + Roles: []auth.Role{ + auth.RoleOwner, + auth.RoleAdmin, + }, + Permissions: []auth.Permission{ + auth.NewPermission("*", "*"), // Wildcard permission for development + }, + ExpiresAt: time.Now().Add(24 * time.Hour), + Raw: map[string]any{ + "mock": true, + "session_id": "mock-session-123", + "member_id": "mock-member-123", + }, + }, nil +} + +// GetRolePermissions returns empty permissions for the mock adapter. +func (m *MockAuthAdapter) GetRolePermissions(ctx context.Context, roleID string) ([]auth.Permission, error) { + m.logger.Debug("Mock adapter returning all permissions for role", map[string]any{ + "role_id": roleID, + }) + + // Return wildcard permission for development + return []auth.Permission{ + auth.NewPermission("*", "*"), + }, nil +} + +// ValidatePermission always returns true in mock mode. +func (m *MockAuthAdapter) ValidatePermission(ctx context.Context, identity *auth.Identity, resource, action string) error { + m.logger.Debug("Mock adapter allowing all permissions", map[string]any{ + "resource": resource, + "action": action, + "user_id": identity.UserID, + }) + return nil +} + +// RefreshSession is not implemented in mock mode. +func (m *MockAuthAdapter) RefreshSession(ctx context.Context, sessionToken string) (*auth.Identity, error) { + return nil, fmt.Errorf("mock adapter: RefreshSession not implemented") +} diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/rbac_policy.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/rbac_policy.go new file mode 100644 index 0000000..7cbc21a --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/rbac_policy.go @@ -0,0 +1,208 @@ +package stytch + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac" +) + +const ( + // Redis cache key for RBAC policy + rbacPolicyCacheKey = "auth:stytch:rbac:policy" + // Cache TTL matches Stytch SDK default (5 minutes) + rbacPolicyCacheTTL = 5 * time.Minute +) + +// RBACPolicyService fetches and caches the Stytch RBAC policy. +// +// It retrieves the role-permission mappings from Stytch and caches them +// in Redis to avoid API calls on every request. +type RBACPolicyService struct { + client *b2bstytchapi.API + redis redis.Client + logger logger.Logger +} + +// NewRBACPolicyService creates a new RBAC policy service. +func NewRBACPolicyService(client *b2bstytchapi.API, redisClient redis.Client, logger logger.Logger) *RBACPolicyService { + return &RBACPolicyService{ + client: client, + redis: redisClient, + logger: logger, + } +} + +// GetRolePermissions returns all permissions for a given role from Stytch RBAC policy. +// +// Returns permissions in "resource:action" format (e.g., "invoice:create"). +func (s *RBACPolicyService) GetRolePermissions(ctx context.Context, roleID string) ([]auth.Permission, error) { + // Normalize role ID + normalizedRoleID := normalizeRoleID(roleID) + + // Get policy from cache or Stytch + policy, err := s.getPolicy(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get RBAC policy: %w", err) + } + + // Find role in policy + for _, role := range policy.Roles { + if strings.EqualFold(role.RoleID, normalizedRoleID) { + return s.convertPermissions(role.Permissions, policy), nil + } + } + + // Role not found in policy + s.logger.Debug("role not found in Stytch RBAC policy", logger.Fields{ + "role_id": roleID, + "normalized": normalizedRoleID, + }) + return nil, nil +} + +// getPolicy fetches policy from Redis cache or Stytch API. +func (s *RBACPolicyService) getPolicy(ctx context.Context) (*rbac.Policy, error) { + // Try cache first + cached, err := s.redis.Get(ctx, rbacPolicyCacheKey) + if err == nil && cached != "" { + var policy rbac.Policy + if unmarshalErr := json.Unmarshal([]byte(cached), &policy); unmarshalErr == nil { + s.logger.Debug("RBAC policy fetched from cache", logger.Fields{}) + return &policy, nil + } else { + s.logger.Warn("failed to unmarshal cached RBAC policy", logger.Fields{ + "error": unmarshalErr.Error(), + }) + } + } + + // Cache miss - fetch from Stytch + policy, err := s.fetchPolicyFromStytch(ctx) + if err != nil { + return nil, err + } + + // Cache the policy + s.cachePolicy(ctx, policy) + + return policy, nil +} + +// fetchPolicyFromStytch fetches RBAC policy from Stytch API. +func (s *RBACPolicyService) fetchPolicyFromStytch(ctx context.Context) (*rbac.Policy, error) { + s.logger.Info("fetching RBAC policy from Stytch", logger.Fields{}) + + resp, err := s.client.RBAC.Policy(ctx, &rbac.PolicyParams{}) + if err != nil { + return nil, fmt.Errorf("stytch RBAC policy API call failed: %w", err) + } + + if resp.Policy == nil { + return nil, fmt.Errorf("stytch returned empty policy") + } + + s.logger.Info("successfully fetched RBAC policy", logger.Fields{ + "roles_count": len(resp.Policy.Roles), + "resources_count": len(resp.Policy.Resources), + }) + + return resp.Policy, nil +} + +// cachePolicy stores policy in Redis. +func (s *RBACPolicyService) cachePolicy(ctx context.Context, policy *rbac.Policy) { + data, err := json.Marshal(policy) + if err != nil { + s.logger.Warn("failed to marshal RBAC policy for caching", logger.Fields{ + "error": err.Error(), + }) + return + } + + if err := s.redis.Set(ctx, rbacPolicyCacheKey, string(data), rbacPolicyCacheTTL); err != nil { + s.logger.Warn("failed to cache RBAC policy in Redis", logger.Fields{ + "error": err.Error(), + }) + } +} + +// convertPermissions converts Stytch permission format to auth.Permission slice. +// +// Handles wildcard expansion: +// +// Input: []PolicyRolePermission{{ResourceID: "invoice", Actions: ["view", "create"]}} +// Output: [Permission("invoice:view"), Permission("invoice:create")] +func (s *RBACPolicyService) convertPermissions(permissions []rbac.PolicyRolePermission, policy *rbac.Policy) []auth.Permission { + if len(permissions) == 0 { + return nil + } + + result := make([]auth.Permission, 0, len(permissions)*5) + + for _, perm := range permissions { + resourceID := strings.ToLower(perm.ResourceID) + if resourceID == "" { + continue + } + + // Expand wildcard actions + expandedActions := s.expandWildcardActions(perm.ResourceID, perm.Actions, policy) + + // Convert each action to Permission + for _, action := range expandedActions { + if action == "" { + continue + } + result = append(result, auth.NewPermission(resourceID, strings.ToLower(action))) + } + } + + return result +} + +// expandWildcardActions expands wildcard (*) to all resource actions from policy. +func (s *RBACPolicyService) expandWildcardActions(resourceID string, actions []string, policy *rbac.Policy) []string { + // Check if actions contain wildcard + hasWildcard := false + for _, action := range actions { + if action == "*" { + hasWildcard = true + break + } + } + + if !hasWildcard { + return actions + } + + // Find resource definition to get all actions + for _, resource := range policy.Resources { + if strings.EqualFold(resource.ResourceID, resourceID) { + if len(resource.Actions) > 0 { + s.logger.Debug("expanded wildcard permission", logger.Fields{ + "resource": resourceID, + "actions_count": len(resource.Actions), + }) + return resource.Actions + } + } + } + + // Resource not found, keep wildcard as-is + return actions +} + +// normalizeRoleID removes common prefixes from role IDs. +func normalizeRoleID(roleID string) string { + roleID = strings.TrimSpace(roleID) + roleID = strings.TrimPrefix(roleID, "stytch_") + return roleID +} diff --git a/go-b2b-starter/src/pkg/auth/adapters/stytch/token_verifier.go b/go-b2b-starter/src/pkg/auth/adapters/stytch/token_verifier.go new file mode 100644 index 0000000..856c2e1 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/adapters/stytch/token_verifier.go @@ -0,0 +1,486 @@ +package stytch + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/sessions" + "github.com/stytchauth/stytch-go/v16/stytch/stytcherror" +) + +// TokenVerifier verifies Stytch session JWTs using a two-tier strategy: +// 1. Fast Path: Local JWT verification using cached JWKS (no API calls) +// 2. Slow Path: Stytch API verification (fallback when local fails) +// +// This optimization saves 300-500ms per request for the common case. +type TokenVerifier struct { + client *b2bstytchapi.API + jwksCache *JWKSCache + jwtParser *JWTParser + policyService *RBACPolicyService + cfg *Config + logger logger.Logger +} + +// NewTokenVerifier creates a new token verifier with two-tier verification. +func NewTokenVerifier( + client *b2bstytchapi.API, + jwksCache *JWKSCache, + policyService *RBACPolicyService, + cfg *Config, + logger logger.Logger, +) *TokenVerifier { + return &TokenVerifier{ + client: client, + jwksCache: jwksCache, + jwtParser: NewJWTParser(), + policyService: policyService, + cfg: cfg, + logger: logger, + } +} + +// internalClaims holds parsed JWT claims before conversion to auth.Identity. +type internalClaims struct { + Subject string + Email string + EmailVerified bool + OrganizationID string + Roles []string + Permissions []auth.Permission + IssuedAt time.Time + ExpiresAt time.Time + NotBefore time.Time + Issuer string + Audience []string + Raw map[string]any +} + +// Verify validates the token and returns an Identity. +// +// It tries local verification first (fast path), falling back to +// Stytch API verification if local verification fails. +func (v *TokenVerifier) Verify(ctx context.Context, token string) (*auth.Identity, error) { + // Check for test mode (DANGEROUS - only for development) + if v.cfg.DisableSessionVerification { + v.logger.Warn("session verification disabled - test mode only", logger.Fields{}) + return v.verifyWithoutSignature(ctx, token) + } + + // Fast path: Local JWT verification + identity, err := v.verifyLocally(ctx, token) + if err == nil { + v.logger.Debug("token verified locally (fast path)", logger.Fields{ + "user_id": identity.UserID, + "email": identity.Email, + }) + return identity, nil + } + + // Log fast path failure + v.logger.Warn("local verification failed, trying Stytch API", logger.Fields{ + "error": err.Error(), + }) + + // Slow path: Stytch API verification + return v.verifyViaAPI(ctx, token) +} + +// verifyLocally verifies the token using cached JWKS (fast path). +func (v *TokenVerifier) verifyLocally(ctx context.Context, token string) (*auth.Identity, error) { + // 1. Parse token header to get key ID + kid, err := v.jwtParser.ExtractKeyID(token) + if err != nil { + return nil, fmt.Errorf("failed to extract key ID: %w", err) + } + + // 2. Get public key from cache + publicKey, err := v.jwksCache.GetPublicKey(ctx, kid) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + // 3. Verify token signature + jwtToken, err := jwt.Parse(token, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return publicKey, nil + }) + if err != nil { + if strings.Contains(err.Error(), "expired") { + return nil, auth.ErrTokenExpired + } + return nil, auth.ErrInvalidToken + } + + if !jwtToken.Valid { + return nil, auth.ErrInvalidToken + } + + // 4. Parse claims from token + _, claimsMap, err := v.jwtParser.ParseWithoutVerification(token) + if err != nil { + return nil, auth.ErrInvalidToken + } + + claims := v.parseClaimsFromMap(claimsMap) + + // 5. Validate claims + if err := v.validateClaims(claims); err != nil { + return nil, err + } + + // 6. Derive permissions from roles + permissions := v.derivePermissions(ctx, claims.Roles) + + // 7. Convert to Identity + return &auth.Identity{ + UserID: claims.Subject, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + OrganizationID: claims.OrganizationID, + Roles: v.convertRoles(claims.Roles), + Permissions: permissions, + ExpiresAt: claims.ExpiresAt, + Raw: claims.Raw, + }, nil +} + +// verifyViaAPI verifies the token using Stytch API (slow path). +func (v *TokenVerifier) verifyViaAPI(ctx context.Context, token string) (*auth.Identity, error) { + // Add timeout + ctx, cancel := context.WithTimeout(ctx, v.cfg.APITimeout) + defer cancel() + + req := &sessions.AuthenticateParams{ + SessionJWT: token, + } + if v.cfg.SessionDurationMinutes > 0 { + req.SessionDurationMinutes = v.cfg.SessionDurationMinutes + } + + resp, err := v.client.Sessions.Authenticate(ctx, req) + if err != nil { + return nil, v.translateStytchError(err) + } + + member := resp.Member + session := resp.MemberSession + + // Check email verification + if !member.EmailAddressVerified { + return nil, auth.ErrEmailNotVerified + } + + // Derive permissions from roles + permissions := v.derivePermissions(ctx, session.Roles) + + // Build identity + identity := &auth.Identity{ + UserID: session.MemberID, + Email: member.EmailAddress, + EmailVerified: member.EmailAddressVerified, + OrganizationID: session.OrganizationID, + Roles: v.convertRoles(session.Roles), + Permissions: permissions, + ExpiresAt: timeValue(session.ExpiresAt), + Raw: map[string]any{ + "member_session": session, + "member": member, + "custom_claims": session.CustomClaims, + }, + } + + v.logger.Debug("token verified via Stytch API (slow path)", logger.Fields{ + "user_id": identity.UserID, + "email": identity.Email, + }) + + return identity, nil +} + +// verifyWithoutSignature parses the token without verifying signature (test mode only). +func (v *TokenVerifier) verifyWithoutSignature(ctx context.Context, token string) (*auth.Identity, error) { + _, claimsMap, err := v.jwtParser.ParseWithoutVerification(token) + if err != nil { + return nil, auth.ErrInvalidToken + } + + claims := v.parseClaimsFromMap(claimsMap) + permissions := v.derivePermissions(ctx, claims.Roles) + + return &auth.Identity{ + UserID: claims.Subject, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + OrganizationID: claims.OrganizationID, + Roles: v.convertRoles(claims.Roles), + Permissions: permissions, + ExpiresAt: claims.ExpiresAt, + Raw: claims.Raw, + }, nil +} + +// parseClaimsFromMap extracts claims from JWT payload. +func (v *TokenVerifier) parseClaimsFromMap(claimsMap map[string]any) *internalClaims { + claims := &internalClaims{ + Raw: claimsMap, + } + + // Extract subject + if sub, ok := claimsMap["sub"].(string); ok { + claims.Subject = sub + } + + // Extract email from Stytch session authentication factors + // Format: https://stytch.com/session.authentication_factors[].email_factor.email_address + if sessionObj, ok := claimsMap["https://stytch.com/session"].(map[string]any); ok { + if factors, ok := sessionObj["authentication_factors"].([]any); ok { + for _, factor := range factors { + if factorMap, ok := factor.(map[string]any); ok { + if emailFactor, ok := factorMap["email_factor"].(map[string]any); ok { + if emailAddr, ok := emailFactor["email_address"].(string); ok { + claims.Email = emailAddr + break + } + } + } + } + } + + // Extract roles from session + if rolesIface, ok := sessionObj["roles"].([]any); ok { + roles := make([]string, 0, len(rolesIface)) + for _, r := range rolesIface { + if roleStr, ok := r.(string); ok { + roles = append(roles, roleStr) + } + } + claims.Roles = roles + } + } + + // Fallback to standard email claim + if claims.Email == "" { + if email, ok := claimsMap["email"].(string); ok { + claims.Email = email + } + } + + // Extract email_verified + if verified, ok := claimsMap["email_verified"].(bool); ok { + claims.EmailVerified = verified + } else if verified, ok := claimsMap["https://stytch.com/email_verified"].(bool); ok { + claims.EmailVerified = verified + } + + // Extract organization ID from Stytch custom claim + // Format: https://stytch.com/organization.organization_id + if orgObj, ok := claimsMap["https://stytch.com/organization"].(map[string]any); ok { + if orgID, ok := orgObj["organization_id"].(string); ok { + claims.OrganizationID = orgID + } + } + // Fallback to standard claims + if claims.OrganizationID == "" { + if orgID, ok := claimsMap["organization_id"].(string); ok { + claims.OrganizationID = orgID + } else if orgID, ok := claimsMap["org_id"].(string); ok { + claims.OrganizationID = orgID + } + } + + // Parse timestamps + claims.IssuedAt = parseNumericTime(claimsMap["iat"]) + claims.ExpiresAt = parseNumericTime(claimsMap["exp"]) + claims.NotBefore = parseNumericTime(claimsMap["nbf"]) + + // Parse issuer + if iss, ok := claimsMap["iss"].(string); ok { + claims.Issuer = iss + } + + // Parse audience + claims.Audience = parseStringSlice(claimsMap["aud"]) + + // Fallback for roles + if len(claims.Roles) == 0 { + claims.Roles = parseStringSlice(claimsMap["roles"]) + } + + return claims +} + +// validateClaims validates security-critical claims. +func (v *TokenVerifier) validateClaims(claims *internalClaims) error { + now := time.Now() + + // Check expiry + if !claims.ExpiresAt.IsZero() && now.After(claims.ExpiresAt) { + return auth.ErrTokenExpired + } + + // Check not before + if !claims.NotBefore.IsZero() && now.Before(claims.NotBefore) { + return auth.ErrInvalidToken + } + + // Validate issuer (must be from Stytch) + if claims.Issuer != "" && !strings.Contains(strings.ToLower(claims.Issuer), "stytch.com") { + return auth.ErrIssuerMismatch + } + + // Check email verification if claim is present + if _, hasEmailVerified := claims.Raw["email_verified"]; hasEmailVerified { + if !claims.EmailVerified { + return auth.ErrEmailNotVerified + } + } + + return nil +} + +// derivePermissions derives permissions from roles. +// +// Fast path: Use hardcoded permissions for standard roles (no API calls). +// Slow path: Fetch from Stytch RBAC policy for custom roles. +func (v *TokenVerifier) derivePermissions(ctx context.Context, roles []string) []auth.Permission { + permSet := make(map[auth.Permission]struct{}) + + for _, roleStr := range roles { + if roleStr == "" { + continue + } + + // Normalize role + normalizedRole := auth.NormalizeRole(roleStr) + + // Fast path: Use hardcoded permissions for standard roles + if perms := auth.GetRolePermissions(normalizedRole); len(perms) > 0 { + for _, p := range perms { + permSet[p] = struct{}{} + } + continue + } + + // Slow path: Fetch from Stytch RBAC policy + if v.policyService != nil { + stytchPerms, err := v.policyService.GetRolePermissions(ctx, roleStr) + if err != nil { + v.logger.Warn("failed to get role permissions from Stytch", logger.Fields{ + "role": roleStr, + "error": err.Error(), + }) + continue + } + + for _, p := range stytchPerms { + permSet[p] = struct{}{} + } + } + } + + // Convert set to slice + if len(permSet) == 0 { + return nil + } + + permissions := make([]auth.Permission, 0, len(permSet)) + for p := range permSet { + permissions = append(permissions, p) + } + + return permissions +} + +// convertRoles converts string role names to auth.Role. +func (v *TokenVerifier) convertRoles(roles []string) []auth.Role { + if len(roles) == 0 { + return nil + } + + result := make([]auth.Role, 0, len(roles)) + seen := make(map[auth.Role]struct{}) + + for _, roleStr := range roles { + role := auth.NormalizeRole(roleStr) + if _, exists := seen[role]; !exists { + seen[role] = struct{}{} + result = append(result, role) + } + } + + return result +} + +// translateStytchError converts Stytch errors to auth errors. +func (v *TokenVerifier) translateStytchError(err error) error { + var stErr *stytcherror.Error + if errors.As(err, &stErr) { + if strings.Contains(strings.ToLower(string(stErr.ErrorType)), "expired") { + return auth.ErrTokenExpired + } + switch stErr.StatusCode { + case 401, 403, 404: + return auth.ErrInvalidToken + default: + if stErr.StatusCode >= 500 { + return fmt.Errorf("stytch service error: %w", err) + } + return auth.ErrInvalidToken + } + } + return fmt.Errorf("stytch error: %w", err) +} + +// Helper functions + +func parseStringSlice(value any) []string { + switch v := value.(type) { + case string: + if v == "" { + return nil + } + return []string{v} + case []string: + return v + case []any: + res := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + res = append(res, s) + } + } + return res + default: + return nil + } +} + +func parseNumericTime(value any) time.Time { + switch v := value.(type) { + case float64: + return time.Unix(int64(v), 0) + case int64: + return time.Unix(v, 0) + case int: + return time.Unix(int64(v), 0) + default: + return time.Time{} + } +} + +func timeValue(ts *time.Time) time.Time { + if ts == nil { + return time.Time{} + } + return ts.UTC() +} diff --git a/go-b2b-starter/src/pkg/auth/auth.go b/go-b2b-starter/src/pkg/auth/auth.go new file mode 100644 index 0000000..ab3ff9a --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/auth.go @@ -0,0 +1,199 @@ +// Package auth provides a unified authentication and authorization layer. +// +// This package abstracts away the authentication provider (Stytch, Auth0, etc.) +// and provides a clean interface for the rest of the application to use. +// +// # Architecture +// +// The auth package follows the adapter pattern: +// +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Application Layer │ +// │ (handlers, services - use auth.GetRequestContext, auth.RequirePermission) │ +// └─────────────────────────────────────────────────────────────────┘ +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ auth package │ +// │ • AuthProvider interface │ +// │ • Identity (provider-agnostic user representation) │ +// │ • RequestContext (resolved database IDs) │ +// │ • Middleware (RequireAuth, RequireOrganization, RequirePermission) │ +// │ • Type-safe context helpers │ +// └─────────────────────────────────────────────────────────────────┘ +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ auth/adapters/stytch │ +// │ (Stytch-specific implementation - hidden from app layer) │ +// └─────────────────────────────────────────────────────────────────┘ +// +// # Usage +// +// In routes: +// +// router.Use( +// auth.RequireAuth(authProvider), +// auth.RequireOrganization(orgRepo, accountRepo, logger), +// ) +// router.GET("/resource", auth.RequirePermission("resource", "view"), handler) +// +// In handlers: +// +// func Handler(c *gin.Context) { +// reqCtx := auth.GetRequestContext(c) +// orgID := reqCtx.OrganizationID // int32, type-safe +// accountID := reqCtx.AccountID // int32, type-safe +// } +// +// # Adding a New Auth Provider +// +// To add a new authentication provider (e.g., Auth0, Firebase): +// +// 1. Create a new adapter in auth/adapters// +// 2. Implement the AuthProvider interface +// 3. Map provider-specific claims to auth.Identity +// 4. Register the adapter in the DI container +// +// See auth/adapters/stytch/ for a reference implementation. +package auth + +import ( + "context" + "time" +) + +// AuthProvider abstracts the authentication provider (Stytch, Auth0, Firebase, etc.). +// +// Implementations must: +// - Verify the token signature and validity +// - Extract user identity information +// - Derive permissions from roles (if applicable) +// - Return appropriate errors for invalid/expired tokens +// +// The application layer should only depend on this interface, never on +// provider-specific implementations. +type AuthProvider interface { + // VerifyToken validates the provided token and returns the user's identity. + // + // The token is typically a JWT from the Authorization header. + // Returns ErrInvalidToken, ErrTokenExpired, or other auth errors on failure. + VerifyToken(ctx context.Context, token string) (*Identity, error) +} + +// Identity represents an authenticated user in a provider-agnostic way. +// +// This struct contains all the information needed by the application +// after a user has been authenticated. Provider-specific data is stored +// in the Raw field for debugging or advanced use cases. +type Identity struct { + // UserID is the unique identifier for the user from the auth provider. + // For Stytch, this is the member_id. For Auth0, this is the sub claim. + UserID string `json:"user_id"` + + // Email is the user's email address. + Email string `json:"email"` + + // EmailVerified indicates whether the email has been verified. + EmailVerified bool `json:"email_verified"` + + // OrganizationID is the auth provider's organization/tenant identifier. + // This is a string UUID from the provider, NOT the database int32 ID. + // Use RequestContext.OrganizationID for the database ID. + OrganizationID string `json:"organization_id"` + + // Roles contains the user's role assignments (e.g., "admin", "member"). + Roles []Role `json:"roles"` + + // Permissions contains the derived permissions in "resource:action" format. + // These are derived from roles by the auth provider or adapter. + Permissions []Permission `json:"permissions"` + + // ExpiresAt is when the token/session expires. + ExpiresAt time.Time `json:"expires_at"` + + // Raw contains provider-specific data for debugging or advanced use cases. + // This should NOT be used in normal application logic. + Raw map[string]any `json:"raw,omitempty"` +} + +// HasRole checks if the identity has a specific role. +func (i *Identity) HasRole(role Role) bool { + for _, r := range i.Roles { + if r == role { + return true + } + } + return false +} + +// HasPermission checks if the identity has a specific permission. +func (i *Identity) HasPermission(permission Permission) bool { + for _, p := range i.Permissions { + if p == permission { + return true + } + } + return false +} + +// HasResourcePermission checks if the identity has permission for a resource and action. +func (i *Identity) HasResourcePermission(resource, action string) bool { + return i.HasPermission(NewPermission(resource, action)) +} + +// RequestContext holds the resolved database IDs for the current request. +// +// This is set by the RequireOrganization middleware after looking up +// the organization and account in the database using the Identity's +// provider-specific IDs. +// +// Use auth.GetRequestContext(c) to retrieve this in handlers. +type RequestContext struct { + // Identity contains the authenticated user information from the auth provider. + Identity *Identity `json:"identity"` + + // OrganizationID is the database primary key (int32) for the organization. + // This is resolved from Identity.OrganizationID by the middleware. + OrganizationID int32 `json:"organization_id"` + + // AccountID is the database primary key (int32) for the user's account. + // This is resolved from Identity.Email by the middleware. + AccountID int32 `json:"account_id"` + + // ProviderOrgID preserves the original provider organization ID for reference. + // Use this when making calls back to the auth provider. + ProviderOrgID string `json:"provider_org_id,omitempty"` +} + +// OrganizationRepository defines the interface for looking up organizations. +// +// This is used by the RequireOrganization middleware to resolve +// the auth provider's organization ID to a database ID. +type OrganizationRepository interface { + // GetByProviderID looks up an organization by the auth provider's organization ID. + // Returns the organization with its database ID, or an error if not found. + GetByProviderID(ctx context.Context, providerOrgID string) (*Organization, error) +} + +// AccountRepository defines the interface for looking up accounts. +// +// This is used by the RequireOrganization middleware to resolve +// the user's email to a database account ID within an organization. +type AccountRepository interface { + // GetByEmail looks up an account by email within an organization. + // Returns the account with its database ID, or an error if not found. + GetByEmail(ctx context.Context, orgID int32, email string) (*Account, error) +} + +// Organization represents the minimal organization data needed by the auth package. +type Organization struct { + ID int32 `json:"id"` + Name string `json:"name"` +} + +// Account represents the minimal account data needed by the auth package. +type Account struct { + ID int32 `json:"id"` + Email string `json:"email"` +} diff --git a/go-b2b-starter/src/pkg/auth/cmd/init.go b/go-b2b-starter/src/pkg/auth/cmd/init.go new file mode 100644 index 0000000..44d6ef0 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/cmd/init.go @@ -0,0 +1,104 @@ +// Package cmd provides initialization for the auth module. +package cmd + +import ( + "fmt" + "strings" + + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/auth/adapters/stytch" + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" + "go.uber.org/dig" +) + +// Init initializes the auth module and registers core dependencies. +// +// This sets up: +// - stytch.Config +// - auth.AuthProvider (Stytch adapter) +// +// Note: The auth middleware is NOT initialized here because it requires +// organization/account resolvers from the organizations module. +// Use InitMiddleware after the organizations module is initialized. +// +// # Prerequisites +// +// The following modules must be initialized first: +// - redis (for caching) +// - logger +// +// # Usage +// +// // In main/cmd/init_mods.go: +// if err := authCmd.Init(container); err != nil { +// panic(err) +// } +func Init(container *dig.Container) error { + // Stytch configuration + if err := container.Provide(func() (*stytch.Config, error) { + return stytch.LoadConfig() + }); err != nil { + return fmt.Errorf("failed to provide stytch config: %w", err) + } + + // Stytch Auth Adapter (implements auth.AuthProvider) + if err := container.Provide(func( + cfg *stytch.Config, + redisClient redis.Client, + log logger.Logger, + ) (auth.AuthProvider, error) { + // Check for placeholder credentials + if isPlaceholderCredentials(cfg) { + log.Warn("Stytch credentials are placeholders - using development mode", map[string]any{ + "project_id": cfg.ProjectID, + "message": "Update STYTCH_PROJECT_ID and STYTCH_SECRET in app.env with real credentials", + }) + return stytch.NewMockAuthAdapter(log), nil + } + + adapter, err := stytch.NewStytchAuthAdapter(cfg, redisClient, log) + if err != nil { + return nil, fmt.Errorf("failed to create stytch adapter: %w", err) + } + return adapter, nil + }); err != nil { + return fmt.Errorf("failed to provide auth provider: %w", err) + } + + return nil +} + +// InitMiddleware initializes the auth middleware with resolvers. +// +// This must be called after the organizations module is initialized, +// as it depends on organization and account repositories. +// +// # Prerequisites +// +// The following must be available in the container: +// - auth.AuthProvider (from Init) +// - auth.OrganizationResolver +// - auth.AccountResolver +// - serverDomain.Server (for registering named middlewares) +// +// # Usage +// +// // After organizations module init: +// if err := authCmd.InitMiddleware(container); err != nil { +// panic(err) +// } +func InitMiddleware(container *dig.Container) error { + if err := auth.SetupMiddleware(container); err != nil { + return fmt.Errorf("failed to setup auth middleware: %w", err) + } + return nil +} + +// isPlaceholderCredentials checks if the Stytch credentials are placeholder values. +func isPlaceholderCredentials(cfg *stytch.Config) bool { + return strings.Contains(cfg.ProjectID, "REPLACE") || + strings.Contains(cfg.Secret, "REPLACE") || + cfg.ProjectID == "" || + cfg.Secret == "" +} diff --git a/go-b2b-starter/src/pkg/auth/context.go b/go-b2b-starter/src/pkg/auth/context.go new file mode 100644 index 0000000..800472f --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/context.go @@ -0,0 +1,168 @@ +package auth + +import ( + "context" + + "github.com/gin-gonic/gin" +) + +// Context keys for storing auth data. +// Using unexported type to prevent collisions with other packages. +type contextKey string + +const ( + // identityKey is the context key for storing the authenticated Identity. + identityKey contextKey = "auth_identity" + + // requestContextKey is the context key for storing the RequestContext. + requestContextKey contextKey = "auth_request_context" +) + +// SetIdentity stores the Identity in the Gin context. +// +// This is called by the RequireAuth middleware after successful authentication. +// Application code should not call this directly. +func SetIdentity(c *gin.Context, identity *Identity) { + c.Set(string(identityKey), identity) +} + +// GetIdentity retrieves the Identity from the Gin context. +// +// Returns nil if no identity is set (user not authenticated). +// Use MustGetIdentity if you expect authentication middleware to have run. +// +// Example: +// +// identity := auth.GetIdentity(c) +// if identity == nil { +// // Handle unauthenticated request +// } +func GetIdentity(c *gin.Context) *Identity { + if val, exists := c.Get(string(identityKey)); exists { + if identity, ok := val.(*Identity); ok { + return identity + } + } + return nil +} + +// MustGetIdentity retrieves the Identity from the Gin context. +// +// Panics if no identity is set. Only use this after RequireAuth middleware. +// For handlers where authentication is optional, use GetIdentity instead. +func MustGetIdentity(c *gin.Context) *Identity { + identity := GetIdentity(c) + if identity == nil { + panic("auth: MustGetIdentity called without Identity in context - ensure RequireAuth middleware is applied") + } + return identity +} + +// SetRequestContext stores the RequestContext in the Gin context. +// +// This is called by the RequireOrganization middleware after resolving +// the database IDs. Application code should not call this directly. +func SetRequestContext(c *gin.Context, reqCtx *RequestContext) { + c.Set(string(requestContextKey), reqCtx) +} + +// GetRequestContext retrieves the RequestContext from the Gin context. +// +// Returns nil if no request context is set. +// Use MustGetRequestContext if you expect the organization middleware to have run. +// +// Example: +// +// reqCtx := auth.GetRequestContext(c) +// if reqCtx == nil { +// // Handle request without organization context +// } +// orgID := reqCtx.OrganizationID // int32, type-safe +func GetRequestContext(c *gin.Context) *RequestContext { + if val, exists := c.Get(string(requestContextKey)); exists { + if reqCtx, ok := val.(*RequestContext); ok { + return reqCtx + } + } + return nil +} + +// MustGetRequestContext retrieves the RequestContext from the Gin context. +// +// Panics if no request context is set. Only use this after RequireOrganization middleware. +// For handlers where organization context is optional, use GetRequestContext instead. +// +// Example: +// +// reqCtx := auth.MustGetRequestContext(c) +// orgID := reqCtx.OrganizationID +// accountID := reqCtx.AccountID +func MustGetRequestContext(c *gin.Context) *RequestContext { + reqCtx := GetRequestContext(c) + if reqCtx == nil { + panic("auth: MustGetRequestContext called without RequestContext in context - ensure RequireOrganization middleware is applied") + } + return reqCtx +} + +// GetOrganizationID is a convenience function to get the database organization ID. +// +// Returns 0 if no request context is set. +// Use MustGetRequestContext().OrganizationID if you expect the middleware to have run. +func GetOrganizationID(c *gin.Context) int32 { + if reqCtx := GetRequestContext(c); reqCtx != nil { + return reqCtx.OrganizationID + } + return 0 +} + +// GetAccountID is a convenience function to get the database account ID. +// +// Returns 0 if no request context is set. +// Use MustGetRequestContext().AccountID if you expect the middleware to have run. +func GetAccountID(c *gin.Context) int32 { + if reqCtx := GetRequestContext(c); reqCtx != nil { + return reqCtx.AccountID + } + return 0 +} + +// WithIdentity adds the Identity to a context.Context. +// +// This is useful for passing auth context through service layers +// that don't use Gin context directly. +func WithIdentity(ctx context.Context, identity *Identity) context.Context { + return context.WithValue(ctx, identityKey, identity) +} + +// IdentityFromContext retrieves the Identity from a context.Context. +// +// Returns nil if no identity is set. +func IdentityFromContext(ctx context.Context) *Identity { + if val := ctx.Value(identityKey); val != nil { + if identity, ok := val.(*Identity); ok { + return identity + } + } + return nil +} + +// WithRequestContext adds the RequestContext to a context.Context. +// +// This is useful for passing auth context through service layers +// that don't use Gin context directly. +func WithRequestContext(ctx context.Context, reqCtx *RequestContext) context.Context { + return context.WithValue(ctx, requestContextKey, reqCtx) +} + +// RequestContextFromContext retrieves the RequestContext from a context.Context. +// +// Returns nil if no request context is set. +func RequestContextFromContext(ctx context.Context) *RequestContext { + if val := ctx.Value(requestContextKey); val != nil { + if reqCtx, ok := val.(*RequestContext); ok { + return reqCtx + } + } + return nil +} diff --git a/go-b2b-starter/src/pkg/auth/errors.go b/go-b2b-starter/src/pkg/auth/errors.go new file mode 100644 index 0000000..ca9bcf0 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/errors.go @@ -0,0 +1,90 @@ +package auth + +import "errors" + +// Authentication and authorization errors. +// +// These errors are returned by the auth package and can be checked +// by application code to handle specific error cases. +var ( + // ErrUnauthorized is returned when authentication is required but not provided. + // HTTP status: 401 Unauthorized + ErrUnauthorized = errors.New("authentication required") + + // ErrInvalidToken is returned when the provided token is malformed or invalid. + // HTTP status: 401 Unauthorized + ErrInvalidToken = errors.New("invalid token") + + // ErrTokenExpired is returned when the token has expired. + // HTTP status: 401 Unauthorized + ErrTokenExpired = errors.New("token expired") + + // ErrEmailNotVerified is returned when the user's email is not verified. + // HTTP status: 403 Forbidden + ErrEmailNotVerified = errors.New("email not verified") + + // ErrForbidden is returned when the user lacks required permissions. + // HTTP status: 403 Forbidden + ErrForbidden = errors.New("insufficient permissions") + + // ErrOrganizationNotFound is returned when the organization cannot be found. + // This typically means the organization in the token doesn't exist in our database. + // HTTP status: 403 Forbidden + ErrOrganizationNotFound = errors.New("organization not found") + + // ErrAccountNotFound is returned when the user's account cannot be found. + // This typically means the user exists in the auth provider but not in our database. + // HTTP status: 403 Forbidden + ErrAccountNotFound = errors.New("account not found") + + // ErrMissingOrganization is returned when the token doesn't contain an organization ID. + // HTTP status: 403 Forbidden + ErrMissingOrganization = errors.New("no organization in token") + + // ErrMissingEmail is returned when the token doesn't contain an email. + // HTTP status: 403 Forbidden + ErrMissingEmail = errors.New("no email in token") + + // ErrAudienceMismatch is returned when the token audience doesn't match. + // HTTP status: 401 Unauthorized + ErrAudienceMismatch = errors.New("token audience mismatch") + + // ErrIssuerMismatch is returned when the token issuer doesn't match. + // HTTP status: 401 Unauthorized + ErrIssuerMismatch = errors.New("token issuer mismatch") +) + +// IsAuthError returns true if the error is an authentication error (401). +func IsAuthError(err error) bool { + return errors.Is(err, ErrUnauthorized) || + errors.Is(err, ErrInvalidToken) || + errors.Is(err, ErrTokenExpired) || + errors.Is(err, ErrAudienceMismatch) || + errors.Is(err, ErrIssuerMismatch) +} + +// IsForbiddenError returns true if the error is an authorization error (403). +func IsForbiddenError(err error) bool { + return errors.Is(err, ErrForbidden) || + errors.Is(err, ErrEmailNotVerified) || + errors.Is(err, ErrOrganizationNotFound) || + errors.Is(err, ErrAccountNotFound) || + errors.Is(err, ErrMissingOrganization) || + errors.Is(err, ErrMissingEmail) +} + +// HTTPStatusCode returns the appropriate HTTP status code for an auth error. +// +// Returns: +// - 401 for authentication errors +// - 403 for authorization errors +// - 500 for unknown errors +func HTTPStatusCode(err error) int { + if IsAuthError(err) { + return 401 + } + if IsForbiddenError(err) { + return 403 + } + return 500 +} diff --git a/go-b2b-starter/src/pkg/auth/go.mod b/go-b2b-starter/src/pkg/auth/go.mod new file mode 100644 index 0000000..30c19ec --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/go.mod @@ -0,0 +1,67 @@ +module github.com/moasq/go-b2b-starter/pkg/auth + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.10.1 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + github.com/moasq/go-b2b-starter/pkg/redis v0.0.0 + github.com/spf13/viper v1.19.0 + github.com/stytchauth/stytch-go/v16 v16.40.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/MicahParks/keyfunc/v2 v2.0.1 // indirect + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/rs/zerolog v1.33.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../logger + +replace github.com/moasq/go-b2b-starter/pkg/redis => ../redis diff --git a/go-b2b-starter/src/pkg/auth/go.sum b/go-b2b-starter/src/pkg/auth/go.sum new file mode 100644 index 0000000..7d74b05 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/go.sum @@ -0,0 +1,144 @@ +github.com/MicahParks/keyfunc/v2 v2.0.1 h1:6FrNNvG/20gEKkjxV+5anrkq0VOF666G2zUn8lk8dgk= +github.com/MicahParks/keyfunc/v2 v2.0.1/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stytchauth/stytch-go/v16 v16.40.0 h1:xT9QyPtWi4j6rJPhkROfGCDzDeVBqvS2KQge1dv8rfs= +github.com/stytchauth/stytch-go/v16 v16.40.0/go.mod h1:b2Dj63HNogYxAwJz7l9S7aJ8k3xyFYrMOtkzdTme+tk= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/pkg/auth/middleware.go b/go-b2b-starter/src/pkg/auth/middleware.go new file mode 100644 index 0000000..72baf22 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/middleware.go @@ -0,0 +1,482 @@ +package auth + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// OrganizationResolver looks up organization by provider org ID. +// +// This interface decouples auth middleware from the organizations domain. +// Implement this interface by wrapping your organization repository. +type OrganizationResolver interface { + // ResolveByProviderID looks up organization by the auth provider's org ID (e.g., Stytch org UUID). + // Returns the database organization ID (int32) or error if not found. + ResolveByProviderID(ctx context.Context, providerOrgID string) (int32, error) +} + +// AccountResolver looks up account by email within an organization. +// +// This interface decouples auth middleware from the organizations domain. +// Implement this interface by wrapping your account repository. +type AccountResolver interface { + // ResolveByEmail looks up account by email within the given organization. + // Returns the database account ID (int32) or error if not found. + ResolveByEmail(ctx context.Context, orgID int32, email string) (int32, error) +} + +// MiddlewareConfig configures the auth middleware behavior. +type MiddlewareConfig struct { + // ErrorHandler is called when an error occurs. If nil, default JSON responses are used. + ErrorHandler func(c *gin.Context, statusCode int, message string, err error) +} + +// DefaultMiddlewareConfig returns the default middleware configuration. +func DefaultMiddlewareConfig() *MiddlewareConfig { + return &MiddlewareConfig{ + ErrorHandler: defaultErrorHandler, + } +} + +// defaultErrorHandler sends JSON error responses. +func defaultErrorHandler(c *gin.Context, statusCode int, message string, err error) { + response := gin.H{ + "error": message, + "success": false, + } + if err != nil && statusCode >= 500 { + response["detail"] = err.Error() + } + c.JSON(statusCode, response) +} + +// Middleware provides auth middleware functions. +// +// Use NewMiddleware to create an instance with proper dependencies. +type Middleware struct { + provider AuthProvider + orgResolver OrganizationResolver + accResolver AccountResolver + config *MiddlewareConfig +} + +// NewMiddleware creates a new auth middleware instance. +// +// Parameters: +// - provider: The auth provider for token verification (e.g., Stytch adapter) +// - orgResolver: Resolves org by provider ID (optional, required for RequireOrganization) +// - accResolver: Resolves account by email (optional, required for RequireOrganization) +// - config: Middleware configuration (optional, uses defaults if nil) +func NewMiddleware( + provider AuthProvider, + orgResolver OrganizationResolver, + accResolver AccountResolver, + config *MiddlewareConfig, +) *Middleware { + if config == nil { + config = DefaultMiddlewareConfig() + } + return &Middleware{ + provider: provider, + orgResolver: orgResolver, + accResolver: accResolver, + config: config, + } +} + +// RequireAuth returns middleware that verifies the JWT token. +// +// This middleware: +// 1. Extracts Bearer token from Authorization header +// 2. Verifies token using the AuthProvider +// 3. Sets Identity in Gin context (accessible via GetIdentity) +// +// Must be called before any middleware that requires authentication. +// +// Usage: +// +// router.Use(authMiddleware.RequireAuth()) +func (m *Middleware) RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip OPTIONS requests (CORS preflight) + if c.Request.Method == "OPTIONS" { + c.Next() + return + } + + // Extract Bearer token + token, err := extractBearerToken(c) + if err != nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "missing or invalid authorization header", err) + c.Abort() + return + } + + // Verify token + identity, err := m.provider.VerifyToken(c.Request.Context(), token) + if err != nil { + statusCode := HTTPStatusCode(err) + message := errorMessage(err) + m.config.ErrorHandler(c, statusCode, message, err) + c.Abort() + return + } + + // Set identity in context + SetIdentity(c, identity) + + c.Next() + } +} + +// RequireOrganization returns middleware that resolves org/account from Identity. +// +// This middleware: +// 1. Gets Identity from context (requires RequireAuth to run first) +// 2. Looks up organization by provider org ID +// 3. Looks up account by email within organization +// 4. Sets RequestContext in Gin context (accessible via GetRequestContext) +// +// Must be called after RequireAuth middleware. +// +// Usage: +// +// router.Use(authMiddleware.RequireAuth()) +// router.Use(authMiddleware.RequireOrganization()) +func (m *Middleware) RequireOrganization() gin.HandlerFunc { + return func(c *gin.Context) { + // Get identity from context + identity := GetIdentity(c) + if identity == nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + // Validate required fields + if identity.OrganizationID == "" { + m.config.ErrorHandler(c, http.StatusForbidden, "no organization in token", ErrMissingOrganization) + c.Abort() + return + } + + if identity.Email == "" { + m.config.ErrorHandler(c, http.StatusForbidden, "no email in token", ErrMissingEmail) + c.Abort() + return + } + + // Resolve organization + orgID, err := m.orgResolver.ResolveByProviderID(c.Request.Context(), identity.OrganizationID) + if err != nil { + m.config.ErrorHandler(c, http.StatusForbidden, "organization not found", err) + c.Abort() + return + } + + // Resolve account + accountID, err := m.accResolver.ResolveByEmail(c.Request.Context(), orgID, identity.Email) + if err != nil { + m.config.ErrorHandler(c, http.StatusForbidden, "account not found", err) + c.Abort() + return + } + + // Set request context + reqCtx := &RequestContext{ + Identity: identity, + OrganizationID: orgID, + AccountID: accountID, + ProviderOrgID: identity.OrganizationID, + } + SetRequestContext(c, reqCtx) + + // Also set individual values for backward compatibility + c.Set("organization_id", orgID) + c.Set("account_id", accountID) + c.Set("stytch_org_id", identity.OrganizationID) + + c.Next() + } +} + +// RequirePermission returns middleware that checks for a specific permission. +// +// This middleware: +// 1. Gets Identity from context (requires RequireAuth to run first) +// 2. Checks if user has the required permission +// 3. Falls back to role-based permissions if not found in Identity +// +// Must be called after RequireAuth middleware. +// +// Usage: +// +// router.GET("/invoices", authMiddleware.RequirePermission("invoice", "view"), handler) +// router.POST("/invoices", authMiddleware.RequirePermission("invoice", "create"), handler) +func (m *Middleware) RequirePermission(resource, action string) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + if !hasPermission(identity, resource, action) { + m.config.ErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) + c.Abort() + return + } + + c.Next() + } +} + +// RequireAnyPermission returns middleware that checks for any of the given permissions. +// +// This middleware succeeds if the user has at least one of the specified permissions. +// Useful when multiple permissions can grant access to the same resource. +// +// Must be called after RequireAuth middleware. +// +// Usage: +// +// router.GET("/reports", authMiddleware.RequireAnyPermission( +// auth.PermPaymentOptSchedule, +// auth.PermPaymentOptExport, +// ), handler) +func (m *Middleware) RequireAnyPermission(permissions ...Permission) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + for _, perm := range permissions { + if hasPermission(identity, perm.Resource(), perm.Action()) { + c.Next() + return + } + } + + m.config.ErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) + c.Abort() + } +} + +// RequireAllPermissions returns middleware that checks for all given permissions. +// +// This middleware succeeds only if the user has all of the specified permissions. +// +// Must be called after RequireAuth middleware. +// +// Usage: +// +// router.DELETE("/org", authMiddleware.RequireAllPermissions( +// auth.NewPermission("org", "view"), +// auth.NewPermission("org", "manage"), +// ), handler) +func (m *Middleware) RequireAllPermissions(permissions ...Permission) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + for _, perm := range permissions { + if !hasPermission(identity, perm.Resource(), perm.Action()) { + m.config.ErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) + c.Abort() + return + } + } + + c.Next() + } +} + +// RequireRole returns middleware that checks for a specific role. +// +// Must be called after RequireAuth middleware. +// +// Usage: +// +// router.POST("/admin", authMiddleware.RequireRole(auth.RoleAdmin), handler) +func (m *Middleware) RequireRole(role Role) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + if !hasRole(identity, role) { + m.config.ErrorHandler(c, http.StatusForbidden, "insufficient role", nil) + c.Abort() + return + } + + c.Next() + } +} + +// RequireAnyRole returns middleware that checks for any of the given roles. +// +// Must be called after RequireAuth middleware. +// +// Usage: +// +// router.GET("/dashboard", authMiddleware.RequireAnyRole(auth.RoleAdmin, auth.RoleApprover), handler) +func (m *Middleware) RequireAnyRole(roles ...Role) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + m.config.ErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + for _, role := range roles { + if hasRole(identity, role) { + c.Next() + return + } + } + + m.config.ErrorHandler(c, http.StatusForbidden, "insufficient role", nil) + c.Abort() + } +} + +// Helper functions + +// extractBearerToken extracts the JWT from Authorization header. +func extractBearerToken(c *gin.Context) (string, error) { + header := c.GetHeader("Authorization") + if header == "" { + return "", ErrUnauthorized + } + + fields := strings.Fields(header) + if len(fields) != 2 || !strings.EqualFold(fields[0], "bearer") { + return "", ErrInvalidToken + } + + return fields[1], nil +} + +// hasPermission checks if identity has the required permission. +func hasPermission(identity *Identity, resource, action string) bool { + perm := NewPermission(resource, action) + + // Check explicit permissions in identity + for _, p := range identity.Permissions { + if p == perm || p.MatchesWithWildcard(perm) { + return true + } + } + + // Fallback: Check role-based permissions + for _, role := range identity.Roles { + if HasRolePermission(role, resource, action) { + return true + } + } + + return false +} + +// hasRole checks if identity has the required role. +func hasRole(identity *Identity, role Role) bool { + normalized := NormalizeRole(string(role)) + for _, r := range identity.Roles { + if NormalizeRole(string(r)) == normalized { + return true + } + } + return false +} + +// errorMessage returns a user-friendly message for auth errors. +func errorMessage(err error) string { + switch err { + case ErrTokenExpired: + return "token expired" + case ErrInvalidToken: + return "invalid token" + case ErrEmailNotVerified: + return "email not verified" + case ErrAudienceMismatch: + return "invalid token audience" + case ErrIssuerMismatch: + return "invalid token issuer" + default: + return "authentication failed" + } +} + +// Standalone middleware functions for simpler usage + +// RequirePermissionFunc returns a standalone middleware that checks permissions. +// +// This is a convenience function that doesn't require a Middleware instance. +// It reads Identity directly from Gin context. +// +// Usage: +// +// router.GET("/invoices", auth.RequirePermissionFunc("invoice", "view"), handler) +func RequirePermissionFunc(resource, action string) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + defaultErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + if !hasPermission(identity, resource, action) { + defaultErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) + c.Abort() + return + } + + c.Next() + } +} + +// RequireAnyPermissionFunc returns a standalone middleware that checks for any permission. +// +// Usage: +// +// router.GET("/reports", auth.RequireAnyPermissionFunc( +// auth.PermPaymentOptSchedule, +// auth.PermPaymentOptExport, +// ), handler) +func RequireAnyPermissionFunc(permissions ...Permission) gin.HandlerFunc { + return func(c *gin.Context) { + identity := GetIdentity(c) + if identity == nil { + defaultErrorHandler(c, http.StatusUnauthorized, "authentication required", nil) + c.Abort() + return + } + + for _, perm := range permissions { + if hasPermission(identity, perm.Resource(), perm.Action()) { + c.Next() + return + } + } + + defaultErrorHandler(c, http.StatusForbidden, "insufficient permissions", nil) + c.Abort() + } +} diff --git a/go-b2b-starter/src/pkg/auth/permissions.go b/go-b2b-starter/src/pkg/auth/permissions.go new file mode 100644 index 0000000..95714b1 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/permissions.go @@ -0,0 +1,192 @@ +package auth + +import ( + "fmt" + "strings" +) + +// Permission represents an authorization permission in "resource:action" format. +// +// Permissions follow the pattern "resource:action" where: +// - resource: The entity being accessed (e.g., "invoice", "org", "approval") +// - action: The operation being performed (e.g., "view", "create", "manage") +// +// # Examples +// +// auth.NewPermission("invoice", "create") // "invoice:create" +// auth.NewPermission("org", "manage") // "org:manage" +// auth.NewPermission("approval", "approve") // "approval:approve" +// +// # Adding New Permissions +// +// To add a new permission: +// 1. Add it to the appropriate role in DefaultRolePermissions (roles.go) +// 2. Configure it in your auth provider (e.g., Stytch RBAC policy) +// 3. Use RequirePermission("resource", "action") in your routes +type Permission string + +// NewPermission creates a permission from resource and action. +// +// Example: +// +// perm := auth.NewPermission("invoice", "create") +// // perm = "invoice:create" +func NewPermission(resource, action string) Permission { + return Permission(fmt.Sprintf("%s:%s", resource, action)) +} + +// String returns the string representation of the permission. +func (p Permission) String() string { + return string(p) +} + +// Resource returns the resource part of the permission. +// +// Example: +// +// perm := auth.NewPermission("invoice", "create") +// perm.Resource() // "invoice" +func (p Permission) Resource() string { + parts := strings.SplitN(string(p), ":", 2) + if len(parts) > 0 { + return parts[0] + } + return "" +} + +// Action returns the action part of the permission. +// +// Example: +// +// perm := auth.NewPermission("invoice", "create") +// perm.Action() // "create" +func (p Permission) Action() string { + parts := strings.SplitN(string(p), ":", 2) + if len(parts) > 1 { + return parts[1] + } + return "" +} + +// IsValid checks if the permission has both resource and action parts. +func (p Permission) IsValid() bool { + parts := strings.SplitN(string(p), ":", 2) + return len(parts) == 2 && parts[0] != "" && parts[1] != "" +} + +// Matches checks if this permission matches another permission. +// +// This is a simple equality check. For wildcard matching, +// use MatchesWithWildcard. +func (p Permission) Matches(other Permission) bool { + return p == other +} + +// MatchesWithWildcard checks if this permission matches another, +// supporting wildcards. +// +// Wildcards: +// - "*:*" matches any permission +// - "resource:*" matches any action on that resource +// - "*:action" matches that action on any resource +// +// Example: +// +// auth.Permission("invoice:*").MatchesWithWildcard("invoice:create") // true +// auth.Permission("*:view").MatchesWithWildcard("invoice:view") // true +func (p Permission) MatchesWithWildcard(other Permission) bool { + if p == other { + return true + } + + myResource := p.Resource() + myAction := p.Action() + otherResource := other.Resource() + otherAction := other.Action() + + // Check for wildcards + resourceMatch := myResource == "*" || myResource == otherResource + actionMatch := myAction == "*" || myAction == otherAction + + return resourceMatch && actionMatch +} + +// PermissionSet is a helper for checking multiple permissions efficiently. +type PermissionSet map[Permission]struct{} + +// NewPermissionSet creates a permission set from a slice of permissions. +func NewPermissionSet(permissions []Permission) PermissionSet { + set := make(PermissionSet, len(permissions)) + for _, p := range permissions { + set[p] = struct{}{} + } + return set +} + +// NewPermissionSetFromStrings creates a permission set from string permissions. +func NewPermissionSetFromStrings(permissions []string) PermissionSet { + set := make(PermissionSet, len(permissions)) + for _, p := range permissions { + set[Permission(p)] = struct{}{} + } + return set +} + +// Contains checks if the set contains a permission. +func (ps PermissionSet) Contains(permission Permission) bool { + _, exists := ps[permission] + return exists +} + +// ContainsResourceAction checks if the set contains a resource:action permission. +func (ps PermissionSet) ContainsResourceAction(resource, action string) bool { + return ps.Contains(NewPermission(resource, action)) +} + +// ContainsAny checks if the set contains any of the given permissions. +func (ps PermissionSet) ContainsAny(permissions ...Permission) bool { + for _, p := range permissions { + if ps.Contains(p) { + return true + } + } + return false +} + +// ContainsAll checks if the set contains all of the given permissions. +func (ps PermissionSet) ContainsAll(permissions ...Permission) bool { + for _, p := range permissions { + if !ps.Contains(p) { + return false + } + } + return true +} + +// ToSlice converts the permission set to a slice. +func (ps PermissionSet) ToSlice() []Permission { + result := make([]Permission, 0, len(ps)) + for p := range ps { + result = append(result, p) + } + return result +} + +// PermissionsToStrings converts a slice of Permission to a slice of strings. +func PermissionsToStrings(permissions []Permission) []string { + result := make([]string, len(permissions)) + for i, p := range permissions { + result[i] = string(p) + } + return result +} + +// StringsToPermissions converts a slice of strings to a slice of Permission. +func StringsToPermissions(permissions []string) []Permission { + result := make([]Permission, len(permissions)) + for i, p := range permissions { + result[i] = Permission(p) + } + return result +} + diff --git a/go-b2b-starter/src/pkg/auth/provider.go b/go-b2b-starter/src/pkg/auth/provider.go new file mode 100644 index 0000000..fa75ed6 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/provider.go @@ -0,0 +1,100 @@ +package auth + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "go.uber.org/dig" +) + +// ServerMiddlewareRegistrar is the interface for registering named middleware. +// This matches the server.Server interface's RegisterNamedMiddleware method. +type ServerMiddlewareRegistrar interface { + RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) +} + + +// SetupMiddleware wires the auth middleware into the DI container. +// +// This must be called after the auth provider and resolvers are available. +// +// # Prerequisites +// +// The following must be available in the container: +// - auth.AuthProvider +// - auth.OrganizationResolver +// - auth.AccountResolver +// +// # Usage +// +// if err := auth.SetupMiddleware(container); err != nil { +// return err +// } +func SetupMiddleware(container *dig.Container) error { + if err := container.Provide(func( + provider AuthProvider, + orgResolver OrganizationResolver, + accResolver AccountResolver, + ) *Middleware { + return NewMiddleware(provider, orgResolver, accResolver, nil) + }); err != nil { + return fmt.Errorf("failed to provide auth middleware: %w", err) + } + + return nil +} + +// RegisterNamedMiddlewares registers the auth middleware functions with the server. +// +// This should be called after SetupMiddleware and the server is available. +// It registers the following named middlewares: +// - "auth": RequireAuth middleware (verifies JWT token) +// - "org_context": RequireOrganization middleware (resolves org/account IDs) +// +// # Usage +// +// if err := auth.RegisterNamedMiddlewares(container); err != nil { +// return err +// } +func RegisterNamedMiddlewares(container *dig.Container) error { + return container.Invoke(func( + middleware *Middleware, + server ServerMiddlewareRegistrar, + ) { + // Register auth middleware (verifies JWT and sets Identity) + server.RegisterNamedMiddleware("auth", func() gin.HandlerFunc { + return middleware.RequireAuth() + }) + + // Register organization context middleware (resolves database IDs) + server.RegisterNamedMiddleware("org_context", func() gin.HandlerFunc { + return middleware.RequireOrganization() + }) + }) +} + +// ProvideResolvers provides Organization and Account resolvers. +// +// This is a convenience function for when you have repositories that +// need to be adapted to the resolver interfaces. +// +// # Example +// +// auth.ProvideResolvers(container, func(orgRepo domain.OrganizationRepository) auth.OrganizationResolver { +// return auth.NewOrganizationResolver(repo) +// }, func(accRepo domain.AccountRepository) auth.AccountResolver { +// return auth.NewAccountResolver(repo) +// }) +func ProvideResolvers( + container *dig.Container, + orgResolverProvider any, + accResolverProvider any, +) error { + if err := container.Provide(orgResolverProvider); err != nil { + return fmt.Errorf("failed to provide organization resolver: %w", err) + } + if err := container.Provide(accResolverProvider); err != nil { + return fmt.Errorf("failed to provide account resolver: %w", err) + } + return nil +} diff --git a/go-b2b-starter/src/pkg/auth/rbac.go b/go-b2b-starter/src/pkg/auth/rbac.go new file mode 100644 index 0000000..747cea9 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/rbac.go @@ -0,0 +1,438 @@ +package auth + +// ============================================================================= +// RBAC DEFINITIONS - Roles and Permissions +// ============================================================================= +// +// This file is the SINGLE SOURCE OF TRUTH for all role and permission definitions. +// Customize these for your business domain. +// +// ============================================================================= + +// ============================================================================= +// PERMISSIONS - Customize for your business domain +// ============================================================================= +// +// The default uses "resource" as a generic placeholder. +// Change "resource" to match your domain entity: +// +// E-commerce: "product:view", "product:create", "order:manage" +// Healthcare: "patient:view", "records:manage", "prescription:create" +// Project Mgmt: "project:view", "task:create", "task:assign" +// CRM: "contact:view", "deal:create", "deal:close" +// Invoice System: "invoice:view", "invoice:create", "invoice:approve" +// +// Simply rename "resource" to your domain entity! +// +// ============================================================================= + +var ( + // Resource permissions - rename "resource" to your domain entity + PermResourceView = NewPermission("resource", "view") + PermResourceCreate = NewPermission("resource", "create") + PermResourceEdit = NewPermission("resource", "edit") + PermResourceDelete = NewPermission("resource", "delete") + PermResourceApprove = NewPermission("resource", "approve") + + // Organization permissions + PermOrgView = NewPermission("org", "view") + PermOrgManage = NewPermission("org", "manage") +) + +// AllPermissions is the complete list of all permissions in the system. +// Update this when you add or remove permissions. +var AllPermissions = []Permission{ + PermResourceView, + PermResourceCreate, + PermResourceEdit, + PermResourceDelete, + PermResourceApprove, + PermOrgView, + PermOrgManage, +} + +// ============================================================================= +// ROLES - Customize role names and permissions as needed +// ============================================================================= +// +// Default roles follow a simple hierarchy: +// - Member: Basic access (view, create) +// - Manager: Elevated access (edit, delete, approve) +// - Admin: Full control (everything + org management) +// +// To customize: +// 1. Change role IDs if needed (must match Stytch configuration) +// 2. Adjust permissions for each role +// 3. Add new roles if needed +// +// ============================================================================= + +// RoleInfo contains complete information about a role including its permissions. +// Used for API responses and role lookups. +type RoleInfo struct { + // ID is the unique identifier for the role (e.g., "member", "manager", "admin") + ID string + // Name is the display name for the role + Name string + // Description explains the purpose and scope of the role + Description string + // Permissions is the list of permissions granted to this role + Permissions []Permission +} + +var ( + // RoleMemberInfo - Basic user access + // Typical users: Employees, staff, basic users + RoleMemberInfo = RoleInfo{ + ID: "member", + Name: "Member", + Description: "Basic access. Can view and create resources.", + Permissions: []Permission{ + PermResourceView, + PermResourceCreate, + }, + } + + // RoleManagerInfo - Elevated access with approval rights + // Typical users: Team leads, supervisors, managers + RoleManagerInfo = RoleInfo{ + ID: "manager", + Name: "Manager", + Description: "Elevated access. Can edit, delete, and approve resources.", + Permissions: []Permission{ + PermResourceView, + PermResourceCreate, + PermResourceEdit, + PermResourceDelete, + PermResourceApprove, + PermOrgView, + }, + } + + // RoleAdminInfo - Full system control + // Typical users: Business owners, administrators + RoleAdminInfo = RoleInfo{ + ID: "admin", + Name: "Admin", + Description: "Full control. Can manage organization settings and users.", + Permissions: []Permission{ + PermResourceView, + PermResourceCreate, + PermResourceEdit, + PermResourceDelete, + PermResourceApprove, + PermOrgView, + PermOrgManage, + }, + } +) + +// AllRoles is the complete list of all roles in the RBAC system. +// Update this when you add or remove roles. +var AllRoles = []RoleInfo{ + RoleMemberInfo, + RoleManagerInfo, + RoleAdminInfo, +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +// GetRoleInfo retrieves role information by role ID. +// Returns nil if the role is not found. +func GetRoleInfo(roleID string) *RoleInfo { + for i := range AllRoles { + if AllRoles[i].ID == roleID { + return &AllRoles[i] + } + } + return nil +} + +// GetRolePermissionIDs returns just the permission IDs (strings) for a given role. +// Useful for Stytch integration and API responses. +func GetRolePermissionIDs(roleID string) []string { + role := GetRoleInfo(roleID) + if role == nil { + return []string{} + } + + ids := make([]string, len(role.Permissions)) + for i, perm := range role.Permissions { + ids[i] = string(perm) + } + return ids +} + +// HasPermission checks if a role has a specific permission. +func HasPermission(roleID string, permission Permission) bool { + role := GetRoleInfo(roleID) + if role == nil { + return false + } + + for _, perm := range role.Permissions { + if perm == permission { + return true + } + } + return false +} + +// ============================================================================= +// PERMISSION MATRIX (for reference) +// ============================================================================= +// +// | Permission | Member | Manager | Admin | +// |-------------------|--------|---------|-------| +// | resource:view | ✓ | ✓ | ✓ | +// | resource:create | ✓ | ✓ | ✓ | +// | resource:edit | | ✓ | ✓ | +// | resource:delete | | ✓ | ✓ | +// | resource:approve | | ✓ | ✓ | +// | org:view | | ✓ | ✓ | +// | org:manage | | | ✓ | +// +// Role totals: +// - Member: 2 permissions +// - Manager: 6 permissions +// - Admin: 7 permissions (all) +// +// ============================================================================= + +// ============================================================================= +// API RESPONSE TYPES (DTOs) +// ============================================================================= + +// PermissionDTO represents a permission in API responses +type PermissionDTO struct { + ID string `json:"id"` + Resource string `json:"resource"` + Action string `json:"action"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Category string `json:"category"` +} + +// NewPermissionDTO converts a Permission to a DTO +func NewPermissionDTO(perm Permission) PermissionDTO { + return PermissionDTO{ + ID: string(perm), + Resource: perm.Resource(), + Action: perm.Action(), + // Generic display name and description for simple permissions + DisplayName: perm.Resource() + " " + perm.Action(), + Description: "Can " + perm.Action() + " " + perm.Resource(), + Category: "General", + } +} + +// RoleDTO represents a role with its permissions in API responses +type RoleDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []PermissionDTO `json:"permissions"` +} + +// NewRoleDTO converts a RoleInfo to a DTO +func NewRoleDTO(role RoleInfo) RoleDTO { + permDTOs := make([]PermissionDTO, len(role.Permissions)) + for i, perm := range role.Permissions { + permDTOs[i] = NewPermissionDTO(perm) + } + + return RoleDTO{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + Permissions: permDTOs, + } +} + +// RolesResponse is the response body for GET /rbac/roles +type RolesResponse struct { + Roles []RoleDTO `json:"roles"` +} + +// PermissionsResponse is the response body for GET /rbac/permissions +type PermissionsResponse struct { + Permissions []PermissionDTO `json:"permissions"` +} + +// PermissionsByCategoryResponse is the response body for GET /rbac/permissions/by-category +type PermissionsByCategoryResponse struct { + Categories map[string][]PermissionDTO `json:"categories"` +} + +// RolePermissionsResponse contains role information with detailed metadata +type RolePermissionsResponse struct { + Role RoleDTO `json:"role"` + Statistics RoleStatistics `json:"statistics"` + Restrictions RoleRestrictions `json:"restrictions"` +} + +// RoleStatistics provides summary information about a role +type RoleStatistics struct { + TotalPermissions int `json:"total_permissions"` + CanApprove bool `json:"can_approve"` + CanManageOrg bool `json:"can_manage_org"` + Description string `json:"description"` +} + +// RoleRestrictions documents what a role cannot do +type RoleRestrictions struct { + CannotDo []string `json:"cannot_do"` + DataAccessLevel string `json:"data_access_level"` + Scope string `json:"scope"` +} + +// NewRolePermissionsResponse creates a detailed response for a role +func NewRolePermissionsResponse(roleID string) *RolePermissionsResponse { + role := GetRoleInfo(roleID) + if role == nil { + return nil + } + + stats := RoleStatistics{ + TotalPermissions: len(role.Permissions), + CanApprove: HasPermission(roleID, PermResourceApprove), + CanManageOrg: HasPermission(roleID, PermOrgManage), + Description: role.Description, + } + + // Define restrictions based on role + var restrictions RoleRestrictions + switch roleID { + case "member": + restrictions = RoleRestrictions{ + CannotDo: []string{"Edit resources", "Delete resources", "Approve requests", "Manage organization"}, + DataAccessLevel: "Basic - view and create only", + Scope: "Limited to own resources", + } + case "manager": + restrictions = RoleRestrictions{ + CannotDo: []string{"Manage organization settings"}, + DataAccessLevel: "Elevated - full resource access", + Scope: "Team-wide access", + } + case "admin": + restrictions = RoleRestrictions{ + CannotDo: []string{}, + DataAccessLevel: "Full - all data access", + Scope: "Organization-wide", + } + default: + restrictions = RoleRestrictions{ + CannotDo: []string{}, + DataAccessLevel: "Unknown", + Scope: "Unknown", + } + } + + return &RolePermissionsResponse{ + Role: NewRoleDTO(*role), + Statistics: stats, + Restrictions: restrictions, + } +} + +// PermissionCheckRequest is used to verify if a role has a permission +type PermissionCheckRequest struct { + RoleID string `json:"role_id" binding:"required"` + PermissionID string `json:"permission_id" binding:"required"` +} + +// PermissionCheckResponse indicates whether a role has a permission +type PermissionCheckResponse struct { + RoleID string `json:"role_id"` + PermissionID string `json:"permission_id"` + HasPermission bool `json:"has_permission"` +} + +// RBACMetadata provides summary information about the RBAC system +type RBACMetadata struct { + TotalRoles int `json:"total_roles"` + TotalPermissions int `json:"total_permissions"` + PermissionsByRole map[string]int `json:"permissions_by_role"` + Description string `json:"description"` +} + +// NewRBACMetadata creates metadata about the RBAC system +func NewRBACMetadata() RBACMetadata { + permsByRole := make(map[string]int) + for _, role := range AllRoles { + permsByRole[role.ID] = len(role.Permissions) + } + + return RBACMetadata{ + TotalRoles: len(AllRoles), + TotalPermissions: len(AllPermissions), + PermissionsByRole: permsByRole, + Description: "Simple RBAC system with 3 roles (Member, Manager, Admin) and 7 generic permissions", + } +} + +// ============================================================================= +// SERVICE INTERFACE AND IMPLEMENTATION +// ============================================================================= + +// RBACService provides business logic for RBAC operations +type RBACService interface { + GetAllRoles() []RoleInfo + GetRoleInfo(roleID string) *RoleInfo + GetAllPermissions() []Permission + GetRolePermissions(roleID string) []Permission + GetPermissionsByCategory() map[string][]Permission + GetPermissionsByRoleID(roleID string) []string + HasPermission(roleID string, permissionID string) bool + GetRBACMetadata() RBACMetadata +} + +// defaultRBACService implements the RBACService interface +type defaultRBACService struct{} + +// NewRBACService creates a new RBAC service +func NewRBACService() RBACService { + return &defaultRBACService{} +} + +func (s *defaultRBACService) GetAllRoles() []RoleInfo { + return AllRoles +} + +func (s *defaultRBACService) GetRoleInfo(roleID string) *RoleInfo { + return GetRoleInfo(roleID) +} + +func (s *defaultRBACService) GetAllPermissions() []Permission { + return AllPermissions +} + +func (s *defaultRBACService) GetRolePermissions(roleID string) []Permission { + role := GetRoleInfo(roleID) + if role == nil { + return []Permission{} + } + return role.Permissions +} + +func (s *defaultRBACService) GetPermissionsByCategory() map[string][]Permission { + // For simplicity, return all permissions in one "General" category + return map[string][]Permission{ + "General": AllPermissions, + } +} + +func (s *defaultRBACService) GetPermissionsByRoleID(roleID string) []string { + return GetRolePermissionIDs(roleID) +} + +func (s *defaultRBACService) HasPermission(roleID string, permissionID string) bool { + return HasPermission(roleID, Permission(permissionID)) +} + +func (s *defaultRBACService) GetRBACMetadata() RBACMetadata { + return NewRBACMetadata() +} diff --git a/go-b2b-starter/src/pkg/auth/resolvers.go b/go-b2b-starter/src/pkg/auth/resolvers.go new file mode 100644 index 0000000..c4e053b --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/resolvers.go @@ -0,0 +1,105 @@ +package auth + +import ( + "context" + "fmt" +) + +// OrganizationLookup is the minimal interface needed to resolve organizations. +// +// This interface should be implemented by your organization repository. +// It abstracts the specific repository implementation from the auth package. +type OrganizationLookup interface { + // GetByStytchID returns an organization by Stytch organization ID. + // The returned value must have an ID field (int32). + GetByStytchID(ctx context.Context, stytchOrgID string) (OrganizationEntity, error) +} + +// OrganizationEntity is the minimal interface for an organization entity. +type OrganizationEntity interface { + GetID() int32 +} + +// AccountLookup is the minimal interface needed to resolve accounts. +// +// This interface should be implemented by your account repository. +// It abstracts the specific repository implementation from the auth package. +type AccountLookup interface { + // GetByEmail returns an account by email within an organization. + // The returned value must have an ID field (int32). + GetByEmail(ctx context.Context, orgID int32, email string) (AccountEntity, error) +} + +// AccountEntity is the minimal interface for an account entity. +type AccountEntity interface { + GetID() int32 +} + +// NewOrganizationResolver creates an OrganizationResolver from an OrganizationLookup. +// +// This is a convenience function for creating resolvers from repositories +// that implement the OrganizationLookup interface. +// +// # Usage +// +// // In your organizations module provider: +// container.Provide(func(repo domain.OrganizationRepository) auth.OrganizationResolver { +// return auth.NewOrganizationResolver(repo) +// }) +func NewOrganizationResolver(lookup OrganizationLookup) OrganizationResolver { + return &orgResolverAdapter{lookup: lookup} +} + +// NewAccountResolver creates an AccountResolver from an AccountLookup. +// +// # Usage +// +// // In your organizations module provider: +// container.Provide(func(repo domain.AccountRepository) auth.AccountResolver { +// return auth.NewAccountResolver(repo) +// }) +func NewAccountResolver(lookup AccountLookup) AccountResolver { + return &accResolverAdapter{lookup: lookup} +} + +// orgResolverAdapter adapts OrganizationLookup to OrganizationResolver. +type orgResolverAdapter struct { + lookup OrganizationLookup +} + +func (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, providerOrgID string) (int32, error) { + org, err := a.lookup.GetByStytchID(ctx, providerOrgID) + if err != nil { + return 0, fmt.Errorf("organization not found for provider ID %s: %w", providerOrgID, err) + } + return org.GetID(), nil +} + +// accResolverAdapter adapts AccountLookup to AccountResolver. +type accResolverAdapter struct { + lookup AccountLookup +} + +func (a *accResolverAdapter) ResolveByEmail(ctx context.Context, orgID int32, email string) (int32, error) { + acc, err := a.lookup.GetByEmail(ctx, orgID, email) + if err != nil { + return 0, fmt.Errorf("account not found for email %s in org %d: %w", email, orgID, err) + } + return acc.GetID(), nil +} + +// SimpleOrganization is a simple implementation of OrganizationEntity. +// Use this if your domain entity doesn't already implement GetID(). +type SimpleOrganization struct { + ID int32 +} + +func (o *SimpleOrganization) GetID() int32 { return o.ID } + +// SimpleAccount is a simple implementation of AccountEntity. +// Use this if your domain entity doesn't already implement GetID(). +type SimpleAccount struct { + ID int32 +} + +func (a *SimpleAccount) GetID() int32 { return a.ID } diff --git a/go-b2b-starter/src/pkg/auth/roles.go b/go-b2b-starter/src/pkg/auth/roles.go new file mode 100644 index 0000000..6db4915 --- /dev/null +++ b/go-b2b-starter/src/pkg/auth/roles.go @@ -0,0 +1,144 @@ +package auth + +// Role represents a user role in the system. +// +// Roles are assigned to users and determine their base permissions. +// The application uses a three-tier RBAC system: +// +// - Member: Basic access (view and create) +// - Manager: Elevated access (edit, delete, approve) +// - Admin: Full system control +// +// # Adding New Roles +// +// To add a new role: +// 1. Add the role constant below +// 2. Add role info in rbac.go (AllRoles) +// 3. Configure the role in your auth provider (e.g., Stytch dashboard) +// +// # Role Source of Truth +// +// The auth provider (e.g., Stytch) is the source of truth for roles at runtime. +// The definitions in rbac.go are used as a fallback when the auth provider +// doesn't provide explicit permissions. +type Role string + +// Core RBAC roles. +// +// These must match the roles configured in the auth provider. +const ( + // RoleMember is for basic users with view and create access. + // Can: View resources, create resources + // Cannot: Edit, delete, approve, manage org + RoleMember Role = "member" + + // RoleManager is for users with elevated access. + // Can: View, create, edit, delete, approve resources + // Cannot: Manage organization settings + RoleManager Role = "manager" + + // RoleAdmin has full system control. + // Can: Everything - no restrictions + RoleAdmin Role = "admin" +) + +// Legacy role aliases for backward compatibility. +// +// These map to the new role constants and will be removed in a future version. +const ( + // RoleOwner is a legacy alias for RoleAdmin. + // Deprecated: Use RoleAdmin instead. + RoleOwner Role = "owner" + + // RoleApprover is a legacy alias for RoleManager. + // Deprecated: Use RoleManager instead. + RoleApprover Role = "approver" + + // RoleReviewer is a legacy alias for RoleManager. + // Deprecated: Use RoleManager instead. + RoleReviewer Role = "reviewer" + + // RoleEmployee is a legacy alias for RoleMember. + // Deprecated: Use RoleMember instead. + RoleEmployee Role = "employee" +) + +// String returns the string representation of the role. +func (r Role) String() string { + return string(r) +} + +// IsValid checks if the role is a known role. +func (r Role) IsValid() bool { + normalized := NormalizeRole(string(r)) + roleInfo := GetRoleInfo(string(normalized)) + return roleInfo != nil +} + +// NormalizeRole converts legacy role names to current ones. +// +// This handles backward compatibility for old role assignments: +// - "owner" -> "admin" +// - "approver" -> "manager" +// - "reviewer" -> "manager" +// - "employee" -> "member" +// - "stytch_member" -> "member" (strips provider prefix) +func NormalizeRole(roleStr string) Role { + role := Role(roleStr) + + // Handle provider-prefixed roles (e.g., "stytch_member") + switch role { + case "stytch_member": + return RoleMember + case "stytch_admin": + return RoleAdmin + case "stytch_manager": + return RoleManager + } + + // Handle legacy roles + switch role { + case RoleOwner: + return RoleAdmin + case RoleApprover, RoleReviewer: + return RoleManager + case RoleEmployee: + return RoleMember + } + + return role +} + +// GetRolePermissions returns the default permissions for a role. +// +// Returns nil if the role is not recognized. +// This is used as a fallback when the auth provider doesn't provide permissions. +// +// Permissions are defined in rbac.go which is the single source of truth. +func GetRolePermissions(role Role) []Permission { + // Normalize the role to handle legacy names + normalized := NormalizeRole(string(role)) + + // Get role info from rbac.go (single source of truth) + roleInfo := GetRoleInfo(string(normalized)) + if roleInfo == nil { + return nil + } + + return roleInfo.Permissions +} + +// HasRolePermission checks if a role has a specific permission. +// +// This uses the role definitions from rbac.go and is used as a fallback +// when the auth provider doesn't include explicit permissions. +func HasRolePermission(role Role, resource, action string) bool { + perms := GetRolePermissions(role) + target := NewPermission(resource, action) + for _, p := range perms { + if p == target { + return true + } + } + return false +} diff --git a/go-b2b-starter/src/pkg/common/errors/errors.go b/go-b2b-starter/src/pkg/common/errors/errors.go new file mode 100644 index 0000000..dc1428f --- /dev/null +++ b/go-b2b-starter/src/pkg/common/errors/errors.go @@ -0,0 +1,72 @@ +package errors + +import ( + "net/http" +) + +// IsNotFoundError checks if the error is a NotFoundError +func IsNotFoundError(err error) bool { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.StatusCode == http.StatusNotFound + } + return false +} + +// IsConflictError checks if the error is a ConflictError (e.g., duplicate username) +func IsConflictError(err error) bool { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.StatusCode == http.StatusConflict + } + return false +} + +// IsBadRequestError checks if the error is a BadRequestError +func IsBadRequestError(err error) bool { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.StatusCode == http.StatusBadRequest + } + return false +} + +// IsAuthenticationError checks if the error is an authentication error +func IsAuthenticationError(err error) bool { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.StatusCode == http.StatusUnauthorized + } + return false +} + +// IsAuthorizationError checks if the error is an authorization error +func IsAuthorizationError(err error) bool { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.StatusCode == http.StatusForbidden + } + return false +} + +// IsInternalServerError checks if the error is an internal server error +func IsInternalServerError(err error) bool { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.StatusCode == http.StatusInternalServerError + } + return false +} + +// GetErrorCode extracts the error code from an HTTPError, or returns empty string +func GetErrorCode(err error) string { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.Code + } + return "" +} + +// GetErrorMessage extracts the error message from an HTTPError, or returns the error's message +func GetErrorMessage(err error) string { + if httpErr, ok := err.(*HTTPError); ok { + return httpErr.Message + } + if err != nil { + return err.Error() + } + return "" +} diff --git a/go-b2b-starter/src/pkg/common/errors/http_error.go b/go-b2b-starter/src/pkg/common/errors/http_error.go new file mode 100644 index 0000000..341313d --- /dev/null +++ b/go-b2b-starter/src/pkg/common/errors/http_error.go @@ -0,0 +1,19 @@ +package errors + +type HTTPError struct { + StatusCode int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e HTTPError) Error() string { + return e.Message +} + +func NewHTTPError(statusCode int, code, message string) HTTPError { + return HTTPError{ + StatusCode: statusCode, + Code: code, + Message: message, + } +} diff --git a/go-b2b-starter/src/pkg/common/go.mod b/go-b2b-starter/src/pkg/common/go.mod new file mode 100644 index 0000000..d3f921e --- /dev/null +++ b/go-b2b-starter/src/pkg/common/go.mod @@ -0,0 +1,3 @@ +module github.com/moasq/go-b2b-starter/pkg/common + +go 1.25 diff --git a/go-b2b-starter/src/pkg/common/listing_shared/pagination.go b/go-b2b-starter/src/pkg/common/listing_shared/pagination.go new file mode 100644 index 0000000..50071bd --- /dev/null +++ b/go-b2b-starter/src/pkg/common/listing_shared/pagination.go @@ -0,0 +1,71 @@ +package listingshared + +import "fmt" + +type PagePagination[T any] struct { + Items []T `json:"items"` + Meta Meta `json:"meta"` +} + +type Meta struct { + TotalItems int `json:"total_items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + ReturnedItemsCount int `json:"returned_items_count"` + HasMore bool `json:"has_more"` + FirstPageURL string `json:"first_page_url"` + PreviousPageURL string `json:"previous_page_url"` + NextPageURL string `json:"next_page_url"` + LastPageURL string `json:"last_page_url"` + TotalPages int `json:"total_pages"` +} + +func NewPagePagination[T any](totalItems, page, pageSize int, items []T) *PagePagination[T] { + // Calculate total pages properly + totalPages := (totalItems + pageSize - 1) / pageSize + + p := &PagePagination[T]{ + Meta: Meta{ + TotalItems: totalItems, + Page: page, + PageSize: pageSize, + ReturnedItemsCount: len(items), + HasMore: page < totalPages, // Set HasMore + TotalPages: totalPages, + }, + Items: items, + } + + // First page URL only if we're not on first page + if page > 1 { + p.Meta.FirstPageURL = fmt.Sprintf("?page=%d&pageSize=%d", 1, pageSize) + } + + // Last page URL only if there are multiple pages and we're not on last page + if page < totalPages { + p.Meta.LastPageURL = fmt.Sprintf("?page=%d&pageSize=%d", totalPages, pageSize) + } + + // Previous page URL only if we're not on first page + if page > 1 { + p.Meta.PreviousPageURL = fmt.Sprintf("?page=%d&pageSize=%d", page-1, pageSize) + } + + // Next page URL only if there are more pages + if page < totalPages { + p.Meta.NextPageURL = fmt.Sprintf("?page=%d&pageSize=%d", page+1, pageSize) + } + + return p +} + +func PageToOffset(page int, limit int) (int, error) { + if page < 1 { + return 0, fmt.Errorf("page must be greater than 0") + } + if limit < 1 { + return 0, fmt.Errorf("limit must be greater than 0") + } + + return (page - 1) * limit, nil +} diff --git a/go-b2b-starter/src/pkg/common/listing_shared/pramas.go b/go-b2b-starter/src/pkg/common/listing_shared/pramas.go new file mode 100644 index 0000000..0097cd8 --- /dev/null +++ b/go-b2b-starter/src/pkg/common/listing_shared/pramas.go @@ -0,0 +1,46 @@ +package listingshared + +type SearchableParams struct { + Q string `form:"q" binding:"required,min=1,max=100"` + Page int `form:"page" binding:"omitempty,min=1" default:"1"` + Limit int `form:"limit" binding:"omitempty,min=1,max=100" default:"10"` + Lang string `form:"lang" binding:"omitempty,oneof=en ar" default:"en"` +} + +func (s *SearchableParams) Validate() error { + + // Then, set default values for empty fields + if s.Page == 0 { + s.Page = 1 + } + if s.Limit == 0 { + s.Limit = 10 + } + if s.Lang == "" { + s.Lang = "en" + } + + return nil +} + +type ListableParams struct { + Page int `form:"page" binding:"omitempty,min=1" default:"1"` + Limit int `form:"limit" binding:"omitempty,min=1,max=100" default:"10"` + Lang string `form:"lang" binding:"omitempty,oneof=en ar" default:"en"` +} + +func (s *ListableParams) Validate() error { + + // Then, set default values for empty fields + if s.Page == 0 { + s.Page = 1 + } + if s.Limit == 0 { + s.Limit = 10 + } + if s.Lang == "" { + s.Lang = "en" + } + + return nil +} diff --git a/go-b2b-starter/src/pkg/common/listing_shared/util.go b/go-b2b-starter/src/pkg/common/listing_shared/util.go new file mode 100644 index 0000000..18533b4 --- /dev/null +++ b/go-b2b-starter/src/pkg/common/listing_shared/util.go @@ -0,0 +1,17 @@ +package listingshared + +// PaginationCalc converts offset and limit to page number and page size. +// Assumes default values if not specified. +func PaginationCalc(offset, limit int) (page, pageSize int) { + if limit <= 0 { + limit = 20 // Default limit + } + if offset < 0 { + offset = 0 // Default offset + } + + page = (offset / limit) + 1 + pageSize = limit + + return page, pageSize +} diff --git a/go-b2b-starter/src/pkg/common/utils/slugify.go b/go-b2b-starter/src/pkg/common/utils/slugify.go new file mode 100644 index 0000000..238079b --- /dev/null +++ b/go-b2b-starter/src/pkg/common/utils/slugify.go @@ -0,0 +1,30 @@ +package utils + +import ( + "regexp" + "strings" +) + +// Slugify converts a string into a URL-friendly slug +// Example: "My Organization Name!" -> "my-organization-name" +func Slugify(s string) string { + // Convert to lowercase + s = strings.ToLower(s) + + // Replace spaces and underscores with hyphens + s = strings.ReplaceAll(s, " ", "-") + s = strings.ReplaceAll(s, "_", "-") + + // Remove all non-alphanumeric characters except hyphens + reg := regexp.MustCompile(`[^a-z0-9-]+`) + s = reg.ReplaceAllString(s, "") + + // Replace multiple consecutive hyphens with a single hyphen + reg = regexp.MustCompile(`-+`) + s = reg.ReplaceAllString(s, "-") + + // Trim hyphens from start and end + s = strings.Trim(s, "-") + + return s +} diff --git a/go-b2b-starter/src/pkg/db/README.md b/go-b2b-starter/src/pkg/db/README.md new file mode 100644 index 0000000..9dfbc49 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/README.md @@ -0,0 +1,231 @@ +# Database Layer Guide + +This guide shows you how to work with the database layer using our adapter pattern. It's designed to be simple and practical. + +## What's the Adapter Pattern? + +We keep database code separate from business logic: +- **`adapters/`** - Interface definitions (what operations are available) +- **`postgres/adapter_impl/`** - Actual database code (how PostgreSQL does it) + +Your business modules only need to know about `adapters/`. They never touch PostgreSQL directly. + +## The Workflow + +Here's the typical flow when you need to add something to the database: + +### 1. Create a Database Migration + +Use the Makefile to create migration files: + +```bash +make create-migration MIGRATION_NAME=add_users_table +``` + +This creates two files in `postgres/sqlc/migrations/`: +- `000XXX_add_users_table.up.sql` (creates your changes) +- `000XXX_add_users_table.down.sql` (removes your changes) + +Write your SQL in these files. Keep it simple. + +### 2. Apply Your Migration + +Run the migration to update your database: + +```bash +make migrateup +``` + +If something goes wrong, you can rollback: + +```bash +make migratedown +``` + +### 3. Write SQL Queries + +Create a file in `postgres/sqlc/query/` with your queries. Use SQLC's comments to tell it what to generate: + +```sql +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1; + +-- name: CreateUser :one +INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *; + +-- name: UpdateUserBalance :exec +UPDATE users SET balance = balance + $1 WHERE id = $2; +``` + +The comment annotations tell SQLC what kind of method to create (`:one` returns single row, `:many` returns multiple, `:exec` runs without returning). + +### 4. Generate Go Code + +Let SQLC generate type-safe Go code from your queries: + +```bash +make sqlc +``` + +This creates Go methods in `postgres/sqlc/gen/` that you can use safely without writing SQL in Go code. + +### 5. Create an Adapter Interface + +Define what operations your business logic needs in `adapters/user_adapter.go`: + +```go +package adapters + +import ( + "context" + db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +type UserAdapter interface { + GetUserByID(ctx context.Context, id int32) (db.User, error) + CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) + + // For transactions - aggregate multiple operations + TransferMoney(ctx context.Context, fromUserID, toUserID int32, amount int32) error +} +``` + +### 6. Implement the Adapter + +Create the actual implementation in `postgres/adapter_impl/user_adapter.go`: + +```go +package adapterimpl + +import ( + "context" + "fmt" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +type userAdapter struct { + store sqlc.Store +} + +func NewUserAdapter(store sqlc.Store) adapters.UserAdapter { + return &userAdapter{store: store} +} + +func (a *userAdapter) GetUserByID(ctx context.Context, id int32) (sqlc.User, error) { + return a.store.GetUserByID(ctx, id) +} + +func (a *userAdapter) CreateUser(ctx context.Context, arg sqlc.CreateUserParams) (sqlc.User, error) { + return a.store.CreateUser(ctx, arg) +} + +// TransferMoney - transaction example aggregating multiple queries +func (a *userAdapter) TransferMoney(ctx context.Context, fromUserID, toUserID int32, amount int32) error { + // Start transaction using the store's ExecTx method + return a.store.ExecTx(ctx, func(q sqlc.Querier) error { + // All queries run within this transaction + + // 1. Deduct from sender + err := q.UpdateUserBalance(ctx, sqlc.UpdateUserBalanceParams{ + ID: fromUserID, + Amount: -amount, + }) + if err != nil { + return fmt.Errorf("failed to deduct balance: %w", err) + } + + // 2. Add to receiver + err = q.UpdateUserBalance(ctx, sqlc.UpdateUserBalanceParams{ + ID: toUserID, + Amount: amount, + }) + if err != nil { + return fmt.Errorf("failed to add balance: %w", err) + } + + // 3. Verify sender has enough balance + sender, err := q.GetUserByID(ctx, fromUserID) + if err != nil { + return fmt.Errorf("failed to get sender: %w", err) + } + if sender.Balance < 0 { + return fmt.Errorf("insufficient balance") + } + + // If any query fails, transaction auto-rollbacks + // If all succeed, transaction auto-commits + return nil + }) +} +``` + +### 7. Register in Dependency Injection + +Add your adapter to `inject.go` so it's available everywhere: + +```go +if err := container.Provide(func(sqlcStore sqlc.Store) adapters.UserAdapter { + return adapterImpl.NewUserAdapter(sqlcStore) +}); err != nil { + return fmt.Errorf("failed to provide user adapter: %w", err) +} +``` + +### 8. Use in Your Module + +Now your business modules can request the adapter through dependency injection: + +```go +type UserService struct { + userAdapter adapters.UserAdapter +} + +func NewUserService(userAdapter adapters.UserAdapter) *UserService { + return &UserService{userAdapter: userAdapter} +} + +func (s *UserService) GetUser(ctx context.Context, id int32) (*User, error) { + user, err := s.userAdapter.GetUserByID(ctx, id) + if err != nil { + return nil, err + } + // ... your business logic here +} + +func (s *UserService) TransferFunds(ctx context.Context, fromID, toID int32, amount int32) error { + // The adapter handles the transaction internally + return s.userAdapter.TransferMoney(ctx, fromID, toID, amount) +} +``` + +## Working with Transactions + +When you need multiple queries to succeed or fail together, add a transaction method to your adapter: + +**Key Points:** +- Your module only depends on the adapter interface (never touches the database pool) +- The adapter implementation handles transaction logic using `store.ExecTx()` +- All queries inside `ExecTx()` run in a transaction +- If any query fails, everything rolls back automatically +- If all succeed, everything commits automatically + +## Common Commands + +```bash +make create-migration MIGRATION_NAME=your_change # Create migration files +make migrateup # Apply migrations +make migratedown # Rollback last migration +make sqlc # Generate Go code from SQL +make build # Verify everything compiles +``` + +## Why This Pattern? + +- **Clean separation** - modules only know about adapters, not databases +- **Easy to test** - mock the adapter interface, no database needed +- **Type safety** - SQLC catches SQL errors at compile time +- **Transaction safety** - adapters encapsulate transaction logic +- **Single dependency** - modules only inject adapters + +That's it! The pattern keeps your business logic clean and focused. diff --git a/go-b2b-starter/src/pkg/db/adapters/cognitive_store.go b/go-b2b-starter/src/pkg/db/adapters/cognitive_store.go new file mode 100644 index 0000000..34736a2 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/adapters/cognitive_store.go @@ -0,0 +1,41 @@ +package adapters + +import ( + "context" + + db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/pgvector/pgvector-go" +) + +// EmbeddingStore provides database operations for document embeddings +type EmbeddingStore interface { + CreateDocumentEmbedding(ctx context.Context, arg db.CreateDocumentEmbeddingParams) (db.CognitiveDocumentEmbedding, error) + GetDocumentEmbeddingByID(ctx context.Context, arg db.GetDocumentEmbeddingByIDParams) (db.CognitiveDocumentEmbedding, error) + GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg db.GetDocumentEmbeddingsByDocumentIDParams) ([]db.CognitiveDocumentEmbedding, error) + SearchSimilarDocuments(ctx context.Context, arg db.SearchSimilarDocumentsParams) ([]db.SearchSimilarDocumentsRow, error) + DeleteDocumentEmbeddings(ctx context.Context, arg db.DeleteDocumentEmbeddingsParams) error + CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) +} + +// ChatStore provides database operations for chat sessions and messages +type ChatStore interface { + // Sessions + CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.CognitiveChatSession, error) + GetChatSessionByID(ctx context.Context, arg db.GetChatSessionByIDParams) (db.CognitiveChatSession, error) + ListChatSessionsByAccount(ctx context.Context, arg db.ListChatSessionsByAccountParams) ([]db.CognitiveChatSession, error) + UpdateChatSessionTitle(ctx context.Context, arg db.UpdateChatSessionTitleParams) (db.CognitiveChatSession, error) + DeleteChatSession(ctx context.Context, arg db.DeleteChatSessionParams) error + + // Messages + CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.CognitiveChatMessage, error) + GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]db.CognitiveChatMessage, error) + GetRecentChatMessages(ctx context.Context, arg db.GetRecentChatMessagesParams) ([]db.CognitiveChatMessage, error) + CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) + DeleteChatMessage(ctx context.Context, id int32) error +} + +// VectorHelper provides utilities for working with pgvector +type VectorHelper interface { + ToVector(embedding []float64) pgvector.Vector + FromVector(v pgvector.Vector) []float64 +} diff --git a/go-b2b-starter/src/pkg/db/adapters/document_store.go b/go-b2b-starter/src/pkg/db/adapters/document_store.go new file mode 100644 index 0000000..54bd3a4 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/adapters/document_store.go @@ -0,0 +1,22 @@ +package adapters + +import ( + "context" + + db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +// DocumentStore provides database operations for documents +type DocumentStore interface { + CreateDocument(ctx context.Context, arg db.CreateDocumentParams) (db.DocumentsDocument, error) + GetDocumentByID(ctx context.Context, arg db.GetDocumentByIDParams) (db.DocumentsDocument, error) + GetDocumentByFileAssetID(ctx context.Context, arg db.GetDocumentByFileAssetIDParams) (db.DocumentsDocument, error) + ListDocumentsByOrganization(ctx context.Context, arg db.ListDocumentsByOrganizationParams) ([]db.DocumentsDocument, error) + ListDocumentsByStatus(ctx context.Context, arg db.ListDocumentsByStatusParams) ([]db.DocumentsDocument, error) + UpdateDocumentStatus(ctx context.Context, arg db.UpdateDocumentStatusParams) (db.DocumentsDocument, error) + UpdateDocumentExtractedText(ctx context.Context, arg db.UpdateDocumentExtractedTextParams) (db.DocumentsDocument, error) + UpdateDocument(ctx context.Context, arg db.UpdateDocumentParams) (db.DocumentsDocument, error) + DeleteDocument(ctx context.Context, arg db.DeleteDocumentParams) error + CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) + CountDocumentsByStatus(ctx context.Context, arg db.CountDocumentsByStatusParams) (int64, error) +} diff --git a/go-b2b-starter/src/pkg/db/adapters/file_asset_store.go b/go-b2b-starter/src/pkg/db/adapters/file_asset_store.go new file mode 100644 index 0000000..4b8080e --- /dev/null +++ b/go-b2b-starter/src/pkg/db/adapters/file_asset_store.go @@ -0,0 +1,33 @@ +package adapters + +import ( + "context" + + db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +// FileAssetStore defines the interface for file asset database operations +// It exposes only file asset-related methods and returns SQLC types directly +type FileAssetStore interface { + // Basic file asset operations - using SQLC method signatures + CreateFileAsset(ctx context.Context, arg db.CreateFileAssetParams) (db.FileManagerFileAsset, error) + GetFileAssetByID(ctx context.Context, id int32) (db.FileManagerFileAsset, error) + DeleteFileAsset(ctx context.Context, id int32) error + GetFileAssetsByEntity(ctx context.Context, arg db.GetFileAssetsByEntityParams) ([]db.FileManagerFileAsset, error) + GetFileAssetsByEntityAndPurpose(ctx context.Context, arg db.GetFileAssetsByEntityAndPurposeParams) ([]db.FileManagerFileAsset, error) + + // Category and context-based operations + GetFileAssetsByCategory(ctx context.Context, categoryName string) ([]db.GetFileAssetsByCategoryRow, error) + GetFileAssetsByContext(ctx context.Context, contextName string) ([]db.GetFileAssetsByContextRow, error) + + // Update operations + UpdateFileAsset(ctx context.Context, arg db.UpdateFileAssetParams) error + + // Search and lookup operations + GetFileAssetByStoragePath(ctx context.Context, storagePath string) (db.FileManagerFileAsset, error) + ListFileAssets(ctx context.Context, arg db.ListFileAssetsParams) ([]db.ListFileAssetsRow, error) + + // Lookup tables operations + GetFileCategories(ctx context.Context) ([]db.FileManagerFileCategory, error) + GetFileContexts(ctx context.Context) ([]db.FileManagerFileContext, error) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/db/adapters/organization_store.go b/go-b2b-starter/src/pkg/db/adapters/organization_store.go new file mode 100644 index 0000000..973faf4 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/adapters/organization_store.go @@ -0,0 +1,37 @@ +package adapters + +import ( + "context" + + db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/jackc/pgx/v5/pgtype" +) + +// OrganizationStore provides database operations for organizations +type OrganizationStore interface { + CreateOrganization(ctx context.Context, arg db.CreateOrganizationParams) (db.OrganizationsOrganization, error) + GetOrganizationByID(ctx context.Context, id int32) (db.OrganizationsOrganization, error) + GetOrganizationBySlug(ctx context.Context, slug string) (db.OrganizationsOrganization, error) + GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (db.OrganizationsOrganization, error) + GetOrganizationByUserEmail(ctx context.Context, email string) (db.OrganizationsOrganization, error) + UpdateOrganization(ctx context.Context, arg db.UpdateOrganizationParams) (db.OrganizationsOrganization, error) + UpdateOrganizationStytchInfo(ctx context.Context, arg db.UpdateOrganizationStytchInfoParams) (db.OrganizationsOrganization, error) + ListOrganizations(ctx context.Context, arg db.ListOrganizationsParams) ([]db.OrganizationsOrganization, error) + DeleteOrganization(ctx context.Context, id int32) error + GetOrganizationStats(ctx context.Context, id int32) (db.GetOrganizationStatsRow, error) +} + +// AccountStore provides database operations for accounts +type AccountStore interface { + CreateAccount(ctx context.Context, arg db.CreateAccountParams) (db.OrganizationsAccount, error) + GetAccountByID(ctx context.Context, arg db.GetAccountByIDParams) (db.OrganizationsAccount, error) + GetAccountByEmail(ctx context.Context, arg db.GetAccountByEmailParams) (db.OrganizationsAccount, error) + ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]db.OrganizationsAccount, error) + UpdateAccount(ctx context.Context, arg db.UpdateAccountParams) (db.OrganizationsAccount, error) + UpdateAccountStytchInfo(ctx context.Context, arg db.UpdateAccountStytchInfoParams) (db.OrganizationsAccount, error) + UpdateAccountLastLogin(ctx context.Context, arg db.UpdateAccountLastLoginParams) (db.OrganizationsAccount, error) + DeleteAccount(ctx context.Context, arg db.DeleteAccountParams) error + GetAccountOrganization(ctx context.Context, id int32) (db.OrganizationsOrganization, error) + CheckAccountPermission(ctx context.Context, arg db.CheckAccountPermissionParams) (db.CheckAccountPermissionRow, error) + GetAccountStats(ctx context.Context, id int32) (db.GetAccountStatsRow, error) +} diff --git a/go-b2b-starter/src/pkg/db/adapters/subscription_store.go b/go-b2b-starter/src/pkg/db/adapters/subscription_store.go new file mode 100644 index 0000000..6e856a0 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/adapters/subscription_store.go @@ -0,0 +1,27 @@ +package adapters + +import ( + "context" + + db "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +// SubscriptionStore provides database operations for subscription billing +type SubscriptionStore interface { + // Subscription operations + GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (db.SubscriptionBillingSubscription, error) + GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (db.SubscriptionBillingSubscription, error) + UpsertSubscription(ctx context.Context, arg db.UpsertSubscriptionParams) (db.SubscriptionBillingSubscription, error) + DeleteSubscription(ctx context.Context, organizationID int32) error + ListActiveSubscriptions(ctx context.Context) ([]db.SubscriptionBillingSubscription, error) + + // Quota operations + GetQuotaByOrgID(ctx context.Context, organizationID int32) (db.SubscriptionBillingQuotaTracking, error) + UpsertQuota(ctx context.Context, arg db.UpsertQuotaParams) (db.SubscriptionBillingQuotaTracking, error) + DecrementInvoiceCount(ctx context.Context, organizationID int32) (db.SubscriptionBillingQuotaTracking, error) + ResetQuotaForPeriod(ctx context.Context, arg db.ResetQuotaForPeriodParams) (db.SubscriptionBillingQuotaTracking, error) + + // Combined operations + GetQuotaStatus(ctx context.Context, organizationID int32) (db.GetQuotaStatusRow, error) + ListQuotasNearLimit(ctx context.Context, threshold int32) ([]db.ListQuotasNearLimitRow, error) +} diff --git a/go-b2b-starter/src/pkg/db/cmd/init.go b/go-b2b-starter/src/pkg/db/cmd/init.go new file mode 100644 index 0000000..66e7543 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/cmd/init.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "go.uber.org/dig" +) + +func Init(container *dig.Container) { + ProvideDependencies(container) + +} diff --git a/go-b2b-starter/src/pkg/db/cmd/providers.go b/go-b2b-starter/src/pkg/db/cmd/providers.go new file mode 100644 index 0000000..3cd7b3f --- /dev/null +++ b/go-b2b-starter/src/pkg/db/cmd/providers.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/moasq/go-b2b-starter/pkg/db" + "go.uber.org/dig" +) + +// ProvideDependencies registers all database dependencies using the centralized inject +func ProvideDependencies(container *dig.Container) error { + // Use the centralized inject function with default options + return db.Inject(container) +} + +// ProvideDependenciesWithOptions registers database dependencies with custom options +func ProvideDependenciesWithOptions(container *dig.Container, opts db.InjectOptions) error { + return db.InjectWithOptions(container, opts) +} diff --git a/go-b2b-starter/src/pkg/db/core/connection.go b/go-b2b-starter/src/pkg/db/core/connection.go new file mode 100644 index 0000000..7bcefb9 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/core/connection.go @@ -0,0 +1,73 @@ +package core + +import ( + "context" + "time" +) + +// Connection represents a database connection interface +type Connection interface { + // Execute runs a query that doesn't return rows + Execute(ctx context.Context, query string, args ...any) error + + // Query executes a query that returns rows + Query(ctx context.Context, query string, args ...any) (Rows, error) + + // QueryRow executes a query that returns a single row + QueryRow(ctx context.Context, query string, args ...any) Row + + // BeginTx starts a new transaction + BeginTx(ctx context.Context) (Transaction, error) + + // Ping verifies the connection to the database is still alive + Ping(ctx context.Context) error + + // Close closes the database connection + Close() error +} + +// Pool represents a connection pool interface +type Pool interface { + Connection + + // Stats returns connection pool statistics + Stats() PoolStats + + // SetMaxConnections sets the maximum number of connections in the pool + SetMaxConnections(n int) + + // SetMaxConnectionLifetime sets the maximum lifetime of a connection + SetMaxConnectionLifetime(d time.Duration) + + // SetMaxConnectionIdleTime sets the maximum idle time of a connection + SetMaxConnectionIdleTime(d time.Duration) +} + +// PoolStats contains connection pool statistics +type PoolStats struct { + TotalConnections int + IdleConnections int + AcquiredConnections int + MaxConnections int +} + +// Rows represents the result of a query +type Rows interface { + // Next prepares the next row for reading + Next() bool + + // Scan reads the values from the current row + Scan(dest ...any) error + + // Close closes the rows + Close() error + + // Err returns any error that occurred during iteration + Err() error +} + +// Row represents a single row result +type Row interface { + // Scan reads the values from the row + Scan(dest ...any) error +} diff --git a/go-b2b-starter/src/pkg/db/core/errors.go b/go-b2b-starter/src/pkg/db/core/errors.go new file mode 100644 index 0000000..4a76502 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/core/errors.go @@ -0,0 +1,77 @@ +package core + +import ( + "errors" + "fmt" +) + +// Common database errors +var ( + // ErrNoRows is returned when a query returns no rows + ErrNoRows = errors.New("no rows in result set") + + // ErrTxClosed is returned when an operation is attempted on a closed transaction + ErrTxClosed = errors.New("transaction has already been committed or rolled back") + + // ErrPoolClosed is returned when an operation is attempted on a closed pool + ErrPoolClosed = errors.New("connection pool is closed") + + // ErrInvalidConnection is returned when the connection is invalid + ErrInvalidConnection = errors.New("invalid database connection") + + // ErrTimeout is returned when a database operation times out + ErrTimeout = errors.New("database operation timed out") +) + +// ErrTxRollbackFailed is returned when a transaction rollback fails +type ErrTxRollbackFailed struct { + OriginalErr error + RollbackErr error +} + +func (e ErrTxRollbackFailed) Error() string { + return fmt.Sprintf("transaction rollback failed: %v (original error: %v)", e.RollbackErr, e.OriginalErr) +} + +func (e ErrTxRollbackFailed) Unwrap() error { + return e.OriginalErr +} + +// ErrTxCommitFailed is returned when a transaction commit fails +type ErrTxCommitFailed struct { + Err error +} + +func (e ErrTxCommitFailed) Error() string { + return fmt.Sprintf("transaction commit failed: %v", e.Err) +} + +func (e ErrTxCommitFailed) Unwrap() error { + return e.Err +} + +// ErrConstraintViolation represents a database constraint violation +type ErrConstraintViolation struct { + Constraint string + Message string +} + +func (e ErrConstraintViolation) Error() string { + return fmt.Sprintf("constraint violation '%s': %s", e.Constraint, e.Message) +} + +// IsNoRowsError checks if an error is a no rows error +func IsNoRowsError(err error) bool { + return errors.Is(err, ErrNoRows) +} + +// IsConstraintError checks if an error is a constraint violation +func IsConstraintError(err error) bool { + var constraintErr ErrConstraintViolation + return errors.As(err, &constraintErr) +} + +// IsTimeoutError checks if an error is a timeout error +func IsTimeoutError(err error) bool { + return errors.Is(err, ErrTimeout) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/db/core/transaction.go b/go-b2b-starter/src/pkg/db/core/transaction.go new file mode 100644 index 0000000..5ce11c9 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/core/transaction.go @@ -0,0 +1,49 @@ +package core + +import "context" + +// Transaction represents a database transaction +type Transaction interface { + Connection + + // Commit commits the transaction + Commit(ctx context.Context) error + + // Rollback rolls back the transaction + Rollback(ctx context.Context) error +} + +// TxFunc represents a function that runs within a transaction +type TxFunc func(ctx context.Context, tx Transaction) error + +// WithTransaction executes a function within a transaction +// It automatically handles commit/rollback based on the function's return value +func WithTransaction(ctx context.Context, pool Pool, fn TxFunc) error { + tx, err := pool.BeginTx(ctx) + if err != nil { + return err + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback(ctx) + panic(p) + } + }() + + if err := fn(ctx, tx); err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return ErrTxRollbackFailed{ + OriginalErr: err, + RollbackErr: rbErr, + } + } + return err + } + + if err := tx.Commit(ctx); err != nil { + return ErrTxCommitFailed{Err: err} + } + + return nil +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/db/go.mod b/go-b2b-starter/src/pkg/db/go.mod new file mode 100644 index 0000000..af3cdc1 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/go.mod @@ -0,0 +1,44 @@ +module github.com/moasq/go-b2b-starter/pkg/db + +go 1.25 + + +require ( + github.com/golang-migrate/migrate/v4 v4.17.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/pgvector/pgvector-go v0.3.0 + github.com/shopspring/decimal v1.4.0 + github.com/spf13/viper v1.19.0 + github.com/twpayne/go-geom v1.6.1 + go.uber.org/dig v1.19.0 +) + +require github.com/rogpeppe/go-internal v1.10.0 // indirect + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/pkg/db/go.sum b/go-b2b-starter/src/pkg/db/go.sum new file mode 100644 index 0000000..f0b426d --- /dev/null +++ b/go-b2b-starter/src/pkg/db/go.sum @@ -0,0 +1,139 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/go-b2b-starter/src/pkg/db/inject.go b/go-b2b-starter/src/pkg/db/inject.go new file mode 100644 index 0000000..e5671d2 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/inject.go @@ -0,0 +1,173 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + "github.com/moasq/go-b2b-starter/pkg/db/postgres" + adapterImpl "github.com/moasq/go-b2b-starter/pkg/db/postgres/adapter_impl" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +// Inject registers all database dependencies in the DI container +func Inject(container *dig.Container) error { + // Register configuration + if err := container.Provide(postgres.LoadConfig); err != nil { + return fmt.Errorf("failed to provide database config: %w", err) + } + + // Register connection pool + if err := container.Provide(provideDBPool); err != nil { + return fmt.Errorf("failed to provide database pool: %w", err) + } + + // Register SQLC store + if err := container.Provide(provideSQLCStore); err != nil { + return fmt.Errorf("failed to provide SQLC store: %w", err) + } + + // Register *sql.DB for modules that need standard database/sql interface + if err := container.Provide(provideSQLDB); err != nil { + return fmt.Errorf("failed to provide SQL DB: %w", err) + } + + // Register domain stores + if err := registerDomainStores(container); err != nil { + return fmt.Errorf("failed to register domain stores: %w", err) + } + + // Register database manager + if err := container.Provide(provideDBManager); err != nil { + return fmt.Errorf("failed to provide database manager: %w", err) + } + + return nil +} + +// provideDBPool creates the database connection pool +func provideDBPool(config postgres.Config) (*pgxpool.Pool, error) { + return postgres.InitDB(config) +} + +// provideSQLCStore creates the SQLC store +func provideSQLCStore(pool *pgxpool.Pool) sqlc.Store { + return sqlc.NewStore(pool) +} + +// provideSQLDB creates a *sql.DB from the pgxpool for compatibility +func provideSQLDB(pool *pgxpool.Pool) *sql.DB { + // Use pgx stdlib to create a sql.DB from the pool connection string + connConfig := pool.Config().ConnConfig + return stdlib.OpenDB(*connConfig) +} + +// provideDBManager creates the database manager for migrations and health checks +func provideDBManager(config postgres.Config, pool *pgxpool.Pool) *postgres.PostgresManager { + return postgres.NewPostgresManager(config, pool) +} + +// registerDomainStores registers all domain-specific stores +func registerDomainStores(container *dig.Container) error { + // Register VisaStore - DISABLED: visa management backed up + // if err := container.Provide(func(sqlcStore sqlc.Store) adapters.VisaStore { + // return adapterImpl.NewVisaStore(sqlcStore) + // }); err != nil { + // return fmt.Errorf("failed to provide visa store: %w", err) + // } + + // Register FileAssetStore - thin wrapper for file management operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.FileAssetStore { + return adapterImpl.NewFileAssetStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide file asset store: %w", err) + } + + // Register OrganizationStore - thin wrapper for organization operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.OrganizationStore { + return adapterImpl.NewOrganizationStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide organization store: %w", err) + } + + // Register AccountStore - thin wrapper for account operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.AccountStore { + return adapterImpl.NewAccountStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide account store: %w", err) + } + + // Register SubscriptionStore - thin wrapper for subscription billing operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.SubscriptionStore { + return adapterImpl.NewSubscriptionStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide subscription store: %w", err) + } + + // Register DocumentStore - thin wrapper for document operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.DocumentStore { + return adapterImpl.NewDocumentStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide document store: %w", err) + } + + // Register EmbeddingStore - thin wrapper for cognitive embedding operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.EmbeddingStore { + return adapterImpl.NewEmbeddingStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide embedding store: %w", err) + } + + // Register ChatStore - thin wrapper for cognitive chat operations + if err := container.Provide(func(sqlcStore sqlc.Store) adapters.ChatStore { + return adapterImpl.NewChatStore(sqlcStore) + }); err != nil { + return fmt.Errorf("failed to provide chat store: %w", err) + } + + return nil +} + +// InjectWithOptions allows injecting with custom options +type InjectOptions struct { + // SkipMigrations skips running database migrations + SkipMigrations bool + + // SkipHealthCheck skips the initial health check + SkipHealthCheck bool +} + +// InjectWithOptions registers database dependencies with options +func InjectWithOptions(container *dig.Container, opts InjectOptions) error { + if err := Inject(container); err != nil { + return err + } + + // Optionally run migrations and health checks + if !opts.SkipMigrations || !opts.SkipHealthCheck { + if err := container.Invoke(func(manager *postgres.PostgresManager) error { + if !opts.SkipHealthCheck { + if err := manager.CheckHealth(context.Background()); err != nil { + return fmt.Errorf("database health check failed: %w", err) + } + } + + if !opts.SkipMigrations { + if err := manager.RunMigrations(); err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + } + + return nil + }); err != nil { + return err + } + } + + return nil +} diff --git a/go-b2b-starter/src/pkg/db/postgres/adapter_impl/cognitive_store.go b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/cognitive_store.go new file mode 100644 index 0000000..4cd0e2b --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/cognitive_store.go @@ -0,0 +1,96 @@ +package adapterimpl + +import ( + "context" + + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +// embeddingStore implements adapters.EmbeddingStore +type embeddingStore struct { + store sqlc.Store +} + +// NewEmbeddingStore creates a new embedding store +func NewEmbeddingStore(store sqlc.Store) adapters.EmbeddingStore { + return &embeddingStore{store: store} +} + +func (s *embeddingStore) CreateDocumentEmbedding(ctx context.Context, arg sqlc.CreateDocumentEmbeddingParams) (sqlc.CognitiveDocumentEmbedding, error) { + return s.store.CreateDocumentEmbedding(ctx, arg) +} + +func (s *embeddingStore) GetDocumentEmbeddingByID(ctx context.Context, arg sqlc.GetDocumentEmbeddingByIDParams) (sqlc.CognitiveDocumentEmbedding, error) { + return s.store.GetDocumentEmbeddingByID(ctx, arg) +} + +func (s *embeddingStore) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg sqlc.GetDocumentEmbeddingsByDocumentIDParams) ([]sqlc.CognitiveDocumentEmbedding, error) { + return s.store.GetDocumentEmbeddingsByDocumentID(ctx, arg) +} + +func (s *embeddingStore) SearchSimilarDocuments(ctx context.Context, arg sqlc.SearchSimilarDocumentsParams) ([]sqlc.SearchSimilarDocumentsRow, error) { + return s.store.SearchSimilarDocuments(ctx, arg) +} + +func (s *embeddingStore) DeleteDocumentEmbeddings(ctx context.Context, arg sqlc.DeleteDocumentEmbeddingsParams) error { + return s.store.DeleteDocumentEmbeddings(ctx, arg) +} + +func (s *embeddingStore) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) { + return s.store.CountDocumentEmbeddingsByOrganization(ctx, organizationID) +} + +// chatStore implements adapters.ChatStore +type chatStore struct { + store sqlc.Store +} + +// NewChatStore creates a new chat store +func NewChatStore(store sqlc.Store) adapters.ChatStore { + return &chatStore{store: store} +} + +// Sessions + +func (s *chatStore) CreateChatSession(ctx context.Context, arg sqlc.CreateChatSessionParams) (sqlc.CognitiveChatSession, error) { + return s.store.CreateChatSession(ctx, arg) +} + +func (s *chatStore) GetChatSessionByID(ctx context.Context, arg sqlc.GetChatSessionByIDParams) (sqlc.CognitiveChatSession, error) { + return s.store.GetChatSessionByID(ctx, arg) +} + +func (s *chatStore) ListChatSessionsByAccount(ctx context.Context, arg sqlc.ListChatSessionsByAccountParams) ([]sqlc.CognitiveChatSession, error) { + return s.store.ListChatSessionsByAccount(ctx, arg) +} + +func (s *chatStore) UpdateChatSessionTitle(ctx context.Context, arg sqlc.UpdateChatSessionTitleParams) (sqlc.CognitiveChatSession, error) { + return s.store.UpdateChatSessionTitle(ctx, arg) +} + +func (s *chatStore) DeleteChatSession(ctx context.Context, arg sqlc.DeleteChatSessionParams) error { + return s.store.DeleteChatSession(ctx, arg) +} + +// Messages + +func (s *chatStore) CreateChatMessage(ctx context.Context, arg sqlc.CreateChatMessageParams) (sqlc.CognitiveChatMessage, error) { + return s.store.CreateChatMessage(ctx, arg) +} + +func (s *chatStore) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]sqlc.CognitiveChatMessage, error) { + return s.store.GetChatMessagesBySession(ctx, sessionID) +} + +func (s *chatStore) GetRecentChatMessages(ctx context.Context, arg sqlc.GetRecentChatMessagesParams) ([]sqlc.CognitiveChatMessage, error) { + return s.store.GetRecentChatMessages(ctx, arg) +} + +func (s *chatStore) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) { + return s.store.CountChatMessagesBySession(ctx, sessionID) +} + +func (s *chatStore) DeleteChatMessage(ctx context.Context, id int32) error { + return s.store.DeleteChatMessage(ctx, id) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/adapter_impl/document_store.go b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/document_store.go new file mode 100644 index 0000000..5a4531f --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/document_store.go @@ -0,0 +1,62 @@ +package adapterimpl + +import ( + "context" + + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" +) + +// documentStore implements adapters.DocumentStore +type documentStore struct { + store sqlc.Store +} + +// NewDocumentStore creates a new document store +func NewDocumentStore(store sqlc.Store) adapters.DocumentStore { + return &documentStore{store: store} +} + +func (s *documentStore) CreateDocument(ctx context.Context, arg sqlc.CreateDocumentParams) (sqlc.DocumentsDocument, error) { + return s.store.CreateDocument(ctx, arg) +} + +func (s *documentStore) GetDocumentByID(ctx context.Context, arg sqlc.GetDocumentByIDParams) (sqlc.DocumentsDocument, error) { + return s.store.GetDocumentByID(ctx, arg) +} + +func (s *documentStore) GetDocumentByFileAssetID(ctx context.Context, arg sqlc.GetDocumentByFileAssetIDParams) (sqlc.DocumentsDocument, error) { + return s.store.GetDocumentByFileAssetID(ctx, arg) +} + +func (s *documentStore) ListDocumentsByOrganization(ctx context.Context, arg sqlc.ListDocumentsByOrganizationParams) ([]sqlc.DocumentsDocument, error) { + return s.store.ListDocumentsByOrganization(ctx, arg) +} + +func (s *documentStore) ListDocumentsByStatus(ctx context.Context, arg sqlc.ListDocumentsByStatusParams) ([]sqlc.DocumentsDocument, error) { + return s.store.ListDocumentsByStatus(ctx, arg) +} + +func (s *documentStore) UpdateDocumentStatus(ctx context.Context, arg sqlc.UpdateDocumentStatusParams) (sqlc.DocumentsDocument, error) { + return s.store.UpdateDocumentStatus(ctx, arg) +} + +func (s *documentStore) UpdateDocumentExtractedText(ctx context.Context, arg sqlc.UpdateDocumentExtractedTextParams) (sqlc.DocumentsDocument, error) { + return s.store.UpdateDocumentExtractedText(ctx, arg) +} + +func (s *documentStore) UpdateDocument(ctx context.Context, arg sqlc.UpdateDocumentParams) (sqlc.DocumentsDocument, error) { + return s.store.UpdateDocument(ctx, arg) +} + +func (s *documentStore) DeleteDocument(ctx context.Context, arg sqlc.DeleteDocumentParams) error { + return s.store.DeleteDocument(ctx, arg) +} + +func (s *documentStore) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) { + return s.store.CountDocumentsByOrganization(ctx, organizationID) +} + +func (s *documentStore) CountDocumentsByStatus(ctx context.Context, arg sqlc.CountDocumentsByStatusParams) (int64, error) { + return s.store.CountDocumentsByStatus(ctx, arg) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/adapter_impl/file_asset_store.go b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/file_asset_store.go new file mode 100644 index 0000000..fbab225 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/file_asset_store.go @@ -0,0 +1,74 @@ +package adapterimpl + +import ( + "context" + + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" +) + +// fileAssetStore is a thin wrapper around SQLC store that implements adapters.FileAssetStore +// It only exposes file asset-related methods and delegates directly to the underlying store +type fileAssetStore struct { + store sqlc.Store +} + +// NewFileAssetStore creates a new file asset store wrapper +func NewFileAssetStore(store sqlc.Store) adapters.FileAssetStore { + return &fileAssetStore{ + store: store, + } +} + +// Basic file asset operations - direct delegation to SQLC store +func (f *fileAssetStore) CreateFileAsset(ctx context.Context, arg sqlc.CreateFileAssetParams) (sqlc.FileManagerFileAsset, error) { + return f.store.CreateFileAsset(ctx, arg) +} + +func (f *fileAssetStore) GetFileAssetByID(ctx context.Context, id int32) (sqlc.FileManagerFileAsset, error) { + return f.store.GetFileAssetByID(ctx, id) +} + +func (f *fileAssetStore) DeleteFileAsset(ctx context.Context, id int32) error { + return f.store.DeleteFileAsset(ctx, id) +} + +func (f *fileAssetStore) GetFileAssetsByEntity(ctx context.Context, arg sqlc.GetFileAssetsByEntityParams) ([]sqlc.FileManagerFileAsset, error) { + return f.store.GetFileAssetsByEntity(ctx, arg) +} + +func (f *fileAssetStore) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg sqlc.GetFileAssetsByEntityAndPurposeParams) ([]sqlc.FileManagerFileAsset, error) { + return f.store.GetFileAssetsByEntityAndPurpose(ctx, arg) +} + +// Category and context-based operations - direct delegation +func (f *fileAssetStore) GetFileAssetsByCategory(ctx context.Context, categoryName string) ([]sqlc.GetFileAssetsByCategoryRow, error) { + return f.store.GetFileAssetsByCategory(ctx, categoryName) +} + +func (f *fileAssetStore) GetFileAssetsByContext(ctx context.Context, contextName string) ([]sqlc.GetFileAssetsByContextRow, error) { + return f.store.GetFileAssetsByContext(ctx, contextName) +} + +// Update operations - direct delegation +func (f *fileAssetStore) UpdateFileAsset(ctx context.Context, arg sqlc.UpdateFileAssetParams) error { + return f.store.UpdateFileAsset(ctx, arg) +} + +// Search and lookup operations - direct delegation +func (f *fileAssetStore) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (sqlc.FileManagerFileAsset, error) { + return f.store.GetFileAssetByStoragePath(ctx, storagePath) +} + +func (f *fileAssetStore) ListFileAssets(ctx context.Context, arg sqlc.ListFileAssetsParams) ([]sqlc.ListFileAssetsRow, error) { + return f.store.ListFileAssets(ctx, arg) +} + +// Lookup tables operations - direct delegation +func (f *fileAssetStore) GetFileCategories(ctx context.Context) ([]sqlc.FileManagerFileCategory, error) { + return f.store.GetFileCategories(ctx) +} + +func (f *fileAssetStore) GetFileContexts(ctx context.Context) ([]sqlc.FileManagerFileContext, error) { + return f.store.GetFileContexts(ctx) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/adapter_impl/organization_store.go b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/organization_store.go new file mode 100644 index 0000000..3e1a8e8 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/organization_store.go @@ -0,0 +1,113 @@ +package adapterimpl + +import ( + "context" + + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + "github.com/jackc/pgx/v5/pgtype" +) + +// organizationStore implements adapters.OrganizationStore +type organizationStore struct { + store sqlc.Store +} + +// NewOrganizationStore creates a new organization store +func NewOrganizationStore(store sqlc.Store) adapters.OrganizationStore { + return &organizationStore{store: store} +} + +func (s *organizationStore) GetOrganizationByID(ctx context.Context, id int32) (sqlc.OrganizationsOrganization, error) { + return s.store.GetOrganizationByID(ctx, id) +} + +func (s *organizationStore) GetOrganizationBySlug(ctx context.Context, slug string) (sqlc.OrganizationsOrganization, error) { + return s.store.GetOrganizationBySlug(ctx, slug) +} + +func (s *organizationStore) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (sqlc.OrganizationsOrganization, error) { + return s.store.GetOrganizationByStytchID(ctx, stytchOrgID) +} + +func (s *organizationStore) GetOrganizationByUserEmail(ctx context.Context, email string) (sqlc.OrganizationsOrganization, error) { + return s.store.GetOrganizationByUserEmail(ctx, email) +} + +func (s *organizationStore) CreateOrganization(ctx context.Context, arg sqlc.CreateOrganizationParams) (sqlc.OrganizationsOrganization, error) { + return s.store.CreateOrganization(ctx, arg) +} + +func (s *organizationStore) UpdateOrganization(ctx context.Context, arg sqlc.UpdateOrganizationParams) (sqlc.OrganizationsOrganization, error) { + return s.store.UpdateOrganization(ctx, arg) +} + +func (s *organizationStore) UpdateOrganizationStytchInfo(ctx context.Context, arg sqlc.UpdateOrganizationStytchInfoParams) (sqlc.OrganizationsOrganization, error) { + return s.store.UpdateOrganizationStytchInfo(ctx, arg) +} + +func (s *organizationStore) ListOrganizations(ctx context.Context, arg sqlc.ListOrganizationsParams) ([]sqlc.OrganizationsOrganization, error) { + return s.store.ListOrganizations(ctx, arg) +} + +func (s *organizationStore) DeleteOrganization(ctx context.Context, id int32) error { + return s.store.DeleteOrganization(ctx, id) +} + +func (s *organizationStore) GetOrganizationStats(ctx context.Context, id int32) (sqlc.GetOrganizationStatsRow, error) { + return s.store.GetOrganizationStats(ctx, id) +} + +// accountStore implements adapters.AccountStore +type accountStore struct { + store sqlc.Store +} + +// NewAccountStore creates a new account store +func NewAccountStore(store sqlc.Store) adapters.AccountStore { + return &accountStore{store: store} +} + +func (s *accountStore) CreateAccount(ctx context.Context, arg sqlc.CreateAccountParams) (sqlc.OrganizationsAccount, error) { + return s.store.CreateAccount(ctx, arg) +} + +func (s *accountStore) GetAccountByID(ctx context.Context, arg sqlc.GetAccountByIDParams) (sqlc.OrganizationsAccount, error) { + return s.store.GetAccountByID(ctx, arg) +} + +func (s *accountStore) GetAccountByEmail(ctx context.Context, arg sqlc.GetAccountByEmailParams) (sqlc.OrganizationsAccount, error) { + return s.store.GetAccountByEmail(ctx, arg) +} + +func (s *accountStore) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]sqlc.OrganizationsAccount, error) { + return s.store.ListAccountsByOrganization(ctx, organizationID) +} + +func (s *accountStore) UpdateAccount(ctx context.Context, arg sqlc.UpdateAccountParams) (sqlc.OrganizationsAccount, error) { + return s.store.UpdateAccount(ctx, arg) +} + +func (s *accountStore) UpdateAccountStytchInfo(ctx context.Context, arg sqlc.UpdateAccountStytchInfoParams) (sqlc.OrganizationsAccount, error) { + return s.store.UpdateAccountStytchInfo(ctx, arg) +} + +func (s *accountStore) UpdateAccountLastLogin(ctx context.Context, arg sqlc.UpdateAccountLastLoginParams) (sqlc.OrganizationsAccount, error) { + return s.store.UpdateAccountLastLogin(ctx, arg) +} + +func (s *accountStore) DeleteAccount(ctx context.Context, arg sqlc.DeleteAccountParams) error { + return s.store.DeleteAccount(ctx, arg) +} + +func (s *accountStore) GetAccountOrganization(ctx context.Context, id int32) (sqlc.OrganizationsOrganization, error) { + return s.store.GetAccountOrganization(ctx, id) +} + +func (s *accountStore) CheckAccountPermission(ctx context.Context, arg sqlc.CheckAccountPermissionParams) (sqlc.CheckAccountPermissionRow, error) { + return s.store.CheckAccountPermission(ctx, arg) +} + +func (s *accountStore) GetAccountStats(ctx context.Context, id int32) (sqlc.GetAccountStatsRow, error) { + return s.store.GetAccountStats(ctx, id) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/adapter_impl/subscription_store.go b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/subscription_store.go new file mode 100644 index 0000000..fcc2dbb --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/adapter_impl/subscription_store.go @@ -0,0 +1,68 @@ +package adapterimpl + +import ( + "context" + + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" +) + +// subscriptionStore implements adapters.SubscriptionStore +type subscriptionStore struct { + store sqlc.Store +} + +// NewSubscriptionStore creates a new subscription store +func NewSubscriptionStore(store sqlc.Store) adapters.SubscriptionStore { + return &subscriptionStore{store: store} +} + +// Subscription operations + +func (s *subscriptionStore) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingSubscription, error) { + return s.store.GetSubscriptionByOrgID(ctx, organizationID) +} + +func (s *subscriptionStore) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (sqlc.SubscriptionBillingSubscription, error) { + return s.store.GetSubscriptionBySubscriptionID(ctx, subscriptionID) +} + +func (s *subscriptionStore) UpsertSubscription(ctx context.Context, arg sqlc.UpsertSubscriptionParams) (sqlc.SubscriptionBillingSubscription, error) { + return s.store.UpsertSubscription(ctx, arg) +} + +func (s *subscriptionStore) DeleteSubscription(ctx context.Context, organizationID int32) error { + return s.store.DeleteSubscription(ctx, organizationID) +} + +func (s *subscriptionStore) ListActiveSubscriptions(ctx context.Context) ([]sqlc.SubscriptionBillingSubscription, error) { + return s.store.ListActiveSubscriptions(ctx) +} + +// Quota operations + +func (s *subscriptionStore) GetQuotaByOrgID(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingQuotaTracking, error) { + return s.store.GetQuotaByOrgID(ctx, organizationID) +} + +func (s *subscriptionStore) UpsertQuota(ctx context.Context, arg sqlc.UpsertQuotaParams) (sqlc.SubscriptionBillingQuotaTracking, error) { + return s.store.UpsertQuota(ctx, arg) +} + +func (s *subscriptionStore) DecrementInvoiceCount(ctx context.Context, organizationID int32) (sqlc.SubscriptionBillingQuotaTracking, error) { + return s.store.DecrementInvoiceCount(ctx, organizationID) +} + +func (s *subscriptionStore) ResetQuotaForPeriod(ctx context.Context, arg sqlc.ResetQuotaForPeriodParams) (sqlc.SubscriptionBillingQuotaTracking, error) { + return s.store.ResetQuotaForPeriod(ctx, arg) +} + +// Combined operations + +func (s *subscriptionStore) GetQuotaStatus(ctx context.Context, organizationID int32) (sqlc.GetQuotaStatusRow, error) { + return s.store.GetQuotaStatus(ctx, organizationID) +} + +func (s *subscriptionStore) ListQuotasNearLimit(ctx context.Context, threshold int32) ([]sqlc.ListQuotasNearLimitRow, error) { + return s.store.ListQuotasNearLimit(ctx, threshold) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/connection.go b/go-b2b-starter/src/pkg/db/postgres/connection.go new file mode 100644 index 0000000..5677e17 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/connection.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + "fmt" + "log" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func connPool(cfg Config) (*pgxpool.Pool, error) { + // Create a pool configuration + poolConfig, err := pgxpool.ParseConfig(cfg.ConnectionString()) + if err != nil { + return nil, fmt.Errorf("unable to parse pool config: %w", err) + } + + // Set pool configuration parameters + poolConfig.MaxConns = int32(cfg.MaxConns) + poolConfig.MinConns = int32(cfg.MinConns) + poolConfig.MaxConnLifetime = cfg.ConnLifetime + poolConfig.MaxConnIdleTime = cfg.ConnIdleTime + poolConfig.HealthCheckPeriod = cfg.HealthCheckPeriod + + // Add connection lifecycle callbacks + poolConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { + // Optional validation before using a connection + return true + } + + poolConfig.AfterRelease = func(conn *pgx.Conn) bool { + // Clean up after connection use if needed + return true + } + + // Create the connection pool with the configured settings + pool, err := pgxpool.NewWithConfig(context.Background(), poolConfig) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %w", err) + } + + // Test the connection + if err := pool.Ping(context.Background()); err != nil { + return nil, fmt.Errorf("unable to ping database: %w", err) + } + + log.Printf("Successfully connected to PostgreSQL database at %s:%s", cfg.Host, cfg.Port) + + return pool, nil +} diff --git a/go-b2b-starter/src/pkg/db/postgres/db_config.go b/go-b2b-starter/src/pkg/db/postgres/db_config.go new file mode 100644 index 0000000..125e78c --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/db_config.go @@ -0,0 +1,70 @@ +package postgres + +import ( + "fmt" + "time" + + "github.com/spf13/viper" +) + +type Config struct { + Host string `mapstructure:"POSTGRES_HOST"` + Port string `mapstructure:"POSTGRES_PORT"` + User string `mapstructure:"POSTGRES_USER"` + Password string `mapstructure:"POSTGRES_PASSWORD"` + DBName string `mapstructure:"POSTGRES_DB"` + SSLMode string `mapstructure:"DB_SSL_MODE"` + MigrationURL string `mapstructure:"MIGRATION_URL"` + SeedURL string `mapstructure:"SEED_URL"` + + // Connection pool settings + MaxConns int `mapstructure:"DB_MAX_CONNS"` + MinConns int `mapstructure:"DB_MIN_CONNS"` + ConnLifetime time.Duration `mapstructure:"DB_CONN_LIFETIME"` + ConnIdleTime time.Duration `mapstructure:"DB_CONN_IDLE_TIME"` + HealthCheckPeriod time.Duration `mapstructure:"DB_HEALTH_CHECK_PERIOD"` +} + +// ConnectionString returns a formatted PostgreSQL connection string +func (c Config) ConnectionString() string { + return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s application_name=nomadezy_api", + c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode) +} + +// LoadConfig reads configuration from file or environment variables. +func LoadConfig() (Config, error) { + var cfg Config + + viper.SetConfigName("app") // Name of the config file (without extension) + viper.SetConfigType("env") // Set the type of the configuration files - .env + viper.AddConfigPath(".") // Optionally look for config in the working directory + viper.AutomaticEnv() + + // Set default values, these are overridden if values are present in config or environment variables + viper.SetDefault("POSTGRES_PORT", "5432") + viper.SetDefault("DB_SSL_MODE", "disable") // Use "require" in production, "disable" for local dev + viper.SetDefault("POSTGRES_HOST", "localhost") + viper.SetDefault("POSTGRES_USER", "user") + viper.SetDefault("POSTGRES_PASSWORD", "password") + viper.SetDefault("POSTGRES_DB", "mydatabase") + + // Connection pool defaults + viper.SetDefault("DB_MAX_CONNS", 20) + viper.SetDefault("DB_MIN_CONNS", 5) + viper.SetDefault("DB_CONN_LIFETIME", "1h") + viper.SetDefault("DB_CONN_IDLE_TIME", "30m") + viper.SetDefault("DB_HEALTH_CHECK_PERIOD", "1m") + + viper.SetDefault("MIGRATION_URL", "/migrations") + viper.SetDefault("SEED_URL", "/seed") + + if err := viper.ReadInConfig(); err == nil { + _ = err // Placeholder statement to avoid empty branch error + } + + if err := viper.Unmarshal(&cfg); err != nil { + return cfg, err + } + + return cfg, nil +} diff --git a/go-b2b-starter/src/pkg/db/postgres/init.go b/go-b2b-starter/src/pkg/db/postgres/init.go new file mode 100644 index 0000000..246dfb5 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/init.go @@ -0,0 +1,46 @@ +package postgres + +import ( + "context" + "log" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// InitDB initializes and returns a connection pool to the database +func InitDB(cfg Config) (*pgxpool.Pool, error) { + + // Create connection pool with retry logic + var pool *pgxpool.Pool + + // Setup context with timeout for initial connection + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := RetryOperation(ctx, func(ctx context.Context) error { + var connErr error + pool, connErr = connPool(cfg) + return connErr + }) + + if err != nil { + log.Printf("Failed to initialize database connection after retries: %v", err) + return nil, err + } + + log.Println("Database connection successfully initialized") + + // Perform initial health check + healthCtx, healthCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer healthCancel() + + manager := NewPostgresManager(cfg, pool) + if err := manager.CheckHealth(healthCtx); err != nil { + log.Printf("Initial database health check failed: %v", err) + pool.Close() + return nil, err + } + + return pool, nil +} diff --git a/go-b2b-starter/src/pkg/db/postgres/postgres_manager.go b/go-b2b-starter/src/pkg/db/postgres/postgres_manager.go new file mode 100644 index 0000000..47896ef --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/postgres_manager.go @@ -0,0 +1,150 @@ +package postgres + +import ( + "fmt" + "log" + "os" + "sort" + + "github.com/jackc/pgx/v5/pgxpool" + + "context" + + "path/filepath" + "strings" + "time" + + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +// PostgresManager implements DatabaseManager for PostgreSQL +type PostgresManager struct { + config Config + connPool *pgxpool.Pool +} + +// NewPostgresManager creates a new PostgresManager +func NewPostgresManager(config Config, connPool *pgxpool.Pool) *PostgresManager { + return &PostgresManager{ + config: config, + connPool: connPool, + } +} + +// CheckHealth performs a health check on the database connection +func (pm *PostgresManager) CheckHealth(ctx context.Context) error { + // Create a context with timeout for health check + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + // Try to ping the database + err := pm.connPool.Ping(ctx) + if err != nil { + log.Printf("Database health check failed: %v", err) + return fmt.Errorf("database health check failed: %w", err) + } + + log.Println("Database health check successful") + return nil +} + +func (pm *PostgresManager) RunMigrations() error { + ctx := context.Background() + + // Log the migration URL + log.Printf("Migration URL: %s", pm.config.MigrationURL) + + // Check if the directory exists + if _, err := os.Stat(pm.config.MigrationURL); os.IsNotExist(err) { + log.Printf("Migration directory does not exist: %s", pm.config.MigrationURL) + return fmt.Errorf("migration directory does not exist: %w", err) + } + + files, err := os.ReadDir(pm.config.MigrationURL) + if err != nil { + log.Printf("Failed to read migrations directory: %v", err) + return fmt.Errorf("failed to read migrations directory: %w", err) + } + + log.Printf("Total files in migration directory: %d", len(files)) + + // Filter and sort migration files + var migrationFiles []string + for _, file := range files { + log.Printf("Found file: %s", file.Name()) + if strings.HasSuffix(file.Name(), ".up.sql") { + migrationFiles = append(migrationFiles, file.Name()) + } + } + sort.Strings(migrationFiles) + + log.Printf("Migration files to execute: %v", migrationFiles) + + for _, fileName := range migrationFiles { + fullPath := filepath.Join(pm.config.MigrationURL, fileName) + log.Printf("Attempting to read file: %s", fullPath) + + content, err := os.ReadFile(fullPath) + if err != nil { + log.Printf("Failed to read migration file %s: %v", fileName, err) + return fmt.Errorf("failed to read migration file %s: %w", fileName, err) + } + + _, err = pm.connPool.Exec(ctx, string(content)) + if err != nil { + log.Printf("Failed to execute migration file %s: %v", fileName, err) + return fmt.Errorf("failed to execute migration file %s: %w", fileName, err) + } + + log.Printf("Executed migration file: %s", fileName) + } + + return nil +} + +func (pm *PostgresManager) RunSeeds() error { + ctx := context.Background() + + // Log the seed URL + log.Printf("Seed URL: %s", pm.config.SeedURL) + + // Check if the directory exists + if _, err := os.Stat(pm.config.SeedURL); os.IsNotExist(err) { + log.Printf("Seed directory does not exist: %s", pm.config.SeedURL) + return fmt.Errorf("seed directory does not exist: %w", err) + } + + files, err := os.ReadDir(pm.config.SeedURL) + if err != nil { + log.Printf("Failed to read seeds directory: %v", err) + return fmt.Errorf("failed to read seeds directory: %w", err) + } + + log.Printf("Total files in seed directory: %d", len(files)) + + for _, file := range files { + log.Printf("Found file: %s", file.Name()) + if !strings.HasSuffix(file.Name(), ".sql") { + continue + } + + fullPath := filepath.Join(pm.config.SeedURL, file.Name()) + log.Printf("Attempting to read file: %s", fullPath) + + content, err := os.ReadFile(fullPath) + if err != nil { + log.Printf("Failed to read seed file %s: %v", file.Name(), err) + return fmt.Errorf("failed to read seed file %s: %w", file.Name(), err) + } + + _, err = pm.connPool.Exec(ctx, string(content)) + if err != nil { + log.Printf("Failed to execute seed file %s: %v", file.Name(), err) + return fmt.Errorf("failed to execute seed file %s: %w", file.Name(), err) + } + + log.Printf("Executed seed file: %s", file.Name()) + } + + return nil +} diff --git a/go-b2b-starter/src/pkg/db/postgres/retry.go b/go-b2b-starter/src/pkg/db/postgres/retry.go new file mode 100644 index 0000000..071e795 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/retry.go @@ -0,0 +1,58 @@ +package postgres + +import ( + "context" + "errors" + "log" + "time" +) + +const ( + maxRetries = 3 + retryDelay = 100 * time.Millisecond + maxRetryDelay = 1 * time.Second +) + +// RetryOperation executes a database operation with exponential backoff retry +func RetryOperation(ctx context.Context, operation func(context.Context) error) error { + var err error + + backoff := retryDelay + for i := 0; i < maxRetries; i++ { + // Execute the operation + err = operation(ctx) + + // If no error or context cancelled, return immediately + if err == nil || errors.Is(err, context.Canceled) { + return err + } + + // Log the error for debugging + log.Printf("Database operation failed (attempt %d/%d): %v", i+1, maxRetries, err) + + // Don't retry if the final attempt + if i == maxRetries-1 { + break + } + + // Wait with backoff before retrying + select { + case <-time.After(backoff): + // Exponential backoff + backoff *= 2 + if backoff > maxRetryDelay { + backoff = maxRetryDelay + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return err +} + +// CreateDBContext creates a context with an appropriate timeout for database operations +func CreateDBContext(parent context.Context) (context.Context, context.CancelFunc) { + // Default timeout of 10 seconds for database operations + return context.WithTimeout(parent, 10*time.Second) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/cognitive.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/cognitive.sql.go new file mode 100644 index 0000000..cd87363 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/cognitive.sql.go @@ -0,0 +1,506 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: cognitive.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" + pgvector_go "github.com/pgvector/pgvector-go" +) + +const countChatMessagesBySession = `-- name: CountChatMessagesBySession :one +SELECT COUNT(*) FROM cognitive.chat_messages +WHERE session_id = $1 +` + +func (q *Queries) CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) { + row := q.db.QueryRow(ctx, countChatMessagesBySession, sessionID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countDocumentEmbeddingsByOrganization = `-- name: CountDocumentEmbeddingsByOrganization :one +SELECT COUNT(*) FROM cognitive.document_embeddings +WHERE organization_id = $1 +` + +func (q *Queries) CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) { + row := q.db.QueryRow(ctx, countDocumentEmbeddingsByOrganization, organizationID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createChatMessage = `-- name: CreateChatMessage :one + +INSERT INTO cognitive.chat_messages ( + session_id, + role, + content, + referenced_docs, + tokens_used +) VALUES ( + $1, $2, $3, $4, $5 +) RETURNING id, session_id, role, content, referenced_docs, tokens_used, created_at +` + +type CreateChatMessageParams struct { + SessionID int32 `json:"session_id"` + Role string `json:"role"` + Content string `json:"content"` + ReferencedDocs []int32 `json:"referenced_docs"` + TokensUsed pgtype.Int4 `json:"tokens_used"` +} + +// Chat Messages +func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (CognitiveChatMessage, error) { + row := q.db.QueryRow(ctx, createChatMessage, + arg.SessionID, + arg.Role, + arg.Content, + arg.ReferencedDocs, + arg.TokensUsed, + ) + var i CognitiveChatMessage + err := row.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Content, + &i.ReferencedDocs, + &i.TokensUsed, + &i.CreatedAt, + ) + return i, err +} + +const createChatSession = `-- name: CreateChatSession :one + +INSERT INTO cognitive.chat_sessions ( + organization_id, + account_id, + title +) VALUES ( + $1, $2, $3 +) RETURNING id, organization_id, account_id, title, created_at, updated_at +` + +type CreateChatSessionParams struct { + OrganizationID int32 `json:"organization_id"` + AccountID int32 `json:"account_id"` + Title pgtype.Text `json:"title"` +} + +// Chat Sessions +func (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionParams) (CognitiveChatSession, error) { + row := q.db.QueryRow(ctx, createChatSession, arg.OrganizationID, arg.AccountID, arg.Title) + var i CognitiveChatSession + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.AccountID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDocumentEmbedding = `-- name: CreateDocumentEmbedding :one + + +INSERT INTO cognitive.document_embeddings ( + document_id, + organization_id, + embedding, + content_hash, + content_preview, + chunk_index +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at +` + +type CreateDocumentEmbeddingParams struct { + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + Embedding pgvector_go.Vector `json:"embedding"` + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` + ChunkIndex pgtype.Int4 `json:"chunk_index"` +} + +// Cognitive Agent queries +// Document Embeddings +func (q *Queries) CreateDocumentEmbedding(ctx context.Context, arg CreateDocumentEmbeddingParams) (CognitiveDocumentEmbedding, error) { + row := q.db.QueryRow(ctx, createDocumentEmbedding, + arg.DocumentID, + arg.OrganizationID, + arg.Embedding, + arg.ContentHash, + arg.ContentPreview, + arg.ChunkIndex, + ) + var i CognitiveDocumentEmbedding + err := row.Scan( + &i.ID, + &i.DocumentID, + &i.OrganizationID, + &i.Embedding, + &i.ContentHash, + &i.ContentPreview, + &i.ChunkIndex, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteChatMessage = `-- name: DeleteChatMessage :exec +DELETE FROM cognitive.chat_messages +WHERE id = $1 +` + +func (q *Queries) DeleteChatMessage(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteChatMessage, id) + return err +} + +const deleteChatSession = `-- name: DeleteChatSession :exec +DELETE FROM cognitive.chat_sessions +WHERE id = $1 AND organization_id = $2 +` + +type DeleteChatSessionParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) DeleteChatSession(ctx context.Context, arg DeleteChatSessionParams) error { + _, err := q.db.Exec(ctx, deleteChatSession, arg.ID, arg.OrganizationID) + return err +} + +const deleteDocumentEmbeddings = `-- name: DeleteDocumentEmbeddings :exec +DELETE FROM cognitive.document_embeddings +WHERE document_id = $1 AND organization_id = $2 +` + +type DeleteDocumentEmbeddingsParams struct { + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) DeleteDocumentEmbeddings(ctx context.Context, arg DeleteDocumentEmbeddingsParams) error { + _, err := q.db.Exec(ctx, deleteDocumentEmbeddings, arg.DocumentID, arg.OrganizationID) + return err +} + +const getChatMessagesBySession = `-- name: GetChatMessagesBySession :many +SELECT id, session_id, role, content, referenced_docs, tokens_used, created_at FROM cognitive.chat_messages +WHERE session_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]CognitiveChatMessage, error) { + rows, err := q.db.Query(ctx, getChatMessagesBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CognitiveChatMessage{} + for rows.Next() { + var i CognitiveChatMessage + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Content, + &i.ReferencedDocs, + &i.TokensUsed, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatSessionByID = `-- name: GetChatSessionByID :one +SELECT id, organization_id, account_id, title, created_at, updated_at FROM cognitive.chat_sessions +WHERE id = $1 AND organization_id = $2 +` + +type GetChatSessionByIDParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetChatSessionByID(ctx context.Context, arg GetChatSessionByIDParams) (CognitiveChatSession, error) { + row := q.db.QueryRow(ctx, getChatSessionByID, arg.ID, arg.OrganizationID) + var i CognitiveChatSession + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.AccountID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDocumentEmbeddingByID = `-- name: GetDocumentEmbeddingByID :one +SELECT id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at FROM cognitive.document_embeddings +WHERE id = $1 AND organization_id = $2 +` + +type GetDocumentEmbeddingByIDParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetDocumentEmbeddingByID(ctx context.Context, arg GetDocumentEmbeddingByIDParams) (CognitiveDocumentEmbedding, error) { + row := q.db.QueryRow(ctx, getDocumentEmbeddingByID, arg.ID, arg.OrganizationID) + var i CognitiveDocumentEmbedding + err := row.Scan( + &i.ID, + &i.DocumentID, + &i.OrganizationID, + &i.Embedding, + &i.ContentHash, + &i.ContentPreview, + &i.ChunkIndex, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDocumentEmbeddingsByDocumentID = `-- name: GetDocumentEmbeddingsByDocumentID :many +SELECT id, document_id, organization_id, embedding, content_hash, content_preview, chunk_index, created_at, updated_at FROM cognitive.document_embeddings +WHERE document_id = $1 AND organization_id = $2 +ORDER BY chunk_index +` + +type GetDocumentEmbeddingsByDocumentIDParams struct { + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg GetDocumentEmbeddingsByDocumentIDParams) ([]CognitiveDocumentEmbedding, error) { + rows, err := q.db.Query(ctx, getDocumentEmbeddingsByDocumentID, arg.DocumentID, arg.OrganizationID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CognitiveDocumentEmbedding{} + for rows.Next() { + var i CognitiveDocumentEmbedding + if err := rows.Scan( + &i.ID, + &i.DocumentID, + &i.OrganizationID, + &i.Embedding, + &i.ContentHash, + &i.ContentPreview, + &i.ChunkIndex, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRecentChatMessages = `-- name: GetRecentChatMessages :many +SELECT id, session_id, role, content, referenced_docs, tokens_used, created_at FROM cognitive.chat_messages +WHERE session_id = $1 +ORDER BY created_at DESC +LIMIT $2 +` + +type GetRecentChatMessagesParams struct { + SessionID int32 `json:"session_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) GetRecentChatMessages(ctx context.Context, arg GetRecentChatMessagesParams) ([]CognitiveChatMessage, error) { + rows, err := q.db.Query(ctx, getRecentChatMessages, arg.SessionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CognitiveChatMessage{} + for rows.Next() { + var i CognitiveChatMessage + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Content, + &i.ReferencedDocs, + &i.TokensUsed, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChatSessionsByAccount = `-- name: ListChatSessionsByAccount :many +SELECT id, organization_id, account_id, title, created_at, updated_at FROM cognitive.chat_sessions +WHERE organization_id = $1 AND account_id = $2 +ORDER BY updated_at DESC +LIMIT $3 OFFSET $4 +` + +type ListChatSessionsByAccountParams struct { + OrganizationID int32 `json:"organization_id"` + AccountID int32 `json:"account_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListChatSessionsByAccount(ctx context.Context, arg ListChatSessionsByAccountParams) ([]CognitiveChatSession, error) { + rows, err := q.db.Query(ctx, listChatSessionsByAccount, + arg.OrganizationID, + arg.AccountID, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CognitiveChatSession{} + for rows.Next() { + var i CognitiveChatSession + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.AccountID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchSimilarDocuments = `-- name: SearchSimilarDocuments :many +SELECT + de.id, + de.document_id, + de.organization_id, + de.content_hash, + de.content_preview, + de.chunk_index, + de.created_at, + de.updated_at, + (1 - (de.embedding <=> $1::vector))::double precision as similarity_score +FROM cognitive.document_embeddings de +WHERE de.organization_id = $2 +ORDER BY de.embedding <=> $1::vector +LIMIT $3 +` + +type SearchSimilarDocumentsParams struct { + Column1 pgvector_go.Vector `json:"column_1"` + OrganizationID int32 `json:"organization_id"` + Limit int32 `json:"limit"` +} + +type SearchSimilarDocumentsRow struct { + ID int32 `json:"id"` + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` + ChunkIndex pgtype.Int4 `json:"chunk_index"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + SimilarityScore float64 `json:"similarity_score"` +} + +func (q *Queries) SearchSimilarDocuments(ctx context.Context, arg SearchSimilarDocumentsParams) ([]SearchSimilarDocumentsRow, error) { + rows, err := q.db.Query(ctx, searchSimilarDocuments, arg.Column1, arg.OrganizationID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchSimilarDocumentsRow{} + for rows.Next() { + var i SearchSimilarDocumentsRow + if err := rows.Scan( + &i.ID, + &i.DocumentID, + &i.OrganizationID, + &i.ContentHash, + &i.ContentPreview, + &i.ChunkIndex, + &i.CreatedAt, + &i.UpdatedAt, + &i.SimilarityScore, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateChatSessionTitle = `-- name: UpdateChatSessionTitle :one +UPDATE cognitive.chat_sessions +SET title = $3, updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING id, organization_id, account_id, title, created_at, updated_at +` + +type UpdateChatSessionTitleParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + Title pgtype.Text `json:"title"` +} + +func (q *Queries) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSessionTitleParams) (CognitiveChatSession, error) { + row := q.db.QueryRow(ctx, updateChatSessionTitle, arg.ID, arg.OrganizationID, arg.Title) + var i CognitiveChatSession + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.AccountID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/db.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/db.go new file mode 100644 index 0000000..0c307ea --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/documents.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/documents.sql.go new file mode 100644 index 0000000..19adfb4 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/documents.sql.go @@ -0,0 +1,382 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: documents.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countDocumentsByOrganization = `-- name: CountDocumentsByOrganization :one +SELECT COUNT(*) FROM documents.documents +WHERE organization_id = $1 +` + +func (q *Queries) CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) { + row := q.db.QueryRow(ctx, countDocumentsByOrganization, organizationID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countDocumentsByStatus = `-- name: CountDocumentsByStatus :one +SELECT COUNT(*) FROM documents.documents +WHERE organization_id = $1 AND status = $2 +` + +type CountDocumentsByStatusParams struct { + OrganizationID int32 `json:"organization_id"` + Status string `json:"status"` +} + +func (q *Queries) CountDocumentsByStatus(ctx context.Context, arg CountDocumentsByStatusParams) (int64, error) { + row := q.db.QueryRow(ctx, countDocumentsByStatus, arg.OrganizationID, arg.Status) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createDocument = `-- name: CreateDocument :one + +INSERT INTO documents.documents ( + organization_id, + file_asset_id, + title, + file_name, + content_type, + file_size, + extracted_text, + status, + metadata +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at +` + +type CreateDocumentParams struct { + OrganizationID int32 `json:"organization_id"` + FileAssetID int32 `json:"file_asset_id"` + Title string `json:"title"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + FileSize int64 `json:"file_size"` + ExtractedText pgtype.Text `json:"extracted_text"` + Status string `json:"status"` + Metadata []byte `json:"metadata"` +} + +// Documents queries +func (q *Queries) CreateDocument(ctx context.Context, arg CreateDocumentParams) (DocumentsDocument, error) { + row := q.db.QueryRow(ctx, createDocument, + arg.OrganizationID, + arg.FileAssetID, + arg.Title, + arg.FileName, + arg.ContentType, + arg.FileSize, + arg.ExtractedText, + arg.Status, + arg.Metadata, + ) + var i DocumentsDocument + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteDocument = `-- name: DeleteDocument :exec +DELETE FROM documents.documents +WHERE id = $1 AND organization_id = $2 +` + +type DeleteDocumentParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error { + _, err := q.db.Exec(ctx, deleteDocument, arg.ID, arg.OrganizationID) + return err +} + +const getDocumentByFileAssetID = `-- name: GetDocumentByFileAssetID :one +SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents +WHERE file_asset_id = $1 AND organization_id = $2 +` + +type GetDocumentByFileAssetIDParams struct { + FileAssetID int32 `json:"file_asset_id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetDocumentByFileAssetID(ctx context.Context, arg GetDocumentByFileAssetIDParams) (DocumentsDocument, error) { + row := q.db.QueryRow(ctx, getDocumentByFileAssetID, arg.FileAssetID, arg.OrganizationID) + var i DocumentsDocument + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDocumentByID = `-- name: GetDocumentByID :one +SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents +WHERE id = $1 AND organization_id = $2 +` + +type GetDocumentByIDParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (DocumentsDocument, error) { + row := q.db.QueryRow(ctx, getDocumentByID, arg.ID, arg.OrganizationID) + var i DocumentsDocument + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDocumentsByOrganization = `-- name: ListDocumentsByOrganization :many +SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents +WHERE organization_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3 +` + +type ListDocumentsByOrganizationParams struct { + OrganizationID int32 `json:"organization_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListDocumentsByOrganization(ctx context.Context, arg ListDocumentsByOrganizationParams) ([]DocumentsDocument, error) { + rows, err := q.db.Query(ctx, listDocumentsByOrganization, arg.OrganizationID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DocumentsDocument{} + for rows.Next() { + var i DocumentsDocument + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDocumentsByStatus = `-- name: ListDocumentsByStatus :many +SELECT id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at FROM documents.documents +WHERE organization_id = $1 AND status = $2 +ORDER BY created_at DESC +LIMIT $3 OFFSET $4 +` + +type ListDocumentsByStatusParams struct { + OrganizationID int32 `json:"organization_id"` + Status string `json:"status"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListDocumentsByStatus(ctx context.Context, arg ListDocumentsByStatusParams) ([]DocumentsDocument, error) { + rows, err := q.db.Query(ctx, listDocumentsByStatus, + arg.OrganizationID, + arg.Status, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DocumentsDocument{} + for rows.Next() { + var i DocumentsDocument + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateDocument = `-- name: UpdateDocument :one +UPDATE documents.documents +SET + title = COALESCE($3, title), + metadata = COALESCE($4, metadata), + updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at +` + +type UpdateDocumentParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + Title string `json:"title"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (DocumentsDocument, error) { + row := q.db.QueryRow(ctx, updateDocument, + arg.ID, + arg.OrganizationID, + arg.Title, + arg.Metadata, + ) + var i DocumentsDocument + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateDocumentExtractedText = `-- name: UpdateDocumentExtractedText :one +UPDATE documents.documents +SET extracted_text = $3, status = 'processed', updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at +` + +type UpdateDocumentExtractedTextParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + ExtractedText pgtype.Text `json:"extracted_text"` +} + +func (q *Queries) UpdateDocumentExtractedText(ctx context.Context, arg UpdateDocumentExtractedTextParams) (DocumentsDocument, error) { + row := q.db.QueryRow(ctx, updateDocumentExtractedText, arg.ID, arg.OrganizationID, arg.ExtractedText) + var i DocumentsDocument + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateDocumentStatus = `-- name: UpdateDocumentStatus :one +UPDATE documents.documents +SET status = $3, updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING id, organization_id, file_asset_id, title, file_name, content_type, file_size, extracted_text, status, metadata, created_at, updated_at +` + +type UpdateDocumentStatusParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + Status string `json:"status"` +} + +func (q *Queries) UpdateDocumentStatus(ctx context.Context, arg UpdateDocumentStatusParams) (DocumentsDocument, error) { + row := q.db.QueryRow(ctx, updateDocumentStatus, arg.ID, arg.OrganizationID, arg.Status) + var i DocumentsDocument + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.FileAssetID, + &i.Title, + &i.FileName, + &i.ContentType, + &i.FileSize, + &i.ExtractedText, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/error.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/error.go new file mode 100644 index 0000000..892b304 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/error.go @@ -0,0 +1,27 @@ +package postgres + +import ( + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +const ( + ForeignKeyViolation = "23503" + UniqueViolation = "23505" +) + +var ErrRecordNotFound = pgx.ErrNoRows + +var ErrUniqueViolation = &pgconn.PgError{ + Code: UniqueViolation, +} + +func ErrorCode(err error) string { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code + } + return "" +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/example_resource.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/example_resource.sql.go new file mode 100644 index 0000000..8c8efcb --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/example_resource.sql.go @@ -0,0 +1,731 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: example_resource.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const assignResourceApproval = `-- name: AssignResourceApproval :exec +UPDATE example_resources SET + approval_assigned_to_id = $3, + approval_status = 'pending', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true +` + +type AssignResourceApprovalParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + ApprovalAssignedToID pgtype.Int4 `json:"approval_assigned_to_id"` +} + +// Assign resource to someone for approval +func (q *Queries) AssignResourceApproval(ctx context.Context, arg AssignResourceApprovalParams) error { + _, err := q.db.Exec(ctx, assignResourceApproval, arg.ID, arg.OrganizationID, arg.ApprovalAssignedToID) + return err +} + +const attachFileToResource = `-- name: AttachFileToResource :exec +UPDATE example_resources SET + file_id = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true +` + +type AttachFileToResourceParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + FileID pgtype.Int4 `json:"file_id"` +} + +// Attach a file to a resource +func (q *Queries) AttachFileToResource(ctx context.Context, arg AttachFileToResourceParams) error { + _, err := q.db.Exec(ctx, attachFileToResource, arg.ID, arg.OrganizationID, arg.FileID) + return err +} + +const countResources = `-- name: CountResources :one +SELECT COUNT(*) FROM example_resources +WHERE organization_id = $1 AND is_active = true + AND ($2::smallint IS NULL OR status_id = $2) + AND ($3::varchar IS NULL OR approval_status = $3) + AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%') +` + +type CountResourcesParams struct { + OrganizationID int32 `json:"organization_id"` + Column2 int16 `json:"column_2"` + Column3 string `json:"column_3"` + Column4 string `json:"column_4"` +} + +// Count resources for pagination +func (q *Queries) CountResources(ctx context.Context, arg CountResourcesParams) (int64, error) { + row := q.db.QueryRow(ctx, countResources, + arg.OrganizationID, + arg.Column2, + arg.Column3, + arg.Column4, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createMinimalResource = `-- name: CreateMinimalResource :one +INSERT INTO example_resources ( + resource_number, title, organization_id, created_by_account_id, status_id +) VALUES ( + $1, $2, $3, $4, 1 +) 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 +` + +type CreateMinimalResourceParams struct { + ResourceNumber string `json:"resource_number"` + Title string `json:"title"` + OrganizationID int32 `json:"organization_id"` + CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` +} + +// Creates a minimal placeholder resource +func (q *Queries) CreateMinimalResource(ctx context.Context, arg CreateMinimalResourceParams) (ExampleResource, error) { + row := q.db.QueryRow(ctx, createMinimalResource, + arg.ResourceNumber, + arg.Title, + arg.OrganizationID, + arg.CreatedByAccountID, + ) + var i ExampleResource + err := row.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.FileID, + &i.ExtractedData, + &i.ProcessedData, + &i.Confidence, + &i.OrganizationID, + &i.CreatedByAccountID, + &i.ApprovalStatus, + &i.ApprovalAssignedToID, + &i.ApprovalActionTakerID, + &i.ApprovalNotes, + &i.Metadata, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createResource = `-- name: CreateResource :one + + +INSERT INTO example_resources ( + resource_number, title, description, status_id, file_id, + extracted_data, processed_data, confidence, + organization_id, created_by_account_id, metadata +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 +) 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 +` + +type CreateResourceParams struct { + ResourceNumber string `json:"resource_number"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + StatusID int16 `json:"status_id"` + FileID pgtype.Int4 `json:"file_id"` + ExtractedData []byte `json:"extracted_data"` + ProcessedData []byte `json:"processed_data"` + Confidence pgtype.Numeric `json:"confidence"` + OrganizationID int32 `json:"organization_id"` + CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` + Metadata []byte `json:"metadata"` +} + +// Example Resource Queries +// Demonstrates Clean Architecture patterns with CRUD operations, +// file attachments, OCR/LLM processing, and approval workflows +// CREATE operations +func (q *Queries) CreateResource(ctx context.Context, arg CreateResourceParams) (ExampleResource, error) { + row := q.db.QueryRow(ctx, createResource, + arg.ResourceNumber, + arg.Title, + arg.Description, + arg.StatusID, + arg.FileID, + arg.ExtractedData, + arg.ProcessedData, + arg.Confidence, + arg.OrganizationID, + arg.CreatedByAccountID, + arg.Metadata, + ) + var i ExampleResource + err := row.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.FileID, + &i.ExtractedData, + &i.ProcessedData, + &i.Confidence, + &i.OrganizationID, + &i.CreatedByAccountID, + &i.ApprovalStatus, + &i.ApprovalAssignedToID, + &i.ApprovalActionTakerID, + &i.ApprovalNotes, + &i.Metadata, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteResource = `-- name: DeleteResource :exec + +UPDATE example_resources SET + is_active = false, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +` + +type DeleteResourceParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +// DELETE operations +// Soft delete a resource +func (q *Queries) DeleteResource(ctx context.Context, arg DeleteResourceParams) error { + _, err := q.db.Exec(ctx, deleteResource, arg.ID, arg.OrganizationID) + return err +} + +const getRecentResources = `-- name: GetRecentResources :many +SELECT + id, resource_number, title, status_id, confidence, + created_by_account_id, created_at +FROM example_resources +WHERE organization_id = $1 AND is_active = true +ORDER BY created_at DESC +LIMIT $2 +` + +type GetRecentResourcesParams struct { + OrganizationID int32 `json:"organization_id"` + Limit int32 `json:"limit"` +} + +type GetRecentResourcesRow struct { + ID int32 `json:"id"` + ResourceNumber string `json:"resource_number"` + Title string `json:"title"` + StatusID int16 `json:"status_id"` + Confidence pgtype.Numeric `json:"confidence"` + CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` + CreatedAt pgtype.Timestamp `json:"created_at"` +} + +// Get most recently created resources +func (q *Queries) GetRecentResources(ctx context.Context, arg GetRecentResourcesParams) ([]GetRecentResourcesRow, error) { + rows, err := q.db.Query(ctx, getRecentResources, arg.OrganizationID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRecentResourcesRow{} + for rows.Next() { + var i GetRecentResourcesRow + if err := rows.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.StatusID, + &i.Confidence, + &i.CreatedByAccountID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getResourceByID = `-- name: GetResourceByID :one + +SELECT 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 +WHERE id = $1 AND organization_id = $2 AND is_active = true +` + +type GetResourceByIDParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +// READ operations +func (q *Queries) GetResourceByID(ctx context.Context, arg GetResourceByIDParams) (ExampleResource, error) { + row := q.db.QueryRow(ctx, getResourceByID, arg.ID, arg.OrganizationID) + var i ExampleResource + err := row.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.FileID, + &i.ExtractedData, + &i.ProcessedData, + &i.Confidence, + &i.OrganizationID, + &i.CreatedByAccountID, + &i.ApprovalStatus, + &i.ApprovalAssignedToID, + &i.ApprovalActionTakerID, + &i.ApprovalNotes, + &i.Metadata, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getResourceByNumber = `-- name: GetResourceByNumber :one +SELECT 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 +WHERE resource_number = $1 AND organization_id = $2 AND is_active = true +` + +type GetResourceByNumberParams struct { + ResourceNumber string `json:"resource_number"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetResourceByNumber(ctx context.Context, arg GetResourceByNumberParams) (ExampleResource, error) { + row := q.db.QueryRow(ctx, getResourceByNumber, arg.ResourceNumber, arg.OrganizationID) + var i ExampleResource + err := row.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.FileID, + &i.ExtractedData, + &i.ProcessedData, + &i.Confidence, + &i.OrganizationID, + &i.CreatedByAccountID, + &i.ApprovalStatus, + &i.ApprovalAssignedToID, + &i.ApprovalActionTakerID, + &i.ApprovalNotes, + &i.Metadata, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getResourceStats = `-- name: GetResourceStats :one + +SELECT + COUNT(*) as total_resources, + COUNT(*) FILTER (WHERE status_id = 1) as draft_count, + COUNT(*) FILTER (WHERE status_id = 2) as processing_count, + COUNT(*) FILTER (WHERE status_id = 3) as completed_count, + COUNT(*) FILTER (WHERE approval_status = 'pending') as pending_approval, + COUNT(*) FILTER (WHERE approval_status = 'approved') as approved_count, + AVG(confidence) as avg_confidence +FROM example_resources +WHERE organization_id = $1 AND is_active = true +` + +type GetResourceStatsRow struct { + TotalResources int64 `json:"total_resources"` + DraftCount int64 `json:"draft_count"` + ProcessingCount int64 `json:"processing_count"` + CompletedCount int64 `json:"completed_count"` + PendingApproval int64 `json:"pending_approval"` + ApprovedCount int64 `json:"approved_count"` + AvgConfidence float64 `json:"avg_confidence"` +} + +// ANALYTICS queries +// Get statistics for dashboard +func (q *Queries) GetResourceStats(ctx context.Context, organizationID int32) (GetResourceStatsRow, error) { + row := q.db.QueryRow(ctx, getResourceStats, organizationID) + var i GetResourceStatsRow + err := row.Scan( + &i.TotalResources, + &i.DraftCount, + &i.ProcessingCount, + &i.CompletedCount, + &i.PendingApproval, + &i.ApprovedCount, + &i.AvgConfidence, + ) + return i, err +} + +const getResourcesByCreator = `-- name: GetResourcesByCreator :many +SELECT 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 +WHERE organization_id = $1 + AND created_by_account_id = $2 + AND is_active = true +ORDER BY created_at DESC +LIMIT $3 OFFSET $4 +` + +type GetResourcesByCreatorParams struct { + OrganizationID int32 `json:"organization_id"` + CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +// Get resources created by a specific user +func (q *Queries) GetResourcesByCreator(ctx context.Context, arg GetResourcesByCreatorParams) ([]ExampleResource, error) { + rows, err := q.db.Query(ctx, getResourcesByCreator, + arg.OrganizationID, + arg.CreatedByAccountID, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ExampleResource{} + for rows.Next() { + var i ExampleResource + if err := rows.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.FileID, + &i.ExtractedData, + &i.ProcessedData, + &i.Confidence, + &i.OrganizationID, + &i.CreatedByAccountID, + &i.ApprovalStatus, + &i.ApprovalAssignedToID, + &i.ApprovalActionTakerID, + &i.ApprovalNotes, + &i.Metadata, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const hardDeleteResource = `-- name: HardDeleteResource :exec +DELETE FROM example_resources +WHERE id = $1 AND organization_id = $2 +` + +type HardDeleteResourceParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +// Hard delete a resource (use with caution) +func (q *Queries) HardDeleteResource(ctx context.Context, arg HardDeleteResourceParams) error { + _, err := q.db.Exec(ctx, hardDeleteResource, arg.ID, arg.OrganizationID) + return err +} + +const listResources = `-- name: ListResources :many +SELECT + id, resource_number, title, description, status_id, file_id, + confidence, organization_id, created_by_account_id, + approval_status, approval_assigned_to_id, + is_active, created_at, updated_at +FROM example_resources +WHERE organization_id = $1 AND is_active = true + AND ($2::smallint IS NULL OR status_id = $2) + AND ($3::varchar IS NULL OR approval_status = $3) + AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%') +ORDER BY created_at DESC +LIMIT $5 OFFSET $6 +` + +type ListResourcesParams struct { + OrganizationID int32 `json:"organization_id"` + Column2 int16 `json:"column_2"` + Column3 string `json:"column_3"` + Column4 string `json:"column_4"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListResourcesRow struct { + ID int32 `json:"id"` + ResourceNumber string `json:"resource_number"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + StatusID int16 `json:"status_id"` + FileID pgtype.Int4 `json:"file_id"` + Confidence pgtype.Numeric `json:"confidence"` + OrganizationID int32 `json:"organization_id"` + CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` + ApprovalStatus pgtype.Text `json:"approval_status"` + ApprovalAssignedToID pgtype.Int4 `json:"approval_assigned_to_id"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// List resources with filtering and pagination +func (q *Queries) ListResources(ctx context.Context, arg ListResourcesParams) ([]ListResourcesRow, error) { + rows, err := q.db.Query(ctx, listResources, + arg.OrganizationID, + arg.Column2, + arg.Column3, + arg.Column4, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListResourcesRow{} + for rows.Next() { + var i ListResourcesRow + if err := rows.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.FileID, + &i.Confidence, + &i.OrganizationID, + &i.CreatedByAccountID, + &i.ApprovalStatus, + &i.ApprovalAssignedToID, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchResourcesByText = `-- name: SearchResourcesByText :many + +SELECT + id, resource_number, title, description, status_id, + confidence, created_at, updated_at, + ts_rank(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')), to_tsquery('english', $2)) AS rank +FROM example_resources +WHERE organization_id = $1 + AND is_active = true + AND to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ to_tsquery('english', $2) +ORDER BY rank DESC, created_at DESC +LIMIT $3 OFFSET $4 +` + +type SearchResourcesByTextParams struct { + OrganizationID int32 `json:"organization_id"` + ToTsquery string `json:"to_tsquery"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type SearchResourcesByTextRow struct { + ID int32 `json:"id"` + ResourceNumber string `json:"resource_number"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + StatusID int16 `json:"status_id"` + Confidence pgtype.Numeric `json:"confidence"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + Rank float32 `json:"rank"` +} + +// SEARCH operations +// Full-text search on title and description +func (q *Queries) SearchResourcesByText(ctx context.Context, arg SearchResourcesByTextParams) ([]SearchResourcesByTextRow, error) { + rows, err := q.db.Query(ctx, searchResourcesByText, + arg.OrganizationID, + arg.ToTsquery, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchResourcesByTextRow{} + for rows.Next() { + var i SearchResourcesByTextRow + if err := rows.Scan( + &i.ID, + &i.ResourceNumber, + &i.Title, + &i.Description, + &i.StatusID, + &i.Confidence, + &i.CreatedAt, + &i.UpdatedAt, + &i.Rank, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateResource = `-- name: UpdateResource :exec + +UPDATE example_resources SET + title = COALESCE($1, title), + description = COALESCE($2, description), + status_id = COALESCE($3, status_id), + metadata = COALESCE($4, metadata), + updated_at = CURRENT_TIMESTAMP +WHERE id = $5 AND organization_id = $6 AND is_active = true +` + +type UpdateResourceParams struct { + Title pgtype.Text `json:"title"` + Description pgtype.Text `json:"description"` + StatusID pgtype.Int2 `json:"status_id"` + Metadata []byte `json:"metadata"` + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +// UPDATE operations +func (q *Queries) UpdateResource(ctx context.Context, arg UpdateResourceParams) error { + _, err := q.db.Exec(ctx, updateResource, + arg.Title, + arg.Description, + arg.StatusID, + arg.Metadata, + arg.ID, + arg.OrganizationID, + ) + return err +} + +const updateResourceApprovalStatus = `-- name: UpdateResourceApprovalStatus :exec +UPDATE example_resources SET + approval_status = $3, + approval_action_taker_id = $4, + approval_notes = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true +` + +type UpdateResourceApprovalStatusParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + ApprovalStatus pgtype.Text `json:"approval_status"` + ApprovalActionTakerID pgtype.Int4 `json:"approval_action_taker_id"` + ApprovalNotes pgtype.Text `json:"approval_notes"` +} + +// Update approval workflow status +func (q *Queries) UpdateResourceApprovalStatus(ctx context.Context, arg UpdateResourceApprovalStatusParams) error { + _, err := q.db.Exec(ctx, updateResourceApprovalStatus, + arg.ID, + arg.OrganizationID, + arg.ApprovalStatus, + arg.ApprovalActionTakerID, + arg.ApprovalNotes, + ) + return err +} + +const updateResourceProcessingData = `-- name: UpdateResourceProcessingData :exec +UPDATE example_resources SET + extracted_data = COALESCE($1, extracted_data), + processed_data = COALESCE($2, processed_data), + confidence = COALESCE($3, confidence), + status_id = COALESCE($4, status_id), + updated_at = CURRENT_TIMESTAMP +WHERE id = $5 AND organization_id = $6 +` + +type UpdateResourceProcessingDataParams struct { + ExtractedData []byte `json:"extracted_data"` + ProcessedData []byte `json:"processed_data"` + Confidence pgtype.Numeric `json:"confidence"` + StatusID pgtype.Int2 `json:"status_id"` + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +// Update OCR/LLM processing results +func (q *Queries) UpdateResourceProcessingData(ctx context.Context, arg UpdateResourceProcessingDataParams) error { + _, err := q.db.Exec(ctx, updateResourceProcessingData, + arg.ExtractedData, + arg.ProcessedData, + arg.Confidence, + arg.StatusID, + arg.ID, + arg.OrganizationID, + ) + return err +} + +const updateResourceStatus = `-- name: UpdateResourceStatus :exec +UPDATE example_resources SET + status_id = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true +` + +type UpdateResourceStatusParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + StatusID int16 `json:"status_id"` +} + +func (q *Queries) UpdateResourceStatus(ctx context.Context, arg UpdateResourceStatusParams) error { + _, err := q.db.Exec(ctx, updateResourceStatus, arg.ID, arg.OrganizationID, arg.StatusID) + return err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/exec.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/exec.go new file mode 100644 index 0000000..80bcc43 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/exec.go @@ -0,0 +1,25 @@ +package postgres + +import ( + "context" + "fmt" +) + +// ExecTx executes a function within a database transaction +func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error { + tx, err := store.connPool.Begin(ctx) + if err != nil { + return err + } + + q := New(tx) + err = fn(q) + if err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr) + } + return err + } + + return tx.Commit(ctx) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/file_manager.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/file_manager.sql.go new file mode 100644 index 0000000..6cc7e37 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/file_manager.sql.go @@ -0,0 +1,535 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: file_manager.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createFileAsset = `-- name: CreateFileAsset :one +INSERT INTO file_manager.file_assets ( + 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 +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 +) +RETURNING 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 +` + +type CreateFileAssetParams struct { + FileName string `json:"file_name"` + OriginalFileName string `json:"original_file_name"` + StoragePath string `json:"storage_path"` + BucketName string `json:"bucket_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + FileCategoryID int16 `json:"file_category_id"` + FileContextID int16 `json:"file_context_id"` + IsPublic pgtype.Bool `json:"is_public"` + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` + Purpose pgtype.Text `json:"purpose"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) CreateFileAsset(ctx context.Context, arg CreateFileAssetParams) (FileManagerFileAsset, error) { + row := q.db.QueryRow(ctx, createFileAsset, + arg.FileName, + arg.OriginalFileName, + arg.StoragePath, + arg.BucketName, + arg.FileSize, + arg.MimeType, + arg.FileCategoryID, + arg.FileContextID, + arg.IsPublic, + arg.EntityType, + arg.EntityID, + arg.Purpose, + arg.Metadata, + ) + var i FileManagerFileAsset + err := row.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteFileAsset = `-- name: DeleteFileAsset :exec +DELETE FROM file_manager.file_assets +WHERE id = $1 +` + +func (q *Queries) DeleteFileAsset(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteFileAsset, id) + return err +} + +const getFileAssetByID = `-- name: GetFileAssetByID :one +SELECT 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 file_manager.file_assets +WHERE id = $1 +` + +func (q *Queries) GetFileAssetByID(ctx context.Context, id int32) (FileManagerFileAsset, error) { + row := q.db.QueryRow(ctx, getFileAssetByID, id) + var i FileManagerFileAsset + err := row.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getFileAssetByStoragePath = `-- name: GetFileAssetByStoragePath :one +SELECT 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 file_manager.file_assets +WHERE storage_path = $1 +` + +func (q *Queries) GetFileAssetByStoragePath(ctx context.Context, storagePath string) (FileManagerFileAsset, error) { + row := q.db.QueryRow(ctx, getFileAssetByStoragePath, storagePath) + var i FileManagerFileAsset + err := row.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getFileAssetsByCategory = `-- name: GetFileAssetsByCategory :many +SELECT 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 +FROM file_manager.file_assets fa +JOIN file_manager.file_categories fc ON fa.file_category_id = fc.id +WHERE fc.name = $1 +ORDER BY fa.created_at DESC +` + +type GetFileAssetsByCategoryRow struct { + ID int32 `json:"id"` + FileName string `json:"file_name"` + OriginalFileName string `json:"original_file_name"` + StoragePath string `json:"storage_path"` + BucketName string `json:"bucket_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + FileCategoryID int16 `json:"file_category_id"` + FileContextID int16 `json:"file_context_id"` + IsPublic pgtype.Bool `json:"is_public"` + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` + Purpose pgtype.Text `json:"purpose"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CategoryName string `json:"category_name"` +} + +func (q *Queries) GetFileAssetsByCategory(ctx context.Context, name string) ([]GetFileAssetsByCategoryRow, error) { + rows, err := q.db.Query(ctx, getFileAssetsByCategory, name) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetFileAssetsByCategoryRow{} + for rows.Next() { + var i GetFileAssetsByCategoryRow + if err := rows.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + &i.CategoryName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileAssetsByContext = `-- name: GetFileAssetsByContext :many +SELECT 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 +FROM file_manager.file_assets fa +JOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id +WHERE fctx.name = $1 +ORDER BY fa.created_at DESC +` + +type GetFileAssetsByContextRow struct { + ID int32 `json:"id"` + FileName string `json:"file_name"` + OriginalFileName string `json:"original_file_name"` + StoragePath string `json:"storage_path"` + BucketName string `json:"bucket_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + FileCategoryID int16 `json:"file_category_id"` + FileContextID int16 `json:"file_context_id"` + IsPublic pgtype.Bool `json:"is_public"` + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` + Purpose pgtype.Text `json:"purpose"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ContextName string `json:"context_name"` +} + +func (q *Queries) GetFileAssetsByContext(ctx context.Context, name string) ([]GetFileAssetsByContextRow, error) { + rows, err := q.db.Query(ctx, getFileAssetsByContext, name) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetFileAssetsByContextRow{} + for rows.Next() { + var i GetFileAssetsByContextRow + if err := rows.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileAssetsByEntity = `-- name: GetFileAssetsByEntity :many +SELECT 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 file_manager.file_assets +WHERE entity_type = $1 AND entity_id = $2 +` + +type GetFileAssetsByEntityParams struct { + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` +} + +func (q *Queries) GetFileAssetsByEntity(ctx context.Context, arg GetFileAssetsByEntityParams) ([]FileManagerFileAsset, error) { + rows, err := q.db.Query(ctx, getFileAssetsByEntity, arg.EntityType, arg.EntityID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FileManagerFileAsset{} + for rows.Next() { + var i FileManagerFileAsset + if err := rows.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileAssetsByEntityAndPurpose = `-- name: GetFileAssetsByEntityAndPurpose :many +SELECT 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 file_manager.file_assets +WHERE entity_type = $1 AND entity_id = $2 AND purpose = $3 +ORDER BY created_at DESC +` + +type GetFileAssetsByEntityAndPurposeParams struct { + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` + Purpose pgtype.Text `json:"purpose"` +} + +func (q *Queries) GetFileAssetsByEntityAndPurpose(ctx context.Context, arg GetFileAssetsByEntityAndPurposeParams) ([]FileManagerFileAsset, error) { + rows, err := q.db.Query(ctx, getFileAssetsByEntityAndPurpose, arg.EntityType, arg.EntityID, arg.Purpose) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FileManagerFileAsset{} + for rows.Next() { + var i FileManagerFileAsset + if err := rows.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileCategories = `-- name: GetFileCategories :many +SELECT id, name, max_size_bytes FROM file_manager.file_categories ORDER BY name +` + +func (q *Queries) GetFileCategories(ctx context.Context) ([]FileManagerFileCategory, error) { + rows, err := q.db.Query(ctx, getFileCategories) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FileManagerFileCategory{} + for rows.Next() { + var i FileManagerFileCategory + if err := rows.Scan(&i.ID, &i.Name, &i.MaxSizeBytes); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileContexts = `-- name: GetFileContexts :many +SELECT id, name FROM file_manager.file_contexts ORDER BY name +` + +func (q *Queries) GetFileContexts(ctx context.Context) ([]FileManagerFileContext, error) { + rows, err := q.db.Query(ctx, getFileContexts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FileManagerFileContext{} + for rows.Next() { + var i FileManagerFileContext + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFileAssets = `-- name: ListFileAssets :many +SELECT 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 +FROM file_manager.file_assets fa +JOIN file_manager.file_categories fc ON fa.file_category_id = fc.id +JOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id +ORDER BY fa.created_at DESC +LIMIT $1 OFFSET $2 +` + +type ListFileAssetsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListFileAssetsRow struct { + ID int32 `json:"id"` + FileName string `json:"file_name"` + OriginalFileName string `json:"original_file_name"` + StoragePath string `json:"storage_path"` + BucketName string `json:"bucket_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + FileCategoryID int16 `json:"file_category_id"` + FileContextID int16 `json:"file_context_id"` + IsPublic pgtype.Bool `json:"is_public"` + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` + Purpose pgtype.Text `json:"purpose"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CategoryName string `json:"category_name"` + ContextName string `json:"context_name"` +} + +func (q *Queries) ListFileAssets(ctx context.Context, arg ListFileAssetsParams) ([]ListFileAssetsRow, error) { + rows, err := q.db.Query(ctx, listFileAssets, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListFileAssetsRow{} + for rows.Next() { + var i ListFileAssetsRow + if err := rows.Scan( + &i.ID, + &i.FileName, + &i.OriginalFileName, + &i.StoragePath, + &i.BucketName, + &i.FileSize, + &i.MimeType, + &i.FileCategoryID, + &i.FileContextID, + &i.IsPublic, + &i.EntityType, + &i.EntityID, + &i.Purpose, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + &i.CategoryName, + &i.ContextName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateFileAsset = `-- name: UpdateFileAsset :exec +UPDATE file_manager.file_assets +SET + file_name = $2, + storage_path = $3, + purpose = $4, + metadata = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +type UpdateFileAssetParams struct { + ID int32 `json:"id"` + FileName string `json:"file_name"` + StoragePath string `json:"storage_path"` + Purpose pgtype.Text `json:"purpose"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) UpdateFileAsset(ctx context.Context, arg UpdateFileAssetParams) error { + _, err := q.db.Exec(ctx, updateFileAsset, + arg.ID, + arg.FileName, + arg.StoragePath, + arg.Purpose, + arg.Metadata, + ) + return err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/models.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/models.go new file mode 100644 index 0000000..d524dea --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/models.go @@ -0,0 +1,227 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package postgres + +import ( + "github.com/jackc/pgx/v5/pgtype" + pgvector_go "github.com/pgvector/pgvector-go" +) + +// Messages within chat sessions with role (user/assistant/system) +type CognitiveChatMessage struct { + ID int32 `json:"id"` + SessionID int32 `json:"session_id"` + Role string `json:"role"` + Content string `json:"content"` + ReferencedDocs []int32 `json:"referenced_docs"` + TokensUsed pgtype.Int4 `json:"tokens_used"` + CreatedAt pgtype.Timestamp `json:"created_at"` +} + +// Conversational AI sessions for RAG-based chat +type CognitiveChatSession struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + AccountID int32 `json:"account_id"` + Title pgtype.Text `json:"title"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Vector embeddings for documents using OpenAI text-embedding-3-small (1536 dimensions) +type CognitiveDocumentEmbedding struct { + ID int32 `json:"id"` + DocumentID int32 `json:"document_id"` + OrganizationID int32 `json:"organization_id"` + // Vector embedding for semantic similarity search + Embedding pgvector_go.Vector `json:"embedding"` + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` + // Index for chunked documents (0 for single-chunk docs) + ChunkIndex pgtype.Int4 `json:"chunk_index"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Stores uploaded documents (PDFs) with extracted text for RAG +type DocumentsDocument struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + FileAssetID int32 `json:"file_asset_id"` + Title string `json:"title"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + FileSize int64 `json:"file_size"` + // Text extracted from PDF using OCR or direct parsing + ExtractedText pgtype.Text `json:"extracted_text"` + // Processing status: pending, processing, processed, failed + Status string `json:"status"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Stores potential duplicate resources found via vector similarity and LLM adjudication +type DuplicateCandidate struct { + ID int32 `json:"id"` + ResourceID int32 `json:"resource_id"` + CandidateResourceID int32 `json:"candidate_resource_id"` + // Cosine similarity score from pgvector (0.0000 = completely different, 1.0000 = identical) + SimilarityScore pgtype.Numeric `json:"similarity_score"` + // How the duplicate was detected: exact_match (similarity >= 0.95) or llm_adjudicated (similarity >= 0.85, confirmed by LLM) + DetectionMethod string `json:"detection_method"` + ConfidenceLevel pgtype.Text `json:"confidence_level"` + LlmReason pgtype.Text `json:"llm_reason"` + LlmSimilarFields []byte `json:"llm_similar_fields"` + LlmResponse []byte `json:"llm_response"` + OrganizationID int32 `json:"organization_id"` + Status pgtype.Text `json:"status"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Example module demonstrating Clean Architecture patterns with file uploads, OCR/LLM processing, RBAC, approval workflows, and multi-tenancy +type ExampleResource struct { + ID int32 `json:"id"` + ResourceNumber string `json:"resource_number"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + StatusID int16 `json:"status_id"` + FileID pgtype.Int4 `json:"file_id"` + // Raw OCR-extracted text and metadata + ExtractedData []byte `json:"extracted_data"` + // LLM-processed structured data + ProcessedData []byte `json:"processed_data"` + // AI confidence score between 0 and 1 + Confidence pgtype.Numeric `json:"confidence"` + OrganizationID int32 `json:"organization_id"` + CreatedByAccountID pgtype.Int4 `json:"created_by_account_id"` + // Workflow status: pending, approved, rejected + ApprovalStatus pgtype.Text `json:"approval_status"` + ApprovalAssignedToID pgtype.Int4 `json:"approval_assigned_to_id"` + ApprovalActionTakerID pgtype.Int4 `json:"approval_action_taker_id"` + ApprovalNotes pgtype.Text `json:"approval_notes"` + Metadata []byte `json:"metadata"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +type FileManagerFileAsset struct { + ID int32 `json:"id"` + FileName string `json:"file_name"` + OriginalFileName string `json:"original_file_name"` + StoragePath string `json:"storage_path"` + BucketName string `json:"bucket_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + FileCategoryID int16 `json:"file_category_id"` + FileContextID int16 `json:"file_context_id"` + IsPublic pgtype.Bool `json:"is_public"` + EntityType pgtype.Text `json:"entity_type"` + EntityID pgtype.Int4 `json:"entity_id"` + Purpose pgtype.Text `json:"purpose"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type FileManagerFileCategory struct { + ID int16 `json:"id"` + Name string `json:"name"` + MaxSizeBytes int64 `json:"max_size_bytes"` +} + +type FileManagerFileContext struct { + ID int16 `json:"id"` + Name string `json:"name"` +} + +// User accounts within organizations +type OrganizationsAccount struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + Email string `json:"email"` + FullName string `json:"full_name"` + // Stytch member identifier (member_xxx) + StytchMemberID pgtype.Text `json:"stytch_member_id"` + // Stytch role identifier assigned to the member + StytchRoleID pgtype.Text `json:"stytch_role_id"` + // Human-readable Stytch role slug assigned to the member + StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` + // Whether Stytch reports the member email as verified + StytchEmailVerified bool `json:"stytch_email_verified"` + // Last known role for business logic (e.g., owner, reviewer, employee) + Role string `json:"role"` + Status string `json:"status"` + LastLoginAt pgtype.Timestamp `json:"last_login_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Organizations (tenants) in the system +type OrganizationsOrganization struct { + ID int32 `json:"id"` + // URL-friendly unique identifier for organization + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + // Stytch organization identifier (org_xxx) + StytchOrgID pgtype.Text `json:"stytch_org_id"` + // Optional Stytch connection or project identifier associated with the organization + StytchConnectionID pgtype.Text `json:"stytch_connection_id"` + // Optional Stytch connection name associated with the organization + StytchConnectionName pgtype.Text `json:"stytch_connection_name"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Stores vector embeddings for resources using OpenAI text-embedding-3-small (1536 dimensions) +type ResourceEmbedding struct { + ID int32 `json:"id"` + ResourceID int32 `json:"resource_id"` + // Vector embedding for semantic similarity search (1536 dimensions from OpenAI) + Embedding pgvector_go.Vector `json:"embedding"` + OrganizationID int32 `json:"organization_id"` + // SHA-256 hash of normalized content for exact duplicate detection + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + +// Tracks usage quotas per organization for fast quota checks +type SubscriptionBillingQuotaTracking struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + MaxSeats pgtype.Int4 `json:"max_seats"` + PeriodStart pgtype.Timestamp `json:"period_start"` + PeriodEnd pgtype.Timestamp `json:"period_end"` + LastSyncedAt pgtype.Timestamp `json:"last_synced_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + // Remaining invoices in current billing period (decremented on use) + InvoiceCount int32 `json:"invoice_count"` +} + +// Stores subscription details from Polar, synced via webhooks +type SubscriptionBillingSubscription struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + // Polar customer ID (maps to organization via stytch_org_id) + ExternalCustomerID string `json:"external_customer_id"` + SubscriptionID string `json:"subscription_id"` + SubscriptionStatus string `json:"subscription_status"` + ProductID string `json:"product_id"` + ProductName pgtype.Text `json:"product_name"` + PlanName pgtype.Text `json:"plan_name"` + CurrentPeriodStart pgtype.Timestamp `json:"current_period_start"` + CurrentPeriodEnd pgtype.Timestamp `json:"current_period_end"` + CancelAtPeriodEnd pgtype.Bool `json:"cancel_at_period_end"` + CanceledAt pgtype.Timestamp `json:"canceled_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + Metadata []byte `json:"metadata"` +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/organizations.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/organizations.sql.go new file mode 100644 index 0000000..ad41f3d --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/organizations.sql.go @@ -0,0 +1,962 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: organizations.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const checkAccountPermission = `-- name: CheckAccountPermission :one +SELECT + a.id, + a.role, + a.status, + o.status as org_status +FROM organizations.accounts a +INNER JOIN organizations.organizations o ON a.organization_id = o.id +WHERE a.id = $1 AND a.organization_id = $2 +` + +type CheckAccountPermissionParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +type CheckAccountPermissionRow struct { + ID int32 `json:"id"` + Role string `json:"role"` + Status string `json:"status"` + OrgStatus string `json:"org_status"` +} + +func (q *Queries) CheckAccountPermission(ctx context.Context, arg CheckAccountPermissionParams) (CheckAccountPermissionRow, error) { + row := q.db.QueryRow(ctx, checkAccountPermission, arg.ID, arg.OrganizationID) + var i CheckAccountPermissionRow + err := row.Scan( + &i.ID, + &i.Role, + &i.Status, + &i.OrgStatus, + ) + return i, err +} + +const createAccount = `-- name: CreateAccount :one + +INSERT INTO organizations.accounts ( + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +` + +type CreateAccountParams struct { + OrganizationID int32 `json:"organization_id"` + Email string `json:"email"` + FullName string `json:"full_name"` + StytchMemberID pgtype.Text `json:"stytch_member_id"` + StytchRoleID pgtype.Text `json:"stytch_role_id"` + StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` + StytchEmailVerified bool `json:"stytch_email_verified"` + Role string `json:"role"` + Status string `json:"status"` +} + +// Accounts queries +func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (OrganizationsAccount, error) { + row := q.db.QueryRow(ctx, createAccount, + arg.OrganizationID, + arg.Email, + arg.FullName, + arg.StytchMemberID, + arg.StytchRoleID, + arg.StytchRoleSlug, + arg.StytchEmailVerified, + arg.Role, + arg.Status, + ) + var i OrganizationsAccount + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createOrganization = `-- name: CreateOrganization :one +INSERT INTO organizations.organizations ( + slug, + name, + status +) VALUES ( + $1, + $2, + $3 +) RETURNING + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +` + +type CreateOrganizationParams struct { + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` +} + +func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, createOrganization, arg.Slug, arg.Name, arg.Status) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAccount = `-- name: DeleteAccount :exec +UPDATE organizations.accounts +SET + status = 'inactive', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +` + +type DeleteAccountParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error { + _, err := q.db.Exec(ctx, deleteAccount, arg.ID, arg.OrganizationID) + return err +} + +const deleteOrganization = `-- name: DeleteOrganization :exec +DELETE FROM organizations.organizations +WHERE id = $1 +` + +func (q *Queries) DeleteOrganization(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteOrganization, id) + return err +} + +const getAccountByEmail = `-- name: GetAccountByEmail :one +SELECT + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +FROM organizations.accounts +WHERE email = $1 AND organization_id = $2 +` + +type GetAccountByEmailParams struct { + Email string `json:"email"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetAccountByEmail(ctx context.Context, arg GetAccountByEmailParams) (OrganizationsAccount, error) { + row := q.db.QueryRow(ctx, getAccountByEmail, arg.Email, arg.OrganizationID) + var i OrganizationsAccount + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAccountByID = `-- name: GetAccountByID :one +SELECT + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +FROM organizations.accounts +WHERE id = $1 AND organization_id = $2 +` + +type GetAccountByIDParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) GetAccountByID(ctx context.Context, arg GetAccountByIDParams) (OrganizationsAccount, error) { + row := q.db.QueryRow(ctx, getAccountByID, arg.ID, arg.OrganizationID) + var i OrganizationsAccount + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAccountOrganization = `-- name: GetAccountOrganization :one +SELECT + o.id, + o.slug, + o.name, + o.status, + o.stytch_org_id, + o.stytch_connection_id, + o.stytch_connection_name, + o.created_at, + o.updated_at +FROM organizations.organizations o +INNER JOIN organizations.accounts a ON o.id = a.organization_id +WHERE a.id = $1 +` + +func (q *Queries) GetAccountOrganization(ctx context.Context, id int32) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, getAccountOrganization, id) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAccountStats = `-- name: GetAccountStats :one +SELECT + a.id, + a.organization_id, + a.email, + a.full_name, + a.stytch_member_id, + a.stytch_role_id, + a.stytch_role_slug, + a.stytch_email_verified, + a.role, + a.status, + a.last_login_at, + a.created_at, + a.updated_at, + o.name as organization_name, + o.slug as organization_slug +FROM organizations.accounts a +INNER JOIN organizations.organizations o ON a.organization_id = o.id +WHERE a.id = $1 +` + +type GetAccountStatsRow struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + Email string `json:"email"` + FullName string `json:"full_name"` + StytchMemberID pgtype.Text `json:"stytch_member_id"` + StytchRoleID pgtype.Text `json:"stytch_role_id"` + StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` + StytchEmailVerified bool `json:"stytch_email_verified"` + Role string `json:"role"` + Status string `json:"status"` + LastLoginAt pgtype.Timestamp `json:"last_login_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + OrganizationName string `json:"organization_name"` + OrganizationSlug string `json:"organization_slug"` +} + +func (q *Queries) GetAccountStats(ctx context.Context, id int32) (GetAccountStatsRow, error) { + row := q.db.QueryRow(ctx, getAccountStats, id) + var i GetAccountStatsRow + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.OrganizationName, + &i.OrganizationSlug, + ) + return i, err +} + +const getOrganizationByID = `-- name: GetOrganizationByID :one +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +WHERE id = $1 +` + +func (q *Queries) GetOrganizationByID(ctx context.Context, id int32) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, getOrganizationByID, id) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOrganizationBySlug = `-- name: GetOrganizationBySlug :one +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +WHERE slug = $1 +` + +func (q *Queries) GetOrganizationBySlug(ctx context.Context, slug string) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, getOrganizationBySlug, slug) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOrganizationByStytchID = `-- name: GetOrganizationByStytchID :one +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +WHERE stytch_org_id = $1 +` + +func (q *Queries) GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, getOrganizationByStytchID, stytchOrgID) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOrganizationByUserEmail = `-- name: GetOrganizationByUserEmail :one + +SELECT + o.id, + o.slug, + o.name, + o.status, + o.stytch_org_id, + o.stytch_connection_id, + o.stytch_connection_name, + o.created_at, + o.updated_at +FROM organizations.organizations o +INNER JOIN organizations.accounts a ON o.id = a.organization_id +WHERE a.email = $1 + AND a.status = 'active' + AND o.status = 'active' +LIMIT 1 +` + +// Organization membership queries +func (q *Queries) GetOrganizationByUserEmail(ctx context.Context, email string) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, getOrganizationByUserEmail, email) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOrganizationStats = `-- name: GetOrganizationStats :one + +SELECT + o.id, + o.slug, + o.name, + o.status, + o.stytch_org_id, + o.stytch_connection_id, + o.stytch_connection_name, + o.created_at, + o.updated_at, + COUNT(a.id) as account_count, + COUNT(CASE WHEN a.status = 'active' THEN 1 END) as active_account_count +FROM organizations.organizations o +LEFT JOIN organizations.accounts a ON o.id = a.organization_id +WHERE o.id = $1 +GROUP BY o.id +` + +type GetOrganizationStatsRow struct { + ID int32 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + StytchOrgID pgtype.Text `json:"stytch_org_id"` + StytchConnectionID pgtype.Text `json:"stytch_connection_id"` + StytchConnectionName pgtype.Text `json:"stytch_connection_name"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + AccountCount int64 `json:"account_count"` + ActiveAccountCount int64 `json:"active_account_count"` +} + +// Statistics queries (useful for admin panels) +func (q *Queries) GetOrganizationStats(ctx context.Context, id int32) (GetOrganizationStatsRow, error) { + row := q.db.QueryRow(ctx, getOrganizationStats, id) + var i GetOrganizationStatsRow + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + &i.AccountCount, + &i.ActiveAccountCount, + ) + return i, err +} + +const listAccountsByOrganization = `-- name: ListAccountsByOrganization :many +SELECT + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +FROM organizations.accounts +WHERE organization_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]OrganizationsAccount, error) { + rows, err := q.db.Query(ctx, listAccountsByOrganization, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []OrganizationsAccount{} + for rows.Next() { + var i OrganizationsAccount + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOrganizations = `-- name: ListOrganizations :many +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +ORDER BY created_at DESC +LIMIT $1 OFFSET $2 +` + +type ListOrganizationsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListOrganizations(ctx context.Context, arg ListOrganizationsParams) ([]OrganizationsOrganization, error) { + rows, err := q.db.Query(ctx, listOrganizations, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []OrganizationsOrganization{} + for rows.Next() { + var i OrganizationsOrganization + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAccount = `-- name: UpdateAccount :one +UPDATE organizations.accounts +SET + full_name = $3, + stytch_role_id = $4, + stytch_role_slug = $5, + stytch_email_verified = $6, + role = $7, + status = $8, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +` + +type UpdateAccountParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + FullName string `json:"full_name"` + StytchRoleID pgtype.Text `json:"stytch_role_id"` + StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` + StytchEmailVerified bool `json:"stytch_email_verified"` + Role string `json:"role"` + Status string `json:"status"` +} + +func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (OrganizationsAccount, error) { + row := q.db.QueryRow(ctx, updateAccount, + arg.ID, + arg.OrganizationID, + arg.FullName, + arg.StytchRoleID, + arg.StytchRoleSlug, + arg.StytchEmailVerified, + arg.Role, + arg.Status, + ) + var i OrganizationsAccount + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAccountLastLogin = `-- name: UpdateAccountLastLogin :one +UPDATE organizations.accounts +SET + last_login_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +` + +type UpdateAccountLastLoginParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` +} + +func (q *Queries) UpdateAccountLastLogin(ctx context.Context, arg UpdateAccountLastLoginParams) (OrganizationsAccount, error) { + row := q.db.QueryRow(ctx, updateAccountLastLogin, arg.ID, arg.OrganizationID) + var i OrganizationsAccount + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAccountStytchInfo = `-- name: UpdateAccountStytchInfo :one +UPDATE organizations.accounts +SET + stytch_member_id = $3, + stytch_role_id = $4, + stytch_role_slug = $5, + stytch_email_verified = $6, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +` + +type UpdateAccountStytchInfoParams struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + StytchMemberID pgtype.Text `json:"stytch_member_id"` + StytchRoleID pgtype.Text `json:"stytch_role_id"` + StytchRoleSlug pgtype.Text `json:"stytch_role_slug"` + StytchEmailVerified bool `json:"stytch_email_verified"` +} + +func (q *Queries) UpdateAccountStytchInfo(ctx context.Context, arg UpdateAccountStytchInfoParams) (OrganizationsAccount, error) { + row := q.db.QueryRow(ctx, updateAccountStytchInfo, + arg.ID, + arg.OrganizationID, + arg.StytchMemberID, + arg.StytchRoleID, + arg.StytchRoleSlug, + arg.StytchEmailVerified, + ) + var i OrganizationsAccount + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Email, + &i.FullName, + &i.StytchMemberID, + &i.StytchRoleID, + &i.StytchRoleSlug, + &i.StytchEmailVerified, + &i.Role, + &i.Status, + &i.LastLoginAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateOrganization = `-- name: UpdateOrganization :one +UPDATE organizations.organizations +SET + name = $2, + status = $3, + stytch_org_id = $4, + stytch_connection_id = $5, + stytch_connection_name = $6, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +` + +type UpdateOrganizationParams struct { + ID int32 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + StytchOrgID pgtype.Text `json:"stytch_org_id"` + StytchConnectionID pgtype.Text `json:"stytch_connection_id"` + StytchConnectionName pgtype.Text `json:"stytch_connection_name"` +} + +func (q *Queries) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, updateOrganization, + arg.ID, + arg.Name, + arg.Status, + arg.StytchOrgID, + arg.StytchConnectionID, + arg.StytchConnectionName, + ) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateOrganizationStytchInfo = `-- name: UpdateOrganizationStytchInfo :one +UPDATE organizations.organizations +SET + stytch_org_id = $2, + stytch_connection_id = $3, + stytch_connection_name = $4, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +` + +type UpdateOrganizationStytchInfoParams struct { + ID int32 `json:"id"` + StytchOrgID pgtype.Text `json:"stytch_org_id"` + StytchConnectionID pgtype.Text `json:"stytch_connection_id"` + StytchConnectionName pgtype.Text `json:"stytch_connection_name"` +} + +func (q *Queries) UpdateOrganizationStytchInfo(ctx context.Context, arg UpdateOrganizationStytchInfoParams) (OrganizationsOrganization, error) { + row := q.db.QueryRow(ctx, updateOrganizationStytchInfo, + arg.ID, + arg.StytchOrgID, + arg.StytchConnectionID, + arg.StytchConnectionName, + ) + var i OrganizationsOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.Status, + &i.StytchOrgID, + &i.StytchConnectionID, + &i.StytchConnectionName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/querier.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/querier.go new file mode 100644 index 0000000..8763968 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/querier.go @@ -0,0 +1,146 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +type Querier interface { + // Assign resource to someone for approval + AssignResourceApproval(ctx context.Context, arg AssignResourceApprovalParams) error + // Attach a file to a resource + AttachFileToResource(ctx context.Context, arg AttachFileToResourceParams) error + CheckAccountPermission(ctx context.Context, arg CheckAccountPermissionParams) (CheckAccountPermissionRow, error) + CountChatMessagesBySession(ctx context.Context, sessionID int32) (int64, error) + CountDocumentEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) + CountDocumentsByOrganization(ctx context.Context, organizationID int32) (int64, error) + CountDocumentsByStatus(ctx context.Context, arg CountDocumentsByStatusParams) (int64, error) + // Count resources for pagination + CountResources(ctx context.Context, arg CountResourcesParams) (int64, error) + // Accounts queries + CreateAccount(ctx context.Context, arg CreateAccountParams) (OrganizationsAccount, error) + // Chat Messages + CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (CognitiveChatMessage, error) + // Chat Sessions + CreateChatSession(ctx context.Context, arg CreateChatSessionParams) (CognitiveChatSession, error) + // Documents queries + CreateDocument(ctx context.Context, arg CreateDocumentParams) (DocumentsDocument, error) + // Cognitive Agent queries + // Document Embeddings + CreateDocumentEmbedding(ctx context.Context, arg CreateDocumentEmbeddingParams) (CognitiveDocumentEmbedding, error) + CreateFileAsset(ctx context.Context, arg CreateFileAssetParams) (FileManagerFileAsset, error) + // Creates a minimal placeholder resource + CreateMinimalResource(ctx context.Context, arg CreateMinimalResourceParams) (ExampleResource, error) + CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (OrganizationsOrganization, error) + // Example Resource Queries + // Demonstrates Clean Architecture patterns with CRUD operations, + // file attachments, OCR/LLM processing, and approval workflows + // CREATE operations + CreateResource(ctx context.Context, arg CreateResourceParams) (ExampleResource, error) + // Decrement invoice count by 1 (called after successful invoice processing) + DecrementInvoiceCount(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) + DeleteAccount(ctx context.Context, arg DeleteAccountParams) error + DeleteChatMessage(ctx context.Context, id int32) error + DeleteChatSession(ctx context.Context, arg DeleteChatSessionParams) error + DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error + DeleteDocumentEmbeddings(ctx context.Context, arg DeleteDocumentEmbeddingsParams) error + DeleteFileAsset(ctx context.Context, id int32) error + DeleteOrganization(ctx context.Context, id int32) error + // DELETE operations + // Soft delete a resource + DeleteResource(ctx context.Context, arg DeleteResourceParams) error + // Delete subscription (when subscription is permanently deleted) + DeleteSubscription(ctx context.Context, organizationID int32) error + GetAccountByEmail(ctx context.Context, arg GetAccountByEmailParams) (OrganizationsAccount, error) + GetAccountByID(ctx context.Context, arg GetAccountByIDParams) (OrganizationsAccount, error) + GetAccountOrganization(ctx context.Context, id int32) (OrganizationsOrganization, error) + GetAccountStats(ctx context.Context, id int32) (GetAccountStatsRow, error) + GetChatMessagesBySession(ctx context.Context, sessionID int32) ([]CognitiveChatMessage, error) + GetChatSessionByID(ctx context.Context, arg GetChatSessionByIDParams) (CognitiveChatSession, error) + GetDocumentByFileAssetID(ctx context.Context, arg GetDocumentByFileAssetIDParams) (DocumentsDocument, error) + GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (DocumentsDocument, error) + GetDocumentEmbeddingByID(ctx context.Context, arg GetDocumentEmbeddingByIDParams) (CognitiveDocumentEmbedding, error) + GetDocumentEmbeddingsByDocumentID(ctx context.Context, arg GetDocumentEmbeddingsByDocumentIDParams) ([]CognitiveDocumentEmbedding, error) + GetFileAssetByID(ctx context.Context, id int32) (FileManagerFileAsset, error) + GetFileAssetByStoragePath(ctx context.Context, storagePath string) (FileManagerFileAsset, error) + GetFileAssetsByCategory(ctx context.Context, name string) ([]GetFileAssetsByCategoryRow, error) + GetFileAssetsByContext(ctx context.Context, name string) ([]GetFileAssetsByContextRow, error) + GetFileAssetsByEntity(ctx context.Context, arg GetFileAssetsByEntityParams) ([]FileManagerFileAsset, error) + GetFileAssetsByEntityAndPurpose(ctx context.Context, arg GetFileAssetsByEntityAndPurposeParams) ([]FileManagerFileAsset, error) + GetFileCategories(ctx context.Context) ([]FileManagerFileCategory, error) + GetFileContexts(ctx context.Context) ([]FileManagerFileContext, error) + GetOrganizationByID(ctx context.Context, id int32) (OrganizationsOrganization, error) + GetOrganizationBySlug(ctx context.Context, slug string) (OrganizationsOrganization, error) + GetOrganizationByStytchID(ctx context.Context, stytchOrgID pgtype.Text) (OrganizationsOrganization, error) + // Organization membership queries + GetOrganizationByUserEmail(ctx context.Context, email string) (OrganizationsOrganization, error) + // Statistics queries (useful for admin panels) + GetOrganizationStats(ctx context.Context, id int32) (GetOrganizationStatsRow, error) + // Get quota tracking for an organization + GetQuotaByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) + // Get combined subscription and quota status for fast quota checks + GetQuotaStatus(ctx context.Context, organizationID int32) (GetQuotaStatusRow, error) + GetRecentChatMessages(ctx context.Context, arg GetRecentChatMessagesParams) ([]CognitiveChatMessage, error) + // Get most recently created resources + GetRecentResources(ctx context.Context, arg GetRecentResourcesParams) ([]GetRecentResourcesRow, error) + // READ operations + GetResourceByID(ctx context.Context, arg GetResourceByIDParams) (ExampleResource, error) + GetResourceByNumber(ctx context.Context, arg GetResourceByNumberParams) (ExampleResource, error) + // ANALYTICS queries + // Get statistics for dashboard + GetResourceStats(ctx context.Context, organizationID int32) (GetResourceStatsRow, error) + // Get resources created by a specific user + GetResourcesByCreator(ctx context.Context, arg GetResourcesByCreatorParams) ([]ExampleResource, error) + // Get subscription details for an organization + GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingSubscription, error) + // Get subscription by Polar subscription ID + GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (SubscriptionBillingSubscription, error) + // Hard delete a resource (use with caution) + HardDeleteResource(ctx context.Context, arg HardDeleteResourceParams) error + ListAccountsByOrganization(ctx context.Context, organizationID int32) ([]OrganizationsAccount, error) + // List all active subscriptions for monitoring/admin purposes + ListActiveSubscriptions(ctx context.Context) ([]SubscriptionBillingSubscription, error) + ListChatSessionsByAccount(ctx context.Context, arg ListChatSessionsByAccountParams) ([]CognitiveChatSession, error) + ListDocumentsByOrganization(ctx context.Context, arg ListDocumentsByOrganizationParams) ([]DocumentsDocument, error) + ListDocumentsByStatus(ctx context.Context, arg ListDocumentsByStatusParams) ([]DocumentsDocument, error) + ListFileAssets(ctx context.Context, arg ListFileAssetsParams) ([]ListFileAssetsRow, error) + ListOrganizations(ctx context.Context, arg ListOrganizationsParams) ([]OrganizationsOrganization, error) + // List organizations approaching their quota limit (for alerting) + ListQuotasNearLimit(ctx context.Context, invoiceCount int32) ([]ListQuotasNearLimitRow, error) + // List resources with filtering and pagination + ListResources(ctx context.Context, arg ListResourcesParams) ([]ListResourcesRow, error) + // Reset quota counters for a new billing period + ResetQuotaForPeriod(ctx context.Context, arg ResetQuotaForPeriodParams) (SubscriptionBillingQuotaTracking, error) + // SEARCH operations + // Full-text search on title and description + SearchResourcesByText(ctx context.Context, arg SearchResourcesByTextParams) ([]SearchResourcesByTextRow, error) + SearchSimilarDocuments(ctx context.Context, arg SearchSimilarDocumentsParams) ([]SearchSimilarDocumentsRow, error) + UpdateAccount(ctx context.Context, arg UpdateAccountParams) (OrganizationsAccount, error) + UpdateAccountLastLogin(ctx context.Context, arg UpdateAccountLastLoginParams) (OrganizationsAccount, error) + UpdateAccountStytchInfo(ctx context.Context, arg UpdateAccountStytchInfoParams) (OrganizationsAccount, error) + UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSessionTitleParams) (CognitiveChatSession, error) + UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (DocumentsDocument, error) + UpdateDocumentExtractedText(ctx context.Context, arg UpdateDocumentExtractedTextParams) (DocumentsDocument, error) + UpdateDocumentStatus(ctx context.Context, arg UpdateDocumentStatusParams) (DocumentsDocument, error) + UpdateFileAsset(ctx context.Context, arg UpdateFileAssetParams) error + UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (OrganizationsOrganization, error) + UpdateOrganizationStytchInfo(ctx context.Context, arg UpdateOrganizationStytchInfoParams) (OrganizationsOrganization, error) + // UPDATE operations + UpdateResource(ctx context.Context, arg UpdateResourceParams) error + // Update approval workflow status + UpdateResourceApprovalStatus(ctx context.Context, arg UpdateResourceApprovalStatusParams) error + // Update OCR/LLM processing results + UpdateResourceProcessingData(ctx context.Context, arg UpdateResourceProcessingDataParams) error + UpdateResourceStatus(ctx context.Context, arg UpdateResourceStatusParams) error + // Create or update quota tracking + UpsertQuota(ctx context.Context, arg UpsertQuotaParams) (SubscriptionBillingQuotaTracking, error) + // Create or update subscription from Polar webhook + UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (SubscriptionBillingSubscription, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/resource_embeddings.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/resource_embeddings.sql.go new file mode 100644 index 0000000..c15f29a --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/resource_embeddings.sql.go @@ -0,0 +1,629 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: resource_embeddings.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" + pgvector_go "github.com/pgvector/pgvector-go" +) + +const confirmDuplicate = `-- name: ConfirmDuplicate :exec +UPDATE duplicate_candidates +SET status = 'confirmed', updated_at = NOW() +WHERE id = $1 +` + +// Marks a duplicate candidate as confirmed +func (q *Queries) ConfirmDuplicate(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, confirmDuplicate, id) + return err +} + +const countDuplicatesByStatus = `-- name: CountDuplicatesByStatus :one +SELECT COUNT(*) FROM duplicate_candidates +WHERE organization_id = $1 AND status = $2 +` + +type CountDuplicatesByStatusParams struct { + OrganizationID int32 `json:"organization_id"` + Status pgtype.Text `json:"status"` +} + +// Counts duplicate candidates by status for an organization +func (q *Queries) CountDuplicatesByStatus(ctx context.Context, arg CountDuplicatesByStatusParams) (int64, error) { + row := q.db.QueryRow(ctx, countDuplicatesByStatus, arg.OrganizationID, arg.Status) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countEmbeddingsByOrganization = `-- name: CountEmbeddingsByOrganization :one +SELECT COUNT(*) FROM resource_embeddings +WHERE organization_id = $1 +` + +// Counts total embeddings for an organization +func (q *Queries) CountEmbeddingsByOrganization(ctx context.Context, organizationID int32) (int64, error) { + row := q.db.QueryRow(ctx, countEmbeddingsByOrganization, organizationID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createDuplicateCandidateExactMatch = `-- name: CreateDuplicateCandidateExactMatch :one +INSERT INTO duplicate_candidates ( + resource_id, + candidate_resource_id, + similarity_score, + detection_method, + confidence_level, + organization_id, + status +) VALUES ( + $1, $2, $3, 'exact_match', 'very_high', $4, 'pending' +) 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 +` + +type CreateDuplicateCandidateExactMatchParams struct { + ResourceID int32 `json:"resource_id"` + CandidateResourceID int32 `json:"candidate_resource_id"` + SimilarityScore pgtype.Numeric `json:"similarity_score"` + OrganizationID int32 `json:"organization_id"` +} + +// Creates a duplicate candidate for exact/perfect matches (no LLM data) +func (q *Queries) CreateDuplicateCandidateExactMatch(ctx context.Context, arg CreateDuplicateCandidateExactMatchParams) (DuplicateCandidate, error) { + row := q.db.QueryRow(ctx, createDuplicateCandidateExactMatch, + arg.ResourceID, + arg.CandidateResourceID, + arg.SimilarityScore, + arg.OrganizationID, + ) + var i DuplicateCandidate + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.CandidateResourceID, + &i.SimilarityScore, + &i.DetectionMethod, + &i.ConfidenceLevel, + &i.LlmReason, + &i.LlmSimilarFields, + &i.LlmResponse, + &i.OrganizationID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDuplicateCandidateLLM = `-- name: CreateDuplicateCandidateLLM :one +INSERT INTO duplicate_candidates ( + resource_id, + candidate_resource_id, + similarity_score, + detection_method, + confidence_level, + llm_reason, + llm_similar_fields, + llm_response, + organization_id, + status +) VALUES ( + $1, $2, $3, 'llm_adjudicated', $4, $5, $6, $7, $8, 'pending' +) 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 +` + +type CreateDuplicateCandidateLLMParams struct { + ResourceID int32 `json:"resource_id"` + CandidateResourceID int32 `json:"candidate_resource_id"` + SimilarityScore pgtype.Numeric `json:"similarity_score"` + ConfidenceLevel pgtype.Text `json:"confidence_level"` + LlmReason pgtype.Text `json:"llm_reason"` + LlmSimilarFields []byte `json:"llm_similar_fields"` + LlmResponse []byte `json:"llm_response"` + OrganizationID int32 `json:"organization_id"` +} + +// Creates a duplicate candidate with LLM adjudication data +func (q *Queries) CreateDuplicateCandidateLLM(ctx context.Context, arg CreateDuplicateCandidateLLMParams) (DuplicateCandidate, error) { + row := q.db.QueryRow(ctx, createDuplicateCandidateLLM, + arg.ResourceID, + arg.CandidateResourceID, + arg.SimilarityScore, + arg.ConfidenceLevel, + arg.LlmReason, + arg.LlmSimilarFields, + arg.LlmResponse, + arg.OrganizationID, + ) + var i DuplicateCandidate + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.CandidateResourceID, + &i.SimilarityScore, + &i.DetectionMethod, + &i.ConfidenceLevel, + &i.LlmReason, + &i.LlmSimilarFields, + &i.LlmResponse, + &i.OrganizationID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createResourceDuplicateCandidate = `-- name: CreateResourceDuplicateCandidate :one + +INSERT INTO duplicate_candidates ( + resource_id, + candidate_resource_id, + similarity_score, + detection_method, + confidence_level, + llm_reason, + llm_similar_fields, + llm_response, + organization_id, + status +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 +) 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 +` + +type CreateResourceDuplicateCandidateParams struct { + ResourceID int32 `json:"resource_id"` + CandidateResourceID int32 `json:"candidate_resource_id"` + SimilarityScore pgtype.Numeric `json:"similarity_score"` + DetectionMethod string `json:"detection_method"` + ConfidenceLevel pgtype.Text `json:"confidence_level"` + LlmReason pgtype.Text `json:"llm_reason"` + LlmSimilarFields []byte `json:"llm_similar_fields"` + LlmResponse []byte `json:"llm_response"` + OrganizationID int32 `json:"organization_id"` + Status pgtype.Text `json:"status"` +} + +// Duplicate Candidates Queries +// Creates a new duplicate candidate record +func (q *Queries) CreateResourceDuplicateCandidate(ctx context.Context, arg CreateResourceDuplicateCandidateParams) (DuplicateCandidate, error) { + row := q.db.QueryRow(ctx, createResourceDuplicateCandidate, + arg.ResourceID, + arg.CandidateResourceID, + arg.SimilarityScore, + arg.DetectionMethod, + arg.ConfidenceLevel, + arg.LlmReason, + arg.LlmSimilarFields, + arg.LlmResponse, + arg.OrganizationID, + arg.Status, + ) + var i DuplicateCandidate + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.CandidateResourceID, + &i.SimilarityScore, + &i.DetectionMethod, + &i.ConfidenceLevel, + &i.LlmReason, + &i.LlmSimilarFields, + &i.LlmResponse, + &i.OrganizationID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteDuplicateCandidate = `-- name: DeleteDuplicateCandidate :exec +DELETE FROM duplicate_candidates +WHERE id = $1 +` + +// Deletes a duplicate candidate record +func (q *Queries) DeleteDuplicateCandidate(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteDuplicateCandidate, id) + return err +} + +const deleteResourceEmbedding = `-- name: DeleteResourceEmbedding :exec + +DELETE FROM resource_embeddings +WHERE resource_id = $1 AND organization_id = $2 +` + +type DeleteResourceEmbeddingParams struct { + ResourceID int32 `json:"resource_id"` + OrganizationID int32 `json:"organization_id"` +} + +// Exclude the current resource +// Deletes an embedding for a resource +func (q *Queries) DeleteResourceEmbedding(ctx context.Context, arg DeleteResourceEmbeddingParams) error { + _, err := q.db.Exec(ctx, deleteResourceEmbedding, arg.ResourceID, arg.OrganizationID) + return err +} + +const dismissDuplicate = `-- name: DismissDuplicate :exec +UPDATE duplicate_candidates +SET status = 'dismissed', updated_at = NOW() +WHERE id = $1 +` + +// Marks a duplicate candidate as dismissed +func (q *Queries) DismissDuplicate(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, dismissDuplicate, id) + return err +} + +const findExactDuplicateByHash = `-- name: FindExactDuplicateByHash :many +SELECT + resource_id, + content_hash, + content_preview +FROM resource_embeddings +WHERE organization_id = $1 + AND content_hash = $2 + AND resource_id != $3 +` + +type FindExactDuplicateByHashParams struct { + OrganizationID int32 `json:"organization_id"` + ContentHash pgtype.Text `json:"content_hash"` + ResourceID int32 `json:"resource_id"` +} + +type FindExactDuplicateByHashRow struct { + ResourceID int32 `json:"resource_id"` + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` +} + +// Finds exact duplicates using content hash (faster than vector search for exact matches) +func (q *Queries) FindExactDuplicateByHash(ctx context.Context, arg FindExactDuplicateByHashParams) ([]FindExactDuplicateByHashRow, error) { + rows, err := q.db.Query(ctx, findExactDuplicateByHash, arg.OrganizationID, arg.ContentHash, arg.ResourceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FindExactDuplicateByHashRow{} + for rows.Next() { + var i FindExactDuplicateByHashRow + if err := rows.Scan(&i.ResourceID, &i.ContentHash, &i.ContentPreview); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const findSimilarResources = `-- name: FindSimilarResources :many +SELECT + resource_id, + 1 - (embedding <=> $1::vector) AS similarity_score, + content_hash, + content_preview +FROM resource_embeddings +WHERE organization_id = $2 + AND resource_id != $3 + AND 1 - (embedding <=> $1::vector) >= $4 +ORDER BY embedding <=> $1::vector -- Order by distance (closest first) +LIMIT $5 +` + +type FindSimilarResourcesParams struct { + Column1 pgvector_go.Vector `json:"column_1"` + OrganizationID int32 `json:"organization_id"` + ResourceID int32 `json:"resource_id"` + Embedding pgvector_go.Vector `json:"embedding"` + Limit int32 `json:"limit"` +} + +type FindSimilarResourcesRow struct { + ResourceID int32 `json:"resource_id"` + SimilarityScore int32 `json:"similarity_score"` + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` +} + +// Finds similar resources using vector cosine similarity search +// The <=> operator calculates cosine distance (0 = identical, 2 = opposite) +// We convert to similarity score: 1 - distance/2 = similarity (0 to 1) +// +// Parameters: +// $1: embedding vector to search for +// $2: organization_id to scope the search +// $3: resource_id to exclude (don't match against itself) +// $4: minimum similarity threshold (e.g., 0.85) +// $5: limit on number of results +func (q *Queries) FindSimilarResources(ctx context.Context, arg FindSimilarResourcesParams) ([]FindSimilarResourcesRow, error) { + rows, err := q.db.Query(ctx, findSimilarResources, + arg.Column1, + arg.OrganizationID, + arg.ResourceID, + arg.Embedding, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FindSimilarResourcesRow{} + for rows.Next() { + var i FindSimilarResourcesRow + if err := rows.Scan( + &i.ResourceID, + &i.SimilarityScore, + &i.ContentHash, + &i.ContentPreview, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDuplicateCandidate = `-- name: GetDuplicateCandidate :one +SELECT 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 +WHERE id = $1 +` + +// Gets a specific duplicate candidate by ID +func (q *Queries) GetDuplicateCandidate(ctx context.Context, id int32) (DuplicateCandidate, error) { + row := q.db.QueryRow(ctx, getDuplicateCandidate, id) + var i DuplicateCandidate + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.CandidateResourceID, + &i.SimilarityScore, + &i.DetectionMethod, + &i.ConfidenceLevel, + &i.LlmReason, + &i.LlmSimilarFields, + &i.LlmResponse, + &i.OrganizationID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getResourceDuplicateStats = `-- name: GetResourceDuplicateStats :one +SELECT + COUNT(*) as total_candidates, + COUNT(*) FILTER (WHERE status = 'pending') as pending_count, + COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count, + COUNT(*) FILTER (WHERE status = 'dismissed') as dismissed_count, + COUNT(*) FILTER (WHERE detection_method = 'exact_match') as exact_match_count, + COUNT(*) FILTER (WHERE detection_method = 'llm_adjudicated') as llm_adjudicated_count, + AVG(similarity_score) as avg_similarity_score +FROM duplicate_candidates +WHERE organization_id = $1 +` + +type GetResourceDuplicateStatsRow struct { + TotalCandidates int64 `json:"total_candidates"` + PendingCount int64 `json:"pending_count"` + ConfirmedCount int64 `json:"confirmed_count"` + DismissedCount int64 `json:"dismissed_count"` + ExactMatchCount int64 `json:"exact_match_count"` + LlmAdjudicatedCount int64 `json:"llm_adjudicated_count"` + AvgSimilarityScore float64 `json:"avg_similarity_score"` +} + +// Gets statistics about duplicate detection for an organization +func (q *Queries) GetResourceDuplicateStats(ctx context.Context, organizationID int32) (GetResourceDuplicateStatsRow, error) { + row := q.db.QueryRow(ctx, getResourceDuplicateStats, organizationID) + var i GetResourceDuplicateStatsRow + err := row.Scan( + &i.TotalCandidates, + &i.PendingCount, + &i.ConfirmedCount, + &i.DismissedCount, + &i.ExactMatchCount, + &i.LlmAdjudicatedCount, + &i.AvgSimilarityScore, + ) + return i, err +} + +const getResourceEmbedding = `-- name: GetResourceEmbedding :one +SELECT id, resource_id, embedding, organization_id, content_hash, content_preview, created_at, updated_at FROM resource_embeddings +WHERE resource_id = $1 AND organization_id = $2 +LIMIT 1 +` + +type GetResourceEmbeddingParams struct { + ResourceID int32 `json:"resource_id"` + OrganizationID int32 `json:"organization_id"` +} + +// Retrieves the embedding for a specific resource +func (q *Queries) GetResourceEmbedding(ctx context.Context, arg GetResourceEmbeddingParams) (ResourceEmbedding, error) { + row := q.db.QueryRow(ctx, getResourceEmbedding, arg.ResourceID, arg.OrganizationID) + var i ResourceEmbedding + err := row.Scan( + &i.ID, + &i.ResourceID, + &i.Embedding, + &i.OrganizationID, + &i.ContentHash, + &i.ContentPreview, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDuplicateCandidatesForResource = `-- name: ListDuplicateCandidatesForResource :many +SELECT 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 +WHERE resource_id = $1 AND organization_id = $2 +ORDER BY similarity_score DESC, created_at DESC +` + +type ListDuplicateCandidatesForResourceParams struct { + ResourceID int32 `json:"resource_id"` + OrganizationID int32 `json:"organization_id"` +} + +// Lists all duplicate candidates for a specific resource +func (q *Queries) ListDuplicateCandidatesForResource(ctx context.Context, arg ListDuplicateCandidatesForResourceParams) ([]DuplicateCandidate, error) { + rows, err := q.db.Query(ctx, listDuplicateCandidatesForResource, arg.ResourceID, arg.OrganizationID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DuplicateCandidate{} + for rows.Next() { + var i DuplicateCandidate + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.CandidateResourceID, + &i.SimilarityScore, + &i.DetectionMethod, + &i.ConfidenceLevel, + &i.LlmReason, + &i.LlmSimilarFields, + &i.LlmResponse, + &i.OrganizationID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPendingDuplicates = `-- name: ListPendingDuplicates :many +SELECT 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 +WHERE organization_id = $1 AND status = 'pending' +ORDER BY similarity_score DESC, created_at DESC +LIMIT $2 OFFSET $3 +` + +type ListPendingDuplicatesParams struct { + OrganizationID int32 `json:"organization_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +// Lists all pending duplicate candidates for an organization +func (q *Queries) ListPendingDuplicates(ctx context.Context, arg ListPendingDuplicatesParams) ([]DuplicateCandidate, error) { + rows, err := q.db.Query(ctx, listPendingDuplicates, arg.OrganizationID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DuplicateCandidate{} + for rows.Next() { + var i DuplicateCandidate + if err := rows.Scan( + &i.ID, + &i.ResourceID, + &i.CandidateResourceID, + &i.SimilarityScore, + &i.DetectionMethod, + &i.ConfidenceLevel, + &i.LlmReason, + &i.LlmSimilarFields, + &i.LlmResponse, + &i.OrganizationID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const saveResourceEmbedding = `-- name: SaveResourceEmbedding :exec + +INSERT INTO resource_embeddings ( + resource_id, + embedding, + organization_id, + content_hash, + content_preview +) VALUES ( + $1, $2, $3, $4, $5 +) ON CONFLICT (resource_id, organization_id) +DO UPDATE SET + embedding = EXCLUDED.embedding, + content_hash = EXCLUDED.content_hash, + content_preview = EXCLUDED.content_preview, + updated_at = NOW() +` + +type SaveResourceEmbeddingParams struct { + ResourceID int32 `json:"resource_id"` + Embedding pgvector_go.Vector `json:"embedding"` + OrganizationID int32 `json:"organization_id"` + ContentHash pgtype.Text `json:"content_hash"` + ContentPreview pgtype.Text `json:"content_preview"` +} + +// Resource Embeddings Queries +// These queries demonstrate pgvector usage for semantic similarity search +// Saves or updates an embedding for a resource +// Uses ON CONFLICT to handle duplicate resource_id + organization_id pairs +func (q *Queries) SaveResourceEmbedding(ctx context.Context, arg SaveResourceEmbeddingParams) error { + _, err := q.db.Exec(ctx, saveResourceEmbedding, + arg.ResourceID, + arg.Embedding, + arg.OrganizationID, + arg.ContentHash, + arg.ContentPreview, + ) + return err +} + +const updateDuplicateCandidateStatus = `-- name: UpdateDuplicateCandidateStatus :exec +UPDATE duplicate_candidates +SET status = $2, updated_at = NOW() +WHERE id = $1 +` + +type UpdateDuplicateCandidateStatusParams struct { + ID int32 `json:"id"` + Status pgtype.Text `json:"status"` +} + +// Updates the status of a duplicate candidate +func (q *Queries) UpdateDuplicateCandidateStatus(ctx context.Context, arg UpdateDuplicateCandidateStatusParams) error { + _, err := q.db.Exec(ctx, updateDuplicateCandidateStatus, arg.ID, arg.Status) + return err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/store.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/store.go new file mode 100644 index 0000000..c252f08 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/store.go @@ -0,0 +1,39 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store interface { + Querier +} + +type SQLStore struct { + connPool *pgxpool.Pool + *Queries +} + +// NewStore creates a new store +func NewStore(connPool *pgxpool.Pool) Store { + return &SQLStore{ + connPool: connPool, + Queries: New(connPool), + } +} + +func (store *SQLStore) WithTx(db DBTX) Store { + return &SQLStore{ + Queries: New(db), + } +} + +// ExecTx executes a function within a database transaction +func (store *SQLStore) ExecTx(ctx context.Context, fn func(*Queries) error) error { + return fn(store.Queries) +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/subscription_billing.sql.go b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/subscription_billing.sql.go new file mode 100644 index 0000000..9417a36 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/gen/subscription_billing.sql.go @@ -0,0 +1,465 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: subscription_billing.sql + +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const decrementInvoiceCount = `-- name: DecrementInvoiceCount :one +UPDATE subscription_billing.quota_tracking +SET + invoice_count = invoice_count - 1, + updated_at = CURRENT_TIMESTAMP +WHERE organization_id = $1 +RETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count +` + +// Decrement invoice count by 1 (called after successful invoice processing) +func (q *Queries) DecrementInvoiceCount(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) { + row := q.db.QueryRow(ctx, decrementInvoiceCount, organizationID) + var i SubscriptionBillingQuotaTracking + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.MaxSeats, + &i.PeriodStart, + &i.PeriodEnd, + &i.LastSyncedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.InvoiceCount, + ) + return i, err +} + +const deleteSubscription = `-- name: DeleteSubscription :exec +DELETE FROM subscription_billing.subscriptions +WHERE organization_id = $1 +` + +// Delete subscription (when subscription is permanently deleted) +func (q *Queries) DeleteSubscription(ctx context.Context, organizationID int32) error { + _, err := q.db.Exec(ctx, deleteSubscription, organizationID) + return err +} + +const getQuotaByOrgID = `-- name: GetQuotaByOrgID :one +SELECT id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count FROM subscription_billing.quota_tracking +WHERE organization_id = $1 +LIMIT 1 +` + +// Get quota tracking for an organization +func (q *Queries) GetQuotaByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingQuotaTracking, error) { + row := q.db.QueryRow(ctx, getQuotaByOrgID, organizationID) + var i SubscriptionBillingQuotaTracking + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.MaxSeats, + &i.PeriodStart, + &i.PeriodEnd, + &i.LastSyncedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.InvoiceCount, + ) + return i, err +} + +const getQuotaStatus = `-- name: GetQuotaStatus :one +SELECT + s.subscription_status, + s.current_period_start, + s.current_period_end, + s.cancel_at_period_end, + q.invoice_count, + q.max_seats, + CASE + WHEN s.subscription_status = 'active' AND q.invoice_count > 0 + THEN TRUE + ELSE FALSE + END AS can_process_invoice +FROM subscription_billing.subscriptions s +INNER JOIN subscription_billing.quota_tracking q ON s.organization_id = q.organization_id +WHERE s.organization_id = $1 +LIMIT 1 +` + +type GetQuotaStatusRow struct { + SubscriptionStatus string `json:"subscription_status"` + CurrentPeriodStart pgtype.Timestamp `json:"current_period_start"` + CurrentPeriodEnd pgtype.Timestamp `json:"current_period_end"` + CancelAtPeriodEnd pgtype.Bool `json:"cancel_at_period_end"` + InvoiceCount int32 `json:"invoice_count"` + MaxSeats pgtype.Int4 `json:"max_seats"` + CanProcessInvoice bool `json:"can_process_invoice"` +} + +// Get combined subscription and quota status for fast quota checks +func (q *Queries) GetQuotaStatus(ctx context.Context, organizationID int32) (GetQuotaStatusRow, error) { + row := q.db.QueryRow(ctx, getQuotaStatus, organizationID) + var i GetQuotaStatusRow + err := row.Scan( + &i.SubscriptionStatus, + &i.CurrentPeriodStart, + &i.CurrentPeriodEnd, + &i.CancelAtPeriodEnd, + &i.InvoiceCount, + &i.MaxSeats, + &i.CanProcessInvoice, + ) + return i, err +} + +const getSubscriptionByOrgID = `-- name: GetSubscriptionByOrgID :one +SELECT 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 +WHERE organization_id = $1 +LIMIT 1 +` + +// Get subscription details for an organization +func (q *Queries) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (SubscriptionBillingSubscription, error) { + row := q.db.QueryRow(ctx, getSubscriptionByOrgID, organizationID) + var i SubscriptionBillingSubscription + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.ExternalCustomerID, + &i.SubscriptionID, + &i.SubscriptionStatus, + &i.ProductID, + &i.ProductName, + &i.PlanName, + &i.CurrentPeriodStart, + &i.CurrentPeriodEnd, + &i.CancelAtPeriodEnd, + &i.CanceledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.Metadata, + ) + return i, err +} + +const getSubscriptionBySubscriptionID = `-- name: GetSubscriptionBySubscriptionID :one +SELECT 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 +WHERE subscription_id = $1 +LIMIT 1 +` + +// Get subscription by Polar subscription ID +func (q *Queries) GetSubscriptionBySubscriptionID(ctx context.Context, subscriptionID string) (SubscriptionBillingSubscription, error) { + row := q.db.QueryRow(ctx, getSubscriptionBySubscriptionID, subscriptionID) + var i SubscriptionBillingSubscription + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.ExternalCustomerID, + &i.SubscriptionID, + &i.SubscriptionStatus, + &i.ProductID, + &i.ProductName, + &i.PlanName, + &i.CurrentPeriodStart, + &i.CurrentPeriodEnd, + &i.CancelAtPeriodEnd, + &i.CanceledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.Metadata, + ) + return i, err +} + +const listActiveSubscriptions = `-- name: ListActiveSubscriptions :many +SELECT 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 +WHERE subscription_status = 'active' +ORDER BY created_at DESC +` + +// List all active subscriptions for monitoring/admin purposes +func (q *Queries) ListActiveSubscriptions(ctx context.Context) ([]SubscriptionBillingSubscription, error) { + rows, err := q.db.Query(ctx, listActiveSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SubscriptionBillingSubscription{} + for rows.Next() { + var i SubscriptionBillingSubscription + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.ExternalCustomerID, + &i.SubscriptionID, + &i.SubscriptionStatus, + &i.ProductID, + &i.ProductName, + &i.PlanName, + &i.CurrentPeriodStart, + &i.CurrentPeriodEnd, + &i.CancelAtPeriodEnd, + &i.CanceledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.Metadata, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuotasNearLimit = `-- name: ListQuotasNearLimit :many +SELECT + 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, + s.subscription_status, + s.product_name +FROM subscription_billing.quota_tracking q +INNER JOIN subscription_billing.subscriptions s ON q.organization_id = s.organization_id +WHERE + s.subscription_status = 'active' + AND q.invoice_count <= $1 +ORDER BY q.invoice_count ASC +` + +type ListQuotasNearLimitRow struct { + ID int32 `json:"id"` + OrganizationID int32 `json:"organization_id"` + MaxSeats pgtype.Int4 `json:"max_seats"` + PeriodStart pgtype.Timestamp `json:"period_start"` + PeriodEnd pgtype.Timestamp `json:"period_end"` + LastSyncedAt pgtype.Timestamp `json:"last_synced_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` + InvoiceCount int32 `json:"invoice_count"` + SubscriptionStatus string `json:"subscription_status"` + ProductName pgtype.Text `json:"product_name"` +} + +// List organizations approaching their quota limit (for alerting) +func (q *Queries) ListQuotasNearLimit(ctx context.Context, invoiceCount int32) ([]ListQuotasNearLimitRow, error) { + rows, err := q.db.Query(ctx, listQuotasNearLimit, invoiceCount) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuotasNearLimitRow{} + for rows.Next() { + var i ListQuotasNearLimitRow + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.MaxSeats, + &i.PeriodStart, + &i.PeriodEnd, + &i.LastSyncedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.InvoiceCount, + &i.SubscriptionStatus, + &i.ProductName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resetQuotaForPeriod = `-- name: ResetQuotaForPeriod :one +UPDATE subscription_billing.quota_tracking +SET + invoice_count = $2, + period_start = $3, + period_end = $4, + updated_at = CURRENT_TIMESTAMP +WHERE organization_id = $1 +RETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count +` + +type ResetQuotaForPeriodParams struct { + OrganizationID int32 `json:"organization_id"` + InvoiceCount int32 `json:"invoice_count"` + PeriodStart pgtype.Timestamp `json:"period_start"` + PeriodEnd pgtype.Timestamp `json:"period_end"` +} + +// Reset quota counters for a new billing period +func (q *Queries) ResetQuotaForPeriod(ctx context.Context, arg ResetQuotaForPeriodParams) (SubscriptionBillingQuotaTracking, error) { + row := q.db.QueryRow(ctx, resetQuotaForPeriod, + arg.OrganizationID, + arg.InvoiceCount, + arg.PeriodStart, + arg.PeriodEnd, + ) + var i SubscriptionBillingQuotaTracking + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.MaxSeats, + &i.PeriodStart, + &i.PeriodEnd, + &i.LastSyncedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.InvoiceCount, + ) + return i, err +} + +const upsertQuota = `-- name: UpsertQuota :one +INSERT INTO subscription_billing.quota_tracking ( + organization_id, + invoice_count, + max_seats, + period_start, + period_end, + last_synced_at, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +) +ON CONFLICT (organization_id) +DO UPDATE SET + invoice_count = EXCLUDED.invoice_count, + max_seats = EXCLUDED.max_seats, + period_start = EXCLUDED.period_start, + period_end = EXCLUDED.period_end, + last_synced_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +RETURNING id, organization_id, max_seats, period_start, period_end, last_synced_at, created_at, updated_at, invoice_count +` + +type UpsertQuotaParams struct { + OrganizationID int32 `json:"organization_id"` + InvoiceCount int32 `json:"invoice_count"` + MaxSeats pgtype.Int4 `json:"max_seats"` + PeriodStart pgtype.Timestamp `json:"period_start"` + PeriodEnd pgtype.Timestamp `json:"period_end"` +} + +// Create or update quota tracking +func (q *Queries) UpsertQuota(ctx context.Context, arg UpsertQuotaParams) (SubscriptionBillingQuotaTracking, error) { + row := q.db.QueryRow(ctx, upsertQuota, + arg.OrganizationID, + arg.InvoiceCount, + arg.MaxSeats, + arg.PeriodStart, + arg.PeriodEnd, + ) + var i SubscriptionBillingQuotaTracking + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.MaxSeats, + &i.PeriodStart, + &i.PeriodEnd, + &i.LastSyncedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.InvoiceCount, + ) + return i, err +} + +const upsertSubscription = `-- name: UpsertSubscription :one +INSERT INTO subscription_billing.subscriptions ( + 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, + metadata, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP +) +ON CONFLICT (organization_id) +DO UPDATE SET + external_customer_id = EXCLUDED.external_customer_id, + subscription_id = EXCLUDED.subscription_id, + subscription_status = EXCLUDED.subscription_status, + product_id = EXCLUDED.product_id, + product_name = EXCLUDED.product_name, + plan_name = EXCLUDED.plan_name, + current_period_start = EXCLUDED.current_period_start, + current_period_end = EXCLUDED.current_period_end, + cancel_at_period_end = EXCLUDED.cancel_at_period_end, + canceled_at = EXCLUDED.canceled_at, + metadata = EXCLUDED.metadata, + updated_at = CURRENT_TIMESTAMP +RETURNING 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 +` + +type UpsertSubscriptionParams struct { + OrganizationID int32 `json:"organization_id"` + ExternalCustomerID string `json:"external_customer_id"` + SubscriptionID string `json:"subscription_id"` + SubscriptionStatus string `json:"subscription_status"` + ProductID string `json:"product_id"` + ProductName pgtype.Text `json:"product_name"` + PlanName pgtype.Text `json:"plan_name"` + CurrentPeriodStart pgtype.Timestamp `json:"current_period_start"` + CurrentPeriodEnd pgtype.Timestamp `json:"current_period_end"` + CancelAtPeriodEnd pgtype.Bool `json:"cancel_at_period_end"` + CanceledAt pgtype.Timestamp `json:"canceled_at"` + Metadata []byte `json:"metadata"` +} + +// Create or update subscription from Polar webhook +func (q *Queries) UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (SubscriptionBillingSubscription, error) { + row := q.db.QueryRow(ctx, upsertSubscription, + arg.OrganizationID, + arg.ExternalCustomerID, + arg.SubscriptionID, + arg.SubscriptionStatus, + arg.ProductID, + arg.ProductName, + arg.PlanName, + arg.CurrentPeriodStart, + arg.CurrentPeriodEnd, + arg.CancelAtPeriodEnd, + arg.CanceledAt, + arg.Metadata, + ) + var i SubscriptionBillingSubscription + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.ExternalCustomerID, + &i.SubscriptionID, + &i.SubscriptionStatus, + &i.ProductID, + &i.ProductName, + &i.PlanName, + &i.CurrentPeriodStart, + &i.CurrentPeriodEnd, + &i.CancelAtPeriodEnd, + &i.CanceledAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.Metadata, + ) + return i, err +} diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000001_create_file_manager_schema.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000001_create_file_manager_schema.down.sql new file mode 100644 index 0000000..461fe88 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000001_create_file_manager_schema.down.sql @@ -0,0 +1,17 @@ +BEGIN; + +-- Drop indexes +DROP INDEX IF EXISTS file_manager.idx_file_assets_entity; +DROP INDEX IF EXISTS file_manager.idx_file_assets_category; +DROP INDEX IF EXISTS file_manager.idx_file_assets_context; +DROP INDEX IF EXISTS file_manager.idx_file_assets_created_at; + +-- Drop tables in reverse order +DROP TABLE IF EXISTS file_manager.file_assets; +DROP TABLE IF EXISTS file_manager.file_contexts; +DROP TABLE IF EXISTS file_manager.file_categories; + +-- Drop schema +DROP SCHEMA IF EXISTS file_manager CASCADE; + +COMMIT; \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000001_create_file_manager_schema.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000001_create_file_manager_schema.up.sql new file mode 100644 index 0000000..c7895a9 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000001_create_file_manager_schema.up.sql @@ -0,0 +1,55 @@ +CREATE SCHEMA IF NOT EXISTS file_manager; + +-- Create file categories table (similar to asset_types) +CREATE TABLE file_manager.file_categories ( + id SMALLINT PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, -- 'document', 'image', 'archive' + max_size_bytes BIGINT NOT NULL +); + +-- Create file contexts table +CREATE TABLE file_manager.file_contexts ( + id SMALLINT PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL -- 'invoice', 'receipt', 'contract', etc. +); + +-- Create file_assets table (following assets pattern) +CREATE TABLE file_manager.file_assets ( + id SERIAL PRIMARY KEY, + file_name VARCHAR(255) NOT NULL, + original_file_name VARCHAR(255) NOT NULL, + storage_path VARCHAR(1000) NOT NULL, + bucket_name VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL CHECK (file_size > 0), + mime_type VARCHAR(100) NOT NULL, + file_category_id SMALLINT NOT NULL REFERENCES file_manager.file_categories(id), + file_context_id SMALLINT NOT NULL REFERENCES file_manager.file_contexts(id), + is_public BOOLEAN DEFAULT false, + entity_type VARCHAR(50), -- 'user', 'invoice', 'contract', etc. + entity_id INTEGER, -- The ID of the related entity + purpose VARCHAR(100), -- Additional purpose description + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes following the same pattern +CREATE INDEX idx_file_assets_entity ON file_manager.file_assets(entity_type, entity_id); +CREATE INDEX idx_file_assets_category ON file_manager.file_assets(file_category_id); +CREATE INDEX idx_file_assets_context ON file_manager.file_assets(file_context_id); +CREATE INDEX idx_file_assets_created_at ON file_manager.file_assets(created_at DESC); + +-- Insert default categories +INSERT INTO file_manager.file_categories (id, name, max_size_bytes) VALUES +(1, 'document', 52428800), -- 50MB +(2, 'image', 10485760), -- 10MB +(3, 'archive', 104857600); -- 100MB + +-- Insert default contexts +INSERT INTO file_manager.file_contexts (id, name) VALUES +(1, 'invoice'), +(2, 'receipt'), +(3, 'contract'), +(4, 'report'), +(5, 'profile'), +(6, 'general'); \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000002_create_organizations_schema.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000002_create_organizations_schema.down.sql new file mode 100644 index 0000000..82f404b --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000002_create_organizations_schema.down.sql @@ -0,0 +1,22 @@ +-- Drop triggers +DROP TRIGGER IF EXISTS trigger_accounts_updated_at ON organizations.accounts; +DROP TRIGGER IF EXISTS trigger_organizations_updated_at ON organizations.organizations; + +-- Drop indexes (will be dropped automatically with tables, but being explicit) +DROP INDEX IF EXISTS organizations.idx_accounts_role; +DROP INDEX IF EXISTS organizations.idx_accounts_status; +DROP INDEX IF EXISTS organizations.idx_accounts_email; +DROP INDEX IF EXISTS organizations.idx_accounts_org_id; +DROP INDEX IF EXISTS organizations.idx_accounts_stytch_member_id; + +DROP INDEX IF EXISTS organizations.idx_organizations_created_at; +DROP INDEX IF EXISTS organizations.idx_organizations_status; +DROP INDEX IF EXISTS organizations.idx_organizations_slug; +DROP INDEX IF EXISTS organizations.idx_organizations_stytch_org_id; + +-- Drop tables in dependency order +DROP TABLE IF EXISTS organizations.accounts; +DROP TABLE IF EXISTS organizations.organizations; + +-- Drop the schema +DROP SCHEMA IF EXISTS organizations CASCADE; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000002_create_organizations_schema.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000002_create_organizations_schema.up.sql new file mode 100644 index 0000000..e858d14 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000002_create_organizations_schema.up.sql @@ -0,0 +1,99 @@ +-- Create organizations schema +CREATE SCHEMA IF NOT EXISTS organizations; + +-- Organizations table (top-level tenant) +CREATE TABLE organizations.organizations ( + id SERIAL PRIMARY KEY, + slug VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + status VARCHAR(50) DEFAULT 'active' NOT NULL, + + -- Stytch linkage + stytch_org_id VARCHAR(100) UNIQUE, + stytch_connection_id VARCHAR(100), + stytch_connection_name VARCHAR(255), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT chk_organizations_status CHECK (status IN ('active', 'suspended', 'cancelled')), + CONSTRAINT chk_organizations_slug CHECK (slug ~ '^[a-z0-9-]+$' AND LENGTH(slug) >= 3) +); + +-- Accounts table (users within organizations) +CREATE TABLE organizations.accounts ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + + -- Account info + email VARCHAR(255) NOT NULL, + full_name VARCHAR(255) NOT NULL, + stytch_member_id VARCHAR(100), + stytch_role_id VARCHAR(100), + stytch_role_slug VARCHAR(100), + stytch_email_verified BOOLEAN DEFAULT FALSE NOT NULL, + + -- Role and status (legacy field retained for business logic) + role VARCHAR(50) DEFAULT 'member' NOT NULL, + status VARCHAR(50) DEFAULT 'active' NOT NULL, + + -- Activity tracking + last_login_at TIMESTAMP, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + -- Unique constraints + UNIQUE(organization_id, email), + UNIQUE(organization_id, stytch_member_id), + + -- Check constraints + CONSTRAINT chk_accounts_role CHECK (role IN ('owner', 'admin', 'member', 'reviewer', 'employee')), + CONSTRAINT chk_accounts_status CHECK (status IN ('active', 'inactive', 'suspended')), + CONSTRAINT chk_accounts_email CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') +); + +-- Indexes for performance +CREATE INDEX idx_organizations_slug ON organizations.organizations(slug) WHERE status = 'active'; +CREATE INDEX idx_organizations_status ON organizations.organizations(status); +CREATE INDEX idx_organizations_created_at ON organizations.organizations(created_at DESC); +CREATE UNIQUE INDEX idx_organizations_stytch_org_id ON organizations.organizations(stytch_org_id); + +CREATE INDEX idx_accounts_org_id ON organizations.accounts(organization_id); +CREATE INDEX idx_accounts_email ON organizations.accounts(email); +CREATE INDEX idx_accounts_status ON organizations.accounts(status); +CREATE INDEX idx_accounts_role ON organizations.accounts(role); +CREATE INDEX idx_accounts_stytch_member_id ON organizations.accounts(stytch_member_id); + +-- Updated at trigger function (reuse existing function if available) +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Triggers to automatically update updated_at +CREATE TRIGGER trigger_organizations_updated_at + BEFORE UPDATE ON organizations.organizations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER trigger_accounts_updated_at + BEFORE UPDATE ON organizations.accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Comments for documentation +COMMENT ON SCHEMA organizations IS 'Schema for organization and account management'; +COMMENT ON TABLE organizations.organizations IS 'Organizations (tenants) in the system'; +COMMENT ON TABLE organizations.accounts IS 'User accounts within organizations'; +COMMENT ON COLUMN organizations.organizations.slug IS 'URL-friendly unique identifier for organization'; +COMMENT ON COLUMN organizations.organizations.stytch_org_id IS 'Stytch organization identifier (org_xxx)'; +COMMENT ON COLUMN organizations.organizations.stytch_connection_id IS 'Optional Stytch connection or project identifier associated with the organization'; +COMMENT ON COLUMN organizations.organizations.stytch_connection_name IS 'Optional Stytch connection name associated with the organization'; +COMMENT ON COLUMN organizations.accounts.stytch_member_id IS 'Stytch member identifier (member_xxx)'; +COMMENT ON COLUMN organizations.accounts.stytch_role_id IS 'Stytch role identifier assigned to the member'; +COMMENT ON COLUMN organizations.accounts.stytch_role_slug IS 'Human-readable Stytch role slug assigned to the member'; +COMMENT ON COLUMN organizations.accounts.stytch_email_verified IS 'Whether Stytch reports the member email as verified'; +COMMENT ON COLUMN organizations.accounts.role IS 'Last known role for business logic (e.g., owner, reviewer, employee)'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000003_enforce_role_enum.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000003_enforce_role_enum.down.sql new file mode 100644 index 0000000..29c48e9 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000003_enforce_role_enum.down.sql @@ -0,0 +1,5 @@ +-- Rollback: Remove RBAC roles enum constraint + +-- Drop the check constraint that enforces valid role values +ALTER TABLE organizations.accounts +DROP CONSTRAINT valid_role_enum; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000003_enforce_role_enum.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000003_enforce_role_enum.up.sql new file mode 100644 index 0000000..44377b4 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000003_enforce_role_enum.up.sql @@ -0,0 +1,23 @@ +-- Enforce RBAC roles enum according to PERMISSIONS.md specification +-- Valid roles: member, approver, admin (plus legacy roles for backward compatibility) + +-- Add check constraint to organizations.accounts.role column +-- Ensures only valid role values are stored in the database +ALTER TABLE organizations.accounts +ADD CONSTRAINT valid_role_enum CHECK ( + role IN ('member', 'approver', 'admin', 'owner', 'reviewer', 'employee') +); + +-- Add comment documenting the role enum constraint +COMMENT ON CONSTRAINT valid_role_enum ON organizations.accounts IS +'RBAC Role Enum Constraint per PERMISSIONS.md: +- member: Process invoices day-to-day (Member role) +- approver: Review and approve invoices assigned to them (Approver role) +- admin: Full system control and management (Admin role) + +Legacy roles (for backward compatibility during migration): +- owner: Legacy mapping to admin role +- reviewer: Legacy mapping to approver role +- employee: Legacy mapping to member role + +All new role assignments MUST use the three core roles: member, approver, admin'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.down.sql new file mode 100644 index 0000000..db82357 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.down.sql @@ -0,0 +1,4 @@ +-- Drop subscription billing schema and all its tables +DROP TABLE IF EXISTS subscription_billing.quota_tracking CASCADE; +DROP TABLE IF EXISTS subscription_billing.subscriptions CASCADE; +DROP SCHEMA IF EXISTS subscription_billing CASCADE; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.up.sql new file mode 100644 index 0000000..6d80f18 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000004_create_subscription_billing_schema.up.sql @@ -0,0 +1,74 @@ +-- Create subscription_billing schema for local subscription and quota tracking +CREATE SCHEMA IF NOT EXISTS subscription_billing; + +-- Subscriptions table: Stores Polar subscription data locally for fast access +CREATE TABLE subscription_billing.subscriptions ( + id SERIAL PRIMARY KEY, + organization_id INT NOT NULL UNIQUE REFERENCES organizations.organizations(id) ON DELETE CASCADE, + + -- Polar identifiers + external_customer_id VARCHAR(100) NOT NULL, -- Polar customer ID + subscription_id VARCHAR(100) NOT NULL UNIQUE, -- Polar subscription ID + + -- Subscription details + subscription_status VARCHAR(50) NOT NULL, -- active, canceled, past_due, etc. + product_id VARCHAR(100) NOT NULL, -- Polar product ID + product_name VARCHAR(255), -- Product display name + plan_name VARCHAR(100), -- From subscription metadata + + -- Billing period + current_period_start TIMESTAMP NOT NULL, + current_period_end TIMESTAMP NOT NULL, + + -- Cancellation details + cancel_at_period_end BOOLEAN DEFAULT FALSE, + canceled_at TIMESTAMP, + + -- Audit timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Metadata from Polar (stored as JSONB for flexibility) + metadata JSONB DEFAULT '{}'::jsonb +); + +-- Quota tracking table: Tracks usage quotas per organization +CREATE TABLE subscription_billing.quota_tracking ( + id SERIAL PRIMARY KEY, + organization_id INT NOT NULL UNIQUE REFERENCES organizations.organizations(id) ON DELETE CASCADE, + + -- Invoice quota (main quota we're tracking) + invoice_count_current INT DEFAULT 0, -- Current usage in period + invoice_count_max INT NOT NULL, -- Maximum allowed in period + + -- Additional quotas from product metadata + max_seats INT, -- Maximum seats allowed + + -- Quota period (should match subscription period) + period_start TIMESTAMP NOT NULL, + period_end TIMESTAMP NOT NULL, + + -- Sync tracking + last_synced_at TIMESTAMP, -- Last time synced with Polar + + -- Audit timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for fast lookups +CREATE INDEX idx_subscriptions_organization_id ON subscription_billing.subscriptions(organization_id); +CREATE INDEX idx_subscriptions_subscription_status ON subscription_billing.subscriptions(subscription_status); +CREATE INDEX idx_subscriptions_external_customer_id ON subscription_billing.subscriptions(external_customer_id); + +CREATE INDEX idx_quota_tracking_organization_id ON subscription_billing.quota_tracking(organization_id); +CREATE INDEX idx_quota_tracking_period_end ON subscription_billing.quota_tracking(period_end); + +-- Comments for documentation +COMMENT ON SCHEMA subscription_billing IS 'Local cache of Polar subscription and quota data for fast access'; +COMMENT ON TABLE subscription_billing.subscriptions IS 'Stores subscription details from Polar, synced via webhooks'; +COMMENT ON TABLE subscription_billing.quota_tracking IS 'Tracks usage quotas per organization for fast quota checks'; + +COMMENT ON COLUMN subscription_billing.subscriptions.external_customer_id IS 'Polar customer ID (maps to organization via stytch_org_id)'; +COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_current IS 'Current invoice count in billing period'; +COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_max IS 'Maximum invoices allowed from product metadata'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.down.sql new file mode 100644 index 0000000..dfd115b --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.down.sql @@ -0,0 +1,26 @@ +-- Rollback quota_tracking schema changes +-- Restore invoice_count_max and invoice_count_current + +-- Step 1: Add back old columns +ALTER TABLE subscription_billing.quota_tracking +ADD COLUMN invoice_count_current INT DEFAULT 0, +ADD COLUMN invoice_count_max INT; + +-- Step 2: Migrate data back (this is a best-effort rollback, data may be lost) +-- We can't perfectly restore the original split, so we set current=0 and max=invoice_count +UPDATE subscription_billing.quota_tracking +SET invoice_count_current = 0, + invoice_count_max = invoice_count; + +-- Step 3: Make columns NOT NULL +ALTER TABLE subscription_billing.quota_tracking +ALTER COLUMN invoice_count_current SET NOT NULL, +ALTER COLUMN invoice_count_max SET NOT NULL; + +-- Step 4: Drop new column +ALTER TABLE subscription_billing.quota_tracking +DROP COLUMN invoice_count; + +-- Restore comments +COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_current IS 'Current invoice count in billing period'; +COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count_max IS 'Maximum invoices allowed from product metadata'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.up.sql new file mode 100644 index 0000000..5a6d09b --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000005_update_quota_tracking_schema.up.sql @@ -0,0 +1,24 @@ +-- Update quota_tracking schema to use count-down system +-- Remove invoice_count_max, rename invoice_count_current to invoice_count (remaining invoices) + +-- Step 1: Add new invoice_count column (temporary) +ALTER TABLE subscription_billing.quota_tracking +ADD COLUMN invoice_count INT; + +-- Step 2: Migrate data: invoice_count = invoice_count_max - invoice_count_current +-- For existing rows, calculate remaining invoices +UPDATE subscription_billing.quota_tracking +SET invoice_count = GREATEST(invoice_count_max - invoice_count_current, 0); + +-- Step 3: Make invoice_count NOT NULL with default 0 +ALTER TABLE subscription_billing.quota_tracking +ALTER COLUMN invoice_count SET NOT NULL, +ALTER COLUMN invoice_count SET DEFAULT 0; + +-- Step 4: Drop old columns +ALTER TABLE subscription_billing.quota_tracking +DROP COLUMN invoice_count_current, +DROP COLUMN invoice_count_max; + +-- Update comment +COMMENT ON COLUMN subscription_billing.quota_tracking.invoice_count IS 'Remaining invoices in current billing period (decremented on use)'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000006_create_example_resources.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000006_create_example_resources.down.sql new file mode 100644 index 0000000..e8e8dd5 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000006_create_example_resources.down.sql @@ -0,0 +1,18 @@ +-- Rollback example_resources table + +-- Drop trigger first +DROP TRIGGER IF EXISTS trigger_update_example_resources_updated_at ON example_resources; +DROP FUNCTION IF EXISTS update_example_resources_updated_at(); + +-- Drop indexes +DROP INDEX IF EXISTS idx_example_resources_search; +DROP INDEX IF EXISTS idx_example_resources_active; +DROP INDEX IF EXISTS idx_example_resources_created_at; +DROP INDEX IF EXISTS idx_example_resources_approval_assigned; +DROP INDEX IF EXISTS idx_example_resources_file; +DROP INDEX IF EXISTS idx_example_resources_created_by; +DROP INDEX IF EXISTS idx_example_resources_status; +DROP INDEX IF EXISTS idx_example_resources_organization; + +-- Drop table +DROP TABLE IF EXISTS example_resources; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000006_create_example_resources.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000006_create_example_resources.up.sql new file mode 100644 index 0000000..d9748f6 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000006_create_example_resources.up.sql @@ -0,0 +1,78 @@ +-- Create example_resources table +-- This table demonstrates the full architecture pattern with: +-- - File attachments +-- - OCR/LLM processing +-- - Multi-status workflow +-- - RBAC integration +-- - Multi-tenancy +-- - Approval workflow +-- - Audit tracking + +CREATE TABLE IF NOT EXISTS example_resources ( + id SERIAL PRIMARY KEY, + resource_number VARCHAR(100) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + + -- Status workflow + status_id SMALLINT NOT NULL DEFAULT 1, + + -- File attachment + file_id INTEGER REFERENCES file_manager.file_assets(id) ON DELETE SET NULL, + + -- AI Processing results + extracted_data JSONB DEFAULT '{}', -- OCR output + processed_data JSONB DEFAULT '{}', -- LLM structured output + confidence DECIMAL(5,4), -- AI confidence score (0.0000 to 1.0000) + + -- Multi-tenancy (required) + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + created_by_account_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL, + + -- Approval workflow + approval_status VARCHAR(50) DEFAULT 'pending', + approval_assigned_to_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL, + approval_action_taker_id INTEGER REFERENCES organizations.accounts(id) ON DELETE SET NULL, + approval_notes TEXT, + + -- Additional metadata + metadata JSONB DEFAULT '{}', + + -- Audit fields + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Indexes for performance +CREATE INDEX idx_example_resources_organization ON example_resources(organization_id); +CREATE INDEX idx_example_resources_status ON example_resources(status_id); +CREATE INDEX idx_example_resources_created_by ON example_resources(created_by_account_id); +CREATE INDEX idx_example_resources_file ON example_resources(file_id); +CREATE INDEX idx_example_resources_approval_assigned ON example_resources(approval_assigned_to_id); +CREATE INDEX idx_example_resources_created_at ON example_resources(created_at DESC); +CREATE INDEX idx_example_resources_active ON example_resources(is_active); + +-- Full text search on title and description +CREATE INDEX idx_example_resources_search ON example_resources USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))); + +-- Trigger to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_example_resources_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_example_resources_updated_at + BEFORE UPDATE ON example_resources + FOR EACH ROW + EXECUTE FUNCTION update_example_resources_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE example_resources IS 'Example module demonstrating Clean Architecture patterns with file uploads, OCR/LLM processing, RBAC, approval workflows, and multi-tenancy'; +COMMENT ON COLUMN example_resources.extracted_data IS 'Raw OCR-extracted text and metadata'; +COMMENT ON COLUMN example_resources.processed_data IS 'LLM-processed structured data'; +COMMENT ON COLUMN example_resources.confidence IS 'AI confidence score between 0 and 1'; +COMMENT ON COLUMN example_resources.approval_status IS 'Workflow status: pending, approved, rejected'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000007_create_resource_embeddings.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000007_create_resource_embeddings.down.sql new file mode 100644 index 0000000..8323268 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000007_create_resource_embeddings.down.sql @@ -0,0 +1,15 @@ +-- Drop triggers +DROP TRIGGER IF EXISTS duplicate_candidates_updated_at ON duplicate_candidates; +DROP TRIGGER IF EXISTS resource_embeddings_updated_at ON resource_embeddings; + +-- Drop trigger functions +DROP FUNCTION IF EXISTS update_duplicate_candidates_updated_at(); +DROP FUNCTION IF EXISTS update_resource_embeddings_updated_at(); + +-- Drop tables (cascade to remove dependent objects) +DROP TABLE IF EXISTS duplicate_candidates CASCADE; +DROP TABLE IF EXISTS resource_embeddings CASCADE; + +-- Note: We don't drop the vector extension as it might be used by other tables +-- If you want to remove it completely, uncomment the line below: +-- DROP EXTENSION IF EXISTS vector CASCADE; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000007_create_resource_embeddings.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000007_create_resource_embeddings.up.sql new file mode 100644 index 0000000..cc534ac --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000007_create_resource_embeddings.up.sql @@ -0,0 +1,95 @@ +-- Enable pgvector extension for vector similarity search +CREATE EXTENSION IF NOT EXISTS vector; + +-- Resource embeddings table +-- Stores vector embeddings generated from resource text content for semantic similarity search +CREATE TABLE resource_embeddings ( + id SERIAL PRIMARY KEY, + resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE, + embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension is 1536 + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + content_hash VARCHAR(64), -- SHA-256 hash for exact duplicate detection + content_preview TEXT, -- First 500 chars of content for debugging + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(resource_id, organization_id) -- One embedding per resource per organization +); + +-- Create ivfflat index for fast vector similarity search using cosine distance +-- ivfflat divides vectors into lists for approximate nearest neighbor search +-- lists=100 is a good starting point (adjust based on dataset size) +CREATE INDEX idx_resource_embeddings_vector +ON resource_embeddings +USING ivfflat (embedding vector_cosine_ops) +WITH (lists = 100); + +-- Regular indexes for lookups +CREATE INDEX idx_resource_embeddings_organization ON resource_embeddings(organization_id); +CREATE INDEX idx_resource_embeddings_content_hash ON resource_embeddings(content_hash); +CREATE INDEX idx_resource_embeddings_resource ON resource_embeddings(resource_id); + +-- Duplicate candidates table +-- Stores potential duplicates found through vector similarity search and LLM adjudication +CREATE TABLE duplicate_candidates ( + id SERIAL PRIMARY KEY, + resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE, + candidate_resource_id INTEGER NOT NULL REFERENCES public.example_resources(id) ON DELETE CASCADE, + similarity_score DECIMAL(5,4) NOT NULL, -- Vector cosine similarity score (0.0000 to 1.0000) + detection_method VARCHAR(50) NOT NULL, -- 'exact_match' or 'llm_adjudicated' + confidence_level VARCHAR(50), -- 'very_high', 'high', 'medium', 'low' (from LLM) + llm_reason TEXT, -- LLM's explanation for duplicate decision + llm_similar_fields JSONB, -- Fields identified as similar by LLM + llm_response JSONB, -- Full LLM response for audit trail + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'confirmed', 'dismissed' + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CHECK (resource_id != candidate_resource_id) -- Prevent self-duplicates +); + +-- Indexes for duplicate candidates +CREATE INDEX idx_duplicate_candidates_resource ON duplicate_candidates(resource_id); +CREATE INDEX idx_duplicate_candidates_candidate ON duplicate_candidates(candidate_resource_id); +CREATE INDEX idx_duplicate_candidates_organization ON duplicate_candidates(organization_id); +CREATE INDEX idx_duplicate_candidates_status ON duplicate_candidates(status); +CREATE INDEX idx_duplicate_candidates_method ON duplicate_candidates(detection_method); + +-- Composite index for common query pattern: find all duplicates for a resource +CREATE INDEX idx_duplicate_candidates_resource_org ON duplicate_candidates(resource_id, organization_id); + +-- Auto-update trigger for updated_at +CREATE OR REPLACE FUNCTION update_resource_embeddings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER resource_embeddings_updated_at + BEFORE UPDATE ON resource_embeddings + FOR EACH ROW + EXECUTE FUNCTION update_resource_embeddings_updated_at(); + +CREATE OR REPLACE FUNCTION update_duplicate_candidates_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER duplicate_candidates_updated_at + BEFORE UPDATE ON duplicate_candidates + FOR EACH ROW + EXECUTE FUNCTION update_duplicate_candidates_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE resource_embeddings IS 'Stores vector embeddings for resources using OpenAI text-embedding-3-small (1536 dimensions)'; +COMMENT ON COLUMN resource_embeddings.embedding IS 'Vector embedding for semantic similarity search (1536 dimensions from OpenAI)'; +COMMENT ON COLUMN resource_embeddings.content_hash IS 'SHA-256 hash of normalized content for exact duplicate detection'; +COMMENT ON INDEX idx_resource_embeddings_vector IS 'IVFFlat index for fast approximate nearest neighbor search using cosine distance'; + +COMMENT ON TABLE duplicate_candidates IS 'Stores potential duplicate resources found via vector similarity and LLM adjudication'; +COMMENT ON COLUMN duplicate_candidates.similarity_score IS 'Cosine similarity score from pgvector (0.0000 = completely different, 1.0000 = identical)'; +COMMENT 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)'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000008_create_documents_schema.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000008_create_documents_schema.down.sql new file mode 100644 index 0000000..351da16 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000008_create_documents_schema.down.sql @@ -0,0 +1,5 @@ +-- Drop documents schema +DROP TRIGGER IF EXISTS documents_updated_at ON documents.documents; +DROP FUNCTION IF EXISTS documents.update_documents_updated_at(); +DROP TABLE IF EXISTS documents.documents; +DROP SCHEMA IF EXISTS documents; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000008_create_documents_schema.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000008_create_documents_schema.up.sql new file mode 100644 index 0000000..6a6e6df --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000008_create_documents_schema.up.sql @@ -0,0 +1,44 @@ +-- Documents schema for PDF upload and text extraction +CREATE SCHEMA IF NOT EXISTS documents; + +-- Documents table +CREATE TABLE documents.documents ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + file_asset_id INTEGER NOT NULL REFERENCES file_manager.file_assets(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + file_name VARCHAR(500) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + extracted_text TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT valid_status CHECK (status IN ('pending', 'processing', 'processed', 'failed')) +); + +-- Indexes +CREATE INDEX idx_documents_organization ON documents.documents(organization_id); +CREATE INDEX idx_documents_file_asset ON documents.documents(file_asset_id); +CREATE INDEX idx_documents_status ON documents.documents(status); +CREATE INDEX idx_documents_created_at ON documents.documents(created_at DESC); + +-- Auto-update trigger for updated_at +CREATE OR REPLACE FUNCTION documents.update_documents_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER documents_updated_at + BEFORE UPDATE ON documents.documents + FOR EACH ROW + EXECUTE FUNCTION documents.update_documents_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE documents.documents IS 'Stores uploaded documents (PDFs) with extracted text for RAG'; +COMMENT ON COLUMN documents.documents.extracted_text IS 'Text extracted from PDF using OCR or direct parsing'; +COMMENT ON COLUMN documents.documents.status IS 'Processing status: pending, processing, processed, failed'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000009_create_cognitive_schema.down.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000009_create_cognitive_schema.down.sql new file mode 100644 index 0000000..9cfc457 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000009_create_cognitive_schema.down.sql @@ -0,0 +1,9 @@ +-- Drop cognitive schema +DROP TRIGGER IF EXISTS chat_sessions_updated_at ON cognitive.chat_sessions; +DROP TRIGGER IF EXISTS doc_embeddings_updated_at ON cognitive.document_embeddings; +DROP FUNCTION IF EXISTS cognitive.update_sessions_updated_at(); +DROP FUNCTION IF EXISTS cognitive.update_embeddings_updated_at(); +DROP TABLE IF EXISTS cognitive.chat_messages; +DROP TABLE IF EXISTS cognitive.chat_sessions; +DROP TABLE IF EXISTS cognitive.document_embeddings; +DROP SCHEMA IF EXISTS cognitive; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000009_create_cognitive_schema.up.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000009_create_cognitive_schema.up.sql new file mode 100644 index 0000000..eb39eec --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/migrations/000009_create_cognitive_schema.up.sql @@ -0,0 +1,90 @@ +-- Cognitive Agent schema for RAG and AI-powered features +CREATE SCHEMA IF NOT EXISTS cognitive; + +-- Ensure pgvector extension is available +CREATE EXTENSION IF NOT EXISTS vector; + +-- Document embeddings for RAG (vector search) +CREATE TABLE cognitive.document_embeddings ( + id SERIAL PRIMARY KEY, + document_id INTEGER NOT NULL REFERENCES documents.documents(id) ON DELETE CASCADE, + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension + content_hash VARCHAR(64), + content_preview TEXT, + chunk_index INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(document_id, chunk_index) +); + +-- IVFFlat index for fast vector similarity search using cosine distance +CREATE INDEX idx_doc_embeddings_vector ON cognitive.document_embeddings +USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +CREATE INDEX idx_doc_embeddings_organization ON cognitive.document_embeddings(organization_id); +CREATE INDEX idx_doc_embeddings_document ON cognitive.document_embeddings(document_id); +CREATE INDEX idx_doc_embeddings_content_hash ON cognitive.document_embeddings(content_hash); + +-- Chat sessions for conversational AI +CREATE TABLE cognitive.chat_sessions ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations.organizations(id) ON DELETE CASCADE, + account_id INTEGER NOT NULL, + title VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chat_sessions_organization ON cognitive.chat_sessions(organization_id); +CREATE INDEX idx_chat_sessions_account ON cognitive.chat_sessions(account_id); +CREATE INDEX idx_chat_sessions_created_at ON cognitive.chat_sessions(created_at DESC); + +-- Chat messages within sessions +CREATE TABLE cognitive.chat_messages ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES cognitive.chat_sessions(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + referenced_docs INTEGER[], + tokens_used INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT valid_role CHECK (role IN ('user', 'assistant', 'system')) +); + +CREATE INDEX idx_chat_messages_session ON cognitive.chat_messages(session_id); +CREATE INDEX idx_chat_messages_created_at ON cognitive.chat_messages(created_at); + +-- Auto-update triggers for updated_at +CREATE OR REPLACE FUNCTION cognitive.update_embeddings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER doc_embeddings_updated_at + BEFORE UPDATE ON cognitive.document_embeddings + FOR EACH ROW + EXECUTE FUNCTION cognitive.update_embeddings_updated_at(); + +CREATE OR REPLACE FUNCTION cognitive.update_sessions_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER chat_sessions_updated_at + BEFORE UPDATE ON cognitive.chat_sessions + FOR EACH ROW + EXECUTE FUNCTION cognitive.update_sessions_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE cognitive.document_embeddings IS 'Vector embeddings for documents using OpenAI text-embedding-3-small (1536 dimensions)'; +COMMENT ON COLUMN cognitive.document_embeddings.embedding IS 'Vector embedding for semantic similarity search'; +COMMENT ON COLUMN cognitive.document_embeddings.chunk_index IS 'Index for chunked documents (0 for single-chunk docs)'; +COMMENT ON TABLE cognitive.chat_sessions IS 'Conversational AI sessions for RAG-based chat'; +COMMENT ON TABLE cognitive.chat_messages IS 'Messages within chat sessions with role (user/assistant/system)'; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/query/cognitive.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/cognitive.sql new file mode 100644 index 0000000..7b39dd0 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/cognitive.sql @@ -0,0 +1,111 @@ +-- Cognitive Agent queries + +-- Document Embeddings + +-- name: CreateDocumentEmbedding :one +INSERT INTO cognitive.document_embeddings ( + document_id, + organization_id, + embedding, + content_hash, + content_preview, + chunk_index +) VALUES ( + $1, $2, $3, $4, $5, $6 +) RETURNING *; + +-- name: GetDocumentEmbeddingByID :one +SELECT * FROM cognitive.document_embeddings +WHERE id = $1 AND organization_id = $2; + +-- name: GetDocumentEmbeddingsByDocumentID :many +SELECT * FROM cognitive.document_embeddings +WHERE document_id = $1 AND organization_id = $2 +ORDER BY chunk_index; + +-- name: SearchSimilarDocuments :many +SELECT + de.id, + de.document_id, + de.organization_id, + de.content_hash, + de.content_preview, + de.chunk_index, + de.created_at, + de.updated_at, + (1 - (de.embedding <=> $1::vector))::double precision as similarity_score +FROM cognitive.document_embeddings de +WHERE de.organization_id = $2 +ORDER BY de.embedding <=> $1::vector +LIMIT $3; + +-- name: DeleteDocumentEmbeddings :exec +DELETE FROM cognitive.document_embeddings +WHERE document_id = $1 AND organization_id = $2; + +-- name: CountDocumentEmbeddingsByOrganization :one +SELECT COUNT(*) FROM cognitive.document_embeddings +WHERE organization_id = $1; + +-- Chat Sessions + +-- name: CreateChatSession :one +INSERT INTO cognitive.chat_sessions ( + organization_id, + account_id, + title +) VALUES ( + $1, $2, $3 +) RETURNING *; + +-- name: GetChatSessionByID :one +SELECT * FROM cognitive.chat_sessions +WHERE id = $1 AND organization_id = $2; + +-- name: ListChatSessionsByAccount :many +SELECT * FROM cognitive.chat_sessions +WHERE organization_id = $1 AND account_id = $2 +ORDER BY updated_at DESC +LIMIT $3 OFFSET $4; + +-- name: UpdateChatSessionTitle :one +UPDATE cognitive.chat_sessions +SET title = $3, updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING *; + +-- name: DeleteChatSession :exec +DELETE FROM cognitive.chat_sessions +WHERE id = $1 AND organization_id = $2; + +-- Chat Messages + +-- name: CreateChatMessage :one +INSERT INTO cognitive.chat_messages ( + session_id, + role, + content, + referenced_docs, + tokens_used +) VALUES ( + $1, $2, $3, $4, $5 +) RETURNING *; + +-- name: GetChatMessagesBySession :many +SELECT * FROM cognitive.chat_messages +WHERE session_id = $1 +ORDER BY created_at ASC; + +-- name: GetRecentChatMessages :many +SELECT * FROM cognitive.chat_messages +WHERE session_id = $1 +ORDER BY created_at DESC +LIMIT $2; + +-- name: CountChatMessagesBySession :one +SELECT COUNT(*) FROM cognitive.chat_messages +WHERE session_id = $1; + +-- name: DeleteChatMessage :exec +DELETE FROM cognitive.chat_messages +WHERE id = $1; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/query/documents.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/documents.sql new file mode 100644 index 0000000..8029d45 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/documents.sql @@ -0,0 +1,69 @@ +-- Documents queries + +-- name: CreateDocument :one +INSERT INTO documents.documents ( + organization_id, + file_asset_id, + title, + file_name, + content_type, + file_size, + extracted_text, + status, + metadata +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) RETURNING *; + +-- name: GetDocumentByID :one +SELECT * FROM documents.documents +WHERE id = $1 AND organization_id = $2; + +-- name: GetDocumentByFileAssetID :one +SELECT * FROM documents.documents +WHERE file_asset_id = $1 AND organization_id = $2; + +-- name: ListDocumentsByOrganization :many +SELECT * FROM documents.documents +WHERE organization_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3; + +-- name: ListDocumentsByStatus :many +SELECT * FROM documents.documents +WHERE organization_id = $1 AND status = $2 +ORDER BY created_at DESC +LIMIT $3 OFFSET $4; + +-- name: UpdateDocumentStatus :one +UPDATE documents.documents +SET status = $3, updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING *; + +-- name: UpdateDocumentExtractedText :one +UPDATE documents.documents +SET extracted_text = $3, status = 'processed', updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING *; + +-- name: UpdateDocument :one +UPDATE documents.documents +SET + title = COALESCE($3, title), + metadata = COALESCE($4, metadata), + updated_at = NOW() +WHERE id = $1 AND organization_id = $2 +RETURNING *; + +-- name: DeleteDocument :exec +DELETE FROM documents.documents +WHERE id = $1 AND organization_id = $2; + +-- name: CountDocumentsByOrganization :one +SELECT COUNT(*) FROM documents.documents +WHERE organization_id = $1; + +-- name: CountDocumentsByStatus :one +SELECT COUNT(*) FROM documents.documents +WHERE organization_id = $1 AND status = $2; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/query/example_resource.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/example_resource.sql new file mode 100644 index 0000000..216b9e0 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/example_resource.sql @@ -0,0 +1,169 @@ +-- Example Resource Queries +-- Demonstrates Clean Architecture patterns with CRUD operations, +-- file attachments, OCR/LLM processing, and approval workflows + +-- CREATE operations + +-- name: CreateResource :one +INSERT INTO example_resources ( + resource_number, title, description, status_id, file_id, + extracted_data, processed_data, confidence, + organization_id, created_by_account_id, metadata +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 +) RETURNING *; + +-- name: CreateMinimalResource :one +-- Creates a minimal placeholder resource +INSERT INTO example_resources ( + resource_number, title, organization_id, created_by_account_id, status_id +) VALUES ( + $1, $2, $3, $4, 1 +) RETURNING *; + +-- READ operations + +-- name: GetResourceByID :one +SELECT * FROM example_resources +WHERE id = $1 AND organization_id = $2 AND is_active = true; + +-- name: GetResourceByNumber :one +SELECT * FROM example_resources +WHERE resource_number = $1 AND organization_id = $2 AND is_active = true; + +-- name: ListResources :many +-- List resources with filtering and pagination +SELECT + id, resource_number, title, description, status_id, file_id, + confidence, organization_id, created_by_account_id, + approval_status, approval_assigned_to_id, + is_active, created_at, updated_at +FROM example_resources +WHERE organization_id = $1 AND is_active = true + AND ($2::smallint IS NULL OR status_id = $2) + AND ($3::varchar IS NULL OR approval_status = $3) + AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%') +ORDER BY created_at DESC +LIMIT $5 OFFSET $6; + +-- name: CountResources :one +-- Count resources for pagination +SELECT COUNT(*) FROM example_resources +WHERE organization_id = $1 AND is_active = true + AND ($2::smallint IS NULL OR status_id = $2) + AND ($3::varchar IS NULL OR approval_status = $3) + AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%'); + +-- UPDATE operations + +-- name: UpdateResource :exec +UPDATE example_resources SET + title = COALESCE(sqlc.narg('title'), title), + description = COALESCE(sqlc.narg('description'), description), + status_id = COALESCE(sqlc.narg('status_id'), status_id), + metadata = COALESCE(sqlc.narg('metadata'), metadata), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') AND organization_id = sqlc.arg('organization_id') AND is_active = true; + +-- name: UpdateResourceProcessingData :exec +-- Update OCR/LLM processing results +UPDATE example_resources SET + extracted_data = COALESCE(sqlc.narg('extracted_data'), extracted_data), + processed_data = COALESCE(sqlc.narg('processed_data'), processed_data), + confidence = COALESCE(sqlc.narg('confidence'), confidence), + status_id = COALESCE(sqlc.narg('status_id'), status_id), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') AND organization_id = sqlc.arg('organization_id'); + +-- name: UpdateResourceStatus :exec +UPDATE example_resources SET + status_id = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true; + +-- name: UpdateResourceApprovalStatus :exec +-- Update approval workflow status +UPDATE example_resources SET + approval_status = $3, + approval_action_taker_id = $4, + approval_notes = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true; + +-- name: AssignResourceApproval :exec +-- Assign resource to someone for approval +UPDATE example_resources SET + approval_assigned_to_id = $3, + approval_status = 'pending', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true; + +-- name: AttachFileToResource :exec +-- Attach a file to a resource +UPDATE example_resources SET + file_id = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 AND is_active = true; + +-- DELETE operations + +-- name: DeleteResource :exec +-- Soft delete a resource +UPDATE example_resources SET + is_active = false, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2; + +-- name: HardDeleteResource :exec +-- Hard delete a resource (use with caution) +DELETE FROM example_resources +WHERE id = $1 AND organization_id = $2; + +-- SEARCH operations + +-- name: SearchResourcesByText :many +-- Full-text search on title and description +SELECT + id, resource_number, title, description, status_id, + confidence, created_at, updated_at, + ts_rank(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')), to_tsquery('english', $2)) AS rank +FROM example_resources +WHERE organization_id = $1 + AND is_active = true + AND to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) @@ to_tsquery('english', $2) +ORDER BY rank DESC, created_at DESC +LIMIT $3 OFFSET $4; + +-- ANALYTICS queries + +-- name: GetResourceStats :one +-- Get statistics for dashboard +SELECT + COUNT(*) as total_resources, + COUNT(*) FILTER (WHERE status_id = 1) as draft_count, + COUNT(*) FILTER (WHERE status_id = 2) as processing_count, + COUNT(*) FILTER (WHERE status_id = 3) as completed_count, + COUNT(*) FILTER (WHERE approval_status = 'pending') as pending_approval, + COUNT(*) FILTER (WHERE approval_status = 'approved') as approved_count, + AVG(confidence) as avg_confidence +FROM example_resources +WHERE organization_id = $1 AND is_active = true; + +-- name: GetRecentResources :many +-- Get most recently created resources +SELECT + id, resource_number, title, status_id, confidence, + created_by_account_id, created_at +FROM example_resources +WHERE organization_id = $1 AND is_active = true +ORDER BY created_at DESC +LIMIT $2; + +-- name: GetResourcesByCreator :many +-- Get resources created by a specific user +SELECT * FROM example_resources +WHERE organization_id = $1 + AND created_by_account_id = $2 + AND is_active = true +ORDER BY created_at DESC +LIMIT $3 OFFSET $4; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/query/file_manager.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/file_manager.sql new file mode 100644 index 0000000..c4232b5 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/file_manager.sql @@ -0,0 +1,78 @@ +-- name: CreateFileAsset :one +INSERT INTO file_manager.file_assets ( + 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 +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 +) +RETURNING *; + +-- name: GetFileAssetByID :one +SELECT * FROM file_manager.file_assets +WHERE id = $1; + +-- name: DeleteFileAsset :exec +DELETE FROM file_manager.file_assets +WHERE id = $1; + +-- name: GetFileAssetsByEntity :many +SELECT * FROM file_manager.file_assets +WHERE entity_type = $1 AND entity_id = $2; + +-- name: GetFileAssetsByEntityAndPurpose :many +SELECT * FROM file_manager.file_assets +WHERE entity_type = $1 AND entity_id = $2 AND purpose = $3 +ORDER BY created_at DESC; + +-- name: GetFileAssetsByCategory :many +SELECT fa.*, fc.name as category_name +FROM file_manager.file_assets fa +JOIN file_manager.file_categories fc ON fa.file_category_id = fc.id +WHERE fc.name = $1 +ORDER BY fa.created_at DESC; + +-- name: GetFileAssetsByContext :many +SELECT fa.*, fctx.name as context_name +FROM file_manager.file_assets fa +JOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id +WHERE fctx.name = $1 +ORDER BY fa.created_at DESC; + +-- name: UpdateFileAsset :exec +UPDATE file_manager.file_assets +SET + file_name = $2, + storage_path = $3, + purpose = $4, + metadata = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: GetFileAssetByStoragePath :one +SELECT * FROM file_manager.file_assets +WHERE storage_path = $1; + +-- name: ListFileAssets :many +SELECT fa.*, fc.name as category_name, fctx.name as context_name +FROM file_manager.file_assets fa +JOIN file_manager.file_categories fc ON fa.file_category_id = fc.id +JOIN file_manager.file_contexts fctx ON fa.file_context_id = fctx.id +ORDER BY fa.created_at DESC +LIMIT $1 OFFSET $2; + +-- name: GetFileCategories :many +SELECT * FROM file_manager.file_categories ORDER BY name; + +-- name: GetFileContexts :many +SELECT * FROM file_manager.file_contexts ORDER BY name; \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/query/organizations.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/organizations.sql new file mode 100644 index 0000000..66308a7 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/organizations.sql @@ -0,0 +1,378 @@ +-- name: CreateOrganization :one +INSERT INTO organizations.organizations ( + slug, + name, + status +) VALUES ( + $1, + $2, + $3 +) RETURNING + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at; + +-- name: GetOrganizationByID :one +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +WHERE id = $1; + +-- name: GetOrganizationBySlug :one +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +WHERE slug = $1; + +-- name: GetOrganizationByStytchID :one +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +WHERE stytch_org_id = $1; + +-- name: UpdateOrganization :one +UPDATE organizations.organizations +SET + name = $2, + status = $3, + stytch_org_id = $4, + stytch_connection_id = $5, + stytch_connection_name = $6, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at; + +-- name: UpdateOrganizationStytchInfo :one +UPDATE organizations.organizations +SET + stytch_org_id = $2, + stytch_connection_id = $3, + stytch_connection_name = $4, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at; + +-- name: ListOrganizations :many +SELECT + id, + slug, + name, + status, + stytch_org_id, + stytch_connection_id, + stytch_connection_name, + created_at, + updated_at +FROM organizations.organizations +ORDER BY created_at DESC +LIMIT $1 OFFSET $2; + +-- name: DeleteOrganization :exec +DELETE FROM organizations.organizations +WHERE id = $1; + +-- Accounts queries + +-- name: CreateAccount :one +INSERT INTO organizations.accounts ( + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at; + +-- name: GetAccountByID :one +SELECT + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +FROM organizations.accounts +WHERE id = $1 AND organization_id = $2; + +-- name: GetAccountByEmail :one +SELECT + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +FROM organizations.accounts +WHERE email = $1 AND organization_id = $2; + +-- name: ListAccountsByOrganization :many +SELECT + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at +FROM organizations.accounts +WHERE organization_id = $1 +ORDER BY created_at DESC; + +-- name: UpdateAccount :one +UPDATE organizations.accounts +SET + full_name = $3, + stytch_role_id = $4, + stytch_role_slug = $5, + stytch_email_verified = $6, + role = $7, + status = $8, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at; + +-- name: UpdateAccountStytchInfo :one +UPDATE organizations.accounts +SET + stytch_member_id = $3, + stytch_role_id = $4, + stytch_role_slug = $5, + stytch_email_verified = $6, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at; + +-- name: UpdateAccountLastLogin :one +UPDATE organizations.accounts +SET + last_login_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2 +RETURNING + id, + organization_id, + email, + full_name, + stytch_member_id, + stytch_role_id, + stytch_role_slug, + stytch_email_verified, + role, + status, + last_login_at, + created_at, + updated_at; + +-- name: DeleteAccount :exec +UPDATE organizations.accounts +SET + status = 'inactive', + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 AND organization_id = $2; + +-- Organization membership queries + +-- name: GetOrganizationByUserEmail :one +SELECT + o.id, + o.slug, + o.name, + o.status, + o.stytch_org_id, + o.stytch_connection_id, + o.stytch_connection_name, + o.created_at, + o.updated_at +FROM organizations.organizations o +INNER JOIN organizations.accounts a ON o.id = a.organization_id +WHERE a.email = $1 + AND a.status = 'active' + AND o.status = 'active' +LIMIT 1; + +-- name: GetAccountOrganization :one +SELECT + o.id, + o.slug, + o.name, + o.status, + o.stytch_org_id, + o.stytch_connection_id, + o.stytch_connection_name, + o.created_at, + o.updated_at +FROM organizations.organizations o +INNER JOIN organizations.accounts a ON o.id = a.organization_id +WHERE a.id = $1; + +-- name: CheckAccountPermission :one +SELECT + a.id, + a.role, + a.status, + o.status as org_status +FROM organizations.accounts a +INNER JOIN organizations.organizations o ON a.organization_id = o.id +WHERE a.id = $1 AND a.organization_id = $2; + +-- Statistics queries (useful for admin panels) + +-- name: GetOrganizationStats :one +SELECT + o.id, + o.slug, + o.name, + o.status, + o.stytch_org_id, + o.stytch_connection_id, + o.stytch_connection_name, + o.created_at, + o.updated_at, + COUNT(a.id) as account_count, + COUNT(CASE WHEN a.status = 'active' THEN 1 END) as active_account_count +FROM organizations.organizations o +LEFT JOIN organizations.accounts a ON o.id = a.organization_id +WHERE o.id = $1 +GROUP BY o.id; + +-- name: GetAccountStats :one +SELECT + a.id, + a.organization_id, + a.email, + a.full_name, + a.stytch_member_id, + a.stytch_role_id, + a.stytch_role_slug, + a.stytch_email_verified, + a.role, + a.status, + a.last_login_at, + a.created_at, + a.updated_at, + o.name as organization_name, + o.slug as organization_slug +FROM organizations.accounts a +INNER JOIN organizations.organizations o ON a.organization_id = o.id +WHERE a.id = $1; + diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/query/subscription_billing.sql b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/subscription_billing.sql new file mode 100644 index 0000000..38019f1 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/query/subscription_billing.sql @@ -0,0 +1,138 @@ +-- name: GetSubscriptionByOrgID :one +-- Get subscription details for an organization +SELECT * FROM subscription_billing.subscriptions +WHERE organization_id = $1 +LIMIT 1; + +-- name: GetSubscriptionBySubscriptionID :one +-- Get subscription by Polar subscription ID +SELECT * FROM subscription_billing.subscriptions +WHERE subscription_id = $1 +LIMIT 1; + +-- name: UpsertSubscription :one +-- Create or update subscription from Polar webhook +INSERT INTO subscription_billing.subscriptions ( + 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, + metadata, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP +) +ON CONFLICT (organization_id) +DO UPDATE SET + external_customer_id = EXCLUDED.external_customer_id, + subscription_id = EXCLUDED.subscription_id, + subscription_status = EXCLUDED.subscription_status, + product_id = EXCLUDED.product_id, + product_name = EXCLUDED.product_name, + plan_name = EXCLUDED.plan_name, + current_period_start = EXCLUDED.current_period_start, + current_period_end = EXCLUDED.current_period_end, + cancel_at_period_end = EXCLUDED.cancel_at_period_end, + canceled_at = EXCLUDED.canceled_at, + metadata = EXCLUDED.metadata, + updated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- name: DeleteSubscription :exec +-- Delete subscription (when subscription is permanently deleted) +DELETE FROM subscription_billing.subscriptions +WHERE organization_id = $1; + +-- name: GetQuotaByOrgID :one +-- Get quota tracking for an organization +SELECT * FROM subscription_billing.quota_tracking +WHERE organization_id = $1 +LIMIT 1; + +-- name: UpsertQuota :one +-- Create or update quota tracking +INSERT INTO subscription_billing.quota_tracking ( + organization_id, + invoice_count, + max_seats, + period_start, + period_end, + last_synced_at, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +) +ON CONFLICT (organization_id) +DO UPDATE SET + invoice_count = EXCLUDED.invoice_count, + max_seats = EXCLUDED.max_seats, + period_start = EXCLUDED.period_start, + period_end = EXCLUDED.period_end, + last_synced_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- name: DecrementInvoiceCount :one +-- Decrement invoice count by 1 (called after successful invoice processing) +UPDATE subscription_billing.quota_tracking +SET + invoice_count = invoice_count - 1, + updated_at = CURRENT_TIMESTAMP +WHERE organization_id = $1 +RETURNING *; + +-- name: ResetQuotaForPeriod :one +-- Reset quota counters for a new billing period +UPDATE subscription_billing.quota_tracking +SET + invoice_count = $2, + period_start = $3, + period_end = $4, + updated_at = CURRENT_TIMESTAMP +WHERE organization_id = $1 +RETURNING *; + +-- name: GetQuotaStatus :one +-- Get combined subscription and quota status for fast quota checks +SELECT + s.subscription_status, + s.current_period_start, + s.current_period_end, + s.cancel_at_period_end, + q.invoice_count, + q.max_seats, + CASE + WHEN s.subscription_status = 'active' AND q.invoice_count > 0 + THEN TRUE + ELSE FALSE + END AS can_process_invoice +FROM subscription_billing.subscriptions s +INNER JOIN subscription_billing.quota_tracking q ON s.organization_id = q.organization_id +WHERE s.organization_id = $1 +LIMIT 1; + +-- name: ListActiveSubscriptions :many +-- List all active subscriptions for monitoring/admin purposes +SELECT * FROM subscription_billing.subscriptions +WHERE subscription_status = 'active' +ORDER BY created_at DESC; + +-- name: ListQuotasNearLimit :many +-- List organizations approaching their quota limit (for alerting) +SELECT + q.*, + s.subscription_status, + s.product_name +FROM subscription_billing.quota_tracking q +INNER JOIN subscription_billing.subscriptions s ON q.organization_id = s.organization_id +WHERE + s.subscription_status = 'active' + AND q.invoice_count <= $1 +ORDER BY q.invoice_count ASC; diff --git a/go-b2b-starter/src/pkg/db/postgres/sqlc/sqlc.yml b/go-b2b-starter/src/pkg/db/postgres/sqlc/sqlc.yml new file mode 100644 index 0000000..4a51271 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/sqlc/sqlc.yml @@ -0,0 +1,20 @@ +version: "2" +sql: + - schema: "./migrations" + queries: "./query" + engine: "postgresql" + gen: + go: + package: "postgres" + out: "./gen" + sql_package: "pgx/v5" + emit_json_tags: true + emit_interface: true + emit_empty_slices: true + overrides: + - column: "travel.places.geom" + go_type: "string" + - db_type: "vector" + go_type: + type: "Vector" + import: "github.com/pgvector/pgvector-go" diff --git a/go-b2b-starter/src/pkg/db/postgres/types_transform.go b/go-b2b-starter/src/pkg/db/postgres/types_transform.go new file mode 100644 index 0000000..136aea8 --- /dev/null +++ b/go-b2b-starter/src/pkg/db/postgres/types_transform.go @@ -0,0 +1,422 @@ +package postgres + +import ( + "encoding/hex" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/shopspring/decimal" + + geomPkg "github.com/twpayne/go-geom" + "github.com/twpayne/go-geom/encoding/ewkb" + "github.com/twpayne/go-geom/encoding/wkb" +) + +// Int16Ptr converts pgtype.Int2 to *int16 +func Int16Ptr(i pgtype.Int2) *int16 { + if !i.Valid { + return nil + } + return &i.Int16 +} + +// Int32Ptr converts pgtype.Int4 to *int32 +func Int32Ptr(i pgtype.Int4) *int32 { + if !i.Valid { + return nil + } + return &i.Int32 +} + +// Int64Ptr converts pgtype.Int8 to *int64 +func Int64Ptr(i pgtype.Int8) *int64 { + if !i.Valid { + return nil + } + return &i.Int64 +} + +// Float32Ptr converts pgtype.Float4 to *float32 +func Float32Ptr(f pgtype.Float4) *float32 { + if !f.Valid { + return nil + } + return &f.Float32 +} + +// Float64Ptr converts pgtype.Float8 to *float64 +func Float64Ptr(f pgtype.Float8) *float64 { + if !f.Valid { + return nil + } + return &f.Float64 +} + +// StringPtr converts pgtype.Text to *string +func StringPtr(t pgtype.Text) *string { + if !t.Valid { + return nil + } + return &t.String +} + +// TimeStampPtr converts pgtype.Timestamp to *time.Time +func TimeStampPtr(t pgtype.Timestamp) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} + +// time tampz +// TimeStampTzPtr converts pgtype.Timestamptz to *time.Time +func TimeStampTzPtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} + +// time ptr +// TimePtr converts pgtype.Time to *time.Time +func TimePtr(t pgtype.Time) *time.Time { + if !t.Valid { + return nil + } + // Convert microseconds to time.Time + seconds := t.Microseconds / 1_000_000 // Convert to seconds + nanos := (t.Microseconds % 1_000_000) * 1000 // Convert remaining microseconds to nanoseconds + timeVal := time.Unix(seconds, nanos) + return &timeVal +} + +// BoolPtr converts pgtype.Bool to *bool +func BoolPtr(b pgtype.Bool) *bool { + if !b.Valid { + return nil + } + return &b.Bool +} + +// from go types to pg types +// PgInt2 converts *int16 to pgtype.PgInt2 +func PgInt2(i *int16) pgtype.Int2 { + if i == nil { + return pgtype.Int2{Valid: false} + } + return pgtype.Int2{Int16: *i, Valid: true} +} + +// PgInt4 converts *int32 to pgtype.PgInt4 +func PgInt4(i *int32) pgtype.Int4 { + if i == nil { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: *i, Valid: true} +} + +// PgInt8 converts *int64 to pgtype.PgInt8 +func PgInt8(i *int64) pgtype.Int8 { + if i == nil { + return pgtype.Int8{Valid: false} + } + return pgtype.Int8{Int64: *i, Valid: true} +} + +// PgFloat4 converts *float32 to pgtype.PgFloat4 +func PgFloat4(f *float32) pgtype.Float4 { + if f == nil { + return pgtype.Float4{Valid: false} + } + return pgtype.Float4{Float32: *f, Valid: true} +} + +// PgFloat8 converts *float64 to pgtype.PgFloat8 +func PgFloat8(f *float64) pgtype.Float8 { + if f == nil { + return pgtype.Float8{Valid: false} + } + return pgtype.Float8{Float64: *f, Valid: true} +} + +// PgText converts *string to pgtype.PgText +func PgText(s *string) pgtype.Text { + if s == nil { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: *s, Valid: true} +} + +// PgTimestamp converts *time.Time to pgtype.PgTimestamp +func PgTimestamp(t *time.Time) pgtype.Timestamp { + if t == nil { + return pgtype.Timestamp{Valid: false} + } + return pgtype.Timestamp{Time: *t, Valid: true} +} + +// PgTimestamptz converts *time.Time to pgtype.PgTimestamptz +func PgTimestamptz(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +// PgTime converts *time.Time to pgtype.PgTime +func PgTime(t *time.Time) pgtype.Time { + if t == nil { + return pgtype.Time{Valid: false} + } + // Convert time.Time to microseconds + microseconds := t.Unix()*1_000_000 + int64(t.Nanosecond())/1000 + return pgtype.Time{ + Microseconds: microseconds, + Valid: true, + } +} + +// PgBool converts *bool to pgtype.PgBool +func PgBool(b *bool) pgtype.Bool { + if b == nil { + return pgtype.Bool{Valid: false} + } + return pgtype.Bool{Bool: *b, Valid: true} +} + +// UUIDPtr converts pgtype.UUID to *uuid.UUID +func UUIDPtr(u pgtype.UUID) *uuid.UUID { + if !u.Valid { + return nil + } + id := uuid.UUID(u.Bytes) + return &id +} + +// PgUUID converts *uuid.UUID to pgtype.UUID +func PgUUID(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{Valid: false} + } + return pgtype.UUID{ + Bytes: [16]byte(*u), + Valid: true, + } +} + +// NumericPtr converts pgtype.Numeric to *float64 with improved error handling +func NumericPtr(n pgtype.Numeric) *float64 { + if !n.Valid { + return nil + } + + // Use Value() method for safe conversion + val, err := n.Value() + if err != nil { + return nil + } + + // Handle different return types from PostgreSQL numeric + switch v := val.(type) { + case float64: + return &v + case string: + // Parse string representation like "1315.0000" + if f64, err := strconv.ParseFloat(v, 64); err == nil { + return &f64 + } + case int64: + f64 := float64(v) + return &f64 + case int32: + f64 := float64(v) + return &f64 + case int: + f64 := float64(v) + return &f64 + } + return nil +} + +// Numeric converts *float64 to pgtype.Numeric with improved accuracy +func Numeric(f *float64) pgtype.Numeric { + if f == nil { + return pgtype.Numeric{Valid: false} + } + + // Use Scan for proper conversion + numeric := pgtype.Numeric{} + err := numeric.Scan(fmt.Sprintf("%.6f", *f)) + if err != nil { + return pgtype.Numeric{Valid: false} + } + return numeric +} + +// numeric from decimal +// NumericFromDecimal converts decimal.Decimal to pgtype.Numeric +func NumericFromDecimal(d *decimal.Decimal) pgtype.Numeric { + if d == nil { + return pgtype.Numeric{Valid: false} + } + numeric := pgtype.Numeric{} + err := numeric.Scan(d.String()) + if err != nil { + return pgtype.Numeric{Valid: false} + } + return numeric +} + +// NumericFromFloat32 converts float32 to pgtype.Numeric +func NumericFromFloat32(f float32) pgtype.Numeric { + fmt.Printf("[NUMERIC-DEBUG] Converting float32 to Numeric: %f\n", f) + numeric := pgtype.Numeric{} + err := numeric.Scan(fmt.Sprintf("%.6f", f)) + if err != nil { + fmt.Printf("[NUMERIC-DEBUG] Failed to convert float32 to Numeric: %v\n", err) + return pgtype.Numeric{Valid: false} + } + fmt.Printf("[NUMERIC-DEBUG] Numeric conversion successful, Valid: %v\n", numeric.Valid) + return numeric +} + +// Float32FromNumeric converts pgtype.Numeric to float32 +func Float32FromNumeric(n pgtype.Numeric) float32 { + fmt.Printf("[NUMERIC-DEBUG] Converting Numeric to float32, Valid: %v\n", n.Valid) + if !n.Valid { + fmt.Printf("[NUMERIC-DEBUG] Numeric is invalid, returning 0\n") + return 0 + } + + val, err := n.Value() + if err != nil { + fmt.Printf("[NUMERIC-DEBUG] Failed to get value from Numeric: %v\n", err) + return 0 + } + + fmt.Printf("[NUMERIC-DEBUG] Numeric value: %v (type: %T)\n", val, val) + + // Handle different return types from PostgreSQL numeric + switch v := val.(type) { + case float64: + result := float32(v) + fmt.Printf("[NUMERIC-DEBUG] Converted from float64: %f\n", result) + return result + case string: + // Parse string representation like "0.9000" + if f64, err := strconv.ParseFloat(v, 64); err == nil { + result := float32(f64) + fmt.Printf("[NUMERIC-DEBUG] Converted from string: %f\n", result) + return result + } + fmt.Printf("[NUMERIC-DEBUG] Failed to parse string: %s\n", v) + case int64: + result := float32(v) + fmt.Printf("[NUMERIC-DEBUG] Converted from int64: %f\n", result) + return result + case int32: + result := float32(v) + fmt.Printf("[NUMERIC-DEBUG] Converted from int32: %f\n", result) + return result + case int: + result := float32(v) + fmt.Printf("[NUMERIC-DEBUG] Converted from int: %f\n", result) + return result + } + fmt.Printf("[NUMERIC-DEBUG] No matching type conversion, returning 0\n") + return 0 +} + +// DatePtr converts pgtype.Date to *time.Time +func DatePtr(d pgtype.Date) *time.Time { + if !d.Valid { + return nil + } + return &d.Time +} + +// PgDate converts *time.Time to pgtype.Date +func PgDate(t *time.Time) pgtype.Date { + if t == nil { + return pgtype.Date{Valid: false} + } + return pgtype.Date{Time: *t, Valid: true} +} + +// PgTextFromString converts string to pgtype.Text (for non-empty strings) +func PgTextFromString(s string) pgtype.Text { + if s == "" { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: s, Valid: true} +} + +// StringFromPgText converts pgtype.Text to string (empty string if invalid) +func StringFromPgText(t pgtype.Text) string { + if !t.Valid { + return "" + } + return t.String +} + +// PgInt4FromInt32 converts int32 to pgtype.Int4 +func PgInt4FromInt32(i int32) pgtype.Int4 { + return pgtype.Int4{Int32: i, Valid: true} +} + +// Int32FromPgInt4 converts pgtype.Int4 to int32 (0 if invalid) +func Int32FromPgInt4(i pgtype.Int4) int32 { + if !i.Valid { + return 0 + } + return i.Int32 +} + +func ConvertWKBToPoint(wkbHex string) (*geomPkg.Point, error) { + // Decode the hex string to bytes + wkbBytes, err := hex.DecodeString(wkbHex) + if err != nil { + return nil, fmt.Errorf("failed to decode WKB hex: %w", err) + } + + // Parse the WKB bytes + geom, err := wkb.Unmarshal(wkbBytes) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal WKB: %w", err) + } + + // Assert that it's a Point + geom.Bounds() + point, ok := geom.(*geomPkg.Point) + if !ok { + return nil, fmt.Errorf("geometry is not a Point") + } + + return point, nil +} + +func ConvertWKBToPointString(wkbHex string) (string, error) { + // Decode the hex string to bytes + wkbBytes, err := hex.DecodeString(wkbHex) + if err != nil { + return "", fmt.Errorf("failed to decode WKB hex: %w", err) + } + + // Parse the EWKB bytes + g, err := ewkb.Unmarshal(wkbBytes) + if err != nil { + return "", fmt.Errorf("failed to unmarshal EWKB: %w", err) + } + + // Get the coordinates + coords := g.FlatCoords() + if len(coords) < 2 { + return "", fmt.Errorf("invalid point data") + } + + // Convert to "POINT(longitude latitude)" format + return fmt.Sprintf("POINT(%.6f %.6f)", coords[0], coords[1]), nil +} diff --git a/go-b2b-starter/src/pkg/eventbus/bus.go b/go-b2b-starter/src/pkg/eventbus/bus.go new file mode 100644 index 0000000..eb06145 --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/bus.go @@ -0,0 +1,155 @@ +package eventbus + +import ( + "context" + "fmt" + "reflect" + "sync" +) + +// EventBus handles publishing and subscribing to events +type EventBus interface { + // Publish publishes an event to all subscribers + Publish(ctx context.Context, event Event) error + // Subscribe registers a handler for a specific event type + Subscribe(eventName string, handler EventHandler[Event]) error + // Unsubscribe removes a handler for a specific event type + Unsubscribe(eventName string, handler EventHandler[Event]) error + // Close gracefully shuts down the event bus + Close() error +} + +// InMemoryEventBus is an in-memory implementation of EventBus +type InMemoryEventBus struct { + mu sync.RWMutex + subscribers map[string][]EventHandler[Event] + middleware []EventMiddleware + closed bool +} + +// NewInMemoryEventBus creates a new in-memory event bus +func NewInMemoryEventBus(middleware ...EventMiddleware) EventBus { + return &InMemoryEventBus{ + subscribers: make(map[string][]EventHandler[Event]), + middleware: middleware, + closed: false, + } +} + +// Publish publishes an event to all registered handlers +func (bus *InMemoryEventBus) Publish(ctx context.Context, event Event) error { + bus.mu.RLock() + if bus.closed { + bus.mu.RUnlock() + return fmt.Errorf("event bus is closed") + } + + handlers := make([]EventHandler[Event], len(bus.subscribers[event.EventName()])) + copy(handlers, bus.subscribers[event.EventName()]) + bus.mu.RUnlock() + + // Log event publishing + fmt.Printf("DEBUG EventBus: Publishing event '%s' (ID: %s) to %d handler(s)\n", + event.EventName(), event.EventID(), len(handlers)) + + if len(handlers) == 0 { + fmt.Printf("DEBUG EventBus: WARNING - No handlers registered for event '%s'\n", event.EventName()) + } + + // Execute handlers concurrently + var wg sync.WaitGroup + errCh := make(chan error, len(handlers)) + + for i, handler := range handlers { + wg.Add(1) + go func(handlerIndex int, h EventHandler[Event]) { + defer wg.Done() + + fmt.Printf("DEBUG EventBus: Executing handler #%d for event '%s' (ID: %s)\n", + handlerIndex+1, event.EventName(), event.EventID()) + + // Apply middleware chain + finalHandler := h + for i := len(bus.middleware) - 1; i >= 0; i-- { + finalHandler = bus.middleware[i](finalHandler) + } + + if err := finalHandler(ctx, event); err != nil { + fmt.Printf("DEBUG EventBus: Handler #%d ERROR for event '%s': %v\n", + handlerIndex+1, event.EventName(), err) + errCh <- fmt.Errorf("handler error for event %s: %w", event.EventName(), err) + } else { + fmt.Printf("DEBUG EventBus: Handler #%d completed successfully for event '%s'\n", + handlerIndex+1, event.EventName()) + } + }(i, handler) + } + + // Wait for all handlers to complete + wg.Wait() + close(errCh) + + // Collect any errors + var errors []error + for err := range errCh { + errors = append(errors, err) + } + + if len(errors) > 0 { + return fmt.Errorf("event handling errors: %v", errors) + } + + return nil +} + +// Subscribe registers a handler for a specific event type +func (bus *InMemoryEventBus) Subscribe(eventName string, handler EventHandler[Event]) error { + bus.mu.Lock() + defer bus.mu.Unlock() + + if bus.closed { + return fmt.Errorf("event bus is closed") + } + + bus.subscribers[eventName] = append(bus.subscribers[eventName], handler) + handlerCount := len(bus.subscribers[eventName]) + + fmt.Printf("DEBUG EventBus: Subscribed to event '%s' - Total handlers: %d\n", + eventName, handlerCount) + + return nil +} + +// Unsubscribe removes a handler for a specific event type +func (bus *InMemoryEventBus) Unsubscribe(eventName string, handler EventHandler[Event]) error { + bus.mu.Lock() + defer bus.mu.Unlock() + + handlers := bus.subscribers[eventName] + for i, h := range handlers { + // Compare function pointers + if reflect.ValueOf(h).Pointer() == reflect.ValueOf(handler).Pointer() { + bus.subscribers[eventName] = append(handlers[:i], handlers[i+1:]...) + break + } + } + + return nil +} + +// Close gracefully shuts down the event bus +func (bus *InMemoryEventBus) Close() error { + bus.mu.Lock() + defer bus.mu.Unlock() + + bus.closed = true + bus.subscribers = make(map[string][]EventHandler[Event]) + return nil +} + +// GetSubscriberCount returns the number of subscribers for an event (for testing/debugging) +func (bus *InMemoryEventBus) GetSubscriberCount(eventName string) int { + bus.mu.RLock() + defer bus.mu.RUnlock() + return len(bus.subscribers[eventName]) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/eventbus/cmd/init.go b/go-b2b-starter/src/pkg/eventbus/cmd/init.go new file mode 100644 index 0000000..15d5ad6 --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/cmd/init.go @@ -0,0 +1,12 @@ +package cmd + +import "go.uber.org/dig" + +// Init initializes the event bus dependencies +func Init(container *dig.Container) error { + if err := ProvideEventBus(container); err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/eventbus/cmd/provider.go b/go-b2b-starter/src/pkg/eventbus/cmd/provider.go new file mode 100644 index 0000000..a659ef1 --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/cmd/provider.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/pkg/eventbus" + "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +// ProvideEventBus creates and configures the event bus with middleware +func ProvideEventBus(container *dig.Container) error { + return container.Provide(func(logger domain.Logger) eventbus.EventBus { + middleware := []eventbus.EventMiddleware{ + eventbus.RecoveryMiddleware(logger), + eventbus.LoggingMiddleware(logger), + eventbus.MetricsMiddleware(), + } + + return eventbus.NewInMemoryEventBus(middleware...) + }) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/eventbus/event.go b/go-b2b-starter/src/pkg/eventbus/event.go new file mode 100644 index 0000000..ff57d5c --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/event.go @@ -0,0 +1,48 @@ +package eventbus + +import ( + "context" + "time" +) + +// Event represents a domain event that can be published and subscribed to +type Event interface { + // EventName returns the unique name/type of the event + EventName() string + // EventID returns a unique identifier for this specific event instance + EventID() string + // Timestamp returns when the event was created + Timestamp() time.Time + // Metadata returns additional event metadata + Metadata() map[string]interface{} +} + +// BaseEvent provides common event functionality +type BaseEvent struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Meta map[string]interface{} `json:"metadata,omitempty"` +} + +func (e BaseEvent) EventName() string { + return e.Name +} + +func (e BaseEvent) EventID() string { + return e.ID +} + +func (e BaseEvent) Timestamp() time.Time { + return e.CreatedAt +} + +func (e BaseEvent) Metadata() map[string]interface{} { + return e.Meta +} + +// EventHandler represents a function that handles an event +type EventHandler[T Event] func(ctx context.Context, event T) error + +// EventMiddleware can be used to add cross-cutting concerns like logging, metrics, etc. +type EventMiddleware func(next EventHandler[Event]) EventHandler[Event] \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/eventbus/events.go b/go-b2b-starter/src/pkg/eventbus/events.go new file mode 100644 index 0000000..a7df012 --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/events.go @@ -0,0 +1,289 @@ +package eventbus + +import ( + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +// Common event types used across modules + +// Invoice Events +type InvoiceUploaded struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + FileID int32 `json:"file_id"` + VendorName string `json:"vendor_name,omitempty"` + Amount decimal.Decimal `json:"amount,omitempty"` + UserID int32 `json:"user_id"` +} + +func NewInvoiceUploaded(invoiceID, fileID, userID int32, vendorName string, amount decimal.Decimal) *InvoiceUploaded { + return &InvoiceUploaded{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "invoice.uploaded", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + FileID: fileID, + VendorName: vendorName, + Amount: amount, + UserID: userID, + } +} + +type InvoiceValidated struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + FileID int32 `json:"file_id"` + ValidationData map[string]interface{} `json:"validation_data"` +} + +func NewInvoiceValidated(invoiceID, fileID int32, validationData map[string]interface{}) *InvoiceValidated { + return &InvoiceValidated{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "invoice.validated", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + FileID: fileID, + ValidationData: validationData, + } +} + +// OCR Events +type OCRRequested struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + FileID int32 `json:"file_id"` +} + +func NewOCRRequested(invoiceID, fileID int32) *OCRRequested { + return &OCRRequested{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "ocr.requested", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + FileID: fileID, + } +} + +type TextExtracted struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + FileID int32 `json:"file_id"` + ExtractedData map[string]interface{} `json:"extracted_data"` + Confidence float64 `json:"confidence"` +} + +func NewTextExtracted(invoiceID, fileID int32, extractedData map[string]interface{}, confidence float64) *TextExtracted { + return &TextExtracted{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "text.extracted", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + FileID: fileID, + ExtractedData: extractedData, + Confidence: confidence, + } +} + +// Duplicate Detection Events +type DuplicateCheckRequested struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + Data map[string]interface{} `json:"data"` +} + +func NewDuplicateCheckRequested(invoiceID int32, data map[string]interface{}) *DuplicateCheckRequested { + return &DuplicateCheckRequested{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "duplicate.check_requested", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + Data: data, + } +} + +type DuplicateDetected struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + DuplicateOf int32 `json:"duplicate_of"` + SimilarityScore float64 `json:"similarity_score"` + RequiresReview bool `json:"requires_review"` +} + +func NewDuplicateDetected(invoiceID, duplicateOf int32, similarityScore float64, requiresReview bool) *DuplicateDetected { + return &DuplicateDetected{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "duplicate.detected", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + DuplicateOf: duplicateOf, + SimilarityScore: similarityScore, + RequiresReview: requiresReview, + } +} + +type UniqueConfirmed struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` +} + +func NewUniqueConfirmed(invoiceID int32) *UniqueConfirmed { + return &UniqueConfirmed{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "duplicate.unique_confirmed", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + } +} + +// Approval Events +type ApprovalRequested struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + Amount decimal.Decimal `json:"amount"` + VendorID int32 `json:"vendor_id"` + RequesterID int32 `json:"requester_id"` + ApprovalLevel int `json:"approval_level"` +} + +func NewApprovalRequested(invoiceID, vendorID, requesterID int32, amount decimal.Decimal, approvalLevel int) *ApprovalRequested { + return &ApprovalRequested{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "approval.requested", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + Amount: amount, + VendorID: vendorID, + RequesterID: requesterID, + ApprovalLevel: approvalLevel, + } +} + +type ApprovalGranted struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + ApproverID int32 `json:"approver_id"` + ApprovalID int32 `json:"approval_id"` + Comments string `json:"comments,omitempty"` +} + +func NewApprovalGranted(invoiceID, approverID, approvalID int32, comments string) *ApprovalGranted { + return &ApprovalGranted{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "approval.granted", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + ApproverID: approverID, + ApprovalID: approvalID, + Comments: comments, + } +} + +type ApprovalRejected struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + ApproverID int32 `json:"approver_id"` + ApprovalID int32 `json:"approval_id"` + Reason string `json:"reason"` +} + +func NewApprovalRejected(invoiceID, approverID, approvalID int32, reason string) *ApprovalRejected { + return &ApprovalRejected{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "approval.rejected", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + ApproverID: approverID, + ApprovalID: approvalID, + Reason: reason, + } +} + +// Payment Events +type PaymentScheduled struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + PaymentID int32 `json:"payment_id"` + ScheduledDate time.Time `json:"scheduled_date"` + Amount decimal.Decimal `json:"amount"` + DiscountCaptured decimal.Decimal `json:"discount_captured"` + OptimalPayment bool `json:"optimal_payment"` +} + +func NewPaymentScheduled(invoiceID, paymentID int32, scheduledDate time.Time, amount, discountCaptured decimal.Decimal, optimalPayment bool) *PaymentScheduled { + return &PaymentScheduled{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "payment.scheduled", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + PaymentID: paymentID, + ScheduledDate: scheduledDate, + Amount: amount, + DiscountCaptured: discountCaptured, + OptimalPayment: optimalPayment, + } +} + +type PaymentExecuted struct { + BaseEvent + InvoiceID int32 `json:"invoice_id"` + PaymentID int32 `json:"payment_id"` + OrganizationID int32 `json:"organization_id"` + TransactionID string `json:"transaction_id"` + Amount decimal.Decimal `json:"amount"` + DiscountCaptured decimal.Decimal `json:"discount_captured"` + ExecutedDate time.Time `json:"executed_date"` +} + +func NewPaymentExecuted(invoiceID, paymentID, organizationID int32, transactionID string, amount, discountCaptured decimal.Decimal, executedDate time.Time) *PaymentExecuted { + return &PaymentExecuted{ + BaseEvent: BaseEvent{ + ID: uuid.New().String(), + Name: "payment.executed", + CreatedAt: time.Now(), + Meta: make(map[string]interface{}), + }, + InvoiceID: invoiceID, + PaymentID: paymentID, + OrganizationID: organizationID, + TransactionID: transactionID, + Amount: amount, + DiscountCaptured: discountCaptured, + ExecutedDate: executedDate, + } +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/eventbus/go.mod b/go-b2b-starter/src/pkg/eventbus/go.mod new file mode 100644 index 0000000..e0bf49a --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/go.mod @@ -0,0 +1,13 @@ +module github.com/moasq/go-b2b-starter/pkg/eventbus + +go 1.25 + + +require ( + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + github.com/google/uuid v1.6.0 + github.com/shopspring/decimal v1.4.0 + go.uber.org/dig v1.19.0 +) + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../logger diff --git a/go-b2b-starter/src/pkg/eventbus/go.sum b/go-b2b-starter/src/pkg/eventbus/go.sum new file mode 100644 index 0000000..09b00ce --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-b2b-starter/src/pkg/eventbus/middleware.go b/go-b2b-starter/src/pkg/eventbus/middleware.go new file mode 100644 index 0000000..d71b9e9 --- /dev/null +++ b/go-b2b-starter/src/pkg/eventbus/middleware.go @@ -0,0 +1,102 @@ +package eventbus + +import ( + "context" + "fmt" + "runtime/debug" + "time" + + "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +// LoggingMiddleware adds logging to event handling +func LoggingMiddleware(logger domain.Logger) EventMiddleware { + return func(next EventHandler[Event]) EventHandler[Event] { + return func(ctx context.Context, event Event) error { + start := time.Now() + logger.Info("Processing event", map[string]interface{}{ + "event_name": event.EventName(), + "event_id": event.EventID(), + "timestamp": event.Timestamp(), + }) + + err := next(ctx, event) + duration := time.Since(start) + + if err != nil { + logger.Error("Event processing failed", map[string]interface{}{ + "event_name": event.EventName(), + "event_id": event.EventID(), + "error": err.Error(), + "duration": duration, + }) + } else { + logger.Info("Event processed successfully", map[string]interface{}{ + "event_name": event.EventName(), + "event_id": event.EventID(), + "duration": duration, + }) + } + + return err + } + } +} + +// RecoveryMiddleware recovers from panics in event handlers +func RecoveryMiddleware(logger domain.Logger) EventMiddleware { + return func(next EventHandler[Event]) EventHandler[Event] { + return func(ctx context.Context, event Event) (err error) { + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + + // Get event metadata size for debugging + metadata := event.Metadata() + metadataSize := len(fmt.Sprintf("%+v", metadata)) + + logger.Error("Event handler panicked", map[string]interface{}{ + "event_name": event.EventName(), + "event_id": event.EventID(), + "event_timestamp": event.Timestamp(), + "panic": r, + "stack_trace": string(stack), + "metadata_size": metadataSize, + "metadata_keys": getMapKeys(metadata), + "recovery_context": "eventbus_middleware", + }) + err = fmt.Errorf("event handler panicked: %v", r) + } + }() + + return next(ctx, event) + } + } +} + +// Helper function to safely extract map keys +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// MetricsMiddleware adds metrics collection to event handling +func MetricsMiddleware() EventMiddleware { + return func(next EventHandler[Event]) EventHandler[Event] { + return func(ctx context.Context, event Event) error { + start := time.Now() + + err := next(ctx, event) + duration := time.Since(start) + + // Here you could send metrics to Prometheus, StatsD, etc. + // For now, we'll just log the metrics + _ = duration // Placeholder for actual metrics implementation + + return err + } + } +} diff --git a/go-b2b-starter/src/pkg/file_manager/README.md b/go-b2b-starter/src/pkg/file_manager/README.md new file mode 100644 index 0000000..4ac2d9b --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/README.md @@ -0,0 +1,286 @@ +# File Manager Guide + +Simple guide for uploading and managing files with Cloudflare R2 (S3-compatible storage). + +## Setup + +### R2 Configuration + +Add to your `.env`: + +```bash +R2_ACCOUNT_ID=your-cloudflare-account-id +R2_ACCESS_KEY_ID=your-r2-access-key +R2_SECRET_ACCESS_KEY=your-r2-secret-key +R2_BUCKET=your-bucket-name +R2_REGION=auto # Default +``` + +### Get R2 Credentials + +1. Go to Cloudflare Dashboard → R2 +2. Create a bucket or use existing one +3. Go to "Manage R2 API Tokens" +4. Create API token with read/write permissions +5. Copy Account ID, Access Key ID, and Secret Access Key + +## Usage in Your Module + +### 1. Inject File Service + +```go +import "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" + +type InvoiceService struct { + fileService domain.FileService +} + +func NewInvoiceService(fileService domain.FileService) *InvoiceService { + return &InvoiceService{fileService: fileService} +} +``` + +### 2. Upload a File + +```go +func (s *InvoiceService) UploadInvoice(ctx context.Context, file io.Reader, filename string, size int64) (*domain.FileAsset, error) { + // Create upload request + req := &domain.FileUploadRequest{ + Filename: filename, + Size: size, + ContentType: "application/pdf", + Context: file_manager.ContextInvoice, // Business context + Metadata: map[string]any{ + "uploaded_by": userID, + "invoice_number": "INV-2024-001", + }, + } + + // Upload to R2 + fileAsset, err := s.fileService.UploadFile(ctx, req, file) + if err != nil { + return nil, fmt.Errorf("upload failed: %w", err) + } + + s.logger.Info("File uploaded", map[string]any{ + "file_id": fileAsset.ID, + "size": fileAsset.Size, + "path": fileAsset.StoragePath, + }) + + return fileAsset, nil +} +``` + +### 3. Download a File + +```go +func (s *InvoiceService) DownloadInvoice(ctx context.Context, fileID int32) (io.ReadCloser, error) { + content, fileAsset, err := s.fileService.DownloadFile(ctx, fileID) + if err != nil { + return nil, fmt.Errorf("download failed: %w", err) + } + defer content.Close() + + // Use the file content + data, err := io.ReadAll(content) + if err != nil { + return nil, err + } + + return content, nil +} +``` + +### 4. Get Presigned URL + +Get a temporary signed URL for direct browser access: + +```go +func (s *InvoiceService) GetInvoiceURL(ctx context.Context, fileID int32) (string, error) { + // Generate URL valid for 24 hours + url, err := s.fileService.GetFileURL(ctx, fileID, 24) + if err != nil { + return "", err + } + + return url, nil +} +``` + +### 5. Delete a File + +```go +func (s *InvoiceService) DeleteInvoice(ctx context.Context, fileID int32) error { + return s.fileService.DeleteFile(ctx, fileID) +} +``` + +### 6. List Files with Filter + +```go +func (s *InvoiceService) ListInvoices(ctx context.Context) ([]*domain.FileAsset, error) { + // Filter by context + invoiceContext := file_manager.ContextInvoice + + filter := &domain.FileSearchFilter{ + Context: &invoiceContext, + } + + files, err := s.fileService.ListFiles(ctx, filter, 50, 0) + if err != nil { + return nil, err + } + + return files, nil +} +``` + +## File Contexts + +Organize files by business purpose: + +```go +file_manager.ContextInvoice // Invoices +file_manager.ContextReceipt // Receipts +file_manager.ContextContract // Contracts +file_manager.ContextReport // Reports +file_manager.ContextProfile // User profiles +file_manager.ContextPaymentInstruction // Payment instructions +file_manager.ContextPaymentBatch // Payment batches +file_manager.ContextGeneral // General files +``` + +## File Categories & Limits + +**Documents (PDFs):** +- Allowed: `.pdf` +- Max size: 2 MB +- Category: `file_manager.CategoryDocument` + +**Images:** +- Allowed: `.jpg`, `.jpeg`, `.png` +- Max size: 1 MB +- Category: `file_manager.CategoryImage` + +## Security Features + +The file manager automatically: +- ✅ Sanitizes filenames (prevents path traversal) +- ✅ Validates file types (magic byte detection) +- ✅ Enforces size limits +- ✅ Validates content matches extension +- ✅ Stores metadata in PostgreSQL +- ✅ Organizes files in R2 by context and date + +## Real-World Example: Complete Upload Flow + +```go +func (s *InvoiceService) ProcessInvoiceUpload(ctx context.Context, r *http.Request) (*Invoice, error) { + // 1. Parse multipart form + file, header, err := r.FormFile("invoice") + if err != nil { + return nil, err + } + defer file.Close() + + // 2. Upload to R2 via file manager + req := &domain.FileUploadRequest{ + Filename: header.Filename, + Size: header.Size, + ContentType: header.Header.Get("Content-Type"), + Context: file_manager.ContextInvoice, + Metadata: map[string]any{ + "uploaded_by": ctx.Value("user_id"), + "organization_id": ctx.Value("organization_id"), + }, + } + + fileAsset, err := s.fileService.UploadFile(ctx, req, file) + if err != nil { + return nil, err + } + + // 3. Create invoice record with file reference + invoice := &Invoice{ + Number: "INV-2024-001", + FileID: fileAsset.ID, + FileName: fileAsset.Filename, + OrganizationID: organizationID, + } + + err = s.repo.CreateInvoice(ctx, invoice) + if err != nil { + // Rollback: delete the uploaded file + s.fileService.DeleteFile(ctx, fileAsset.ID) + return nil, err + } + + return invoice, nil +} +``` + +## Storage Structure + +Files are organized in R2 with this pattern: +``` +{category}/{context}/{date}/{filename} +``` + +Example: +``` +document/invoice/2024/12/09/invoice-12345.pdf +image/receipt/2024/12/09/receipt-photo.jpg +``` + +## Configuration Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `R2_ACCOUNT_ID` | Yes | Your Cloudflare account ID | +| `R2_ACCESS_KEY_ID` | Yes | R2 API access key ID | +| `R2_SECRET_ACCESS_KEY` | Yes | R2 API secret key | +| `R2_BUCKET` | Yes | R2 bucket name | +| `R2_REGION` | No | Region (default: `auto`) | + +## Best Practices + +**1. Always use contexts:** +```go +// ✅ Good - organized by purpose +Context: file_manager.ContextInvoice + +// ❌ Bad - generic context +Context: file_manager.ContextGeneral +``` + +**2. Store file references:** +```go +type Invoice struct { + FileID int32 `json:"file_id"` + FileName string `json:"file_name"` +} +``` + +**3. Handle deletions:** +```go +// Delete invoice and its file +s.invoiceRepo.Delete(ctx, invoiceID) +s.fileService.DeleteFile(ctx, invoice.FileID) +``` + +**4. Use presigned URLs for downloads:** +```go +// Generate temporary URL instead of downloading in backend +url, _ := s.fileService.GetFileURL(ctx, fileID, 1) // 1 hour +// Return URL to frontend +``` + +## Why R2? + +- **S3-compatible**: Use standard AWS SDK +- **No egress fees**: Free bandwidth +- **Global CDN**: Fast downloads worldwide +- **Cost-effective**: Lower storage costs than S3 + +That's it! Just inject `FileService` and manage files with R2. diff --git a/go-b2b-starter/src/pkg/file_manager/cmd/init.go b/go-b2b-starter/src/pkg/file_manager/cmd/init.go new file mode 100644 index 0000000..6b503b6 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/cmd/init.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "log" + + "go.uber.org/dig" + "github.com/moasq/go-b2b-starter/pkg/file_manager/config" +) + +func Init(container *dig.Container) { + + if err := container.Provide(config.LoadConfig); err != nil { + log.Fatalf("Failed to provide file_manager config: %v", err) + } + + SetupDependencies(container) +} diff --git a/go-b2b-starter/src/pkg/file_manager/cmd/provider.go b/go-b2b-starter/src/pkg/file_manager/cmd/provider.go new file mode 100644 index 0000000..83e83f4 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/cmd/provider.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "strings" + + "go.uber.org/dig" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + "github.com/moasq/go-b2b-starter/pkg/file_manager/config" + "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" + "github.com/moasq/go-b2b-starter/pkg/file_manager/internal/infra" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +func SetupDependencies(container *dig.Container) error { + // Provider for R2 repository with development mode support + if err := container.Provide(func(cfg *config.Config, log logger.Logger) (domain.R2Repository, error) { + // Check for placeholder credentials (development mode) + if isPlaceholderR2Credentials(cfg) { + log.Warn("R2 credentials are placeholders - using mock file storage (development mode)", map[string]any{ + "account_id": cfg.R2.AccountID, + "message": "File upload/download will not work. Update R2_* variables in app.env with real credentials", + }) + // Return mock repository for development mode + return infra.NewMockR2Repository(log), nil + } + + return infra.NewR2Repository(cfg) + }); err != nil { + fmt.Printf("Error providing R2 repository: %v", err) + return err + } + + // Provider for database metadata repository + if err := container.Provide(func(store adapters.FileAssetStore) domain.FileMetadataRepository { + return infra.NewDBRepository(store) + }); err != nil { + fmt.Printf("Error providing database repository: %v", err) + return err + } + + // Provider for composite file repository + if err := container.Provide(infra.NewCompositeRepository); err != nil { + fmt.Printf("Error providing composite file repository: %v", err) + return err + } + + // Provider for file service + if err := container.Provide(domain.NewFileService); err != nil { + fmt.Printf("Error providing file service: %v", err) + return err + } + + return nil +} + +// isPlaceholderR2Credentials checks if the R2 credentials are placeholder values. +func isPlaceholderR2Credentials(cfg *config.Config) bool { + return strings.Contains(cfg.R2.AccountID, "REPLACE") || + strings.Contains(cfg.R2.AccessKeyID, "REPLACE") || + strings.Contains(cfg.R2.SecretAccessKey, "REPLACE") || + cfg.R2.AccountID == "" || + cfg.R2.AccessKeyID == "" +} diff --git a/go-b2b-starter/src/pkg/file_manager/config/config.go b/go-b2b-starter/src/pkg/file_manager/config/config.go new file mode 100644 index 0000000..915a8be --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + R2 R2Config +} + +type R2Config struct { + AccountID string + AccessKeyID string + SecretAccessKey string + BucketName string + Region string +} + +func LoadConfig() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("json") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + // Set environment variable overrides + viper.SetEnvPrefix("APCASH") + viper.AutomaticEnv() + + // Set default values for R2 + viper.SetDefault("r2.region", "auto") + viper.SetDefault("r2.bucketName", "invoices") + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, err + } + } + + // Bind environment variables to viper keys for R2 + viper.BindEnv("r2.accountID", "R2_ACCOUNT_ID") + viper.BindEnv("r2.accessKeyID", "R2_ACCESS_KEY_ID") + viper.BindEnv("r2.secretAccessKey", "R2_SECRET_ACCESS_KEY") + viper.BindEnv("r2.bucketName", "R2_BUCKET") + viper.BindEnv("r2.region", "R2_REGION") + + config := &Config{ + R2: R2Config{ + AccountID: viper.GetString("r2.accountID"), + AccessKeyID: viper.GetString("r2.accessKeyID"), + SecretAccessKey: viper.GetString("r2.secretAccessKey"), + BucketName: viper.GetString("r2.bucketName"), + Region: viper.GetString("r2.region"), + }, + } + + return config, nil +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/file_manager/constants.go b/go-b2b-starter/src/pkg/file_manager/constants.go new file mode 100644 index 0000000..ecea578 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/constants.go @@ -0,0 +1,106 @@ +package file_manager + +import ( + "strings" +) + +// File Type Categories +type FileCategory string + +const ( + CategoryDocument FileCategory = "document" + CategoryImage FileCategory = "image" + CategoryArchive FileCategory = "archive" +) + +// Supported file types +// SECURITY: Restricted to invoice-safe formats only (PDF and common image formats) +// Removed: Office documents (.doc, .docx, .xls, .xlsx), text files (.txt, .csv), +// archives (.zip, .rar, etc.), and risky image formats (.svg, .gif) +var ( + DocumentTypes = []string{".pdf"} + ImageTypes = []string{".jpg", ".jpeg", ".png"} + ArchiveTypes = []string{} // Archives disabled for security +) + +// Business file contexts +type FileContext string + +const ( + ContextInvoice FileContext = "invoice" + ContextReceipt FileContext = "receipt" + ContextContract FileContext = "contract" + ContextReport FileContext = "report" + ContextProfile FileContext = "profile" + ContextGeneral FileContext = "general" + ContextPaymentInstruction FileContext = "payment_instruction" + ContextPaymentBatch FileContext = "payment_batch" +) + +// File size limits (in bytes) +// SECURITY: Strict limits for invoice processing to minimize attack surface +const ( + MaxDocumentSize = 2 * 1024 * 1024 // 2MB - sufficient for most invoice PDFs + MaxImageSize = 1 * 1024 * 1024 // 1MB - sufficient for scanned invoices + MaxArchiveSize = 0 // Archives disabled +) + +// GetFileCategory determines the category based on file extension +func GetFileCategory(filename string) FileCategory { + ext := strings.ToLower(getFileExtension(filename)) + + for _, docType := range DocumentTypes { + if ext == docType { + return CategoryDocument + } + } + + for _, imgType := range ImageTypes { + if ext == imgType { + return CategoryImage + } + } + + for _, archType := range ArchiveTypes { + if ext == archType { + return CategoryArchive + } + } + + return CategoryDocument // Default to document +} + +// GetMaxFileSize returns the maximum file size for a given category +func GetMaxFileSize(category FileCategory) int64 { + switch category { + case CategoryImage: + return MaxImageSize + case CategoryArchive: + return MaxArchiveSize + default: + return MaxDocumentSize + } +} + +// IsAllowedFileType checks if the file type is allowed +func IsAllowedFileType(filename string) bool { + ext := strings.ToLower(getFileExtension(filename)) + + allTypes := append(DocumentTypes, ImageTypes...) + allTypes = append(allTypes, ArchiveTypes...) + + for _, allowedType := range allTypes { + if ext == allowedType { + return true + } + } + + return false +} + +func getFileExtension(filename string) string { + if idx := strings.LastIndex(filename, "."); idx != -1 { + return filename[idx:] + } + return "" +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/file_manager/domain/entity.go b/go-b2b-starter/src/pkg/file_manager/domain/entity.go new file mode 100644 index 0000000..edf331c --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/domain/entity.go @@ -0,0 +1,46 @@ +package domain + +import ( + "time" + + "github.com/moasq/go-b2b-starter/pkg/file_manager" + "github.com/google/uuid" +) + +type FileAsset struct { + ID int32 `json:"id"` // Database ID + UUID uuid.UUID `json:"uuid"` // UUID for external reference + Filename string `json:"filename"` + OriginalFilename string `json:"original_filename"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Category file_manager.FileCategory `json:"category"` + Context file_manager.FileContext `json:"context"` + StoragePath string `json:"storage_path"` // R2 object path + BucketName string `json:"bucket_name"` + IsPublic bool `json:"is_public"` + EntityType string `json:"entity_type,omitempty"` + EntityID int32 `json:"entity_id,omitempty"` + Purpose string `json:"purpose,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + URL string `json:"url,omitempty"` // Presigned URL + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FileUploadRequest struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Context file_manager.FileContext `json:"context"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type FileSearchFilter struct { + Category *file_manager.FileCategory `json:"category,omitempty"` + Context *file_manager.FileContext `json:"context,omitempty"` + MinSize *int64 `json:"min_size,omitempty"` + MaxSize *int64 `json:"max_size,omitempty"` + DateFrom *time.Time `json:"date_from,omitempty"` + DateTo *time.Time `json:"date_to,omitempty"` +} diff --git a/go-b2b-starter/src/pkg/file_manager/domain/helpers.go b/go-b2b-starter/src/pkg/file_manager/domain/helpers.go new file mode 100644 index 0000000..f9d94b0 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/domain/helpers.go @@ -0,0 +1,47 @@ +package domain + +import ( + "context" + "encoding/base64" + "fmt" + "io" +) + +// ConvertFileToBase64 reads a file from storage and converts it to a base64 data URI +// Returns a data URI in the format: data:{mimeType};base64,{encodedContent} +func ConvertFileToBase64(ctx context.Context, repo FileRepository, fileID int32) (string, error) { + // Download the file content and metadata + content, fileAsset, err := repo.Download(ctx, fileID) + if err != nil { + return "", fmt.Errorf("failed to download file %d: %w", fileID, err) + } + defer content.Close() + + // Read all content into memory + data, err := io.ReadAll(content) + if err != nil { + return "", fmt.Errorf("failed to read file content: %w", err) + } + + // Convert to base64 and format as data URI + return formatAsDataURI(data, fileAsset.ContentType), nil +} + +// ConvertReaderToBase64 converts an io.Reader to a base64 data URI +// Useful for converting files that haven't been stored yet +func ConvertReaderToBase64(content io.Reader, contentType string) (string, error) { + // Read all content into memory + data, err := io.ReadAll(content) + if err != nil { + return "", fmt.Errorf("failed to read content: %w", err) + } + + // Convert to base64 and format as data URI + return formatAsDataURI(data, contentType), nil +} + +// formatAsDataURI creates a properly formatted data URI from binary data +func formatAsDataURI(data []byte, contentType string) string { + encoded := base64.StdEncoding.EncodeToString(data) + return fmt.Sprintf("data:%s;base64,%s", contentType, encoded) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/file_manager/domain/repository.go b/go-b2b-starter/src/pkg/file_manager/domain/repository.go new file mode 100644 index 0000000..9812291 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/domain/repository.go @@ -0,0 +1,46 @@ +package domain + +import ( + "context" + "io" + + file_manager "github.com/moasq/go-b2b-starter/pkg/file_manager" +) + +type FileRepository interface { + // Combined operations (R2 + Database) + Upload(ctx context.Context, file *FileAsset, content io.Reader) error + Download(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) + GetByID(ctx context.Context, id int32) (*FileAsset, error) + Delete(ctx context.Context, id int32) error + List(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) + GetURL(ctx context.Context, id int32, expiryHours int) (string, error) + Exists(ctx context.Context, id int32) (bool, error) + + // Additional operations + GetByCategory(ctx context.Context, category file_manager.FileCategory, limit, offset int) ([]*FileAsset, error) + GetByContext(ctx context.Context, context file_manager.FileContext, limit, offset int) ([]*FileAsset, error) + GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*FileAsset, error) +} + +// R2Repository handles only object storage operations (Cloudflare R2) +type R2Repository interface { + UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error + DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) + DeleteObject(ctx context.Context, objectKey string) error + GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) + ObjectExists(ctx context.Context, objectKey string) (bool, error) +} + +// FileMetadataRepository handles only database operations +type FileMetadataRepository interface { + Create(ctx context.Context, file *FileAsset) (*FileAsset, error) + GetByID(ctx context.Context, id int32) (*FileAsset, error) + Update(ctx context.Context, file *FileAsset) error + Delete(ctx context.Context, id int32) error + List(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) + GetByStoragePath(ctx context.Context, storagePath string) (*FileAsset, error) + GetByCategory(ctx context.Context, category string, limit, offset int) ([]*FileAsset, error) + GetByContext(ctx context.Context, context string, limit, offset int) ([]*FileAsset, error) + GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*FileAsset, error) +} diff --git a/go-b2b-starter/src/pkg/file_manager/domain/service.go b/go-b2b-starter/src/pkg/file_manager/domain/service.go new file mode 100644 index 0000000..4aad7d0 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/domain/service.go @@ -0,0 +1,168 @@ +package domain + +import ( + "bytes" + "context" + "fmt" + "io" + "time" + + "github.com/moasq/go-b2b-starter/pkg/file_manager" +) + +type FileService interface { + UploadFile(ctx context.Context, req *FileUploadRequest, content io.Reader) (*FileAsset, error) + DownloadFile(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) + GetFile(ctx context.Context, id int32) (*FileAsset, error) + DeleteFile(ctx context.Context, id int32) error + ListFiles(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) + GetFileURL(ctx context.Context, id int32, expiryHours int) (string, error) +} + +type fileService struct { + repo FileRepository +} + +func NewFileService(repo FileRepository) FileService { + return &fileService{ + repo: repo, + } +} + +func (s *fileService) UploadFile(ctx context.Context, req *FileUploadRequest, content io.Reader) (*FileAsset, error) { + // SECURITY: Sanitize filename to prevent path traversal and dangerous characters + sanitizedFilename := SanitizeFilename(req.Filename) + + // SECURITY: Validate file extension is allowed + if !file_manager.IsAllowedFileType(sanitizedFilename) { + return nil, fmt.Errorf("file type not allowed: %s", sanitizedFilename) + } + + // Get file category + category := file_manager.GetFileCategory(sanitizedFilename) + + // SECURITY: Check file size limits + maxSize := file_manager.GetMaxFileSize(category) + if req.Size > maxSize { + return nil, fmt.Errorf("file size %d exceeds limit %d for category %s", req.Size, maxSize, category) + } + + // SOLUTION: Read entire file into buffer (makes it seekable for R2 retries) + // AWS SDK v2 requires io.ReadSeeker for retryable uploads + // bytes.Reader implements io.ReadSeeker, allowing the SDK to rewind the stream + fileData, err := io.ReadAll(content) + if err != nil { + return nil, fmt.Errorf("failed to read file content: %w", err) + } + + // Verify actual size matches declared size + if int64(len(fileData)) != req.Size { + return nil, fmt.Errorf("file size mismatch: declared %d bytes, actual %d bytes", + req.Size, len(fileData)) + } + + // SECURITY: Validate file content matches declared extension using magic bytes + validationReader := bytes.NewReader(fileData) + if err := ValidateFileContent(validationReader, sanitizedFilename); err != nil { + return nil, fmt.Errorf("file validation failed: %w", err) + } + + // Create file asset + fileAsset := &FileAsset{ + Filename: sanitizedFilename, + OriginalFilename: req.Filename, // Keep original for reference + Size: req.Size, + ContentType: req.ContentType, + Category: category, + Context: req.Context, + Metadata: req.Metadata, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // SOLUTION: Create seekable reader for R2 upload (supports AWS SDK retries) + seekableContent := bytes.NewReader(fileData) + + // Upload to storage + if err := s.repo.Upload(ctx, fileAsset, seekableContent); err != nil { + return nil, fmt.Errorf("failed to upload file: %w", err) + } + + return fileAsset, nil +} + +func (s *fileService) DownloadFile(ctx context.Context, id int32) (io.ReadCloser, *FileAsset, error) { + content, fileAsset, err := s.repo.Download(ctx, id) + if err != nil { + return nil, nil, fmt.Errorf("failed to download file: %w", err) + } + + return content, fileAsset, nil +} + +func (s *fileService) GetFile(ctx context.Context, id int32) (*FileAsset, error) { + return s.repo.GetByID(ctx, id) +} + +func (s *fileService) DeleteFile(ctx context.Context, id int32) error { + exists, err := s.repo.Exists(ctx, id) + if err != nil { + return fmt.Errorf("failed to check file existence: %w", err) + } + + if !exists { + return fmt.Errorf("file not found") + } + + return s.repo.Delete(ctx, id) +} + +func (s *fileService) ListFiles(ctx context.Context, filter *FileSearchFilter, limit, offset int) ([]*FileAsset, error) { + return s.repo.List(ctx, filter, limit, offset) +} + +func (s *fileService) GetFileURL(ctx context.Context, id int32, expiryHours int) (string, error) { + fmt.Printf("[FILE-SERVICE] ==============================================\n") + fmt.Printf("[FILE-SERVICE] GetFileURL requested for file_id=%d, expiry=%dh\n", id, expiryHours) + + fmt.Printf("[FILE-SERVICE] Checking file existence...\n") + exists, err := s.repo.Exists(ctx, id) + if err != nil { + fmt.Printf("[FILE-SERVICE] Exists check failed: %v\n", err) + fmt.Printf("[FILE-SERVICE] Error type: %T\n", err) + fmt.Printf("[FILE-SERVICE] ===========================================\n") + return "", fmt.Errorf("failed to check file existence: %w", err) + } + + fmt.Printf("[FILE-SERVICE] File exists check result: %v\n", exists) + if !exists { + fmt.Printf("[FILE-SERVICE] File not found - returning error\n") + fmt.Printf("[FILE-SERVICE] This could mean:\n") + fmt.Printf(" - File ID %d doesn't exist in database\n", id) + fmt.Printf(" - File exists in database but not in R2 storage\n") + fmt.Printf(" - Storage path mismatch between database and R2\n") + fmt.Printf("[FILE-SERVICE] ===========================================\n") + return "", fmt.Errorf("file not found") + } + + fmt.Printf("[FILE-SERVICE] File exists, generating \n presigned URL...\n") + url, err := s.repo.GetURL(ctx, id, expiryHours) + if err != nil { + fmt.Printf("[FILE-SERVICE] URL generation failed: %v\n", err) + fmt.Printf("[FILE-SERVICE] Error type: %T\n", err) + fmt.Printf("[FILE-SERVICE] ===========================================\n") + return "", err + } + + fmt.Printf("[FILE-SERVICE] URL generation successful:\n") + fmt.Printf(" - URL length: %d characters\n", len(url)) + fmt.Printf(" - URL: %s\n", url) + fmt.Printf("[FILE-SERVICE] ===========================================\n") + return url, nil +} + +// generateFilePath creates a logical path for organizing files +func generateFilePath(category file_manager.FileCategory, context file_manager.FileContext, filename string) string { + timestamp := time.Now().Format("2006/01/02") + return fmt.Sprintf("%s/%s/%s/%s", category, context, timestamp, filename) +} diff --git a/go-b2b-starter/src/pkg/file_manager/domain/validator.go b/go-b2b-starter/src/pkg/file_manager/domain/validator.go new file mode 100644 index 0000000..b56ff5f --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/domain/validator.go @@ -0,0 +1,126 @@ +package domain + +import ( + "fmt" + "io" + "path/filepath" + "regexp" + "strings" + + "github.com/gabriel-vasile/mimetype" +) + +// ValidateFileContent verifies that the actual file content matches the declared file type +// based on magic bytes inspection. This prevents file extension spoofing attacks. +func ValidateFileContent(reader io.Reader, filename string) error { + // Detect actual MIME type from file content (magic bytes) + mtype, err := mimetype.DetectReader(reader) + if err != nil { + return fmt.Errorf("failed to detect file MIME type: %w", err) + } + + // Get file extension + ext := strings.ToLower(filepath.Ext(filename)) + + // Get allowed MIME types for this extension + allowedMIMEs, ok := getAllowedMIMETypes(ext) + if !ok { + return fmt.Errorf("unsupported file extension: %s", ext) + } + + // Check if detected MIME type matches expected types + detectedMIME := mtype.String() + for _, allowed := range allowedMIMEs { + if detectedMIME == allowed { + return nil + } + } + + return fmt.Errorf("file content type (%s) does not match extension (%s)", detectedMIME, ext) +} + +// getAllowedMIMETypes returns the list of allowed MIME types for a given file extension +func getAllowedMIMETypes(ext string) ([]string, bool) { + // Invoice-specific allowed MIME types + mimeMap := map[string][]string{ + ".pdf": { + "application/pdf", + }, + ".png": { + "image/png", + }, + ".jpg": { + "image/jpeg", + }, + ".jpeg": { + "image/jpeg", + }, + } + + mimes, ok := mimeMap[ext] + return mimes, ok +} + +// SanitizeFilename removes dangerous characters and path traversal attempts from filenames +// to prevent security vulnerabilities. +func SanitizeFilename(filename string) string { + // Step 1: Remove any path components (prevents path traversal) + filename = filepath.Base(filename) + + // Step 2: Get extension before sanitization + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + + // Step 3: Remove dangerous characters, keep only safe ones + // Allow: alphanumeric, dash, underscore, space + safePattern := regexp.MustCompile(`[^a-zA-Z0-9\-_ ]`) + nameWithoutExt = safePattern.ReplaceAllString(nameWithoutExt, "_") + + // Step 4: Remove multiple consecutive underscores or spaces + multipleUnderscores := regexp.MustCompile(`_+`) + nameWithoutExt = multipleUnderscores.ReplaceAllString(nameWithoutExt, "_") + + multipleSpaces := regexp.MustCompile(`\s+`) + nameWithoutExt = multipleSpaces.ReplaceAllString(nameWithoutExt, " ") + + // Step 5: Trim leading/trailing whitespace and underscores + nameWithoutExt = strings.Trim(nameWithoutExt, " _") + + // Step 6: If filename is empty after sanitization, use default + if nameWithoutExt == "" { + nameWithoutExt = "file" + } + + // Step 7: Reconstruct filename with extension + sanitized := nameWithoutExt + ext + + // Step 8: Limit total filename length to 255 characters (filesystem limit) + maxLength := 255 + if len(sanitized) > maxLength { + // Keep the extension, truncate the name + extLen := len(ext) + maxNameLen := maxLength - extLen + if maxNameLen > 0 { + sanitized = sanitized[:maxNameLen] + ext + } else { + // If extension itself is too long (unlikely), just truncate + sanitized = sanitized[:maxLength] + } + } + + return sanitized +} + +// IsInvoiceFileType checks if the file extension is allowed for invoice uploads +func IsInvoiceFileType(filename string) bool { + ext := strings.ToLower(filepath.Ext(filename)) + allowedExtensions := []string{".pdf", ".png", ".jpg", ".jpeg"} + + for _, allowed := range allowedExtensions { + if ext == allowed { + return true + } + } + + return false +} diff --git a/go-b2b-starter/src/pkg/file_manager/go.mod b/go-b2b-starter/src/pkg/file_manager/go.mod new file mode 100644 index 0000000..ad1d3c0 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/go.mod @@ -0,0 +1,63 @@ +module github.com/moasq/go-b2b-starter/pkg/file_manager + +go 1.25 + + +require ( + github.com/moasq/go-b2b-starter/pkg/db v0.0.0-00010101000000-000000000000 + github.com/aws/aws-sdk-go-v2 v1.24.0 + github.com/aws/aws-sdk-go-v2/config v1.26.1 + github.com/aws/aws-sdk-go-v2/credentials v1.16.12 + github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 + github.com/aws/smithy-go v1.19.0 + github.com/gabriel-vasile/mimetype v1.4.10 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/spf13/viper v1.19.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pgvector/pgvector-go v0.3.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/pkg/db => ../db diff --git a/go-b2b-starter/src/pkg/file_manager/go.sum b/go-b2b-starter/src/pkg/file_manager/go.sum new file mode 100644 index 0000000..96dcfe3 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/go.sum @@ -0,0 +1,178 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= +github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= +github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= +github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/go-b2b-starter/src/pkg/file_manager/internal/infra/composite_repository.go b/go-b2b-starter/src/pkg/file_manager/internal/infra/composite_repository.go new file mode 100644 index 0000000..1a3f483 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/internal/infra/composite_repository.go @@ -0,0 +1,280 @@ +package infra + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/moasq/go-b2b-starter/pkg/file_manager/config" + "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" + file_manager "github.com/moasq/go-b2b-starter/pkg/file_manager" +) + +type compositeRepository struct { + r2Repo domain.R2Repository + metadataRepo domain.FileMetadataRepository + bucketName string +} + +func NewCompositeRepository(cfg *config.Config, r2Repo domain.R2Repository, metadataRepo domain.FileMetadataRepository) domain.FileRepository { + return &compositeRepository{ + r2Repo: r2Repo, + metadataRepo: metadataRepo, + bucketName: cfg.R2.BucketName, + } +} + +func (r *compositeRepository) Upload(ctx context.Context, file *domain.FileAsset, content io.Reader) error { + fmt.Printf("[UPLOAD-DEBUG] ==============================================\n") + fmt.Printf("[UPLOAD-DEBUG] Starting file upload process\n") + fmt.Printf("[UPLOAD-DEBUG] File details:\n") + fmt.Printf(" - Filename: %s\n", file.Filename) + fmt.Printf(" - Size: %d bytes\n", file.Size) + fmt.Printf(" - Content Type: %s\n", file.ContentType) + fmt.Printf(" - Category: %s\n", file.Category) + fmt.Printf(" - Context: %s\n", file.Context) + + // Set default values + file.BucketName = r.bucketName + file.StoragePath = r.generateStoragePath(file.Category, file.Context, file.Filename) + + fmt.Printf("[UPLOAD-DEBUG] Initial configuration:\n") + fmt.Printf(" - Bucket Name: %s\n", file.BucketName) + fmt.Printf(" - Initial Storage Path: %s\n", file.StoragePath) + + // First, save metadata to get database ID + fmt.Printf("[UPLOAD-DEBUG] Step 1: Saving metadata to database...\n") + savedFile, err := r.metadataRepo.Create(ctx, file) + if err != nil { + fmt.Printf("[UPLOAD-ERROR] Failed to save file metadata: %v\n", err) + return fmt.Errorf("failed to save file metadata: %w", err) + } + + fmt.Printf("[UPLOAD-DEBUG] Metadata saved successfully:\n") + fmt.Printf(" - Assigned File ID: %d\n", savedFile.ID) + fmt.Printf(" - Database Storage Path: %s\n", savedFile.StoragePath) + + // Use database ID as part of the R2 object key + objectKey := r.generateObjectKey(savedFile.ID, savedFile.Filename) + fmt.Printf("[UPLOAD-DEBUG] Step 2: Generated R2 object key: %s\n", objectKey) + + // Upload to R2 + fmt.Printf("[UPLOAD-DEBUG] Step 3: Uploading to R2...\n") + fmt.Printf(" - Bucket: %s\n", r.bucketName) + fmt.Printf(" - Object Key: %s\n", objectKey) + fmt.Printf(" - File Size: %d bytes\n", file.Size) + fmt.Printf(" - Content Type: %s\n", file.ContentType) + + err = r.r2Repo.UploadObject(ctx, objectKey, content, file.Size, file.ContentType) + if err != nil { + fmt.Printf("[UPLOAD-ERROR] R2 upload failed: %v\n", err) + fmt.Printf("[UPLOAD-ERROR] Rolling back database entry...\n") + // Rollback: delete metadata if R2 upload fails + r.metadataRepo.Delete(ctx, savedFile.ID) + return fmt.Errorf("failed to upload file to R2: %w", err) + } + + fmt.Printf("[UPLOAD-DEBUG] R2 upload successful\n") + + // Update storage path with the actual object key + fmt.Printf("[UPLOAD-DEBUG] Step 4: Updating database storage path...\n") + fmt.Printf(" - Old Path: %s\n", savedFile.StoragePath) + fmt.Printf(" - New Path: %s\n", objectKey) + + savedFile.StoragePath = objectKey + err = r.metadataRepo.Update(ctx, savedFile) + if err != nil { + fmt.Printf("[UPLOAD-ERROR] Database storage path update failed: %v\n", err) + fmt.Printf("[UPLOAD-ERROR] Error type: %T\n", err) + fmt.Printf("[UPLOAD-ERROR] Rolling back R2 and database...\n") + // Rollback: delete from R2 and metadata + r.r2Repo.DeleteObject(ctx, objectKey) + r.metadataRepo.Delete(ctx, savedFile.ID) + return fmt.Errorf("failed to update storage path: %w", err) + } + + fmt.Printf("[UPLOAD-SUCCESS] Database storage path updated successfully\n") + + // Update the original file with saved data + *file = *savedFile + + fmt.Printf("[UPLOAD-SUCCESS] File upload completed successfully:\n") + fmt.Printf(" - File ID: %d\n", savedFile.ID) + fmt.Printf(" - Final Storage Path: %s\n", savedFile.StoragePath) + fmt.Printf(" - Bucket: %s\n", savedFile.BucketName) + fmt.Printf("[UPLOAD-SUCCESS] ============================================\n") + + return nil +} + +func (r *compositeRepository) Download(ctx context.Context, id int32) (io.ReadCloser, *domain.FileAsset, error) { + // Get file metadata + file, err := r.metadataRepo.GetByID(ctx, id) + if err != nil { + return nil, nil, fmt.Errorf("failed to get file metadata: %w", err) + } + + // Download from R2 + content, err := r.r2Repo.DownloadObject(ctx, file.StoragePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to download file from R2: %w", err) + } + + return content, file, nil +} + +func (r *compositeRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) { + return r.metadataRepo.GetByID(ctx, id) +} + +func (r *compositeRepository) Delete(ctx context.Context, id int32) error { + // Get file metadata first + file, err := r.metadataRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to get file metadata: %w", err) + } + + // Delete from R2 + err = r.r2Repo.DeleteObject(ctx, file.StoragePath) + if err != nil { + return fmt.Errorf("failed to delete file from R2: %w", err) + } + + // Delete metadata + err = r.metadataRepo.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete file metadata: %w", err) + } + + return nil +} + +func (r *compositeRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) { + return r.metadataRepo.List(ctx, filter, limit, offset) +} + +func (r *compositeRepository) GetURL(ctx context.Context, id int32, expiryHours int) (string, error) { + fmt.Printf("[COMPOSITE-REPO] ==============================================\n") + fmt.Printf("[COMPOSITE-REPO] GetURL requested for file_id=%d, expiry=%dh\n", id, expiryHours) + + // Get file metadata + fmt.Printf("[COMPOSITE-REPO] Fetching file metadata from database...\n") + file, err := r.metadataRepo.GetByID(ctx, id) + if err != nil { + fmt.Printf("[COMPOSITE-REPO] Failed to get file metadata: %v\n", err) + fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) + fmt.Printf("[COMPOSITE-REPO] ===========================================\n") + return "", fmt.Errorf("failed to get file metadata: %w", err) + } + + fmt.Printf("[COMPOSITE-REPO] File metadata retrieved:\n") + fmt.Printf(" - Storage Path: %s\n", file.StoragePath) + fmt.Printf(" - Bucket Name: %s\n", file.BucketName) + fmt.Printf(" - File Name: %s\n", file.Filename) + + // Get presigned URL from R2 + fmt.Printf("[COMPOSITE-REPO] Generating R2 presigned URL...\n") + fmt.Printf("[COMPOSITE-REPO] R2 parameters:\n") + fmt.Printf(" - Bucket: %s\n", r.bucketName) + fmt.Printf(" - Object Key: %s\n", file.StoragePath) + fmt.Printf(" - Expiry: %d hours\n", expiryHours) + + url, err := r.r2Repo.GetPresignedURL(ctx, file.StoragePath, expiryHours) + if err != nil { + fmt.Printf("[COMPOSITE-REPO] Failed to get presigned URL: %v\n", err) + fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) + fmt.Printf("[COMPOSITE-REPO] This could indicate:\n") + fmt.Printf(" - R2 connection issues\n") + fmt.Printf(" - Invalid bucket name: %s\n", r.bucketName) + fmt.Printf(" - Invalid object key: %s\n", file.StoragePath) + fmt.Printf(" - R2 authentication problems\n") + fmt.Printf(" - R2 service issues\n") + fmt.Printf("[COMPOSITE-REPO] ===========================================\n") + return "", fmt.Errorf("failed to get presigned URL: %w", err) + } + + fmt.Printf("[COMPOSITE-REPO] Presigned URL generated successfully:\n") + fmt.Printf(" - URL length: %d characters\n", len(url)) + fmt.Printf(" - URL: %s\n", url) + fmt.Printf("[COMPOSITE-REPO] ===========================================\n") + + return url, nil +} + +func (r *compositeRepository) Exists(ctx context.Context, id int32) (bool, error) { + fmt.Printf("[COMPOSITE-REPO] ==============================================\n") + fmt.Printf("[COMPOSITE-REPO] Checking existence for file_id=%d\n", id) + + // Check if metadata exists + fmt.Printf("[COMPOSITE-REPO] Step 1: Checking file metadata in database...\n") + file, err := r.metadataRepo.GetByID(ctx, id) + if err != nil { + fmt.Printf("[COMPOSITE-REPO] Metadata lookup failed: %v\n", err) + fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) + fmt.Printf("[COMPOSITE-REPO] This means file_id=%d does not exist in database\n", id) + fmt.Printf("[COMPOSITE-REPO] ===========================================\n") + return false, fmt.Errorf("failed to check file metadata: %w", err) // FIX THE BUG + } + + fmt.Printf("[COMPOSITE-REPO] Metadata found successfully:\n") + fmt.Printf(" - File ID: %d\n", file.ID) + fmt.Printf(" - Filename: %s\n", file.Filename) + fmt.Printf(" - Storage Path: %s\n", file.StoragePath) + fmt.Printf(" - Bucket Name: %s\n", file.BucketName) + fmt.Printf(" - Content Type: %s\n", file.ContentType) + fmt.Printf(" - File Size: %d bytes\n", file.Size) + + // Check if object exists in R2 + fmt.Printf("[COMPOSITE-REPO] Step 2: Checking object existence in R2...\n") + fmt.Printf("[COMPOSITE-REPO] Looking for object: %s in bucket: %s\n", file.StoragePath, r.bucketName) + + exists, err := r.r2Repo.ObjectExists(ctx, file.StoragePath) + if err != nil { + fmt.Printf("[COMPOSITE-REPO] R2 existence check failed: %v\n", err) + fmt.Printf("[COMPOSITE-REPO] Error type: %T\n", err) + fmt.Printf("[COMPOSITE-REPO] This could indicate:\n") + fmt.Printf(" - R2 connection problems\n") + fmt.Printf(" - Incorrect bucket name: %s\n", r.bucketName) + fmt.Printf(" - Incorrect object path: %s\n", file.StoragePath) + fmt.Printf(" - R2 authentication issues\n") + fmt.Printf("[COMPOSITE-REPO] ===========================================\n") + return false, fmt.Errorf("failed to check R2 object existence: %w", err) + } + + fmt.Printf("[COMPOSITE-REPO] R2 object existence check result: %v\n", exists) + if !exists { + fmt.Printf("[COMPOSITE-REPO] File metadata exists in database but object missing in R2\n") + fmt.Printf("[COMPOSITE-REPO] Expected object path: %s\n", file.StoragePath) + fmt.Printf("[COMPOSITE-REPO] Expected bucket: %s\n", r.bucketName) + fmt.Printf("[COMPOSITE-REPO] This indicates a storage consistency issue\n") + } else { + fmt.Printf("[COMPOSITE-REPO] File exists in both database and R2 storage\n") + } + + fmt.Printf("[COMPOSITE-REPO] ===========================================\n") + return exists, nil +} + +func (r *compositeRepository) GetByCategory(ctx context.Context, category file_manager.FileCategory, limit, offset int) ([]*domain.FileAsset, error) { + return r.metadataRepo.GetByCategory(ctx, string(category), limit, offset) +} + +func (r *compositeRepository) GetByContext(ctx context.Context, context file_manager.FileContext, limit, offset int) ([]*domain.FileAsset, error) { + return r.metadataRepo.GetByContext(ctx, string(context), limit, offset) +} + +func (r *compositeRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) { + return r.metadataRepo.GetByEntity(ctx, entityType, entityID) +} + +// Helper methods +func (r *compositeRepository) generateStoragePath(category file_manager.FileCategory, context file_manager.FileContext, filename string) string { + timestamp := time.Now().Format("2006/01/02") + return fmt.Sprintf("%s/%s/%s/%s", category, context, timestamp, filename) +} + +func (r *compositeRepository) generateObjectKey(id int32, filename string) string { + // Use database ID as part of the object key for easy lookup + return fmt.Sprintf("files/%d/%s", id, filename) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/file_manager/internal/infra/db_repository.go b/go-b2b-starter/src/pkg/file_manager/internal/infra/db_repository.go new file mode 100644 index 0000000..4b4e123 --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/internal/infra/db_repository.go @@ -0,0 +1,435 @@ +package infra + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen" + "github.com/moasq/go-b2b-starter/pkg/db/adapters" + file_manager "github.com/moasq/go-b2b-starter/pkg/file_manager" + "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" +) + +type dbRepository struct { + store adapters.FileAssetStore +} + +func NewDBRepository(store adapters.FileAssetStore) domain.FileMetadataRepository { + return &dbRepository{ + store: store, + } +} + +func (r *dbRepository) Create(ctx context.Context, file *domain.FileAsset) (*domain.FileAsset, error) { + // Convert metadata map to JSON bytes + metadataBytes, err := json.Marshal(file.Metadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal metadata: %w", err) + } + + // Get category and context IDs + categoryID, err := r.getCategoryID(ctx, file.Category) + if err != nil { + return nil, fmt.Errorf("failed to get category ID: %w", err) + } + + contextID, err := r.getContextID(ctx, file.Context) + if err != nil { + return nil, fmt.Errorf("failed to get context ID: %w", err) + } + + params := sqlc.CreateFileAssetParams{ + FileName: file.Filename, + OriginalFileName: file.OriginalFilename, + StoragePath: file.StoragePath, + BucketName: file.BucketName, + FileSize: file.Size, + MimeType: file.ContentType, + FileCategoryID: categoryID, + FileContextID: contextID, + IsPublic: pgtype.Bool{Bool: file.IsPublic, Valid: true}, + EntityType: pgtype.Text{String: file.EntityType, Valid: file.EntityType != ""}, + EntityID: pgtype.Int4{Int32: file.EntityID, Valid: file.EntityID != 0}, + Purpose: pgtype.Text{String: file.Purpose, Valid: file.Purpose != ""}, + Metadata: metadataBytes, + } + + dbFile, err := r.store.CreateFileAsset(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create file asset: %w", err) + } + + return r.convertFromDBModel(&dbFile), nil +} + +func (r *dbRepository) GetByID(ctx context.Context, id int32) (*domain.FileAsset, error) { + fmt.Printf("[DB-REPO] ==============================================\n") + fmt.Printf("[DB-REPO] Querying file_asset table for id=%d\n", id) + + dbFile, err := r.store.GetFileAssetByID(ctx, id) + if err != nil { + fmt.Printf("[DB-REPO] Database query failed: %v\n", err) + fmt.Printf("[DB-REPO] Error type: %T\n", err) + fmt.Printf("[DB-REPO] This could mean:\n") + fmt.Printf(" - File ID %d does not exist in database\n", id) + fmt.Printf(" - Database connection problems\n") + fmt.Printf(" - SQL query execution issues\n") + fmt.Printf(" - Table or column structure problems\n") + fmt.Printf("[DB-REPO] ===========================================\n") + return nil, fmt.Errorf("failed to get file asset: %w", err) + } + + fmt.Printf("[DB-REPO] File found in database:\n") + fmt.Printf(" - ID: %d\n", dbFile.ID) + fmt.Printf(" - Filename: %s\n", dbFile.FileName) + fmt.Printf(" - Original Filename: %s\n", dbFile.OriginalFileName) + fmt.Printf(" - Storage Path: %s\n", dbFile.StoragePath) + fmt.Printf(" - Bucket Name: %s\n", dbFile.BucketName) + fmt.Printf(" - File Size: %d bytes\n", dbFile.FileSize) + fmt.Printf(" - MIME Type: %s\n", dbFile.MimeType) + fmt.Printf(" - Created At: %v\n", dbFile.CreatedAt.Time) + fmt.Printf(" - Updated At: %v\n", dbFile.UpdatedAt.Time) + + file := r.convertFromDBModel(&dbFile) + fmt.Printf("[DB-REPO] File conversion successful\n") + fmt.Printf("[DB-REPO] ===========================================\n") + + return file, nil +} + +func (r *dbRepository) Update(ctx context.Context, file *domain.FileAsset) error { + fmt.Printf("[DB-UPDATE] ==============================================\n") + fmt.Printf("[DB-UPDATE] Updating file asset in database\n") + fmt.Printf("[DB-UPDATE] File details:\n") + fmt.Printf(" - File ID: %d\n", file.ID) + fmt.Printf(" - Filename: %s\n", file.Filename) + fmt.Printf(" - Storage Path: %s\n", file.StoragePath) + fmt.Printf(" - Purpose: %s\n", file.Purpose) + + metadataBytes, err := json.Marshal(file.Metadata) + if err != nil { + fmt.Printf("[DB-UPDATE-ERROR] Failed to marshal metadata: %v\n", err) + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + params := sqlc.UpdateFileAssetParams{ + ID: file.ID, + FileName: file.Filename, + StoragePath: file.StoragePath, // FIX: Add missing StoragePath field + Purpose: pgtype.Text{String: file.Purpose, Valid: file.Purpose != ""}, + Metadata: metadataBytes, + } + + fmt.Printf("[DB-UPDATE] Executing database update...\n") + fmt.Printf(" - Updating Storage Path to: %s\n", file.StoragePath) + + err = r.store.UpdateFileAsset(ctx, params) + if err != nil { + fmt.Printf("[DB-UPDATE-ERROR] Database update failed: %v\n", err) + fmt.Printf("[DB-UPDATE-ERROR] Error type: %T\n", err) + return err + } + + fmt.Printf("[DB-UPDATE-SUCCESS] Database update completed successfully\n") + fmt.Printf("[DB-UPDATE-SUCCESS] ==========================================\n") + + return nil +} + +func (r *dbRepository) Delete(ctx context.Context, id int32) error { + return r.store.DeleteFileAsset(ctx, id) +} + +func (r *dbRepository) List(ctx context.Context, filter *domain.FileSearchFilter, limit, offset int) ([]*domain.FileAsset, error) { + params := sqlc.ListFileAssetsParams{ + Limit: int32(limit), + Offset: int32(offset), + } + + rows, err := r.store.ListFileAssets(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to list file assets: %w", err) + } + + files := make([]*domain.FileAsset, len(rows)) + for i, row := range rows { + files[i] = r.convertFromListRow(&row) + } + + return files, nil +} + +func (r *dbRepository) GetByStoragePath(ctx context.Context, storagePath string) (*domain.FileAsset, error) { + dbFile, err := r.store.GetFileAssetByStoragePath(ctx, storagePath) + if err != nil { + return nil, fmt.Errorf("failed to get file asset by storage path: %w", err) + } + + return r.convertFromDBModel(&dbFile), nil +} + +func (r *dbRepository) GetByCategory(ctx context.Context, category string, limit, offset int) ([]*domain.FileAsset, error) { + rows, err := r.store.GetFileAssetsByCategory(ctx, category) + if err != nil { + return nil, fmt.Errorf("failed to get file assets by category: %w", err) + } + + files := make([]*domain.FileAsset, len(rows)) + for i, row := range rows { + files[i] = r.convertFromCategoryRow(&row) + } + + return files, nil +} + +func (r *dbRepository) GetByContext(ctx context.Context, context string, limit, offset int) ([]*domain.FileAsset, error) { + rows, err := r.store.GetFileAssetsByContext(ctx, context) + if err != nil { + return nil, fmt.Errorf("failed to get file assets by context: %w", err) + } + + files := make([]*domain.FileAsset, len(rows)) + for i, row := range rows { + files[i] = r.convertFromContextRow(&row) + } + + return files, nil +} + +func (r *dbRepository) GetByEntity(ctx context.Context, entityType string, entityID int32) ([]*domain.FileAsset, error) { + params := sqlc.GetFileAssetsByEntityParams{ + EntityType: pgtype.Text{String: entityType, Valid: true}, + EntityID: pgtype.Int4{Int32: entityID, Valid: true}, + } + + dbFiles, err := r.store.GetFileAssetsByEntity(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to get file assets by entity: %w", err) + } + + files := make([]*domain.FileAsset, len(dbFiles)) + for i, dbFile := range dbFiles { + files[i] = r.convertFromDBModel(&dbFile) + } + + return files, nil +} + +// Helper methods for conversion and lookup +func (r *dbRepository) getCategoryID(ctx context.Context, category file_manager.FileCategory) (int16, error) { + categories, err := r.store.GetFileCategories(ctx) + if err != nil { + return 0, err + } + + for _, cat := range categories { + if cat.Name == string(category) { + return cat.ID, nil + } + } + + return 0, fmt.Errorf("category not found: %s", category) +} + +func (r *dbRepository) getContextID(ctx context.Context, context file_manager.FileContext) (int16, error) { + contexts, err := r.store.GetFileContexts(ctx) + if err != nil { + return 0, err + } + + for _, ctx := range contexts { + if ctx.Name == string(context) { + return ctx.ID, nil + } + } + + return 0, fmt.Errorf("context not found: %s", context) +} + +func (r *dbRepository) convertFromDBModel(dbFile *sqlc.FileManagerFileAsset) *domain.FileAsset { + var metadata map[string]interface{} + if len(dbFile.Metadata) > 0 { + json.Unmarshal(dbFile.Metadata, &metadata) + } + + var entityType string + if dbFile.EntityType.Valid { + entityType = dbFile.EntityType.String + } + + var entityID int32 + if dbFile.EntityID.Valid { + entityID = dbFile.EntityID.Int32 + } + + var purpose string + if dbFile.Purpose.Valid { + purpose = dbFile.Purpose.String + } + + var isPublic bool + if dbFile.IsPublic.Valid { + isPublic = dbFile.IsPublic.Bool + } + + return &domain.FileAsset{ + ID: dbFile.ID, + UUID: uuid.New(), // Generate UUID for external reference + Filename: dbFile.FileName, + OriginalFilename: dbFile.OriginalFileName, + Size: dbFile.FileSize, + ContentType: dbFile.MimeType, + StoragePath: dbFile.StoragePath, + BucketName: dbFile.BucketName, + IsPublic: isPublic, + EntityType: entityType, + EntityID: entityID, + Purpose: purpose, + Metadata: metadata, + CreatedAt: dbFile.CreatedAt.Time, + UpdatedAt: dbFile.UpdatedAt.Time, + } +} + +func (r *dbRepository) convertFromListRow(row *sqlc.ListFileAssetsRow) *domain.FileAsset { + var metadata map[string]interface{} + if len(row.Metadata) > 0 { + json.Unmarshal(row.Metadata, &metadata) + } + + var entityType string + if row.EntityType.Valid { + entityType = row.EntityType.String + } + + var entityID int32 + if row.EntityID.Valid { + entityID = row.EntityID.Int32 + } + + var purpose string + if row.Purpose.Valid { + purpose = row.Purpose.String + } + + var isPublic bool + if row.IsPublic.Valid { + isPublic = row.IsPublic.Bool + } + + return &domain.FileAsset{ + ID: row.ID, + UUID: uuid.New(), + Filename: row.FileName, + OriginalFilename: row.OriginalFileName, + Size: row.FileSize, + ContentType: row.MimeType, + Category: file_manager.FileCategory(row.CategoryName), + Context: file_manager.FileContext(row.ContextName), + StoragePath: row.StoragePath, + BucketName: row.BucketName, + IsPublic: isPublic, + EntityType: entityType, + EntityID: entityID, + Purpose: purpose, + Metadata: metadata, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func (r *dbRepository) convertFromCategoryRow(row *sqlc.GetFileAssetsByCategoryRow) *domain.FileAsset { + var metadata map[string]interface{} + if len(row.Metadata) > 0 { + json.Unmarshal(row.Metadata, &metadata) + } + + var entityType string + if row.EntityType.Valid { + entityType = row.EntityType.String + } + + var entityID int32 + if row.EntityID.Valid { + entityID = row.EntityID.Int32 + } + + var purpose string + if row.Purpose.Valid { + purpose = row.Purpose.String + } + + var isPublic bool + if row.IsPublic.Valid { + isPublic = row.IsPublic.Bool + } + + return &domain.FileAsset{ + ID: row.ID, + UUID: uuid.New(), + Filename: row.FileName, + OriginalFilename: row.OriginalFileName, + Size: row.FileSize, + ContentType: row.MimeType, + Category: file_manager.FileCategory(row.CategoryName), + StoragePath: row.StoragePath, + BucketName: row.BucketName, + IsPublic: isPublic, + EntityType: entityType, + EntityID: entityID, + Purpose: purpose, + Metadata: metadata, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func (r *dbRepository) convertFromContextRow(row *sqlc.GetFileAssetsByContextRow) *domain.FileAsset { + var metadata map[string]interface{} + if len(row.Metadata) > 0 { + json.Unmarshal(row.Metadata, &metadata) + } + + var entityType string + if row.EntityType.Valid { + entityType = row.EntityType.String + } + + var entityID int32 + if row.EntityID.Valid { + entityID = row.EntityID.Int32 + } + + var purpose string + if row.Purpose.Valid { + purpose = row.Purpose.String + } + + var isPublic bool + if row.IsPublic.Valid { + isPublic = row.IsPublic.Bool + } + + return &domain.FileAsset{ + ID: row.ID, + UUID: uuid.New(), + Filename: row.FileName, + OriginalFilename: row.OriginalFileName, + Size: row.FileSize, + ContentType: row.MimeType, + Context: file_manager.FileContext(row.ContextName), + StoragePath: row.StoragePath, + BucketName: row.BucketName, + IsPublic: isPublic, + EntityType: entityType, + EntityID: entityID, + Purpose: purpose, + Metadata: metadata, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} diff --git a/go-b2b-starter/src/pkg/file_manager/internal/infra/mock_r2_repository.go b/go-b2b-starter/src/pkg/file_manager/internal/infra/mock_r2_repository.go new file mode 100644 index 0000000..b6be05a --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/internal/infra/mock_r2_repository.go @@ -0,0 +1,72 @@ +package infra + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" + "github.com/moasq/go-b2b-starter/pkg/logger" +) + +type mockR2Repository struct { + logger logger.Logger +} + +// NewMockR2Repository creates a mock R2 repository for development mode +// This repository simulates R2 operations without actual cloud storage +func NewMockR2Repository(log logger.Logger) domain.R2Repository { + return &mockR2Repository{ + logger: log, + } +} + +func (m *mockR2Repository) UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error { + m.logger.Warn("Mock R2: Simulating file upload (no actual storage)", map[string]any{ + "object_key": objectKey, + "size": size, + "content_type": contentType, + }) + + // Drain the reader to simulate upload + io.Copy(io.Discard, content) + + return nil +} + +func (m *mockR2Repository) DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { + m.logger.Warn("Mock R2: Simulating file download (returning empty content)", map[string]any{ + "object_key": objectKey, + }) + + // Return empty reader + return io.NopCloser(strings.NewReader("")), nil +} + +func (m *mockR2Repository) DeleteObject(ctx context.Context, objectKey string) error { + m.logger.Warn("Mock R2: Simulating file deletion (no actual storage)", map[string]any{ + "object_key": objectKey, + }) + + return nil +} + +func (m *mockR2Repository) GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) { + m.logger.Warn("Mock R2: Generating mock presigned URL", map[string]any{ + "object_key": objectKey, + "expiry_hours": expiryHours, + }) + + // Return a mock URL + return fmt.Sprintf("https://mock-r2-storage.example.com/%s?expires=%dh", objectKey, expiryHours), nil +} + +func (m *mockR2Repository) ObjectExists(ctx context.Context, objectKey string) (bool, error) { + m.logger.Warn("Mock R2: Checking object existence (always returns true)", map[string]any{ + "object_key": objectKey, + }) + + // Always return true for mock + return true, nil +} diff --git a/go-b2b-starter/src/pkg/file_manager/internal/infra/r2_repository.go b/go-b2b-starter/src/pkg/file_manager/internal/infra/r2_repository.go new file mode 100644 index 0000000..20f2f3f --- /dev/null +++ b/go-b2b-starter/src/pkg/file_manager/internal/infra/r2_repository.go @@ -0,0 +1,161 @@ +package infra + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go" + + fileconfig "github.com/moasq/go-b2b-starter/pkg/file_manager/config" + "github.com/moasq/go-b2b-starter/pkg/file_manager/domain" +) + +type r2Repository struct { + client *s3.Client + bucketName string +} + +// NewR2Repository creates a new Cloudflare R2 repository using AWS SDK v2 +func NewR2Repository(cfg *fileconfig.Config) (domain.R2Repository, error) { + // Create custom AWS config for R2 + r2Cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion(cfg.R2.Region), // Always "auto" for R2 + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.R2.AccessKeyID, + cfg.R2.SecretAccessKey, + "", // No session token needed + )), + ) + if err != nil { + return nil, fmt.Errorf("failed to load R2 config: %w", err) + } + + // Create S3 client with R2 endpoint + client := s3.NewFromConfig(r2Cfg, func(o *s3.Options) { + // R2 endpoint format: https://.r2.cloudflarestorage.com + o.BaseEndpoint = aws.String(fmt.Sprintf("https://%s.r2.cloudflarestorage.com", + cfg.R2.AccountID)) + + // R2 only supports path-style URLs (not virtual-host style) + o.UsePathStyle = true + }) + + repo := &r2Repository{ + client: client, + bucketName: cfg.R2.BucketName, + } + + // Ensure bucket exists (R2 doesn't auto-create buckets) + if err := repo.ensureBucket(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ensure R2 bucket exists: %w", err) + } + + return repo, nil +} + +// ensureBucket checks if bucket exists (R2 requires manual bucket creation) +func (r *r2Repository) ensureBucket(ctx context.Context) error { + _, err := r.client.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: aws.String(r.bucketName), + }) + + if err != nil { + return fmt.Errorf("bucket '%s' does not exist in R2. Please create it manually in Cloudflare dashboard: %w", + r.bucketName, err) + } + + return nil +} + +// UploadObject uploads a file to R2 +func (r *r2Repository) UploadObject(ctx context.Context, objectKey string, content io.Reader, size int64, contentType string) error { + _, err := r.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(r.bucketName), + Key: aws.String(objectKey), + Body: content, + ContentLength: aws.Int64(size), + ContentType: aws.String(contentType), + }) + + if err != nil { + return fmt.Errorf("failed to upload object to R2: %w", err) + } + + return nil +} + +// DownloadObject downloads a file from R2 +func (r *r2Repository) DownloadObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { + result, err := r.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(r.bucketName), + Key: aws.String(objectKey), + }) + + if err != nil { + return nil, fmt.Errorf("failed to get object from R2: %w", err) + } + + return result.Body, nil +} + +// DeleteObject deletes a file from R2 +func (r *r2Repository) DeleteObject(ctx context.Context, objectKey string) error { + _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(r.bucketName), + Key: aws.String(objectKey), + }) + + if err != nil { + return fmt.Errorf("failed to delete object from R2: %w", err) + } + + return nil +} + +// GetPresignedURL generates a presigned URL for temporary access +func (r *r2Repository) GetPresignedURL(ctx context.Context, objectKey string, expiryHours int) (string, error) { + // Create presign client + presignClient := s3.NewPresignClient(r.client) + + // Generate presigned URL + request, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(r.bucketName), + Key: aws.String(objectKey), + }, func(opts *s3.PresignOptions) { + opts.Expires = time.Duration(expiryHours) * time.Hour + }) + + if err != nil { + return "", fmt.Errorf("failed to generate R2 presigned URL: %w", err) + } + + return request.URL, nil +} + +// ObjectExists checks if an object exists in R2 +func (r *r2Repository) ObjectExists(ctx context.Context, objectKey string) (bool, error) { + _, err := r.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(r.bucketName), + Key: aws.String(objectKey), + }) + + if err != nil { + // Check if error is "NotFound" using AWS SDK error handling + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" { + return false, nil + } + } + return false, fmt.Errorf("failed to check R2 object existence: %w", err) + } + + return true, nil +} diff --git a/go-b2b-starter/src/pkg/llm/README.md b/go-b2b-starter/src/pkg/llm/README.md new file mode 100644 index 0000000..57f5ae0 --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/README.md @@ -0,0 +1,102 @@ +# LLM Module Guide + +Simple guide for using AI completions and embeddings in your modules. + +## Setup + +Add to your `.env`: + +```bash +OPENAI_API_KEY=your-api-key-here +OPENAI_MODEL=gpt-4 +``` + +## Usage in Your Module + +### 1. Inject the LLM Client + +```go +import "github.com/moasq/go-b2b-starter/pkg/llm/domain" + +type YourService struct { + llmClient domain.LLMClient +} + +func NewYourService(llmClient domain.LLMClient) *YourService { + return &YourService{llmClient: llmClient} +} +``` + +### 2. Use Completions (Prompts) + +Send a prompt, get AI-generated text: + +```go +func (s *YourService) GenerateText(ctx context.Context, prompt string) (string, error) { + req := domain.CompletionRequest{ + Prompt: prompt, + } + + response, err := s.llmClient.Complete(ctx, req) + if err != nil { + return "", err + } + + return response.Text, nil +} +``` + +With options: + +```go +maxTokens := 200 +temperature := float32(0.7) // 0.0 = focused, 1.0 = creative + +req := domain.CompletionRequest{ + Prompt: prompt, + MaxTokens: &maxTokens, + Temperature: &temperature, +} +``` + +### 3. Use Embeddings (Vectors) + +Convert text to vectors for semantic search: + +```go +func (s *DocumentService) GenerateEmbedding(ctx context.Context, text string) ([]float32, error) { + embedding, err := s.llmClient.GenerateEmbedding( + ctx, + text, + "text-embedding-3-small", + ) + if err != nil { + return nil, err + } + + // Convert []float64 to []float32 for database + result := make([]float32, len(embedding)) + for i, v := range embedding { + result[i] = float32(v) + } + + return result, nil +} +``` + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENAI_API_KEY` | *required* | Your OpenAI API key | +| `OPENAI_MODEL` | `gpt-4` | AI model | +| `OPENAI_MAX_TOKENS` | `500` | Max response length | +| `OPENAI_TEMPERATURE` | `0.7` | Creativity level (0.0-1.0) | +| `LLM_TIMEOUT_SEC` | `60` | Request timeout | + +## Common Models + +**Completions:** `gpt-4`, `gpt-4-turbo`, `gpt-3.5-turbo` +**Embeddings:** `text-embedding-3-small` (recommended), `text-embedding-3-large` + +That's it! Just inject `LLMClient` and you're ready to use AI in your module. diff --git a/go-b2b-starter/src/pkg/llm/cmd/init.go b/go-b2b-starter/src/pkg/llm/cmd/init.go new file mode 100644 index 0000000..1a3bf23 --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/cmd/init.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/pkg/llm/domain" + "github.com/moasq/go-b2b-starter/pkg/llm/infra" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +func Init(container *dig.Container) error { + // Register LLMClient (which includes LLMService) + if err := container.Provide(func(logger loggerDomain.Logger) (domain.LLMClient, error) { + config := infra.NewLLMConfig() + return infra.NewOpenAIClient(config, logger) + }); err != nil { + return err + } + + // Also register LLMService for backward compatibility + return container.Provide(func(client domain.LLMClient) domain.LLMService { + return client + }) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/llm/domain/errors.go b/go-b2b-starter/src/pkg/llm/domain/errors.go new file mode 100644 index 0000000..77ec2a0 --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/domain/errors.go @@ -0,0 +1,10 @@ +package domain + +import "errors" + +var ( + ErrInvalidPrompt = errors.New("prompt cannot be empty") + ErrProviderNotFound = errors.New("LLM provider not found") + ErrAPIError = errors.New("LLM API error") + ErrTimeout = errors.New("LLM request timeout") +) \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/llm/domain/service.go b/go-b2b-starter/src/pkg/llm/domain/service.go new file mode 100644 index 0000000..440bd22 --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/domain/service.go @@ -0,0 +1,41 @@ +package domain + +import "context" + +type CompletionRequest struct { + Prompt string + MaxTokens *int + Temperature *float32 +} + +type CompletionResponse struct { + Text string + TokensUsed int + Model string +} + +type EmbeddingRequest struct { + Text string + Model string +} + +type EmbeddingResponse struct { + Embedding []float64 + TokensUsed int + Model string +} + +type StreamChunk struct { + Content string + Done bool +} + +type LLMService interface { + Complete(ctx context.Context, request CompletionRequest) (*CompletionResponse, error) + CompleteStream(ctx context.Context, request CompletionRequest, callback func(StreamChunk) error) (*CompletionResponse, error) +} + +type LLMClient interface { + LLMService + GenerateEmbedding(ctx context.Context, text string, model string) ([]float64, error) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/llm/go.mod b/go-b2b-starter/src/pkg/llm/go.mod new file mode 100644 index 0000000..c9bcc26 --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/go.mod @@ -0,0 +1,10 @@ +module github.com/moasq/go-b2b-starter/pkg/llm + +go 1.25 + +require ( + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + go.uber.org/dig v1.19.0 +) + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../logger diff --git a/go-b2b-starter/src/pkg/llm/go.sum b/go-b2b-starter/src/pkg/llm/go.sum new file mode 100644 index 0000000..a3b19ec --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/go.sum @@ -0,0 +1,8 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-b2b-starter/src/pkg/llm/infra/openai_client.go b/go-b2b-starter/src/pkg/llm/infra/openai_client.go new file mode 100644 index 0000000..3116103 --- /dev/null +++ b/go-b2b-starter/src/pkg/llm/infra/openai_client.go @@ -0,0 +1,933 @@ +package infra + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/moasq/go-b2b-starter/pkg/llm/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +type Config struct { + APIKey string + Model string + MaxTokens int + Temperature float32 + TimeoutSec int + MaxRetries int + DebugMode bool +} + +func (c Config) Validate() error { + if c.APIKey == "" { + return fmt.Errorf("API key is required") + } + if c.Model == "" { + return fmt.Errorf("model is required") + } + return nil +} + +// CircuitBreaker implements a simple circuit breaker pattern +type CircuitBreaker struct { + mu sync.RWMutex + failureCount int64 + successCount int64 + lastFailureTime time.Time + state string // "closed", "open", "half-open" + maxFailures int + resetTimeout time.Duration +} + +// NewCircuitBreaker creates a new circuit breaker +func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker { + return &CircuitBreaker{ + maxFailures: maxFailures, + resetTimeout: resetTimeout, + state: "closed", + } +} + +// CanExecute checks if a request can be executed based on circuit breaker state +func (cb *CircuitBreaker) CanExecute() bool { + cb.mu.RLock() + defer cb.mu.RUnlock() + + if cb.state == "closed" { + return true + } + + if cb.state == "open" { + if time.Since(cb.lastFailureTime) > cb.resetTimeout { + cb.mu.RUnlock() + cb.mu.Lock() + cb.state = "half-open" + cb.mu.Unlock() + cb.mu.RLock() + return true + } + return false + } + + // half-open state - allow one request to test + return true +} + +// RecordSuccess records a successful execution +func (cb *CircuitBreaker) RecordSuccess() { + cb.mu.Lock() + defer cb.mu.Unlock() + + atomic.AddInt64(&cb.successCount, 1) + if cb.state == "half-open" { + cb.state = "closed" + atomic.StoreInt64(&cb.failureCount, 0) + } +} + +// RecordFailure records a failed execution +func (cb *CircuitBreaker) RecordFailure() { + cb.mu.Lock() + defer cb.mu.Unlock() + + atomic.AddInt64(&cb.failureCount, 1) + cb.lastFailureTime = time.Now() + + if int(atomic.LoadInt64(&cb.failureCount)) >= cb.maxFailures { + cb.state = "open" + } +} + +// GetStats returns circuit breaker statistics +func (cb *CircuitBreaker) GetStats() map[string]interface{} { + cb.mu.RLock() + defer cb.mu.RUnlock() + + return map[string]interface{}{ + "state": cb.state, + "failures": atomic.LoadInt64(&cb.failureCount), + "successes": atomic.LoadInt64(&cb.successCount), + "last_failure": cb.lastFailureTime, + } +} + +type OpenAIClient struct { + config Config + client *http.Client + logger loggerDomain.Logger + circuitBreaker *CircuitBreaker +} + +type openAIRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature *float32 `json:"temperature,omitempty"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` // "function" + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` + Refusal string `json:"refusal,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +type openAIResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []openAIChoice `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` + Error *openAIError `json:"error,omitempty"` +} + +type openAIChoice struct { + Index int `json:"index"` + Message openAIMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type CompletionTokensDetails struct { + ReasoningTokens int `json:"reasoning_tokens"` +} + +type openAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CachedTokens int `json:"cached_tokens,omitempty"` + CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` +} + +type openAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param any `json:"param"` // can be string or null + Code any `json:"code"` // can be string, number, or null +} + +func NewLLMConfig() Config { + maxTokens, _ := strconv.Atoi(getEnvOrDefault("OPENAI_MAX_TOKENS", "150")) + temperature, _ := strconv.ParseFloat(getEnvOrDefault("OPENAI_TEMPERATURE", "0.1"), 32) + timeoutSec, _ := strconv.Atoi(getEnvOrDefault("LLM_TIMEOUT_SEC", "60")) // Increased default for GPT-5 + maxRetries, _ := strconv.Atoi(getEnvOrDefault("LLM_MAX_RETRIES", "2")) + debugMode, _ := strconv.ParseBool(getEnvOrDefault("LLM_DEBUG_MODE", "false")) + + return Config{ + APIKey: os.Getenv("OPENAI_API_KEY"), + Model: getEnvOrDefault("OPENAI_MODEL", "gpt-5-mini"), + MaxTokens: maxTokens, + Temperature: float32(temperature), + TimeoutSec: timeoutSec, + MaxRetries: maxRetries, + DebugMode: debugMode, + } +} + +func NewOpenAIClient(config Config, logger loggerDomain.Logger) (domain.LLMClient, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + // Configure transport with keep-alive and proper timeouts + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 10 * time.Second, + } + + // No global timeout - let per-request context control deadlines + client := &http.Client{ + Timeout: 0, + Transport: transport, + } + + // Initialize circuit breaker if enabled + var circuitBreaker *CircuitBreaker + if os.Getenv("LLM_CIRCUIT_BREAKER_ENABLED") == "true" { + maxFailures := 3 // Default failure threshold + resetTimeout := 30 * time.Second // Default reset timeout + + if val := os.Getenv("LLM_CIRCUIT_BREAKER_MAX_FAILURES"); val != "" { + if parsed, err := strconv.Atoi(val); err == nil { + maxFailures = parsed + } + } + + if val := os.Getenv("LLM_CIRCUIT_BREAKER_RESET_TIMEOUT"); val != "" { + if parsed, err := time.ParseDuration(val); err == nil { + resetTimeout = parsed + } + } + + circuitBreaker = NewCircuitBreaker(maxFailures, resetTimeout) + logger.Info("Circuit breaker enabled for OpenAI client", map[string]interface{}{ + "max_failures": maxFailures, + "reset_timeout": resetTimeout, + }) + } + + return &OpenAIClient{ + config: config, + client: client, + logger: logger, + circuitBreaker: circuitBreaker, + }, nil +} + +func (c *OpenAIClient) Complete(ctx context.Context, request domain.CompletionRequest) (*domain.CompletionResponse, error) { + if request.Prompt == "" { + return nil, domain.ErrInvalidPrompt + } + + maxTokens := c.config.MaxTokens + if request.MaxTokens != nil { + maxTokens = *request.MaxTokens + } + + // Right-size tokens for field extraction - avoid excessive budgets + if strings.HasPrefix(c.config.Model, "gpt-5") { + // For GPT-5, use smaller budgets unless explicitly requested + if maxTokens == c.config.MaxTokens && maxTokens > 200 { + maxTokens = 128 // Reasonable default for most extraction tasks + if c.config.DebugMode { + fmt.Println("[DEBUG] Using optimized token budget for GPT-5:", maxTokens) + } + } + } + + temperature := c.config.Temperature + if request.Temperature != nil { + temperature = *request.Temperature + } + + openAIReq := openAIRequest{ + Model: c.config.Model, + Messages: []openAIMessage{ + { + Role: "user", + Content: request.Prompt, + }, + }, + MaxTokens: maxTokens, + Stream: false, // Default to non-streaming for backward compatibility + } + + // Only set temperature for models that support it (GPT-5 models don't accept custom temperature) + if supportsTemperature(c.config.Model) { + openAIReq.Temperature = &temperature + } + + // Only set stop sequences for models that support them (GPT-5 models don't accept stop parameter) + if supportsStop(c.config.Model) { + openAIReq.Stop = []string{"\n\n", "\n---"} + } + + // Enhanced request logging + if c.config.DebugMode { + logData := map[string]any{ + "endpoint": "https://api.openai.com/v1/chat/completions", + "model": c.config.Model, + "input_length": len(request.Prompt), + "max_tokens": maxTokens, + "supports_temperature": supportsTemperature(c.config.Model), + "supports_stop": supportsStop(c.config.Model), + } + if supportsTemperature(c.config.Model) { + logData["temperature"] = temperature + } + if supportsStop(c.config.Model) { + logData["stop_sequences"] = []string{"\n\n", "\n---"} + } + c.logger.Info("Starting OpenAI request", logData) + + debugMsg := fmt.Sprintf("[DEBUG] Starting OpenAI request - Model: %s | MaxTokens: %d", c.config.Model, maxTokens) + if supportsTemperature(c.config.Model) { + debugMsg += fmt.Sprintf(" | Temperature: %.1f", temperature) + } else { + debugMsg += " | Temperature: OMITTED" + } + if supportsStop(c.config.Model) { + debugMsg += " | Stop: [\\n\\n, \\n---]" + } else { + debugMsg += " | Stop: OMITTED" + } + fmt.Println(debugMsg) + } + + var response *domain.CompletionResponse + var err error + + // Check circuit breaker before attempting requests + if c.circuitBreaker != nil && !c.circuitBreaker.CanExecute() { + stats := c.circuitBreaker.GetStats() + c.logger.Warn("Circuit breaker is open, request blocked", map[string]any{ + "model": c.config.Model, + "breaker_state": stats["state"], + "failures": stats["failures"], + "successes": stats["successes"], + }) + return nil, fmt.Errorf("circuit breaker is open due to repeated failures") + } + + // Retry with fresh context per attempt and exponential backoff with jitter + for i := 0; i <= c.config.MaxRetries; i++ { + // Create fresh context per attempt - THIS FIXES THE MAIN BUG + callTimeout := time.Duration(c.config.TimeoutSec) * time.Second + if strings.HasPrefix(c.config.Model, "gpt-5") { + callTimeout += 30 * time.Second // Extra time for reasoning models + } + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + + response, err = c.makeRequest(callCtx, openAIReq) + cancel() // Always cancel to free resources + + if err == nil { + // Record success in circuit breaker + if c.circuitBreaker != nil { + c.circuitBreaker.RecordSuccess() + } + break + } + + // Categorize error and decide on retry strategy + isTemp := isTemporaryError(err) + isPerm := isPermanentError(err) + + // Only record failure in circuit breaker for temporary errors + // Permanent errors (like invalid API key) shouldn't trip the breaker + if c.circuitBreaker != nil && isTemp { + c.circuitBreaker.RecordFailure() + } + + // Don't retry permanent errors + if isPerm { + c.logger.Error("Permanent error detected, not retrying", map[string]any{ + "model": c.config.Model, + "error": err.Error(), + "error_type": "permanent", + "attempt": i + 1, + }) + break + } + + if i < c.config.MaxRetries { + c.logger.Warn("OpenAI request failed, retrying", map[string]any{ + "attempt": i + 1, + "max_retries": c.config.MaxRetries, + "model": c.config.Model, + "error": err.Error(), + "error_type": map[bool]string{true: "temporary", false: "unknown"}[isTemp], + "will_retry": true, + }) + + // Exponential backoff with jitter + backoff := time.Duration(1< 200 { + maxTokens = 128 // Reasonable default for most extraction tasks + if c.config.DebugMode { + fmt.Println("[DEBUG] Using optimized token budget for GPT-5 streaming:", maxTokens) + } + } + } + + temperature := c.config.Temperature + if request.Temperature != nil { + temperature = *request.Temperature + } + + openAIReq := openAIRequest{ + Model: c.config.Model, + Messages: []openAIMessage{ + { + Role: "user", + Content: request.Prompt, + }, + }, + MaxTokens: maxTokens, + Stream: true, // Enable streaming + } + + // Only set temperature for models that support it + if supportsTemperature(c.config.Model) { + openAIReq.Temperature = &temperature + } + + // Only set stop sequences for models that support them + if supportsStop(c.config.Model) { + openAIReq.Stop = []string{"\n\n", "\n---"} + } + + if c.config.DebugMode { + c.logger.Info("Starting OpenAI streaming request", map[string]any{ + "model": c.config.Model, + "max_tokens": maxTokens, + "stream": true, + }) + } + + var response *domain.CompletionResponse + var err error + + // Retry with fresh context per attempt + for i := 0; i <= c.config.MaxRetries; i++ { + callTimeout := time.Duration(c.config.TimeoutSec) * time.Second + if strings.HasPrefix(c.config.Model, "gpt-5") { + callTimeout += 30 * time.Second + } + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + + response, err = c.makeStreamRequest(callCtx, openAIReq, callback) + cancel() + + if err == nil { + break + } + + if i < c.config.MaxRetries { + c.logger.Warn("OpenAI streaming request failed, retrying", map[string]any{ + "attempt": i + 1, + "max_retries": c.config.MaxRetries, + "model": c.config.Model, + "error": err.Error(), + }) + + backoff := time.Duration(1< 0 { + fmt.Println("[WARN] Model returned tool calls, but we don't support tools. Treating as error.") + return nil, fmt.Errorf("model returned tool calls but tools are not supported for this operation") + } + + // Handle refusal (model refused to respond) + if strings.TrimSpace(msg.Refusal) != "" { + fmt.Println("[ERROR] Model refusal:", msg.Refusal) + return nil, fmt.Errorf("model refusal: %s", msg.Refusal) + } + + // Only then check for empty content + if strings.TrimSpace(msg.Content) == "" { + fmt.Println("[ERROR] Empty content returned from OpenAI API, finish_reason:", choice.FinishReason) + return nil, fmt.Errorf("empty assistant content (finish_reason=%s)", choice.FinishReason) + } + + var totalTokens int + var reasoningTokens int + var outputTokens int + if openAIResp.Usage != nil { + totalTokens = openAIResp.Usage.TotalTokens + outputTokens = openAIResp.Usage.CompletionTokens + if openAIResp.Usage.CompletionTokensDetails != nil { + reasoningTokens = openAIResp.Usage.CompletionTokensDetails.ReasoningTokens + } + } + + responseText := msg.Content + if c.config.DebugMode { + textPreview := responseText + if len(textPreview) > 50 { + textPreview = textPreview[:50] + "..." + } + if reasoningTokens > 0 { + fmt.Printf("[DEBUG] OpenAI response - Text: %s | Total: %d tokens (Reasoning: %d, Output: %d)\n", + textPreview, totalTokens, reasoningTokens, outputTokens) + } else { + fmt.Printf("[DEBUG] OpenAI response - Text: %s | Tokens: %d\n", textPreview, totalTokens) + } + } + + return &domain.CompletionResponse{ + Text: responseText, + TokensUsed: totalTokens, + Model: openAIResp.Model, + }, nil +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func supportsTemperature(model string) bool { + // GPT-5 series (gpt-5, gpt-5-mini, gpt-5-nano) don't support custom temperature + return !strings.HasPrefix(model, "gpt-5") +} + +func supportsStop(model string) bool { + // GPT-5 series don't support `stop` parameter on Chat Completions + return !strings.HasPrefix(model, "gpt-5") +} + +// GenerateEmbedding generates a vector embedding for the given text using OpenAI embeddings API +func (c *OpenAIClient) GenerateEmbedding(ctx context.Context, text string, model string) ([]float64, error) { + if text == "" { + return nil, fmt.Errorf("text cannot be empty") + } + + if model == "" { + model = "text-embedding-3-small" // Default embedding model + } + + embeddingReq := map[string]any{ + "model": model, + "input": text, + } + + jsonData, err := json.Marshal(embeddingReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal embedding request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/embeddings", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create embedding request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make embedding request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + c.logger.Error("OpenAI embeddings API returned non-200 status", map[string]any{ + "status_code": resp.StatusCode, + "response_body": string(body), + "model": model, + }) + return nil, fmt.Errorf("OpenAI embeddings API error (status %d): %s", resp.StatusCode, string(body)) + } + + var embeddingResp struct { + Data []struct { + Embedding []float64 `json:"embedding"` + } `json:"data"` + Usage struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Error *openAIError `json:"error,omitempty"` + } + + if err := json.NewDecoder(resp.Body).Decode(&embeddingResp); err != nil { + return nil, fmt.Errorf("failed to decode embedding response: %w", err) + } + + if embeddingResp.Error != nil { + return nil, fmt.Errorf("OpenAI embeddings API error: %s", embeddingResp.Error.Message) + } + + if len(embeddingResp.Data) == 0 { + return nil, fmt.Errorf("no embedding data returned from OpenAI") + } + + embedding := embeddingResp.Data[0].Embedding + if len(embedding) == 0 { + return nil, fmt.Errorf("empty embedding returned from OpenAI") + } + + if c.config.DebugMode { + c.logger.Info("Generated embedding", map[string]any{ + "model": model, + "text_length": len(text), + "embedding_dims": len(embedding), + "tokens_used": embeddingResp.Usage.TotalTokens, + }) + } + + return embedding, nil +} + +type streamResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` +} + +func (c *OpenAIClient) makeStreamRequest(ctx context.Context, request openAIRequest, callback func(domain.StreamChunk) error) (*domain.CompletionResponse, error) { + jsonData, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal stream request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create stream request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make stream request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + c.logger.Error("OpenAI streaming API returned non-200 status", map[string]any{ + "status_code": resp.StatusCode, + "response_body": string(body), + }) + return nil, fmt.Errorf("OpenAI streaming API error (status %d): %s", resp.StatusCode, string(body)) + } + + var fullContent strings.Builder + var totalTokens int + var model string + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "" || line == "data: [DONE]" { + continue + } + + if !strings.HasPrefix(line, "data: ") { + continue + } + + jsonStr := strings.TrimPrefix(line, "data: ") + var streamResp streamResponse + if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil { + // Skip malformed JSON chunks + continue + } + + model = streamResp.Model + + if len(streamResp.Choices) > 0 { + choice := streamResp.Choices[0] + content := choice.Delta.Content + + if content != "" { + fullContent.WriteString(content) + + // Call callback with chunk + if callback != nil { + if err := callback(domain.StreamChunk{ + Content: content, + Done: false, + }); err != nil { + return nil, fmt.Errorf("streaming callback error: %w", err) + } + } + } + + if choice.FinishReason != nil && *choice.FinishReason != "" { + // Final chunk + if callback != nil { + callback(domain.StreamChunk{ + Content: "", + Done: true, + }) + } + break + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading stream: %w", err) + } + + finalContent := fullContent.String() + if strings.TrimSpace(finalContent) == "" { + return nil, fmt.Errorf("empty content from streaming response") + } + + // Estimate token usage (rough approximation) + totalTokens = len(strings.Fields(finalContent)) + 10 // Add some overhead + + return &domain.CompletionResponse{ + Text: finalContent, + TokensUsed: totalTokens, + Model: model, + }, nil +} + +// generateJitter creates random jitter for exponential backoff to avoid thundering herd +func generateJitter(maxJitterMs int64) int64 { + if maxJitterMs <= 0 { + return 0 + } + // Generate random number between 0 and maxJitterMs + n, err := rand.Int(rand.Reader, big.NewInt(maxJitterMs)) + if err != nil { + return maxJitterMs / 2 // fallback to half the max + } + return n.Int64() +} + +// isTemporaryError determines if an error is temporary and should be retried +func isTemporaryError(err error) bool { + errStr := strings.ToLower(err.Error()) + + // Network-level errors that are typically temporary + temporaryErrors := []string{ + "connection reset by peer", + "connection refused", + "timeout", + "context deadline exceeded", + "temporary failure", + "service unavailable", + "bad gateway", + "gateway timeout", + "too many requests", + "rate limit", + "internal server error", + } + + for _, tempErr := range temporaryErrors { + if strings.Contains(errStr, tempErr) { + return true + } + } + + return false +} + +// isPermanentError determines if an error is permanent and should not be retried +func isPermanentError(err error) bool { + errStr := strings.ToLower(err.Error()) + + // Errors that indicate permanent issues + permanentErrors := []string{ + "invalid api key", + "unauthorized", + "forbidden", + "not found", + "bad request", + "invalid request", + "model not found", + "quota exceeded", + "billing", + } + + for _, permErr := range permanentErrors { + if strings.Contains(errStr, permErr) { + return true + } + } + + return false +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/logger/cmd/init.go b/go-b2b-starter/src/pkg/logger/cmd/init.go new file mode 100644 index 0000000..7934ee2 --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/cmd/init.go @@ -0,0 +1,9 @@ +package cmd + +import ( + "go.uber.org/dig" +) + +func Init(container *dig.Container) { + ProvideDependencies(container) +} diff --git a/go-b2b-starter/src/pkg/logger/cmd/provider.go b/go-b2b-starter/src/pkg/logger/cmd/provider.go new file mode 100644 index 0000000..af0c963 --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/cmd/provider.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "github.com/moasq/go-b2b-starter/pkg/logger" + "go.uber.org/dig" +) + +func ProvideDependencies(container *dig.Container) { + container.Provide(logger.New) +} diff --git a/go-b2b-starter/src/pkg/logger/domain/logger.go b/go-b2b-starter/src/pkg/logger/domain/logger.go new file mode 100644 index 0000000..30927a8 --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/domain/logger.go @@ -0,0 +1,30 @@ +package domain + +type Level int + +const ( + DebugLevel Level = iota + InfoLevel + WarnLevel + ErrorLevel + FatalLevel +) + +type OutputType int + +const ( + ConsoleOutput OutputType = iota + FileOutput + BothOutput +) + +type Fields map[string]interface{} + +type Logger interface { + Debug(msg string, fields ...Fields) + Info(msg string, fields ...Fields) + Warn(msg string, fields ...Fields) + Error(msg string, fields ...Fields) + Fatal(msg string, fields ...Fields) + WithFields(fields Fields) Logger +} diff --git a/go-b2b-starter/src/pkg/logger/domain/options.go b/go-b2b-starter/src/pkg/logger/domain/options.go new file mode 100644 index 0000000..b0604d2 --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/domain/options.go @@ -0,0 +1,35 @@ +package domain + +type Option func(*Options) + +type Options struct { + Level Level + Output OutputType + FileOptions FileOptions +} + +type FileOptions struct { + Filename string + MaxSize int + MaxBackups int + MaxAge int + Compress bool +} + +func WithLevel(level Level) Option { + return func(o *Options) { + o.Level = level + } +} + +func WithOutput(output OutputType) Option { + return func(o *Options) { + o.Output = output + } +} + +func WithFileOptions(fileOpts FileOptions) Option { + return func(o *Options) { + o.FileOptions = fileOpts + } +} diff --git a/go-b2b-starter/src/pkg/logger/factory.go b/go-b2b-starter/src/pkg/logger/factory.go new file mode 100644 index 0000000..4433979 --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/factory.go @@ -0,0 +1,48 @@ +package logger + +import ( + "github.com/moasq/go-b2b-starter/pkg/logger/domain" + zerolog "github.com/moasq/go-b2b-starter/pkg/logger/internal/zerologger" +) + +func New(opts ...domain.Option) domain.Logger { + options := &domain.Options{ + Level: domain.InfoLevel, + Output: domain.ConsoleOutput, + FileOptions: domain.FileOptions{ + Filename: "app.log", + MaxSize: 100, + MaxBackups: 3, + MaxAge: 28, + Compress: true, + }, + } + for _, opt := range opts { + opt(options) + } + return zerolog.NewLogger(options) +} + +// Re-export types and constants for ease of use +type ( + Logger = domain.Logger + Fields = domain.Fields + Level = domain.Level + Option = domain.Option +) + +var ( + DebugLevel = domain.DebugLevel + InfoLevel = domain.InfoLevel + WarnLevel = domain.WarnLevel + ErrorLevel = domain.ErrorLevel + FatalLevel = domain.FatalLevel + + ConsoleOutput = domain.ConsoleOutput + FileOutput = domain.FileOutput + BothOutput = domain.BothOutput + + WithLevel = domain.WithLevel + WithOutput = domain.WithOutput + WithFileOptions = domain.WithFileOptions +) diff --git a/go-b2b-starter/src/pkg/logger/go.mod b/go-b2b-starter/src/pkg/logger/go.mod new file mode 100644 index 0000000..6f1ae9b --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/go.mod @@ -0,0 +1,19 @@ +module github.com/moasq/go-b2b-starter/pkg/logger + +go 1.25 + + +require ( + github.com/rs/zerolog v1.33.0 + go.uber.org/dig v1.19.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/sys v0.31.0 // indirect +) diff --git a/go-b2b-starter/src/pkg/logger/go.sum b/go-b2b-starter/src/pkg/logger/go.sum new file mode 100644 index 0000000..fc1da17 --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/go.sum @@ -0,0 +1,22 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-b2b-starter/src/pkg/logger/internal/zerologger/factory.go b/go-b2b-starter/src/pkg/logger/internal/zerologger/factory.go new file mode 100644 index 0000000..d6e4bdf --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/internal/zerologger/factory.go @@ -0,0 +1,9 @@ +package zerolog + +import ( + "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +func NewLogger(opts *domain.Options) domain.Logger { + return newZerologLogger(opts) +} diff --git a/go-b2b-starter/src/pkg/logger/internal/zerologger/logger.go b/go-b2b-starter/src/pkg/logger/internal/zerologger/logger.go new file mode 100644 index 0000000..06a16ea --- /dev/null +++ b/go-b2b-starter/src/pkg/logger/internal/zerologger/logger.go @@ -0,0 +1,101 @@ +package zerolog + +import ( + "io" + "os" + "time" + + logger "github.com/moasq/go-b2b-starter/pkg/logger/domain" + "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" +) + +type zerologLogger struct { + zl zerolog.Logger +} + +func newZerologLogger(opts *logger.Options) logger.Logger { + var output io.Writer + + switch opts.Output { + case logger.ConsoleOutput: + output = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} + case logger.FileOutput: + output = &lumberjack.Logger{ + Filename: opts.FileOptions.Filename, + MaxSize: opts.FileOptions.MaxSize, + MaxBackups: opts.FileOptions.MaxBackups, + MaxAge: opts.FileOptions.MaxAge, + Compress: opts.FileOptions.Compress, + } + case logger.BothOutput: + consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} + fileWriter := &lumberjack.Logger{ + Filename: opts.FileOptions.Filename, + MaxSize: opts.FileOptions.MaxSize, + MaxBackups: opts.FileOptions.MaxBackups, + MaxAge: opts.FileOptions.MaxAge, + Compress: opts.FileOptions.Compress, + } + output = zerolog.MultiLevelWriter(consoleWriter, fileWriter) + default: + output = os.Stdout + } + + zerolog.TimeFieldFormat = time.RFC3339 + + zl := zerolog.New(output).With().Timestamp().Logger() + + // Set the log level + zl = zl.Level(convertLogLevel(opts.Level)) + + return &zerologLogger{zl: zl} +} + +func (l *zerologLogger) Debug(msg string, fields ...logger.Fields) { + l.log(l.zl.Debug(), msg, fields...) +} + +func (l *zerologLogger) Info(msg string, fields ...logger.Fields) { + l.log(l.zl.Info(), msg, fields...) +} + +func (l *zerologLogger) Warn(msg string, fields ...logger.Fields) { + l.log(l.zl.Warn(), msg, fields...) +} + +func (l *zerologLogger) Error(msg string, fields ...logger.Fields) { + l.log(l.zl.Error(), msg, fields...) +} + +func (l *zerologLogger) Fatal(msg string, fields ...logger.Fields) { + l.log(l.zl.Fatal(), msg, fields...) +} + +func (l *zerologLogger) WithFields(fields logger.Fields) logger.Logger { + return &zerologLogger{zl: l.zl.With().Fields(fields).Logger()} +} + +func (l *zerologLogger) log(event *zerolog.Event, msg string, fields ...logger.Fields) { + if len(fields) > 0 { + event.Fields(fields[0]) + } + event.Msg(msg) +} + +func convertLogLevel(level logger.Level) zerolog.Level { + switch level { + case logger.DebugLevel: + return zerolog.DebugLevel + case logger.InfoLevel: + return zerolog.InfoLevel + case logger.WarnLevel: + return zerolog.WarnLevel + case logger.ErrorLevel: + return zerolog.ErrorLevel + case logger.FatalLevel: + return zerolog.FatalLevel + default: + return zerolog.InfoLevel + } +} diff --git a/go-b2b-starter/src/pkg/ocr/README.md b/go-b2b-starter/src/pkg/ocr/README.md new file mode 100644 index 0000000..d86de25 --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/README.md @@ -0,0 +1,127 @@ +# OCR Module Guide + +Simple guide for extracting text from documents using OCR (Optical Character Recognition). + +## Setup + +Add to your `.env`: + +```bash +MISTRAL_API_KEY=your-mistral-api-key-here +``` + +Optional: +```bash +MISTRAL_OCR_ENDPOINT=https://api.mistral.ai/v1/ocr # Default +OCR_TIMEOUT_SEC=120 # Default +``` + +## Usage in Your Module + +### 1. Inject the OCR Service + +```go +import "github.com/moasq/go-b2b-starter/pkg/ocr/domain" + +type InvoiceService struct { + ocrService domain.OCRService +} + +func NewInvoiceService(ocrService domain.OCRService) *InvoiceService { + return &InvoiceService{ocrService: ocrService} +} +``` + +### 2. Extract Text from Document + +```go +func (s *InvoiceService) ProcessDocument(ctx context.Context, base64File string, mimeType string) (string, error) { + // Extract text from the document + response, err := s.ocrService.ExtractText(ctx, base64File, mimeType) + if err != nil { + return "", fmt.Errorf("OCR failed: %w", err) + } + + // Use the extracted text + s.logger.Info("OCR completed", map[string]any{ + "pages": response.Pages, + "confidence": response.Confidence, + "text_length": len(response.Text), + }) + + return response.Text, nil +} +``` + +### 3. Real-World Example: Invoice Processing + +```go +func (s *InvoiceService) ExtractInvoiceData(ctx context.Context, fileData []byte) (*Invoice, error) { + // 1. Convert file to base64 + base64File := base64.StdEncoding.EncodeToString(fileData) + + // 2. Extract text using OCR + ocrResponse, err := s.ocrService.ExtractText(ctx, base64File, "application/pdf") + if err != nil { + return nil, err + } + + // 3. Check confidence score + if ocrResponse.Confidence < 0.7 { + return nil, fmt.Errorf("OCR confidence too low: %.2f", ocrResponse.Confidence) + } + + // 4. Parse the extracted text + invoice := s.parseInvoiceText(ocrResponse.Text) + + return invoice, nil +} +``` + +## Response Structure + +The `OCRResponse` includes: + +```go +type OCRResponse struct { + Text string // Extracted text from the document + Pages int // Number of pages processed + Confidence float32 // OCR confidence (0.0 to 1.0) +} +``` + +## Supported File Types + +- **PDF**: `application/pdf` +- **Images**: `image/jpeg`, `image/png` +- **Other formats** supported by Mistral OCR API + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `MISTRAL_API_KEY` | *required* | Your Mistral API key | +| `MISTRAL_OCR_ENDPOINT` | `https://api.mistral.ai/v1/ocr` | OCR API endpoint | +| `OCR_TIMEOUT_SEC` | `120` | Request timeout in seconds | + +## Best Practices + +**1. Validate confidence scores:** +```go +if response.Confidence < 0.8 { + // Low confidence - may need manual review +} +``` + +**2. Handle timeouts:** +```go +ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) +defer cancel() + +response, err := s.ocrService.ExtractText(ctx, base64File, mimeType) +``` + +**3. Process large files in chunks:** +For multi-page PDFs, OCR processes all pages automatically. Monitor the `Pages` field in the response. + +That's it! Just inject `OCRService` and extract text from any document. diff --git a/go-b2b-starter/src/pkg/ocr/cmd/init.go b/go-b2b-starter/src/pkg/ocr/cmd/init.go new file mode 100644 index 0000000..c6d42fd --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/cmd/init.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "go.uber.org/dig" + + "github.com/moasq/go-b2b-starter/pkg/ocr/domain" + "github.com/moasq/go-b2b-starter/pkg/ocr/infra" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +func Init(container *dig.Container) error { + return container.Provide(func(logger loggerDomain.Logger) (domain.OCRService, error) { + config := infra.NewOCRConfig() + return infra.NewMistralOCRClient(config, logger) + }) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/ocr/domain/entity.go b/go-b2b-starter/src/pkg/ocr/domain/entity.go new file mode 100644 index 0000000..c2ed3e2 --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/domain/entity.go @@ -0,0 +1,8 @@ +package domain + +// OCRResponse represents the result of OCR text extraction +type OCRResponse struct { + Text string `json:"text"` // Extracted text + Pages int `json:"pages"` // Number of pages processed + Confidence float32 `json:"confidence"` // OCR confidence score (0.0 to 1.0) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/ocr/domain/errors.go b/go-b2b-starter/src/pkg/ocr/domain/errors.go new file mode 100644 index 0000000..cb97ee4 --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/domain/errors.go @@ -0,0 +1,14 @@ +package domain + +import "errors" + +var ( + ErrInvalidInput = errors.New("invalid OCR input") + ErrQuotaExceeded = errors.New("OCR quota exceeded") + ErrUnsupportedFile = errors.New("unsupported file type") + ErrAsyncJobFailed = errors.New("async OCR job failed") + ErrJobNotFound = errors.New("OCR job not found") + ErrAuthFailed = errors.New("OCR authentication failed") + ErrTransientError = errors.New("OCR transient error") + ErrNotFound = errors.New("OCR resource not found") +) \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/ocr/domain/service.go b/go-b2b-starter/src/pkg/ocr/domain/service.go new file mode 100644 index 0000000..2f28e8d --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/domain/service.go @@ -0,0 +1,8 @@ +package domain + +import "context" + +// OCRService provides text extraction from files +type OCRService interface { + ExtractText(ctx context.Context, base64File string, mimeType string) (*OCRResponse, error) +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/ocr/go.mod b/go-b2b-starter/src/pkg/ocr/go.mod new file mode 100644 index 0000000..bb67c10 --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/go.mod @@ -0,0 +1,11 @@ +module github.com/moasq/go-b2b-starter/pkg/ocr + +go 1.25 + + +require ( + github.com/moasq/go-b2b-starter/pkg/logger v0.0.0 + go.uber.org/dig v1.19.0 +) + +replace github.com/moasq/go-b2b-starter/pkg/logger => ../logger diff --git a/go-b2b-starter/src/pkg/ocr/go.sum b/go-b2b-starter/src/pkg/ocr/go.sum new file mode 100644 index 0000000..408aeae --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/go.sum @@ -0,0 +1,9 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-b2b-starter/src/pkg/ocr/infra/config.go b/go-b2b-starter/src/pkg/ocr/infra/config.go new file mode 100644 index 0000000..050c34f --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/infra/config.go @@ -0,0 +1,40 @@ +package infra + +import ( + "fmt" + "os" + "strconv" +) + +type Config struct { + MistralAPIKey string + APIEndpoint string + TimeoutSec int +} + +func (c Config) Validate() error { + if c.MistralAPIKey == "" { + return fmt.Errorf("Mistral API key is required") + } + if c.APIEndpoint == "" { + return fmt.Errorf("API endpoint is required") + } + return nil +} + +func NewOCRConfig() Config { + timeoutSec, _ := strconv.Atoi(getEnvOrDefault("OCR_TIMEOUT_SEC", "120")) + + return Config{ + MistralAPIKey: os.Getenv("MISTRAL_API_KEY"), + APIEndpoint: getEnvOrDefault("MISTRAL_OCR_ENDPOINT", "https://api.mistral.ai/v1/ocr"), + TimeoutSec: timeoutSec, + } +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/ocr/infra/mistral_ocr_client.go b/go-b2b-starter/src/pkg/ocr/infra/mistral_ocr_client.go new file mode 100644 index 0000000..b4576e2 --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/infra/mistral_ocr_client.go @@ -0,0 +1,258 @@ +package infra + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/pkg/ocr/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +type MistralOCRClient struct { + config Config + client *http.Client + logger loggerDomain.Logger +} + +// Mistral API request/response structures +type MistralOCRRequest struct { + Model string `json:"model"` + Document MistralDocument `json:"document"` + IncludeImageBase64 bool `json:"include_image_base64"` +} + +type MistralDocument struct { + Type string `json:"type"` // "document_url" or "image_url" + DocumentURL string `json:"document_url,omitempty"` + ImageURL string `json:"image_url,omitempty"` +} + +type MistralOCRResponse struct { + Pages []MistralPage `json:"pages"` +} + +type MistralPage struct { + Index int `json:"index"` + Markdown string `json:"markdown"` + Images []MistralImage `json:"images,omitempty"` + Bboxes []MistralBoundingBox `json:"bboxes,omitempty"` +} + +type MistralImage struct { + Base64 string `json:"base64,omitempty"` +} + +type MistralBoundingBox struct { + X float32 `json:"x"` + Y float32 `json:"y"` + Width float32 `json:"width"` + Height float32 `json:"height"` + Text string `json:"text,omitempty"` +} + +func NewMistralOCRClient(config Config, logger loggerDomain.Logger) (domain.OCRService, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + client := &http.Client{ + Timeout: time.Duration(config.TimeoutSec) * time.Second, + } + + return &MistralOCRClient{ + config: config, + client: client, + logger: logger, + }, nil +} + + +func (m *MistralOCRClient) ExtractText(ctx context.Context, base64File string, mimeType string) (*domain.OCRResponse, error) { + m.logger.Info("Starting Mistral OCR extraction", map[string]any{ + "mime_type": mimeType, + }) + + // Validate file constraints + if err := m.validateInput(base64File, mimeType); err != nil { + return nil, err + } + + // Build Mistral API request + mistralRequest := m.buildMistralRequest(base64File, mimeType) + + // Make API call with retries + mistralResponse, err := m.callMistralAPI(ctx, mistralRequest) + if err != nil { + return nil, err + } + + // Convert response to domain format + response := m.convertResponse(mistralResponse) + + m.logger.Info("Mistral OCR extraction completed", map[string]any{ + "pages": response.Pages, + "text_length": len(response.Text), + "confidence": response.Confidence, + }) + + return response, nil +} + + +func (m *MistralOCRClient) validateInput(base64File string, mimeType string) error { + // Validate base64 file is not empty + if base64File == "" { + return domain.ErrInvalidInput + } + + // Validate supported MIME types + if !m.isSupportedMimeType(mimeType) { + return domain.ErrUnsupportedFile + } + + return nil +} + +func (m *MistralOCRClient) isSupportedMimeType(mimeType string) bool { + supportedTypes := []string{ + "application/pdf", + "image/jpeg", + "image/jpg", + "image/png", + "image/tiff", + "image/avif", + "image/webp", + } + + for _, supported := range supportedTypes { + if mimeType == supported { + return true + } + } + + return false +} + +func (m *MistralOCRClient) buildMistralRequest(base64File string, mimeType string) MistralOCRRequest { + mistralRequest := MistralOCRRequest{ + Model: "mistral-ocr-latest", + IncludeImageBase64: false, // Simplified - no layout extraction + } + + // Determine document type based on MIME type and format as data URI + if mimeType == "application/pdf" { + dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64File) + mistralRequest.Document = MistralDocument{ + Type: "document_url", + DocumentURL: dataURI, + } + } else if strings.HasPrefix(mimeType, "image/") { + dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64File) + mistralRequest.Document = MistralDocument{ + Type: "image_url", + ImageURL: dataURI, + } + } + + return mistralRequest +} + +func (m *MistralOCRClient) callMistralAPI(ctx context.Context, mistralRequest MistralOCRRequest) (*MistralOCRResponse, error) { + requestBody, err := json.Marshal(mistralRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", m.config.APIEndpoint, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+m.config.MistralAPIKey) + + resp, err := m.client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized { + return nil, domain.ErrAuthFailed + } + if resp.StatusCode == http.StatusBadRequest { + return nil, domain.ErrInvalidInput + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, resp.Status) + } + + var response MistralOCRResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + + +func (m *MistralOCRClient) convertResponse(mistralResponse *MistralOCRResponse) *domain.OCRResponse { + // Concatenate all page markdown with form feed separators + var fullText strings.Builder + for i, page := range mistralResponse.Pages { + if i > 0 { + fullText.WriteString("\f") // Page separator + } + fullText.WriteString(page.Markdown) + } + + // Calculate confidence based on content quality + confidence := m.calculateConfidence(fullText.String(), len(mistralResponse.Pages)) + + return &domain.OCRResponse{ + Text: fullText.String(), + Pages: len(mistralResponse.Pages), + Confidence: confidence, + } +} + +func (m *MistralOCRClient) calculateConfidence(text string, pages int) float32 { + if len(text) == 0 { + return 0.0 + } + + // Base confidence for Mistral OCR + confidence := float32(0.90) + + // Adjust based on text length (more text usually means better OCR) + textLength := len(text) + if textLength > 1000 { + confidence += 0.05 + } + if textLength > 5000 { + confidence += 0.03 + } + + // Multi-page documents might have slightly lower confidence + if pages > 5 { + confidence -= 0.02 + } + + // Cap at 1.0 + if confidence > 1.0 { + confidence = 1.0 + } + + return confidence +} \ No newline at end of file diff --git a/go-b2b-starter/src/pkg/ocr/infra/mock_ocr_client.go b/go-b2b-starter/src/pkg/ocr/infra/mock_ocr_client.go new file mode 100644 index 0000000..464f7df --- /dev/null +++ b/go-b2b-starter/src/pkg/ocr/infra/mock_ocr_client.go @@ -0,0 +1,96 @@ +package infra + +import ( + "context" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/pkg/ocr/domain" + loggerDomain "github.com/moasq/go-b2b-starter/pkg/logger/domain" +) + +// MockOCRClient is a mock implementation for development/testing +// In production, this would be replaced with actual Google Vision client +type MockOCRClient struct { + config Config + logger loggerDomain.Logger +} + +func NewMockOCRClient(config Config, logger loggerDomain.Logger) (domain.OCRService, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &MockOCRClient{ + config: config, + logger: logger, + }, nil +} + +func (m *MockOCRClient) ExtractText(ctx context.Context, base64File string, mimeType string) (*domain.OCRResponse, error) { + m.logger.Info("Mock OCR extraction starting", map[string]any{ + "mime_type": mimeType, + }) + + // Simulate processing time + time.Sleep(100 * time.Millisecond) + + // Mock extracted text based on file type + var mockText string + var pages int = 1 + + if mimeType == "application/pdf" { + pages = 2 + mockText = `INVOICE +Invoice Number: INV-2024-001 +Date: January 15, 2024 + +Bill To: +ABC Company +123 Main Street +City, State 12345 + +Description Qty Unit Price Total +Professional Services 10 $150.00 $1,500.00 +Consulting 5 $200.00 $1,000.00 + + Subtotal: $2,500.00 + Tax: $250.00 + Total: $2,750.00 + +Payment Terms: Net 30 +Due Date: February 15, 2024` + } else if strings.HasPrefix(mimeType, "image/") { + mockText = `RECEIPT +Store: Tech Solutions Inc. +Date: 2024-01-10 +Receipt #: R-789456 + +Items: +- Software License $299.99 +- Support Package $99.99 + +Subtotal: $399.98 +Tax: $32.00 +Total: $431.98 + +Thank you for your business!` + } else { + return nil, domain.ErrUnsupportedFile + } + + response := &domain.OCRResponse{ + Text: mockText, + Pages: pages, + Confidence: 0.95, + } + + m.logger.Info("Mock OCR extraction completed", map[string]any{ + "pages": pages, + "text_length": len(mockText), + "confidence": response.Confidence, + }) + + return response, nil +} + diff --git a/go-b2b-starter/src/pkg/paywall/README.md b/go-b2b-starter/src/pkg/paywall/README.md new file mode 100644 index 0000000..0be4958 --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/README.md @@ -0,0 +1,417 @@ +# Paywall Middleware Package + +Provider-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. + +## Key Concept: Separation of Concerns + +This package ONLY handles **access gating** (the "can they use this feature?" question). It does NOT manage subscriptions directly. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PAYWALL (This Package) │ +│ │ +│ "Can this organization access premium features right now?" │ +│ │ +│ - Reads subscription status from LOCAL DATABASE │ +│ - Makes NO external API calls (fast, reliable) │ +│ - Returns 402 if payment required │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ BILLING MODULE (app/billing) │ +│ │ +│ "Manage subscription lifecycle via webhooks" │ +│ │ +│ - Processes Polar.sh webhooks (subscription created, updated, canceled) │ +│ - Updates LOCAL DATABASE with subscription state │ +│ - Tracks quotas and usage │ +│ - No direct API calls during request handling │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Architecture: Event-Driven Integration + +The paywall middleware and billing module are decoupled via **event-driven architecture**: + +``` +┌─────────────┐ webhook ┌─────────────────┐ writes ┌─────────────┐ +│ Polar.sh │ ─────────► │ Billing Module │ ─────────► │ Local DB │ +└─────────────┘ └─────────────────┘ └─────────────┘ + │ + reads │ + ▼ +┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Request │ ─────────► │ Paywall │ ◄───────── │ Local DB │ +└─────────────┘ └─────────────────┘ └─────────────┘ + │ + ▼ + Pass (200) or Block (402) +``` + +**Why Event-Driven?** +- No external API calls during request handling (fast responses) +- Billing provider outage doesn't block your users +- Clean separation between access control and subscription management +- Easy to swap billing providers without touching access logic + +## Request Flow + +``` +HTTP Request + │ + ▼ +┌────────────────┐ +│ Auth Middleware│ ── Verify JWT, extract Identity +└────────────────┘ + │ + ▼ +┌────────────────┐ +│ Org Middleware │ ── Resolve OrganizationID from Identity +└────────────────┘ + │ + ▼ +┌────────────────┐ +│Paywall Middleware│ ── Check subscription status (LOCAL DB) +└────────────────┘ + │ + ├── Active? ──► Handler ──► 200 OK + │ + └── Inactive? ──► 402 Payment Required +``` + +## Usage + +### 1. Setup (Already configured in init_mods.go) + +```go +import ( + "github.com/moasq/go-b2b-starter/pkg/paywall" +) + +// Setup middleware (after billing module is initialized) +if err := paywall.SetupMiddleware(container); err != nil { + panic(err) +} + +// Register named middlewares for route configuration +if err := paywall.RegisterNamedMiddlewares(container); err != nil { + panic(err) +} +``` + +### 2. Protecting Routes + +**Using Named Middleware (Recommended):** + +```go +// In routes.go +func (r *Routes) Routes(router *gin.RouterGroup, resolver serverDomain.MiddlewareResolver) { + // Premium features - require active subscription + premiumGroup := router.Group("/premium") + premiumGroup.Use( + resolver.Get("auth"), // Verify JWT + resolver.Get("org_context"), // Resolve org/account IDs + resolver.Get("paywall"), // Block if no active subscription + ) + { + premiumGroup.POST("/ai/generate", r.handler.Generate) + premiumGroup.POST("/reports/export", r.handler.Export) + } + + // Basic features - auth only, no paywall + basicGroup := router.Group("/basic") + basicGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + // No paywall - allow access to fix billing issues + ) + { + basicGroup.GET("/billing/status", r.handler.GetBillingStatus) + basicGroup.POST("/billing/portal", r.handler.CreatePortalSession) + } +} +``` + +**Direct Middleware Usage:** + +```go +// Get middleware from DI container +var paywallMiddleware *paywall.Middleware +container.Invoke(func(m *paywall.Middleware) { + paywallMiddleware = m +}) + +// Apply to routes +router.Use(paywallMiddleware.RequireActiveSubscription()) +``` + +### 3. Accessing Subscription Status in Handlers + +```go +func (h *Handler) MyHandler(c *gin.Context) { + // Safe get - returns nil if not set + status := paywall.GetSubscriptionStatus(c) + if status != nil { + log.Printf("Org %d status: %s", status.OrganizationID, status.Status) + } + + // Quick boolean check + if paywall.IsSubscriptionActive(c) { + // Show premium features + } + + // Must get - panics if not set (use after RequireActiveSubscription) + status := paywall.MustGetSubscriptionStatus(c) +} +``` + +### 4. Optional Status Check (No Blocking) + +When you want to know status without blocking access: + +```go +// Show "upgrade" prompts to free users +dashboardGroup.Use( + resolver.Get("auth"), + resolver.Get("org_context"), + resolver.Get("paywall_optional"), // Sets status, doesn't block +) +``` + +## Configuration + +```go +config := &paywall.MiddlewareConfig{ + // URL included in 402 responses for upgrading + UpgradeURL: "/billing", + + // Allow trialing subscriptions (default: true) + AllowTrialing: true, + + // Custom error handler (optional) + ErrorHandler: func(c *gin.Context, statusCode int, response *paywall.ErrorResponse) { + c.JSON(statusCode, response) + }, +} + +middleware := paywall.NewMiddleware(provider, config) +``` + +## Subscription Status Mapping + +| DB Status | IsActive | HTTP Response | +|---------------|----------|----------------------| +| `active` | true | Pass through | +| `trialing` | true | Pass through | +| `past_due` | false | 402 Payment Required | +| `canceled` | false | 402 Payment Required | +| `unpaid` | false | 402 Payment Required | +| No subscription | false | 402 Payment Required | + +## Error Response Format + +```json +{ + "error": "subscription_required", + "message": "An active subscription is required to access this feature", + "upgrade_url": "/billing", + "status": "past_due" +} +``` + +## The "Swiss Cheese" Strategy + +Not all routes should require a subscription. Allow users to fix billing issues: + +**Protected (require paywall):** +- AI/ML features +- OCR processing +- Report generation +- Premium API endpoints +- Advanced analytics + +**Unprotected (auth only):** +- Billing status and portal +- Account settings +- Profile management +- Subscription upgrade flow +- Webhooks +- Public pages + +## Payment Verification Flow ("Verification on Redirect") + +When 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**. + +### Frontend Integration + +**1. Configure Polar Success URL:** + +Set your Polar checkout success URL to: +``` +https://yoursaas.com/payment/success?session_id={CHECKOUT_SESSION_ID} +``` + +Polar will replace `{CHECKOUT_SESSION_ID}` with the actual session ID. + +**2. Handle Redirect (Frontend):** + +```javascript +// On /payment/success page +const params = new URLSearchParams(window.location.search); +const sessionId = params.get('session_id'); + +if (sessionId) { + // Show loading spinner + const response = await fetch('/api/subscriptions/verify-payment', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ session_id: sessionId }) + }); + + if (response.ok) { + // Payment verified! Redirect to dashboard + window.location.href = '/dashboard'; + } else { + // Show error message + const error = await response.json(); + showError(error.message); + } +} +``` + +**3. Expected Response:** + +```json +{ + "organization_id": 123, + "has_active_subscription": true, + "can_process_invoices": true, + "invoice_count": 100, + "reason": "Payment verified successfully", + "checked_at": "2025-12-12T10:30:00Z" +} +``` + +### Backend Verification Endpoint + +Endpoint: `POST /api/subscriptions/verify-payment` + +**Request:** +```json +{ + "session_id": "cs_test_xxx" +} +``` + +**Flow:** +1. Fetch checkout session from Polar API +2. Verify status is "succeeded" +3. Extract customer_id and map to organization +4. Fetch full subscription details from Polar +5. Update local database (subscription + quota) +6. Return updated billing status + +**Error Codes:** +- `400` - Checkout session failed or expired +- `404` - Checkout session not found +- `500` - Internal error (failed to sync) + +### Lazy Guarding (Renewal Webhooks) + +For monthly renewals, the middleware includes **lazy guarding** to handle missed webhooks: + +**How It Works:** + +1. User makes request → Middleware checks DB status +2. If DB says "expired" BUT subscription exists: + - Middleware calls Polar API to refresh status + - If Polar says "active" → Grant access and update DB + - If Polar says "inactive" → Block access (truly expired) + +```go +// Automatic in paywall middleware - no configuration needed +if !status.IsActive && status.Status != StatusNone { + freshStatus, err := provider.RefreshSubscriptionStatus(ctx, orgID) + if err == nil && freshStatus.IsActive { + // Webhook was missed! Allow access + status = freshStatus + } +} +``` + +**Benefits:** +- Self-healing: Missed webhooks don't lock out paying users +- Fast: Only checks API when DB says inactive (edge case) +- Reliable: Users always get access if they've paid + +### Architecture Overview + +| Scenario | Primary Mechanism | Fallback Mechanism | +|----------|-------------------|-------------------| +| **User Just Paid** | **Verification on Redirect** (Frontend → Backend → Polar API) | Webhook (processed if arrives later) | +| **Monthly Renewal** | **Webhooks** (Standard processing) | **Lazy Guarding** (Middleware checks API if DB says expired) | + +**Success Metrics:** +- Payment to access time: ~5 seconds (verification) vs. ~minutes (webhook only) +- Webhook miss recovery: Automatic via lazy guarding +- User complaints: Eliminated "I paid but still locked out" issues + +## Implementing SubscriptionStatusProvider + +The middleware requires a `SubscriptionStatusProvider` implementation. This is provided by the billing module: + +```go +// Interface defined in this package +type SubscriptionStatusProvider interface { + GetSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error) +} + +// Implemented in app/billing/infra/adapters/status_provider.go +type StatusProviderAdapter struct { + service services.BillingService +} + +func (a *StatusProviderAdapter) GetSubscriptionStatus(ctx context.Context, orgID int32) (*paywall.SubscriptionStatus, error) { + billingStatus, err := a.service.GetBillingStatus(ctx, orgID) + if err != nil { + return nil, err + } + return &paywall.SubscriptionStatus{ + OrganizationID: orgID, + Status: billingStatus.SubscriptionStatus, + IsActive: billingStatus.HasActiveSubscription, + // ... + }, nil +} +``` + +## Named Middleware Reference + +| Name | Function | Description | +|-----------------------|-----------------------------|--------------------------------| +| `paywall` | RequireActiveSubscription | Block if no active subscription| +| `paywall_optional` | OptionalSubscriptionStatus | Set status, don't block | +| `subscription` (legacy)| RequireActiveSubscription | Deprecated, use `paywall` | + +## Files + +``` +src/pkg/paywall/ +├── subscription.go # Core types and SubscriptionStatusProvider interface +├── middleware.go # Gin middleware (RequireActiveSubscription) +├── context.go # Context helpers (Get/Set SubscriptionStatus) +├── errors.go # Error types (ErrNoSubscription, etc.) +├── provider.go # DI registration and named middleware +└── README.md # This file +``` + +## Related: Billing Module + +See `app/billing/README.md` for: +- Polar.sh webhook integration +- Subscription lifecycle management +- Quota tracking +- Event-driven architecture details diff --git a/go-b2b-starter/src/pkg/paywall/cmd/init.go b/go-b2b-starter/src/pkg/paywall/cmd/init.go new file mode 100644 index 0000000..597f7a8 --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/cmd/init.go @@ -0,0 +1,49 @@ +// Package cmd provides initialization for the paywall module. +package cmd + +import ( + "fmt" + + "github.com/moasq/go-b2b-starter/pkg/paywall" + "go.uber.org/dig" +) + +// InitMiddleware initializes the paywall middleware. +// +// This must be called after the billing module is initialized, +// as it depends on the SubscriptionStatusProvider from that module. +// +// # Prerequisites +// +// The following must be available in the container: +// - paywall.SubscriptionStatusProvider (from app/billing module) +// +// # Usage +// +// // After billing module init: +// if err := paywallCmd.InitMiddleware(container); err != nil { +// panic(err) +// } +func InitMiddleware(container *dig.Container) error { + if err := paywall.SetupMiddleware(container); err != nil { + return fmt.Errorf("failed to setup paywall middleware: %w", err) + } + return nil +} + +// InitMiddlewareWithConfig initializes the paywall middleware with custom configuration. +// +// # Usage +// +// config := &paywall.MiddlewareConfig{ +// UpgradeURL: "/settings/billing", +// } +// if err := paywallCmd.InitMiddlewareWithConfig(container, config); err != nil { +// panic(err) +// } +func InitMiddlewareWithConfig(container *dig.Container, config *paywall.MiddlewareConfig) error { + if err := paywall.SetupMiddlewareWithConfig(container, config); err != nil { + return fmt.Errorf("failed to setup paywall middleware: %w", err) + } + return nil +} diff --git a/go-b2b-starter/src/pkg/paywall/context.go b/go-b2b-starter/src/pkg/paywall/context.go new file mode 100644 index 0000000..e99a059 --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/context.go @@ -0,0 +1,93 @@ +package paywall + +import ( + "context" + + "github.com/gin-gonic/gin" +) + +// Context keys for storing subscription data. +// Using unexported type to prevent collisions with other packages. +type contextKey string + +const ( + // subscriptionStatusKey is the context key for storing the SubscriptionStatus. + subscriptionStatusKey contextKey = "subscription_status" +) + +// SetSubscriptionStatus stores the SubscriptionStatus in the Gin context. +// +// This is called by the RequireActiveSubscription middleware after checking +// subscription status. Application code should not call this directly. +func SetSubscriptionStatus(c *gin.Context, status *SubscriptionStatus) { + c.Set(string(subscriptionStatusKey), status) +} + +// GetSubscriptionStatus retrieves the SubscriptionStatus from the Gin context. +// +// Returns nil if no subscription status is set (middleware not applied). +// Use MustGetSubscriptionStatus if you expect subscription middleware to have run. +// +// Example: +// +// status := subscription.GetSubscriptionStatus(c) +// if status == nil || !status.IsActive { +// // Handle inactive subscription +// } +func GetSubscriptionStatus(c *gin.Context) *SubscriptionStatus { + if val, exists := c.Get(string(subscriptionStatusKey)); exists { + if status, ok := val.(*SubscriptionStatus); ok { + return status + } + } + return nil +} + +// MustGetSubscriptionStatus retrieves the SubscriptionStatus from the Gin context. +// +// Panics if no subscription status is set. Only use this after subscription middleware. +// For handlers where subscription status is optional, use GetSubscriptionStatus instead. +func MustGetSubscriptionStatus(c *gin.Context) *SubscriptionStatus { + status := GetSubscriptionStatus(c) + if status == nil { + panic("subscription: MustGetSubscriptionStatus called without SubscriptionStatus in context - ensure RequireActiveSubscription middleware is applied") + } + return status +} + +// IsSubscriptionActive is a convenience function to check if the subscription is active. +// +// Returns false if no subscription status is set or if the subscription is inactive. +// Use this for quick checks in handlers. +// +// Example: +// +// if !subscription.IsSubscriptionActive(c) { +// // Handle inactive subscription +// } +func IsSubscriptionActive(c *gin.Context) bool { + if status := GetSubscriptionStatus(c); status != nil { + return status.IsActive + } + return false +} + +// WithSubscriptionStatus adds the SubscriptionStatus to a context.Context. +// +// This is useful for passing subscription context through service layers +// that don't use Gin context directly. +func WithSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) context.Context { + return context.WithValue(ctx, subscriptionStatusKey, status) +} + +// SubscriptionStatusFromContext retrieves the SubscriptionStatus from a context.Context. +// +// Returns nil if no subscription status is set. +func SubscriptionStatusFromContext(ctx context.Context) *SubscriptionStatus { + if val := ctx.Value(subscriptionStatusKey); val != nil { + if status, ok := val.(*SubscriptionStatus); ok { + return status + } + } + return nil +} diff --git a/go-b2b-starter/src/pkg/paywall/errors.go b/go-b2b-starter/src/pkg/paywall/errors.go new file mode 100644 index 0000000..68cdf02 --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/errors.go @@ -0,0 +1,79 @@ +package paywall + +import "errors" + +// Subscription errors. +// +// These errors are returned by the subscription package and can be checked +// by application code to handle specific error cases. +var ( + // ErrNoSubscription is returned when no subscription exists for the organization. + // HTTP status: 402 Payment Required + ErrNoSubscription = errors.New("no subscription found") + + // ErrSubscriptionInactive is returned when the subscription exists but is not active. + // This includes statuses like "past_due", "canceled", "unpaid". + // HTTP status: 402 Payment Required + ErrSubscriptionInactive = errors.New("subscription is not active") + + // ErrSubscriptionExpired is returned when the subscription's billing period has ended. + // HTTP status: 402 Payment Required + ErrSubscriptionExpired = errors.New("subscription has expired") + + // ErrSubscriptionCanceled is returned when the subscription has been explicitly canceled. + // HTTP status: 402 Payment Required + ErrSubscriptionCanceled = errors.New("subscription has been canceled") + + // ErrPaymentFailed is returned when the subscription payment has failed. + // HTTP status: 402 Payment Required + ErrPaymentFailed = errors.New("subscription payment failed") + + // ErrMissingOrganization is returned when organization ID is not in context. + // This means RequireOrganization middleware hasn't run. + // HTTP status: 500 Internal Server Error (misconfigured middleware) + ErrMissingOrganization = errors.New("organization context required") +) + +// IsPaymentRequiredError returns true if the error requires payment (402). +func IsPaymentRequiredError(err error) bool { + return errors.Is(err, ErrNoSubscription) || + errors.Is(err, ErrSubscriptionInactive) || + errors.Is(err, ErrSubscriptionExpired) || + errors.Is(err, ErrSubscriptionCanceled) || + errors.Is(err, ErrPaymentFailed) +} + +// HTTPStatusCode returns the appropriate HTTP status code for a subscription error. +// +// Returns: +// - 402 for payment-related errors +// - 500 for configuration errors +func HTTPStatusCode(err error) int { + if IsPaymentRequiredError(err) { + return 402 + } + if errors.Is(err, ErrMissingOrganization) { + return 500 + } + return 500 +} + +// ErrorResponse represents the JSON error response for subscription errors. +// +// This is used by the middleware to return a consistent error format +// that includes helpful information for the client. +type ErrorResponse struct { + // Error is the error code (e.g., "subscription_required", "payment_failed") + Error string `json:"error"` + + // Message is a human-readable description of the error. + Message string `json:"message"` + + // UpgradeURL is the URL where the user can update their subscription. + // Optional - only included when configured. + UpgradeURL string `json:"upgrade_url,omitempty"` + + // Status is the subscription status that caused the error. + // Optional - helps the client understand the specific issue. + Status string `json:"status,omitempty"` +} diff --git a/go-b2b-starter/src/pkg/paywall/go.mod b/go-b2b-starter/src/pkg/paywall/go.mod new file mode 100644 index 0000000..e5f222a --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/go.mod @@ -0,0 +1,47 @@ +module github.com/moasq/go-b2b-starter/pkg/paywall + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.10.1 + github.com/moasq/go-b2b-starter/pkg/auth v0.0.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/moasq/go-b2b-starter/pkg/auth => ../auth diff --git a/go-b2b-starter/src/pkg/paywall/go.sum b/go-b2b-starter/src/pkg/paywall/go.sum new file mode 100644 index 0000000..319cdbc --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/go.sum @@ -0,0 +1,86 @@ +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic v1.12.5/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/pkg/paywall/middleware.go b/go-b2b-starter/src/pkg/paywall/middleware.go new file mode 100644 index 0000000..ab52eb5 --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/middleware.go @@ -0,0 +1,226 @@ +package paywall + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/moasq/go-b2b-starter/pkg/auth" +) + +// MiddlewareConfig configures the subscription middleware behavior. +type MiddlewareConfig struct { + // ErrorHandler is called when subscription check fails. + // If nil, default JSON responses are used. + ErrorHandler func(c *gin.Context, statusCode int, response *ErrorResponse) + + // UpgradeURL is the URL to include in error responses for upgrading subscription. + // Example: "/billing" or "https://app.example.com/billing" + UpgradeURL string + + // AllowTrialing determines if trialing subscriptions are allowed. + // Default: true (trialing is allowed) + AllowTrialing bool +} + +// DefaultMiddlewareConfig returns the default middleware configuration. +func DefaultMiddlewareConfig() *MiddlewareConfig { + return &MiddlewareConfig{ + ErrorHandler: defaultErrorHandler, + UpgradeURL: "/billing", + AllowTrialing: true, + } +} + +// defaultErrorHandler sends JSON error responses. +func defaultErrorHandler(c *gin.Context, statusCode int, response *ErrorResponse) { + c.JSON(statusCode, response) +} + +// Middleware provides subscription middleware functions. +// +// Use NewMiddleware to create an instance with proper dependencies. +type Middleware struct { + provider SubscriptionStatusProvider + config *MiddlewareConfig +} + +// NewMiddleware creates a new subscription middleware instance. +// +// Parameters: +// - provider: The subscription status provider (implements SubscriptionStatusProvider) +// - config: Middleware configuration (optional, uses defaults if nil) +func NewMiddleware(provider SubscriptionStatusProvider, config *MiddlewareConfig) *Middleware { + if config == nil { + config = DefaultMiddlewareConfig() + } + if config.ErrorHandler == nil { + config.ErrorHandler = defaultErrorHandler + } + return &Middleware{ + provider: provider, + config: config, + } +} + +// RequireActiveSubscription returns middleware that checks subscription status. +// +// This middleware: +// 1. Gets OrganizationID from auth context (requires RequireOrganization to run first) +// 2. Checks subscription status from the SubscriptionStatusProvider +// 3. Sets SubscriptionStatus in Gin context if active +// 4. Returns 402 Payment Required if subscription is not active +// +// Must be called AFTER auth.RequireOrganization middleware. +// +// Usage: +// +// router.Use(authMiddleware.RequireAuth()) +// router.Use(authMiddleware.RequireOrganization()) +// router.Use(subscriptionMiddleware.RequireActiveSubscription()) +func (m *Middleware) RequireActiveSubscription() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip OPTIONS requests (CORS preflight) + if c.Request.Method == "OPTIONS" { + c.Next() + return + } + + // Get organization ID from auth context + orgID := auth.GetOrganizationID(c) + if orgID == 0 { + m.config.ErrorHandler(c, http.StatusInternalServerError, &ErrorResponse{ + Error: "configuration_error", + Message: "Organization context required - ensure RequireOrganization middleware is applied", + }) + c.Abort() + return + } + + // Check subscription status from database (fast read) + status, err := m.provider.GetSubscriptionStatus(c.Request.Context(), orgID) + if err != nil { + // No subscription found + m.config.ErrorHandler(c, http.StatusPaymentRequired, &ErrorResponse{ + Error: "subscription_required", + Message: "An active subscription is required to access this feature", + UpgradeURL: m.config.UpgradeURL, + Status: StatusNone, + }) + c.Abort() + return + } + + // Lazy Guarding: If DB says inactive BUT subscription exists (not "none"), + // double-check with payment provider in case we missed a webhook + if !status.IsActive && status.Status != StatusNone { + // Attempt to refresh subscription status from provider + freshStatus, refreshErr := m.provider.RefreshSubscriptionStatus(c.Request.Context(), orgID) + + if refreshErr == nil && freshStatus != nil && freshStatus.IsActive { + // Webhook was missed! Provider says active, update our status + status = freshStatus + // Log this occurrence for monitoring + // Console log for lazy guard activation + fmt.Printf("🔄 LAZY GUARD ACTIVATED - Org: %d | DB said: %s | Provider says: %s | Access granted\n", + orgID, status.Status, freshStatus.Status) + } + // If refresh fails or still inactive, continue with original status + } + + // Check if subscription is active (after potential refresh) + if !status.IsActive { + response := m.buildErrorResponse(status) + m.config.ErrorHandler(c, http.StatusPaymentRequired, response) + c.Abort() + return + } + + // Set subscription status in context for downstream handlers + SetSubscriptionStatus(c, status) + + c.Next() + } +} + +// buildErrorResponse creates an appropriate error response based on subscription status. +func (m *Middleware) buildErrorResponse(status *SubscriptionStatus) *ErrorResponse { + response := &ErrorResponse{ + UpgradeURL: m.config.UpgradeURL, + Status: status.Status, + } + + switch status.Status { + case StatusPastDue: + response.Error = "payment_failed" + response.Message = "Your subscription payment has failed. Please update your payment method." + case StatusCanceled: + response.Error = "subscription_canceled" + response.Message = "Your subscription has been canceled. Please resubscribe to continue." + case StatusUnpaid: + response.Error = "payment_required" + response.Message = "Your subscription is unpaid. Please update your payment method." + default: + response.Error = "subscription_inactive" + response.Message = "An active subscription is required to access this feature" + if status.Reason != "" { + response.Message = status.Reason + } + } + + return response +} + +// RequireActiveSubscriptionFunc is a standalone middleware function. +// +// This is a convenience function that doesn't require a Middleware instance. +// It uses the default configuration for error handling. +// +// Usage: +// +// router.GET("/ai/generate", +// subscription.RequireActiveSubscriptionFunc(provider), +// handler) +func RequireActiveSubscriptionFunc(provider SubscriptionStatusProvider) gin.HandlerFunc { + m := NewMiddleware(provider, nil) + return m.RequireActiveSubscription() +} + +// OptionalSubscriptionStatus returns middleware that checks subscription status +// but doesn't block the request if the subscription is inactive. +// +// This middleware: +// 1. Gets OrganizationID from auth context +// 2. Checks subscription status from the SubscriptionStatusProvider +// 3. Sets SubscriptionStatus in Gin context (active or not) +// 4. Always continues to the next handler +// +// Use this when you want to know subscription status but allow access regardless. +// Handlers can then check subscription.GetSubscriptionStatus(c) to adjust behavior. +// +// Example use case: Show "upgrade" prompts to free users while still allowing access. +func (m *Middleware) OptionalSubscriptionStatus() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip OPTIONS requests (CORS preflight) + if c.Request.Method == "OPTIONS" { + c.Next() + return + } + + // Get organization ID from auth context + orgID := auth.GetOrganizationID(c) + if orgID == 0 { + // No org context, continue without subscription info + c.Next() + return + } + + // Check subscription status (ignore errors, just set status if available) + status, err := m.provider.GetSubscriptionStatus(c.Request.Context(), orgID) + if err == nil && status != nil { + SetSubscriptionStatus(c, status) + } + + c.Next() + } +} diff --git a/go-b2b-starter/src/pkg/paywall/provider.go b/go-b2b-starter/src/pkg/paywall/provider.go new file mode 100644 index 0000000..ff0dc9d --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/provider.go @@ -0,0 +1,103 @@ +package paywall + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "go.uber.org/dig" +) + +// ServerMiddlewareRegistrar is the interface for registering named middleware. +// This matches the server.Server interface's RegisterNamedMiddleware method. +type ServerMiddlewareRegistrar interface { + RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) +} + +// SetupMiddleware wires the subscription middleware into the DI container. +// +// This must be called after the SubscriptionStatusProvider is available. +// +// # Prerequisites +// +// The following must be available in the container: +// - subscription.SubscriptionStatusProvider +// +// # Usage +// +// if err := subscription.SetupMiddleware(container); err != nil { +// return err +// } +func SetupMiddleware(container *dig.Container) error { + if err := container.Provide(func( + provider SubscriptionStatusProvider, + ) *Middleware { + return NewMiddleware(provider, nil) + }); err != nil { + return fmt.Errorf("failed to provide subscription middleware: %w", err) + } + + return nil +} + +// SetupMiddlewareWithConfig wires the subscription middleware with custom configuration. +// +// # Usage +// +// config := &subscription.MiddlewareConfig{ +// UpgradeURL: "/settings/billing", +// } +// if err := subscription.SetupMiddlewareWithConfig(container, config); err != nil { +// return err +// } +func SetupMiddlewareWithConfig(container *dig.Container, config *MiddlewareConfig) error { + if err := container.Provide(func( + provider SubscriptionStatusProvider, + ) *Middleware { + return NewMiddleware(provider, config) + }); err != nil { + return fmt.Errorf("failed to provide subscription middleware: %w", err) + } + + return nil +} + +// RegisterNamedMiddlewares registers the paywall middleware functions with the server. +// +// This should be called after SetupMiddleware and the server is available. +// It registers the following named middlewares: +// - "paywall": RequireActiveSubscription middleware (blocks if inactive) +// - "paywall_optional": OptionalSubscriptionStatus middleware (sets status, no blocking) +// +// For backward compatibility, these legacy names are also registered: +// - "subscription" (deprecated, use "paywall") +// - "subscription_optional" (deprecated, use "paywall_optional") +// +// # Usage +// +// if err := paywall.RegisterNamedMiddlewares(container); err != nil { +// return err +// } +func RegisterNamedMiddlewares(container *dig.Container) error { + return container.Invoke(func( + middleware *Middleware, + server ServerMiddlewareRegistrar, + ) { + // Register paywall middleware (requires active subscription) + server.RegisterNamedMiddleware("paywall", func() gin.HandlerFunc { + return middleware.RequireActiveSubscription() + }) + + // Register optional paywall middleware (sets status but doesn't block) + server.RegisterNamedMiddleware("paywall_optional", func() gin.HandlerFunc { + return middleware.OptionalSubscriptionStatus() + }) + + // Backward compatibility: legacy names (deprecated) + server.RegisterNamedMiddleware("subscription", func() gin.HandlerFunc { + return middleware.RequireActiveSubscription() + }) + server.RegisterNamedMiddleware("subscription_optional", func() gin.HandlerFunc { + return middleware.OptionalSubscriptionStatus() + }) + }) +} diff --git a/go-b2b-starter/src/pkg/paywall/subscription.go b/go-b2b-starter/src/pkg/paywall/subscription.go new file mode 100644 index 0000000..ed01e83 --- /dev/null +++ b/go-b2b-starter/src/pkg/paywall/subscription.go @@ -0,0 +1,158 @@ +// Package paywall provides access gating middleware for B2B SaaS applications. +// +// This package abstracts away the billing provider (Polar, Stripe, Paddle, etc.) +// and provides a clean interface for checking subscription status before allowing +// access to protected resources. It acts as a "payment bouncer" - checking if an +// organization has an active subscription before granting access to premium features. +// +// # Architecture +// +// The paywall package follows the adapter pattern, similar to the auth package: +// +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Application Layer │ +// │ (handlers, services - use paywall.GetSubscriptionStatus) │ +// └─────────────────────────────────────────────────────────────────┘ +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ paywall package │ +// │ • SubscriptionStatusProvider interface │ +// │ • SubscriptionStatus (provider-agnostic status) │ +// │ • Middleware (RequireActiveSubscription) │ +// │ • Type-safe context helpers │ +// └─────────────────────────────────────────────────────────────────┘ +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ app/billing module adapter │ +// │ (Polar/Stripe-specific - hidden from app layer) │ +// └─────────────────────────────────────────────────────────────────┘ +// +// # Event-Driven Integration +// +// The paywall package does NOT manage subscriptions directly. It only reads +// subscription status from local database. The billing module (app/billing) +// handles subscription lifecycle via webhooks and events: +// +// - Polar/Stripe sends webhook → billing module processes it +// - billing module updates local DB → paywall reads from DB +// - No direct coupling between paywall and billing providers +// +// # Usage +// +// In routes: +// +// paywallMiddleware := paywall.NewMiddleware(provider, nil) +// router.Use( +// auth.RequireAuth(authProvider), +// auth.RequireOrganization(orgRepo, accountRepo), +// paywallMiddleware.RequireActiveSubscription(), +// ) +// +// In handlers: +// +// func Handler(c *gin.Context) { +// status := paywall.GetSubscriptionStatus(c) +// if status != nil && status.IsActive { +// // Subscription is active +// } +// } +// +// # The "Swiss Cheese" Strategy +// +// NOT all routes should require active subscription. Users with failed payments +// need access to billing/settings to fix their payment method: +// +// - Protected routes: AI features, OCR, reports, expensive operations +// - Unprotected routes: Billing portal, settings, profile, webhooks +package paywall + +import ( + "context" + "time" +) + +// SubscriptionStatusProvider abstracts how subscription status is retrieved. +// +// The subscriptions module implements this interface. The middleware package +// doesn't know about Polar, Stripe, or any specific provider. +// +// Implementations should: +// - Read from local database only (for speed) +// - Never call external APIs during request handling +// - Return appropriate errors when subscription is missing +type SubscriptionStatusProvider interface { + // GetSubscriptionStatus checks if organization has an active subscription. + // Returns status from local database only (fast, no external API calls). + // The organizationID is the database primary key (int32). + GetSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error) + + // RefreshSubscriptionStatus forces a sync from the payment provider API. + // This is the lazy guarding mechanism - used when DB says expired but we want + // to double-check with the provider in case we missed a webhook. + // Returns updated status after syncing with provider. + RefreshSubscriptionStatus(ctx context.Context, organizationID int32) (*SubscriptionStatus, error) +} + +// SubscriptionStatus represents the organization's billing state. +// +// This is a provider-agnostic representation of subscription status. +// The status is typically synced from the payment provider via webhooks. +type SubscriptionStatus struct { + // OrganizationID is the database primary key for the organization. + OrganizationID int32 `json:"organization_id"` + + // IsActive indicates whether the subscription allows access to protected features. + // True for "active" and "trialing" statuses. + IsActive bool `json:"is_active"` + + // Status is the raw subscription status from the provider. + // Common values: "active", "trialing", "past_due", "canceled", "unpaid" + Status string `json:"status"` + + // ExpiresAt is when the current billing period ends. + // After this time, the subscription may need renewal. + ExpiresAt time.Time `json:"expires_at,omitempty"` + + // Reason provides a human-readable explanation when IsActive is false. + // Examples: "subscription expired", "payment failed", "no subscription found" + Reason string `json:"reason,omitempty"` +} + +// IsTrialing returns true if the subscription is in a trial period. +func (s *SubscriptionStatus) IsTrialing() bool { + return s.Status == StatusTrialing +} + +// IsPastDue returns true if the subscription has a failed payment. +func (s *SubscriptionStatus) IsPastDue() bool { + return s.Status == StatusPastDue +} + +// IsCanceled returns true if the subscription has been canceled. +func (s *SubscriptionStatus) IsCanceled() bool { + return s.Status == StatusCanceled +} + +// Subscription status constants. +// These map to common status values from payment providers. +const ( + StatusActive = "active" + StatusTrialing = "trialing" + StatusPastDue = "past_due" + StatusCanceled = "canceled" + StatusUnpaid = "unpaid" + StatusNone = "none" // No subscription exists +) + +// IsActiveStatus returns true if the given status represents an active subscription. +// Active statuses allow access to protected features. +func IsActiveStatus(status string) bool { + switch status { + case StatusActive, StatusTrialing: + return true + default: + return false + } +} diff --git a/go-b2b-starter/src/pkg/polar/client.go b/go-b2b-starter/src/pkg/polar/client.go new file mode 100644 index 0000000..a1ac4a5 --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/client.go @@ -0,0 +1,102 @@ +package polar + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client provides a low-level HTTP client for Polar API +// This is a generic HTTP wrapper - business logic should be in higher layers +type Client struct { + accessToken string + baseURL string + httpClient *http.Client + debug bool +} + +// NewClient creates a new Polar HTTP client +func NewClient(config *Config) (*Client, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return &Client{ + accessToken: config.AccessToken, + baseURL: config.BaseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + debug: config.Debug, + }, nil +} + +// Get performs a GET request to the Polar API +func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) { + return c.doRequest(ctx, "GET", path, nil) +} + +// Patch performs a PATCH request to the Polar API +func (c *Client) Patch(ctx context.Context, path string, body interface{}) (*http.Response, error) { + return c.doRequest(ctx, "PATCH", path, body) +} + +// Post performs a POST request to the Polar API +func (c *Client) Post(ctx context.Context, path string, body interface{}) (*http.Response, error) { + return c.doRequest(ctx, "POST", path, body) +} + +// doRequest performs an HTTP request to the Polar API +func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { + url := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set required headers + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + if c.debug { + fmt.Printf("[Polar Client] %s %s\n", method, url) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + // Check for HTTP errors + if resp.StatusCode >= 400 { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Polar API error (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + return resp, nil +} + +// DecodeJSON is a helper to decode JSON response +func DecodeJSON(resp *http.Response, v interface{}) error { + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + return fmt.Errorf("failed to decode JSON response: %w", err) + } + return nil +} diff --git a/go-b2b-starter/src/pkg/polar/cmd/init.go b/go-b2b-starter/src/pkg/polar/cmd/init.go new file mode 100644 index 0000000..25097a9 --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/cmd/init.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + + "github.com/moasq/go-b2b-starter/pkg/polar" + "go.uber.org/dig" +) + +// Init initializes the Polar package and registers dependencies +func Init(container *dig.Container) error { + // Provide Polar configuration using viper + if err := container.Provide(func() (*polar.Config, error) { + config, err := polar.LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load Polar configuration: %w", err) + } + + return &config, nil + }); err != nil { + return fmt.Errorf("failed to provide Polar config: %w", err) + } + + // Register Polar client + if err := polar.Module(container); err != nil { + return fmt.Errorf("failed to register Polar module: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/pkg/polar/config.go b/go-b2b-starter/src/pkg/polar/config.go new file mode 100644 index 0000000..aceff8a --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/config.go @@ -0,0 +1,88 @@ +package polar + +import ( + "fmt" + + "github.com/spf13/viper" +) + +// Config holds configuration for the Polar client +type Config struct { + // AccessToken is the Polar Organization Access Token (OAT) + // Required for all API requests + AccessToken string `mapstructure:"POLAR_ACCESS_TOKEN"` + + // BaseURL is the Polar API endpoint + // Use "https://api.polar.sh" for production + // Use "https://sandbox-api.polar.sh" for testing + BaseURL string `mapstructure:"POLAR_BASE_URL"` + + // WebhookSecret is the secret used to verify webhook signatures + // Get this from Polar Dashboard → Settings → Webhooks + WebhookSecret string `mapstructure:"WEBHOOK_SECRET"` + + // Debug enables debug logging + Debug bool `mapstructure:"POLAR_DEBUG"` +} + +// LoadConfig reads configuration from file or environment variables +func LoadConfig() (Config, error) { + var cfg Config + + viper.SetConfigName("app") + viper.SetConfigType("env") + viper.AddConfigPath(".") + viper.AutomaticEnv() + + // Set default values + viper.SetDefault("POLAR_BASE_URL", "https://api.polar.sh") + viper.SetDefault("POLAR_DEBUG", false) + + // Best-effort: ignore missing file, allow env-only usage + if err := viper.ReadInConfig(); err == nil { + _ = err + } + + if err := viper.Unmarshal(&cfg); err != nil { + return cfg, fmt.Errorf("unable to decode polar config: %w", err) + } + + // Validate required fields + if err := cfg.Validate(); err != nil { + return cfg, err + } + + return cfg, nil +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.AccessToken == "" { + return fmt.Errorf("polar access token is required (POLAR_ACCESS_TOKEN)") + } + + if c.BaseURL == "" { + return fmt.Errorf("polar base URL is required (POLAR_BASE_URL)") + } + + // WebhookSecret is optional - only needed for webhook verification + // If not provided, webhook signature verification will be skipped (with warning) + + return nil +} + +// DefaultConfig returns a configuration with sane defaults for production +func DefaultConfig() *Config { + return &Config{ + BaseURL: "https://api.polar.sh", + Debug: false, + } +} + +// SandboxConfig returns a configuration with defaults for sandbox environment +func SandboxConfig() *Config { + return &Config{ + BaseURL: "https://sandbox-api.polar.sh", + Debug: true, + } +} diff --git a/go-b2b-starter/src/pkg/polar/go.mod b/go-b2b-starter/src/pkg/polar/go.mod new file mode 100644 index 0000000..74132ee --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/go.mod @@ -0,0 +1,34 @@ +module github.com/moasq/go-b2b-starter/pkg/polar + +go 1.25 + + +require ( + github.com/spf13/viper v1.19.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/pkg/polar/go.sum b/go-b2b-starter/src/pkg/polar/go.sum new file mode 100644 index 0000000..2e1d00f --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-b2b-starter/src/pkg/polar/inject.go b/go-b2b-starter/src/pkg/polar/inject.go new file mode 100644 index 0000000..e9d62e4 --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/inject.go @@ -0,0 +1,17 @@ +package polar + +import ( + "fmt" + + "go.uber.org/dig" +) + +// Module registers Polar package dependencies in the DI container +func Module(container *dig.Container) error { + // Register Polar client + if err := container.Provide(NewClient); err != nil { + return fmt.Errorf("failed to provide Polar client: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/pkg/polar/webhook.go b/go-b2b-starter/src/pkg/polar/webhook.go new file mode 100644 index 0000000..cfa1949 --- /dev/null +++ b/go-b2b-starter/src/pkg/polar/webhook.go @@ -0,0 +1,110 @@ +package polar + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" +) + +// VerifyWebhookSignature verifies that a webhook request came from Polar +// by validating the HMAC-SHA256 signature using the Standard Webhooks specification +// +// Polar.sh uses the Standard Webhooks format (same as Svix) where the signed content is: +// {webhook-id}.{webhook-timestamp}.{body} +// +// Parameters: +// - secret: The webhook secret from Polar Dashboard +// - webhookID: The Webhook-Id header value +// - timestamp: The Webhook-Timestamp header value +// - payload: The raw request body (must be the exact bytes received) +// - signature: The signature from the Webhook-Signature header +// +// Returns: +// - error if verification fails, nil if successful +func VerifyWebhookSignature(secret string, webhookID string, timestamp string, payload []byte, signature string) error { + if secret == "" { + fmt.Println("DEBUG: Webhook secret is empty") + return fmt.Errorf("webhook secret is not configured") + } + + if signature == "" { + fmt.Println("DEBUG: Webhook signature is missing") + return fmt.Errorf("webhook signature is missing from request") + } + + if webhookID == "" { + fmt.Println("DEBUG: Webhook ID is missing") + return fmt.Errorf("webhook ID is missing from request") + } + + if timestamp == "" { + fmt.Println("DEBUG: Webhook timestamp is missing") + return fmt.Errorf("webhook timestamp is missing from request") + } + + fmt.Printf("DEBUG: Original signature: %s\n", signature) + fmt.Printf("DEBUG: Webhook ID: %s\n", webhookID) + fmt.Printf("DEBUG: Webhook Timestamp: %s\n", timestamp) + + // Strip version prefix (e.g., "v1,") from Polar's signature + if strings.Contains(signature, ",") { + parts := strings.Split(signature, ",") + if len(parts) == 2 { + signature = parts[1] // Get the actual signature after "v1," + fmt.Printf("DEBUG: Stripped signature: %s\n", signature) + } + } + + // Decode base64 signature to bytes (Polar sends base64-encoded HMAC) + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + fmt.Printf("DEBUG: Failed to decode base64 signature: %v\n", err) + return fmt.Errorf("failed to decode signature: %w", err) + } + + fmt.Printf("DEBUG: Signature bytes length: %d\n", len(signatureBytes)) + fmt.Printf("DEBUG: Signature bytes (hex): %x\n", signatureBytes) + + // Construct the signed content according to Standard Webhooks spec + // Format: {webhook-id}.{webhook-timestamp}.{body} + signedContent := webhookID + "." + timestamp + "." + string(payload) + fmt.Printf("DEBUG: Signed content (first 200 chars): %s\n", signedContent[:min(200, len(signedContent))]) + + // Compute HMAC-SHA256 of the signed content + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signedContent)) + expectedSignatureBytes := mac.Sum(nil) + + fmt.Printf("DEBUG: Expected signature bytes (hex): %x\n", expectedSignatureBytes) + fmt.Printf("DEBUG: Signatures match: %v\n", hmac.Equal(signatureBytes, expectedSignatureBytes)) + + // Use constant-time comparison to prevent timing attacks + if !hmac.Equal(signatureBytes, expectedSignatureBytes) { + return fmt.Errorf("webhook signature verification failed: signature mismatch") + } + + fmt.Println("DEBUG: ✓ Signature verification successful!") + return nil +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ComputeWebhookSignature computes the HMAC-SHA256 signature for a payload +// using the Standard Webhooks format +// This is useful for testing webhook signature verification +// Returns the signature in base64 format to match Polar's format +func ComputeWebhookSignature(secret string, webhookID string, timestamp string, payload []byte) string { + // Construct signed content: {webhook-id}.{webhook-timestamp}.{body} + signedContent := webhookID + "." + timestamp + "." + string(payload) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signedContent)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/go-b2b-starter/src/pkg/redis/README.md b/go-b2b-starter/src/pkg/redis/README.md new file mode 100644 index 0000000..68c1033 --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/README.md @@ -0,0 +1,172 @@ +# Redis Module Guide + +Simple guide for using Redis cache in your modules. + +## Setup + +Add to your `.env`: + +```bash +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= # Optional +REDIS_DB=0 # Default database +``` + +For local development, start Redis with Docker: +```bash +make run-deps # Starts Redis and other dependencies +``` + +## Usage in Your Module + +### 1. Inject the Redis Client + +```go +import ( + "github.com/moasq/go-b2b-starter/pkg/redis" +) + +type UserService struct { + cache redis.Client +} + +func NewUserService(cache redis.Client) *UserService { + return &UserService{cache: cache} +} +``` + +### 2. Basic Operations + +**Set a value with TTL:** +```go +func (s *UserService) CacheUser(ctx context.Context, userID string, data string) error { + key := fmt.Sprintf("user:%s", userID) + ttl := 1 * time.Hour + + return s.cache.Set(ctx, key, data, ttl) +} +``` + +**Get a value:** +```go +func (s *UserService) GetCachedUser(ctx context.Context, userID string) (string, error) { + key := fmt.Sprintf("user:%s", userID) + + value, err := s.cache.Get(ctx, key) + if err != nil { + return "", err // redis.Nil if key doesn't exist + } + + return value, nil +} +``` + +**Check if key exists:** +```go +exists, err := s.cache.Exists(ctx, "user:123") +if exists { + // Key is in cache +} +``` + +**Delete a value:** +```go +err := s.cache.Delete(ctx, "user:123") +``` + +### 3. Real-World Example: Cache-Aside Pattern + +```go +func (s *UserService) GetUser(ctx context.Context, userID int32) (*User, error) { + cacheKey := fmt.Sprintf("user:%d", userID) + + // 1. Try to get from cache first + cached, err := s.cache.Get(ctx, cacheKey) + if err == nil { + // Cache hit - deserialize and return + var user User + json.Unmarshal([]byte(cached), &user) + return &user, nil + } + + // 2. Cache miss - get from database + user, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + + // 3. Store in cache for next time + userJSON, _ := json.Marshal(user) + s.cache.Set(ctx, cacheKey, string(userJSON), 5*time.Minute) + + return user, nil +} +``` + +### 4. Invalidation on Update + +```go +func (s *UserService) UpdateUser(ctx context.Context, userID int32, updates *UserUpdates) error { + // 1. Update database + err := s.repo.UpdateUser(ctx, userID, updates) + if err != nil { + return err + } + + // 2. Invalidate cache + cacheKey := fmt.Sprintf("user:%d", userID) + s.cache.Delete(ctx, cacheKey) + + return nil +} +``` + +## Available Methods + +```go +type Client interface { + Set(ctx context.Context, key string, value any, ttl time.Duration) error + Get(ctx context.Context, key string) (string, error) + Delete(ctx context.Context, key string) error + Exists(ctx context.Context, key string) (bool, error) +} +``` + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `REDIS_HOST` | `localhost` | Redis server host | +| `REDIS_PORT` | `6379` | Redis server port | +| `REDIS_PASSWORD` | `` | Redis password (optional) | +| `REDIS_DB` | `0` | Redis database number | + +## Common Patterns + +**Session storage:** +```go +sessionKey := fmt.Sprintf("session:%s", sessionID) +s.cache.Set(ctx, sessionKey, userID, 24*time.Hour) +``` + +**Rate limiting:** +```go +key := fmt.Sprintf("ratelimit:%s", userID) +s.cache.Set(ctx, key, "1", 1*time.Minute) +``` + +**Temporary data:** +```go +key := fmt.Sprintf("temp:%s", requestID) +s.cache.Set(ctx, key, data, 5*time.Minute) +``` + +## TTL Guidelines + +- **User sessions**: 24 hours +- **API responses**: 5-15 minutes +- **Rate limit counters**: 1 minute +- **Temporary data**: 5-10 minutes + +That's it! Just inject `redis.Client` and start caching. diff --git a/go-b2b-starter/src/pkg/redis/cmd/init.go b/go-b2b-starter/src/pkg/redis/cmd/init.go new file mode 100644 index 0000000..d5540c2 --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/cmd/init.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "log" + + "go.uber.org/dig" +) + +func Init(dig *dig.Container) error { + if err := provideRedisDependencies(dig); err != nil { + log.Fatalf("Failed to provide Redis dependencies: %v", err) + return err + } + return nil +} diff --git a/go-b2b-starter/src/pkg/redis/cmd/provider.go b/go-b2b-starter/src/pkg/redis/cmd/provider.go new file mode 100644 index 0000000..36810ef --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/cmd/provider.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + + "github.com/moasq/go-b2b-starter/pkg/redis" + "go.uber.org/dig" +) + +func provideRedisDependencies(container *dig.Container) error { + providers := []any{ + redis.LoadConfig, + provideRedisStore, + } + + for _, provider := range providers { + if err := container.Provide(provider); err != nil { + return fmt.Errorf("failed to provide Redis dependency: %w", err) + } + } + + return nil +} + +func provideRedisStore() (redis.Client, error) { + return redis.InitRedis() +} diff --git a/go-b2b-starter/src/pkg/redis/config.go b/go-b2b-starter/src/pkg/redis/config.go new file mode 100644 index 0000000..7a4382d --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/config.go @@ -0,0 +1,38 @@ +package redis + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Host string `mapstructure:"REDIS_HOST"` + Port string `mapstructure:"REDIS_PORT"` + Password string `mapstructure:"REDIS_PASSWORD"` + DB int `mapstructure:"REDIS_DB"` +} + +// LoadConfig reads configuration from file or environment variables. +func LoadConfig() (Config, error) { + var cfg Config + + viper.SetConfigName("app") + viper.SetConfigType("env") + viper.AddConfigPath(".") + viper.AutomaticEnv() + + // Set default values + viper.SetDefault("REDIS_HOST", "localhost") + viper.SetDefault("REDIS_PORT", "6379") + viper.SetDefault("REDIS_PASSWORD", "") + viper.SetDefault("REDIS_DB", 0) + + if err := viper.ReadInConfig(); err == nil { + _ = err + } + + if err := viper.Unmarshal(&cfg); err != nil { + return cfg, err + } + + return cfg, nil +} diff --git a/go-b2b-starter/src/pkg/redis/go.mod b/go-b2b-starter/src/pkg/redis/go.mod new file mode 100644 index 0000000..813bfde --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/go.mod @@ -0,0 +1,37 @@ +module github.com/moasq/go-b2b-starter/pkg/redis + +go 1.25 + + +require ( + github.com/redis/go-redis/v9 v9.7.0 + github.com/spf13/viper v1.19.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/pkg/redis/go.sum b/go-b2b-starter/src/pkg/redis/go.sum new file mode 100644 index 0000000..adf6f47 --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/go.sum @@ -0,0 +1,36 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-b2b-starter/src/pkg/redis/init.go b/go-b2b-starter/src/pkg/redis/init.go new file mode 100644 index 0000000..fa01cb6 --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/init.go @@ -0,0 +1,19 @@ +package redis + +import "log" + +func InitRedis() (Client, error) { + cfg, err := LoadConfig() + if err != nil { + log.Fatalf("Failed to load Redis configuration: %v", err) + return nil, err + } + + client, err := newRedisClient(cfg) + if err != nil { + log.Fatalf("Failed to initialize Redis connection: %v", err) + return nil, err + } + + return client, nil +} diff --git a/go-b2b-starter/src/pkg/redis/redis.go b/go-b2b-starter/src/pkg/redis/redis.go new file mode 100644 index 0000000..21d9a19 --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/redis.go @@ -0,0 +1,45 @@ +package redis + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +type redisClient struct { + rdb *redis.Client +} + +func newRedisClient(cfg Config) (*redisClient, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + }) + + ctx := context.Background() + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to Redis: %w", err) + } + + return &redisClient{rdb: rdb}, nil +} + +func (c *redisClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { + return c.rdb.Set(ctx, key, value, ttl).Err() +} + +func (c *redisClient) Get(ctx context.Context, key string) (string, error) { + return c.rdb.Get(ctx, key).Result() +} + +func (c *redisClient) Delete(ctx context.Context, key string) error { + return c.rdb.Del(ctx, key).Err() +} + +func (c *redisClient) Exists(ctx context.Context, key string) (bool, error) { + result, err := c.rdb.Exists(ctx, key).Result() + return result > 0, err +} diff --git a/go-b2b-starter/src/pkg/redis/store.go b/go-b2b-starter/src/pkg/redis/store.go new file mode 100644 index 0000000..6fec7d7 --- /dev/null +++ b/go-b2b-starter/src/pkg/redis/store.go @@ -0,0 +1,13 @@ +package redis + +import ( + "context" + "time" +) + +type Client interface { + Set(ctx context.Context, key string, value any, ttl time.Duration) error + Get(ctx context.Context, key string) (string, error) + Delete(ctx context.Context, key string) error + Exists(ctx context.Context, key string) (bool, error) +} diff --git a/go-b2b-starter/src/pkg/server/cmd/di.go b/go-b2b-starter/src/pkg/server/cmd/di.go new file mode 100644 index 0000000..8316276 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/cmd/di.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/gin-gonic/gin" + "github.com/moasq/go-b2b-starter/pkg/auth" + "github.com/moasq/go-b2b-starter/pkg/paywall" + "github.com/moasq/go-b2b-starter/server/config" + "github.com/moasq/go-b2b-starter/server/domain" + ginP "github.com/moasq/go-b2b-starter/server/gin" + "github.com/moasq/go-b2b-starter/server/logging" + "github.com/moasq/go-b2b-starter/server/middleware" + "go.uber.org/dig" +) + +// serverMiddlewareAdapter adapts domain.Server to auth.ServerMiddlewareRegistrar +type serverMiddlewareAdapter struct { + server domain.Server +} + +func (a *serverMiddlewareAdapter) RegisterNamedMiddleware(name string, middleware func() gin.HandlerFunc) { + // Convert func() gin.HandlerFunc to domain.MiddlewareFunc + a.server.RegisterNamedMiddleware(name, domain.MiddlewareFunc(middleware)) +} + +func SetupDependencies(container *dig.Container) { + container.Provide(config.LoadConfig) + container.Provide(logging.InitLogger) + container.Provide(middleware.InitValidator) + container.Provide(func(cfg *config.Config) *gin.Engine { + return ginP.NewGinRouter(cfg).GetHandler() + }) + container.Provide(domain.NewHTTPServer) + + // Provide server as auth.ServerMiddlewareRegistrar for auth package + container.Provide(func(srv domain.Server) auth.ServerMiddlewareRegistrar { + return &serverMiddlewareAdapter{server: srv} + }) + + // Provide server as paywall.ServerMiddlewareRegistrar for paywall package + container.Provide(func(srv domain.Server) paywall.ServerMiddlewareRegistrar { + return &serverMiddlewareAdapter{server: srv} + }) +} diff --git a/go-b2b-starter/src/pkg/server/cmd/init.go b/go-b2b-starter/src/pkg/server/cmd/init.go new file mode 100644 index 0000000..24e1998 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/cmd/init.go @@ -0,0 +1,9 @@ +package cmd + +import "go.uber.org/dig" + +func Init(container *dig.Container) { + + SetupDependencies(container) + +} diff --git a/go-b2b-starter/src/pkg/server/config/config.go b/go-b2b-starter/src/pkg/server/config/config.go new file mode 100644 index 0000000..a9418f9 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/config/config.go @@ -0,0 +1,174 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +type Environment string + +const ( + DEV Environment = "DEV" + PROD Environment = "PROD" +) + +type Config struct { + // Environment (cannot be disabled in production) + Env Environment `mapstructure:"ENV"` + + // Server settings + ServerAddress string `mapstructure:"SERVER_ADDRESS"` + + // Security settings (cannot be disabled in production) + EnableTLS bool `mapstructure:"ENABLE_TLS"` // Must be true in production + TLSCertPath string `mapstructure:"TLS_CERT_PATH"` // Required in production + TLSKeyPath string `mapstructure:"TLS_KEY_PATH"` // Required in production + + // Rate limiting (cannot be disabled in production) + RateLimitPerSecond int `mapstructure:"RATE_LIMIT_PER_SECOND"` + + // CORS settings (more restrictive in production) + AllowedOrigins []string `mapstructure:"ALLOWED_ORIGINS"` + + // Logging (always enabled in production) + LogLevel string `mapstructure:"LOG_LEVEL"` + + // Optional security features + TrustedProxies []string `mapstructure:"TRUSTED_PROXIES"` + MaxRequestSize int `mapstructure:"MAX_REQUEST_SIZE"` + + // IP Protection Settings + IPWhitelist []string `mapstructure:"IP_WHITELIST"` + IPBlacklist []string `mapstructure:"IP_BLACKLIST"` + MaxFailedAttempts int `mapstructure:"MAX_FAILED_ATTEMPTS"` + BlockDuration string `mapstructure:"BLOCK_DURATION"` + + // Request Sanitization + DisableXSS bool `mapstructure:"DISABLE_XSS"` + DisableSQLInjection bool `mapstructure:"DISABLE_SQL_INJECTION"` + DisablePathTraversal bool `mapstructure:"DISABLE_PATH_TRAVERSAL"` + + // Security Logging + SecurityLogPath string `mapstructure:"SECURITY_LOG_PATH"` + LogRetentionDays int `mapstructure:"LOG_RETENTION_DAYS"` + + // Processing Settings + ExtractionTimeoutSeconds int `mapstructure:"EXTRACTION_TIMEOUT_SECONDS"` + + // Duplicate Detection Settings + DuplicateSimilarityThreshold float64 `mapstructure:"DUPLICATE_SIMILARITY_THRESHOLD"` + DuplicateSearchLimit int32 `mapstructure:"DUPLICATE_SEARCH_LIMIT"` +} + +// SanitizationConfig represents security sanitization settings +type SanitizationConfig struct { + DisableXSS bool + DisableSQLInjection bool + DisablePathTraversal bool +} + +// GetSanitizationConfig returns sanitization configuration +func (c *Config) GetSanitizationConfig() SanitizationConfig { + return SanitizationConfig{ + DisableXSS: c.DisableXSS, + DisableSQLInjection: c.DisableSQLInjection, + DisablePathTraversal: c.DisablePathTraversal, + } +} + +func (c *Config) IsProd() bool { + return c.Env == PROD +} + +// LoadConfig reads configuration from environment variables or .env files. +func LoadConfig() (*Config, error) { + var cfg *Config + + viper.SetConfigName("app") + viper.SetConfigType("env") + viper.AddConfigPath(".") + viper.AutomaticEnv() + + // Set default values + viper.SetDefault("ENV", "DEV") + viper.SetDefault("SERVER_ADDRESS", ":8080") + viper.SetDefault("RATE_LIMIT_PER_SECOND", 100) + viper.SetDefault("MAX_REQUEST_SIZE", 1024*1024*10) // 10MB + viper.SetDefault("LOG_LEVEL", "info") + viper.SetDefault("MAX_FAILED_ATTEMPTS", 5) + viper.SetDefault("BLOCK_DURATION", "15m") + viper.SetDefault("DISABLE_XSS", false) + viper.SetDefault("DISABLE_SQL_INJECTION", false) + viper.SetDefault("DISABLE_PATH_TRAVERSAL", false) + viper.SetDefault("SECURITY_LOG_PATH", "logs/security.log") + viper.SetDefault("LOG_RETENTION_DAYS", 30) + viper.SetDefault("EXTRACTION_TIMEOUT_SECONDS", 60) + viper.SetDefault("DUPLICATE_SIMILARITY_THRESHOLD", 0.85) + viper.SetDefault("DUPLICATE_SEARCH_LIMIT", 10) + + if err := viper.ReadInConfig(); err != nil { + return nil, err + } + + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + + // Validate production configuration + if cfg.Env == PROD { + if err := validateProductionConfig(cfg); err != nil { + return nil, err + } + } + + return cfg, nil +} + +func validateProductionConfig(cfg *Config) error { + var errors []string + + // TLS must be enabled in production + if !cfg.EnableTLS { + errors = append(errors, "TLS must be enabled in production") + } + + // TLS certificates must be provided in production + if cfg.EnableTLS { + if cfg.TLSCertPath == "" { + errors = append(errors, "TLS certificate path must be provided in production") + } + if cfg.TLSKeyPath == "" { + errors = append(errors, "TLS key path must be provided in production") + } + } + + // Allowed origins must be set in production + if len(cfg.AllowedOrigins) == 0 { + errors = append(errors, "Allowed origins must be set in production") + } + + // Rate limiting must be reasonable in production + if cfg.RateLimitPerSecond > 1000 { + errors = append(errors, "Rate limit per second cannot exceed 1000 in production") + } + + if len(errors) > 0 { + return fmt.Errorf("invalid production configuration: %s", strings.Join(errors, "; ")) + } + + // if cfg.DisableXSS || cfg.DisableSQLInjection || cfg.DisablePathTraversal { + // errors = append(errors, "Security sanitization cannot be disabled in production") + // } + + // if cfg.MaxFailedAttempts < 3 { + // errors = append(errors, "MaxFailedAttempts must be at least 3 in production") + // } + + // if cfg.SecurityLogPath == "" { + // errors = append(errors, "SecurityLogPath must be set in production") + // } + + return nil +} diff --git a/go-b2b-starter/src/pkg/server/domain/health.go b/go-b2b-starter/src/pkg/server/domain/health.go new file mode 100644 index 0000000..46f3fb4 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/domain/health.go @@ -0,0 +1,43 @@ +package domain + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +func (s *HTTPServer) setupHealthCheck() { + healthHandler := func(c *gin.Context) { + if s.config.IsProd() { + c.JSON(http.StatusOK, gin.H{"status": "OK"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "OK", + "environment": s.config.Env, + "version": "1.0.0", + "timestamp": time.Now().UTC(), + }) + } + + // Register health endpoint at both paths + s.router.GET("/health", healthHandler) + s.router.GET("/api/health", healthHandler) + s.logger.Info("Health check endpoints set up at /health and /api/health") +} + +func (s *HTTPServer) setupRootEndpoint() { + s.router.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "service": "B2B SaaS Starter API", + "version": "1.0.0", + "status": "running", + "health": "/api/health", + "docs": "/api/docs", + "timestamp": time.Now().UTC(), + }) + }) + s.logger.Info("Root endpoint set up at /") +} diff --git a/go-b2b-starter/src/pkg/server/domain/http_server.go b/go-b2b-starter/src/pkg/server/domain/http_server.go new file mode 100644 index 0000000..2814296 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/domain/http_server.go @@ -0,0 +1,157 @@ +package domain + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + config "github.com/moasq/go-b2b-starter/server/config" + "github.com/moasq/go-b2b-starter/server/logging" + "github.com/moasq/go-b2b-starter/server/middleware" + "github.com/gin-gonic/gin" +) + +type HTTPServer struct { + config *config.Config + router *gin.Engine + logger *logging.Logger + securityLogger *logging.SecurityLogger + registrars map[string][]RouteRegistrar + namedMiddlewares map[string]MiddlewareFunc + ipProtection *middleware.IPProtection +} + +// NewHTTPServer creates a new HTTP server instance +func NewHTTPServer( + config *config.Config, + router *gin.Engine, + logger *logging.Logger, +) Server { + if config.IsProd() { + gin.SetMode(gin.ReleaseMode) + } + + ipProtection := middleware.NewIPProtection() + + server := &HTTPServer{ + config: config, + router: router, + logger: logger, + securityLogger: logging.NewSecurityLogger(logger.SugaredLogger), + registrars: make(map[string][]RouteRegistrar), + namedMiddlewares: make(map[string]MiddlewareFunc), + ipProtection: ipProtection, + } + + server.setupMiddleware() + return server +} + +// Start initializes and starts the HTTP server +func (s *HTTPServer) Start() error { + srv := s.createHTTPServer() + s.setupHealthCheck() + s.setupRootEndpoint() + + go s.startServer(srv) + return s.handleGracefulShutdown(srv) +} + +func (s *HTTPServer) MiddlewareResolver() MiddlewareResolver { + return s +} + +// RegisterRoutes registers route handlers with version support +func (s *HTTPServer) RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) { + v := "" + if len(version) > 0 { + v = version[0] + } + + group := s.router.Group(prefix) + if v != "" { + group = group.Group("/" + v) + } + + // Register routes immediately instead of storing for later + registrar(group, s) +} + + +// RegisterNamedMiddleware registers a named middleware for later use +func (s *HTTPServer) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) { + s.namedMiddlewares[name] = middleware + s.logger.Info("Named middleware registered: " + name) +} + +func (s *HTTPServer) createHTTPServer() *http.Server { + return &http.Server{ + Addr: s.config.ServerAddress, + Handler: s.router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, // Increased to accommodate auto-extraction processing + IdleTimeout: 60 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + MaxHeaderBytes: s.config.MaxRequestSize, + } +} + +func (s *HTTPServer) startServer(srv *http.Server) { + s.logger.Info("Starting server on " + s.config.ServerAddress) + var err error + + if s.config.IsProd() { + err = srv.ListenAndServeTLS( + s.config.TLSCertPath, + s.config.TLSKeyPath, + ) + } else { + err = srv.ListenAndServe() + } + + if err != nil && err != http.ErrServerClosed { + s.logger.Fatal("Failed to start server", err) + } +} + +func (s *HTTPServer) handleGracefulShutdown(srv *http.Server) error { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + s.logger.Info("Shutting down server...") + + // Stop IP Protection cleanup goroutine + if s.ipProtection != nil { + s.ipProtection.Stop() + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + s.logger.Fatal("Server forced to shutdown", err) + } + + s.logger.Info("Server exited gracefully") + return nil +} + +// Get implements the MiddlewareResolver interface +func (s *HTTPServer) Get(name string) gin.HandlerFunc { + if middleware, exists := s.namedMiddlewares[name]; exists { + return middleware() + } + // Return a no-op middleware if not found + return func(c *gin.Context) { + s.logger.Warnw("Middleware not found", "name", name) + c.Next() + } +} + +// GetMiddleware returns a middleware by name (compatibility method) +func (s *HTTPServer) GetMiddleware(name string) gin.HandlerFunc { + return s.Get(name) +} diff --git a/go-b2b-starter/src/pkg/server/domain/middleware.go b/go-b2b-starter/src/pkg/server/domain/middleware.go new file mode 100644 index 0000000..c8ac3c1 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/domain/middleware.go @@ -0,0 +1,67 @@ +package domain + +import ( + "time" + + "github.com/moasq/go-b2b-starter/server/middleware" + "github.com/gin-gonic/gin" +) + +func (s *HTTPServer) setupMiddleware() { + ipProtection := middleware.NewIPProtection() + + // Calculate timeout based on extraction timeout + buffer + requestTimeout := time.Duration(s.config.ExtractionTimeoutSeconds+10) * time.Second // Add 10s buffer + + s.router.Use( + middleware.RequestID(), + ipProtection.Protect(), + middleware.RequestSanitization(s.config.GetSanitizationConfig()), + middleware.Recovery(s.logger), + middleware.RequestSizeLimit(int64(s.config.MaxRequestSize)), + middleware.Timeout(requestTimeout), + middleware.RateLimiter(s.config.RateLimitPerSecond), + middleware.CORS(s.config.AllowedOrigins), + s.requestLoggingMiddleware(), + ) + + // production only middleware + if s.config.IsProd() { + s.router.Use( + middleware.SecurityHeaders(), + ) + } + + if len(s.config.TrustedProxies) > 0 { + s.router.SetTrustedProxies(s.config.TrustedProxies) + } +} + +func (s *HTTPServer) requestLoggingMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Skip health check logging in production + if s.config.IsProd() && c.Request.URL.Path == "/health" { + c.Next() + return + } + + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + requestID := middleware.GetRequestID(c) // Get request ID + + c.Next() + + s.logger.Infow("Request completed", + "request_id", requestID, + "status", c.Writer.Status(), + "method", c.Request.Method, + "path", path, + "query", query, + "ip", c.ClientIP(), + "latency", time.Since(start), + "user-agent", c.Request.UserAgent(), + "bytes-out", c.Writer.Size(), + ) + } +} diff --git a/go-b2b-starter/src/pkg/server/domain/middleware_resolver.go b/go-b2b-starter/src/pkg/server/domain/middleware_resolver.go new file mode 100644 index 0000000..f9f5dc8 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/domain/middleware_resolver.go @@ -0,0 +1,8 @@ +package domain + +import "github.com/gin-gonic/gin" + +// MiddlewareResolver provides access to named middleware functions +type MiddlewareResolver interface { + Get(name string) gin.HandlerFunc +} diff --git a/go-b2b-starter/src/pkg/server/domain/server.go b/go-b2b-starter/src/pkg/server/domain/server.go new file mode 100644 index 0000000..8ad2e23 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/domain/server.go @@ -0,0 +1,27 @@ +package domain + +import "github.com/gin-gonic/gin" + +// Constants for API versioning +const ( + ApiPrefix = "/api" + ApiVersion1 = "v1" +) + +// RouteRegistrar is a function type for registering routes to a router group +// domain/server.go +type RouteRegistrar func(*gin.RouterGroup, MiddlewareResolver) + +// MiddlewareFunc is a function type that returns a Gin middleware handler +type MiddlewareFunc func() gin.HandlerFunc + +// Server defines the interface for HTTP server operations +// domain/server.go - Add to the Server interface +// Server defines the interface for HTTP server operations +type Server interface { + Start() error + RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) + RegisterNamedMiddleware(name string, middleware MiddlewareFunc) + MiddlewareResolver() MiddlewareResolver + GetMiddleware(name string) gin.HandlerFunc // Keep this method for compatibility +} diff --git a/go-b2b-starter/src/pkg/server/domain/server_.go b/go-b2b-starter/src/pkg/server/domain/server_.go new file mode 100644 index 0000000..1446d01 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/domain/server_.go @@ -0,0 +1,244 @@ +package domain + +// import ( +// "context" +// "fmt" +// "net/http" +// "os" +// "os/signal" +// "syscall" +// "time" + +// "github.com/gin-gonic/gin" +// config "github.com/moasq/go-b2b-starter/server/config" +// "github.com/moasq/go-b2b-starter/server/logging" +// "github.com/moasq/go-b2b-starter/server/middleware" +// ) + +// // Constants +// const ( +// ApiPrefix = "/api" +// ApiVersion1 = "v1" +// ) + +// // Types and interfaces +// type RouteRegistrar func(*gin.RouterGroup) +// type MiddlewareFunc func() gin.HandlerFunc + +// type Server interface { +// Start() error +// RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) +// RegisterNamedMiddleware(name string, middleware MiddlewareFunc) +// } + +// type HTTPServer struct { +// config *config.Config +// router *gin.Engine +// logger *logging.Logger +// securityLogger *logging.SecurityLogger +// registrars map[string][]RouteRegistrar +// namedMiddlewares map[string]MiddlewareFunc +// } + +// // Constructor +// func NewHTTPServer(config *config.Config, router *gin.Engine, logger *logging.Logger) Server { +// if config.IsProd() { +// gin.SetMode(gin.ReleaseMode) +// } + +// server := &HTTPServer{ +// config: config, +// router: router, +// logger: logger, +// securityLogger: logging.NewSecurityLogger(logger.SugaredLogger), +// registrars: make(map[string][]RouteRegistrar), +// namedMiddlewares: make(map[string]MiddlewareFunc), +// } + +// server.setupMiddleware() +// return server +// } + +// // Public methods +// func (s *HTTPServer) Start() error { +// srv := s.createHTTPServer() + +// // Register all routes +// s.registerAllRoutes() + +// // Setup health check +// s.setupHealthCheck() + +// // Start server +// go s.startServer(srv) + +// return s.handleGracefulShutdown(srv) +// } + +// func (s *HTTPServer) RegisterRoutes(registrar RouteRegistrar, prefix string, version ...string) { +// v := "" +// if len(version) > 0 { +// v = version[0] +// } + +// group := s.router.Group(prefix) +// if v != "" { +// group = group.Group("/" + v) +// } + +// s.registrars[v] = append(s.registrars[v], func(g *gin.RouterGroup) { +// registrar(group) +// }) +// } + +// func (s *HTTPServer) RegisterNamedMiddleware(name string, middleware MiddlewareFunc) { +// s.namedMiddlewares[name] = middleware +// s.logger.Info("Named middleware registered: " + name) +// } + +// // Private methods - Server setup and management +// func (s *HTTPServer) createHTTPServer() *http.Server { +// return &http.Server{ +// Addr: s.config.ServerAddress, +// Handler: s.router, +// ReadTimeout: 15 * time.Second, +// WriteTimeout: 15 * time.Second, +// IdleTimeout: 60 * time.Second, +// ReadHeaderTimeout: 5 * time.Second, +// MaxHeaderBytes: s.config.MaxRequestSize, +// } +// } + +// func (s *HTTPServer) startServer(srv *http.Server) { +// s.logger.Info("Starting server on " + s.config.ServerAddress) +// var err error + +// if s.config.IsProd() { +// err = srv.ListenAndServeTLS( +// s.config.TLSCertPath, +// s.config.TLSKeyPath, +// ) +// } else { +// err = srv.ListenAndServe() +// } + +// if err != nil && err != http.ErrServerClosed { +// s.logger.Fatal("Failed to start server", err) +// } +// } + +// func (s *HTTPServer) handleGracefulShutdown(srv *http.Server) error { +// quit := make(chan os.Signal, 1) +// signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) +// <-quit +// s.logger.Info("Shutting down server...") + +// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +// defer cancel() + +// if err := srv.Shutdown(ctx); err != nil { +// s.logger.Fatal("Server forced to shutdown", err) +// } + +// s.logger.Info("Server exited gracefully") +// return nil +// } + +// func (s *HTTPServer) registerAllRoutes() { +// for version, registrars := range s.registrars { +// group := s.router.Group("/api") +// if version != "" { +// group = group.Group("/" + version) +// } +// for _, registrar := range registrars { +// registrar(group) +// } +// } +// } + +// // Private methods - Middleware setup +// func (s *HTTPServer) setupMiddleware() { +// ipProtection := middleware.NewIPProtection() + +// // Add core middleware +// s.router.Use( +// middleware.SecurityHeaders(), +// ipProtection.Protect(), +// middleware.RequestSanitization(s.config.GetSanitizationConfig()), +// s.recoveryMiddleware(), +// middleware.RequestSizeLimit(int64(s.config.MaxRequestSize)), +// middleware.Timeout(10*time.Second), +// middleware.RateLimiter(s.config.RateLimitPerSecond), +// middleware.CORS(s.config.AllowedOrigins), +// s.requestLoggingMiddleware(), +// ) + +// if len(s.config.TrustedProxies) > 0 { +// s.router.SetTrustedProxies(s.config.TrustedProxies) +// } +// } + +// func (s *HTTPServer) recoveryMiddleware() gin.HandlerFunc { +// return func(c *gin.Context) { +// defer func() { +// if err := recover(); err != nil { +// s.securityLogger.LogSecurityEvent(logging.SecurityEvent{ +// EventType: "PANIC_RECOVERED", +// IP: c.ClientIP(), +// Description: fmt.Sprintf("Panic recovered: %v", err), +// Severity: "HIGH", +// Timestamp: time.Now(), +// RequestPath: c.Request.URL.Path, +// RequestID: c.GetHeader("X-Request-ID"), +// }) +// c.AbortWithStatus(http.StatusInternalServerError) +// } +// }() +// c.Next() +// } +// } + +// func (s *HTTPServer) requestLoggingMiddleware() gin.HandlerFunc { +// return func(c *gin.Context) { +// if s.config.IsProd() && c.Request.URL.Path == "/health" { +// c.Next() +// return +// } + +// start := time.Now() +// path := c.Request.URL.Path +// query := c.Request.URL.RawQuery + +// c.Next() + +// s.logger.Infow("Request completed", +// "status", c.Writer.Status(), +// "method", c.Request.Method, +// "path", path, +// "query", query, +// "ip", c.ClientIP(), +// "latency", time.Since(start), +// "user-agent", c.Request.UserAgent(), +// "request-id", c.GetHeader("X-Request-ID"), +// "bytes-out", c.Writer.Size(), +// ) +// } +// } + +// // Private methods - Health check +// func (s *HTTPServer) setupHealthCheck() { +// s.router.GET("/health", func(c *gin.Context) { +// if s.config.IsProd() { +// c.JSON(http.StatusOK, gin.H{"status": "OK"}) +// return +// } + +// c.JSON(http.StatusOK, gin.H{ +// "status": "OK", +// "environment": s.config.Env, +// "version": "1.0.0", +// "timestamp": time.Now().UTC(), +// }) +// }) +// s.logger.Info("Health check endpoint set up") +// } diff --git a/go-b2b-starter/src/pkg/server/errors/errors.go b/go-b2b-starter/src/pkg/server/errors/errors.go new file mode 100644 index 0000000..e719e91 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/errors/errors.go @@ -0,0 +1,10 @@ +package errors + +type APIError struct { + Code int + Message string +} + +func NewAPIError(code int, message string) *APIError { + return &APIError{Code: code, Message: message} +} diff --git a/go-b2b-starter/src/pkg/server/gin/gin.go b/go-b2b-starter/src/pkg/server/gin/gin.go new file mode 100644 index 0000000..d740211 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/gin/gin.go @@ -0,0 +1,24 @@ +package gin + +import ( + "github.com/moasq/go-b2b-starter/server/config" + "github.com/gin-gonic/gin" +) + +type GinRouter struct { + engine *gin.Engine + v1 *gin.RouterGroup +} + +func NewGinRouter(cfg *config.Config) *GinRouter { + router := gin.New() + router.Use(gin.Recovery()) + return &GinRouter{ + engine: router, + v1: router.Group("/api/v1"), + } +} + +func (g *GinRouter) GetHandler() *gin.Engine { + return g.engine +} diff --git a/go-b2b-starter/src/pkg/server/go.mod b/go-b2b-starter/src/pkg/server/go.mod new file mode 100644 index 0000000..2eb6625 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/go.mod @@ -0,0 +1,67 @@ +module github.com/moasq/go-b2b-starter/server + +go 1.25 + + +require ( + github.com/gin-contrib/cors v1.7.2 + github.com/gin-gonic/gin v1.10.1 + github.com/go-playground/validator/v10 v10.23.0 + github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.20.5 + github.com/spf13/viper v1.19.0 + go.uber.org/dig v1.19.0 + go.uber.org/zap v1.27.0 + golang.org/x/time v0.8.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/sonic v1.12.5 // indirect + github.com/bytedance/sonic/loader v0.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/pkg/server/go.sum b/go-b2b-starter/src/pkg/server/go.sum new file mode 100644 index 0000000..eed6dcb --- /dev/null +++ b/go-b2b-starter/src/pkg/server/go.sum @@ -0,0 +1,134 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/sonic v1.12.5 h1:hoZxY8uW+mT+OpkcUWw4k0fDINtOcVavEsGfzwzFU/w= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/go-b2b-starter/src/pkg/server/logging/logger.go b/go-b2b-starter/src/pkg/server/logging/logger.go new file mode 100644 index 0000000..40b0cfc --- /dev/null +++ b/go-b2b-starter/src/pkg/server/logging/logger.go @@ -0,0 +1,31 @@ +package logging + +import ( + "go.uber.org/zap" +) + +type Logger struct { + *zap.SugaredLogger +} + +func InitLogger() (*Logger, error) { + zapLogger, err := zap.NewProduction() + if err != nil { + return nil, err + } + sugar := zapLogger.Sugar() + return &Logger{sugar}, nil +} + +func (l *Logger) Error(msg string, err error) { + l.SugaredLogger.Errorw(msg, "error", err) +} + +func (l *Logger) Fatal(msg string, err error) { + l.SugaredLogger.Fatalw(msg, "error", err) +} + +// Helper function to create a zap.Field for errors +func Error(err error) zap.Field { + return zap.Error(err) +} diff --git a/go-b2b-starter/src/pkg/server/logging/security_logger.go b/go-b2b-starter/src/pkg/server/logging/security_logger.go new file mode 100644 index 0000000..e1a48f7 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/logging/security_logger.go @@ -0,0 +1,74 @@ +// logging/security_logger.go + +package logging + +import ( + "time" + + "go.uber.org/zap" +) + +type SecurityLogger struct { + logger *zap.SugaredLogger +} + +type SecurityEvent struct { + EventType string + IP string + UserID string + Description string + Severity string + Timestamp time.Time + RequestPath string + RequestID string +} + +func NewSecurityLogger(baseLogger *zap.SugaredLogger) *SecurityLogger { + return &SecurityLogger{ + logger: baseLogger, + } +} + +func (sl *SecurityLogger) LogSecurityEvent(event SecurityEvent) { + sl.logger.Warnw("Security Event", + "event_type", event.EventType, + "ip", event.IP, + "user_id", event.UserID, + "description", event.Description, + "severity", event.Severity, + "timestamp", event.Timestamp, + "request_path", event.RequestPath, + "request_id", event.RequestID, + ) +} + +func (sl *SecurityLogger) LogFailedAuth(ip string, userID string, reason string) { + sl.LogSecurityEvent(SecurityEvent{ + EventType: "AUTH_FAILED", + IP: ip, + UserID: userID, + Description: reason, + Severity: "WARNING", + Timestamp: time.Now(), + }) +} + +func (sl *SecurityLogger) LogSuspiciousActivity(ip string, description string) { + sl.LogSecurityEvent(SecurityEvent{ + EventType: "SUSPICIOUS_ACTIVITY", + IP: ip, + Description: description, + Severity: "WARNING", + Timestamp: time.Now(), + }) +} + +func (sl *SecurityLogger) LogBlacklisted(ip string) { + sl.LogSecurityEvent(SecurityEvent{ + EventType: "IP_BLACKLISTED", + IP: ip, + Description: "IP address has been blacklisted", + Severity: "HIGH", + Timestamp: time.Now(), + }) +} diff --git a/go-b2b-starter/src/pkg/server/metrics/prometheus.go b/go-b2b-starter/src/pkg/server/metrics/prometheus.go new file mode 100644 index 0000000..a49697d --- /dev/null +++ b/go-b2b-starter/src/pkg/server/metrics/prometheus.go @@ -0,0 +1,10 @@ +package metrics + +import ( + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func SetupPrometheus(router *gin.Engine) { + router.GET("/metrics", gin.WrapH(promhttp.Handler())) +} diff --git a/go-b2b-starter/src/pkg/server/middleware/cors.go b/go-b2b-starter/src/pkg/server/middleware/cors.go new file mode 100644 index 0000000..a9e5b47 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/cors.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS(allowedOrigins []string) gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: allowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Organization-ID", "X-Account-ID"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + AllowWildcard: false, + AllowFiles: false, + }) +} diff --git a/go-b2b-starter/src/pkg/server/middleware/ip_protection.go b/go-b2b-starter/src/pkg/server/middleware/ip_protection.go new file mode 100644 index 0000000..809f5f1 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/ip_protection.go @@ -0,0 +1,175 @@ +// middleware/ip_protection.go + +package middleware + +import ( + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type IPProtection struct { + whitelist map[string]bool + blacklist map[string]struct{} + failedAttempts map[string]*FailedAttempt + mu sync.RWMutex + cleanupTicker *time.Ticker + done chan struct{} +} + +type FailedAttempt struct { + count int + firstFail time.Time + lastSeen time.Time +} + +func NewIPProtection() *IPProtection { + ip := &IPProtection{ + whitelist: make(map[string]bool), + blacklist: make(map[string]struct{}), + failedAttempts: make(map[string]*FailedAttempt), + cleanupTicker: time.NewTicker(5 * time.Minute), + done: make(chan struct{}), + } + + // Start cleanup goroutine + go ip.periodicCleanup() + + return ip +} + +// Stop should be called when the server is shutting down +func (ip *IPProtection) Stop() { + ip.cleanupTicker.Stop() + close(ip.done) +} + +// periodicCleanup removes old entries from maps to prevent memory leaks +func (ip *IPProtection) periodicCleanup() { + for { + select { + case <-ip.cleanupTicker.C: + ip.cleanupFailedAttempts() + case <-ip.done: + return + } + } +} + +// cleanupFailedAttempts removes old entries from the failedAttempts map +func (ip *IPProtection) cleanupFailedAttempts() { + cutoff := time.Now().Add(-15 * time.Minute) + + ip.mu.Lock() + defer ip.mu.Unlock() + + for clientIP, attempt := range ip.failedAttempts { + // Remove entries that haven't been seen in the last 15 minutes + if attempt.lastSeen.Before(cutoff) { + delete(ip.failedAttempts, clientIP) + } + } +} + +func (ip *IPProtection) Protect() gin.HandlerFunc { + return func(c *gin.Context) { + clientIP := c.ClientIP() + + // Check if IP is whitelisted + if ip.isWhitelisted(clientIP) { + c.Next() + return + } + + // Check if IP is blacklisted + if ip.isBlacklisted(clientIP) { + c.AbortWithStatusJSON(403, gin.H{"error": "Access denied"}) + return + } + + // Check for suspicious activity + if ip.isSuspicious(clientIP) { + ip.addToBlacklist(clientIP) + c.AbortWithStatusJSON(403, gin.H{"error": "Suspicious activity detected"}) + return + } + + c.Next() + } +} + +func (ip *IPProtection) isWhitelisted(clientIP string) bool { + ip.mu.RLock() + defer ip.mu.RUnlock() + return ip.whitelist[clientIP] +} + +func (ip *IPProtection) isBlacklisted(clientIP string) bool { + ip.mu.RLock() + defer ip.mu.RUnlock() + _, exists := ip.blacklist[clientIP] + return exists +} + +func (ip *IPProtection) isSuspicious(clientIP string) bool { + ip.mu.Lock() + defer ip.mu.Unlock() + + attempt, exists := ip.failedAttempts[clientIP] + if !exists { + return false + } + + // Check if more than 10 failed attempts in 5 minutes + if attempt.count > 10 && time.Since(attempt.firstFail) < 5*time.Minute { + return true + } + + return false +} + +func (ip *IPProtection) RecordFailedAttempt(clientIP string) { + ip.mu.Lock() + defer ip.mu.Unlock() + + now := time.Now() + attempt, exists := ip.failedAttempts[clientIP] + if !exists { + ip.failedAttempts[clientIP] = &FailedAttempt{ + count: 1, + firstFail: now, + lastSeen: now, + } + return + } + + // Reset if last attempt was more than 5 minutes ago + if time.Since(attempt.firstFail) > 5*time.Minute { + attempt.count = 1 + attempt.firstFail = now + } else { + attempt.count++ + } + attempt.lastSeen = now +} + +func (ip *IPProtection) addToBlacklist(clientIP string) { + ip.mu.Lock() + defer ip.mu.Unlock() + ip.blacklist[clientIP] = struct{}{} +} + +// AddToWhitelist adds an IP to the whitelist +func (ip *IPProtection) AddToWhitelist(clientIP string) { + ip.mu.Lock() + defer ip.mu.Unlock() + ip.whitelist[clientIP] = true +} + +// RemoveFromBlacklist removes an IP from the blacklist +func (ip *IPProtection) RemoveFromBlacklist(clientIP string) { + ip.mu.Lock() + defer ip.mu.Unlock() + delete(ip.blacklist, clientIP) +} diff --git a/go-b2b-starter/src/pkg/server/middleware/ratelimit.go b/go-b2b-starter/src/pkg/server/middleware/ratelimit.go new file mode 100644 index 0000000..a44b62a --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/ratelimit.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +func RateLimiter(rateLimitPerSecond int) gin.HandlerFunc { + limiter := rate.NewLimiter(rate.Limit(rateLimitPerSecond), rateLimitPerSecond) + return func(c *gin.Context) { + if !limiter.Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) + return + } + c.Next() + } +} diff --git a/go-b2b-starter/src/pkg/server/middleware/recovery.go b/go-b2b-starter/src/pkg/server/middleware/recovery.go new file mode 100644 index 0000000..a296a88 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/recovery.go @@ -0,0 +1,73 @@ +// middleware/recovery.go + +package middleware + +import ( + "bytes" + "io" + "net/http" + "runtime" + "time" + + "github.com/moasq/go-b2b-starter/server/logging" + "github.com/gin-gonic/gin" +) + +const ( + // Size of the stack buffer + stackSize = 4 << 10 // 4 KB +) + +func Recovery(logger *logging.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + // Get stack trace + stack := stack(3) + + // Get request details + httpRequest := c.Request + headers := make(map[string]string) + for k, v := range httpRequest.Header { + headers[k] = v[0] + } + + // Log the error with context + logger.Errorw("Panic recovered", + "error", err, + "stack", string(stack), + "request_id", GetRequestID(c), + "method", httpRequest.Method, + "path", httpRequest.URL.Path, + "query", httpRequest.URL.RawQuery, + "ip", c.ClientIP(), + "user_agent", httpRequest.UserAgent(), + "time", time.Now().UTC(), + ) + + // Return safe error to client + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "Internal Server Error", + "request_id": GetRequestID(c), + "code": "SERVER_ERROR", + }) + } + }() + c.Next() + } +} + +// stack returns a formatted stack trace of the goroutine that panicked +func stack(_ int) []byte { + buf := new(bytes.Buffer) + + // Get runtime stack + var stackBuf [stackSize]byte + n := runtime.Stack(stackBuf[:], false) + + // Write stack to buffer + _, _ = io.WriteString(buf, "Stack Trace:\n") + _, _ = buf.Write(stackBuf[:n]) + + return buf.Bytes() +} diff --git a/go-b2b-starter/src/pkg/server/middleware/request_id.go b/go-b2b-starter/src/pkg/server/middleware/request_id.go new file mode 100644 index 0000000..20b230a --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/request_id.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const ( + // Headers + RequestIDHeader = "X-Request-ID" + + // Context keys + RequestIDKey = "request_id" +) + +// RequestID middleware ensures each request has a unique ID for tracing +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + // Check if request already has an ID + requestID := c.GetHeader(RequestIDHeader) + + // Generate new ID if none exists + if requestID == "" { + requestID = generateRequestID() + } + + // Set ID in context and response header + c.Set(RequestIDKey, requestID) + c.Header(RequestIDHeader, requestID) + + c.Next() + } +} + +// generateRequestID creates a new UUID v4 for request tracking +func generateRequestID() string { + return uuid.New().String() +} + +// GetRequestID retrieves request ID from context +func GetRequestID(c *gin.Context) string { + if id, exists := c.Get(RequestIDKey); exists { + return id.(string) + } + return "" +} diff --git a/go-b2b-starter/src/pkg/server/middleware/request_size_limit.go b/go-b2b-starter/src/pkg/server/middleware/request_size_limit.go new file mode 100644 index 0000000..1285bd3 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/request_size_limit.go @@ -0,0 +1,44 @@ +// middleware/request_size.go + +package middleware + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +func RequestSizeLimit(maxSize int64) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip check for GET, HEAD, OPTIONS methods + if c.Request.Method == http.MethodGet || + c.Request.Method == http.MethodHead || + c.Request.Method == http.MethodOptions { + c.Next() + return + } + + // Check Content-Length header + contentLength := c.Request.ContentLength + if contentLength > maxSize { + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": fmt.Sprintf("request size limit exceeded: %d bytes > %d bytes", contentLength, maxSize), + }) + return + } + + // Set body size limit for the request + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) + + c.Next() + + // Check if the request was aborted due to size limit + if c.Errors.Last() != nil && c.Errors.Last().Err == http.ErrMissingFile { + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": "request size limit exceeded during processing", + }) + return + } + } +} diff --git a/go-b2b-starter/src/pkg/server/middleware/sanatization.go b/go-b2b-starter/src/pkg/server/middleware/sanatization.go new file mode 100644 index 0000000..e5be6bf --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/sanatization.go @@ -0,0 +1,94 @@ +// middleware/sanitization.go + +package middleware + +import ( + "html" + "net/http" + "strings" + + "github.com/moasq/go-b2b-starter/server/config" + "github.com/gin-gonic/gin" +) + +func RequestSanitization(config config.SanitizationConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Sanitize Path Parameters + for _, param := range c.Params { + if containsPathTraversal(param.Value) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "Invalid path parameter detected", + }) + return + } + } + + // Sanitize Query Parameters + for _, values := range c.Request.URL.Query() { + for _, value := range values { + if !config.DisableXSS && containsXSS(value) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "Potential XSS detected in query parameter", + }) + return + } + if !config.DisableSQLInjection && containsSQLInjection(value) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "Potential SQL injection detected in query parameter", + }) + return + } + } + } + + // Add a custom header indicating security checks passed + c.Header("X-Content-Security", "sanitized") + + c.Next() + } +} + +func containsPathTraversal(path string) bool { + suspicious := []string{"..", "//", "\\\\"} + for _, pattern := range suspicious { + if strings.Contains(path, pattern) { + return true + } + } + return false +} + +func containsXSS(input string) bool { + suspicious := []string{ + "", + "javascript:", "vbscript:", + "onload=", "onerror=", + } + + sanitized := html.EscapeString(input) + for _, pattern := range suspicious { + if strings.Contains(strings.ToLower(sanitized), strings.ToLower(pattern)) { + return true + } + } + return false +} + +func containsSQLInjection(input string) bool { + suspicious := []string{ + "DROP TABLE", + "DELETE FROM", + "INSERT INTO", + "UPDATE", + "--", + "UNION", + "SELECT", + } + + for _, pattern := range suspicious { + if strings.Contains(strings.ToUpper(input), pattern) { + return true + } + } + return false +} diff --git a/go-b2b-starter/src/pkg/server/middleware/security_headers.go b/go-b2b-starter/src/pkg/server/middleware/security_headers.go new file mode 100644 index 0000000..115eb20 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/security_headers.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + // Security headers + c.Header("X-Frame-Options", "DENY") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-XSS-Protection", "1; mode=block") + c.Header("Referrer-Policy", "strict-origin-no-referrer") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline';") + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + c.Header("X-Permitted-Cross-Domain-Policies", "none") + + // Remove sensitive headers + c.Header("Server", "") + c.Header("X-Powered-By", "") + + c.Next() + } +} diff --git a/go-b2b-starter/src/pkg/server/middleware/timeout.go b/go-b2b-starter/src/pkg/server/middleware/timeout.go new file mode 100644 index 0000000..cbafa68 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/timeout.go @@ -0,0 +1,75 @@ +// middleware/timeout.go + +package middleware + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +func Timeout(timeout time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + // Create a context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + defer cancel() // Ensure we call cancel to prevent context leak + + // Create a request with the new context + c.Request = c.Request.WithContext(ctx) + + // Create a channel to signal completion + finished := make(chan struct{}, 1) + + // Use a WaitGroup to wait for the goroutine to finish + var wg sync.WaitGroup + wg.Add(1) + + // Create a copy of the context writer + writer := c.Writer + + // Handle the request in a goroutine + go func() { + defer wg.Done() + + // Create a wrapped writer to capture the response status + blw := &bodyLogWriter{ResponseWriter: c.Writer} + c.Writer = blw + + // Process the handler chain + c.Next() + + // Signal that the handler is complete + finished <- struct{}{} + }() + + // Wait for either completion or timeout + select { + case <-finished: + // Handler completed before timeout + case <-ctx.Done(): + // Timeout occurred or parent context was cancelled + if ctx.Err() == context.DeadlineExceeded { + // Reset the original writer to avoid modifying response after timeout + c.Writer = writer + c.AbortWithStatusJSON(http.StatusGatewayTimeout, gin.H{"error": "request timeout"}) + } + } + + // Wait for the goroutine to finish to prevent potential leaks + wg.Wait() + } +} + +// bodyLogWriter is a wrapper around ResponseWriter to capture the response status +type bodyLogWriter struct { + gin.ResponseWriter + statusCode int +} + +func (w *bodyLogWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} diff --git a/go-b2b-starter/src/pkg/server/middleware/validator.go b/go-b2b-starter/src/pkg/server/middleware/validator.go new file mode 100644 index 0000000..b7c4656 --- /dev/null +++ b/go-b2b-starter/src/pkg/server/middleware/validator.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +var Validate *validator.Validate + +func InitValidator() *validator.Validate { + Validate = validator.New() + return Validate +} + +func ValidateRequest(structPtr interface{}) gin.HandlerFunc { + return func(c *gin.Context) { + if err := c.ShouldBindJSON(structPtr); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + c.Abort() + return + } + if err := Validate.Struct(structPtr); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + c.Abort() + return + } + c.Next() + } +} diff --git a/go-b2b-starter/src/pkg/stytch/client.go b/go-b2b-starter/src/pkg/stytch/client.go new file mode 100644 index 0000000..a602aae --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/client.go @@ -0,0 +1,50 @@ +package stytch + +import ( + "fmt" + "net/http" + "strings" + + "github.com/stytchauth/stytch-go/v16/stytch/b2b/b2bstytchapi" +) + +// Client is a thin wrapper around the autogenerated Stytch B2B API client. +type Client struct { + api *b2bstytchapi.API + config Config +} + +// NewClient constructs the shared Stytch API client using the supplied configuration. +func NewClient(cfg Config) (*Client, error) { + httpClient := &http.Client{ + Timeout: cfg.APITimeout, + } + + var opts []b2bstytchapi.Option + opts = append(opts, b2bstytchapi.WithHTTPClient(httpClient)) + + baseURI := strings.TrimSuffix(cfg.BaseURL, "/") + if baseURI != "" { + opts = append(opts, b2bstytchapi.WithBaseURI(baseURI)) + } + + apiClient, err := b2bstytchapi.NewClient(cfg.ProjectID, cfg.Secret, opts...) + if err != nil { + return nil, fmt.Errorf("create stytch client: %w", err) + } + + return &Client{ + api: apiClient, + config: cfg, + }, nil +} + +// API exposes the underlying autogenerated API for advanced use-cases. +func (c *Client) API() *b2bstytchapi.API { + return c.api +} + +// Config returns the hydrated configuration. +func (c *Client) Config() Config { + return c.config +} diff --git a/go-b2b-starter/src/pkg/stytch/cmd/provider.go b/go-b2b-starter/src/pkg/stytch/cmd/provider.go new file mode 100644 index 0000000..6b17b38 --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/cmd/provider.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" + "github.com/moasq/go-b2b-starter/pkg/stytch" + "go.uber.org/dig" +) + +// ProvideStytchDependencies wires the Stytch configuration and client into the DI container. +func ProvideStytchDependencies(container *dig.Container) error { + providers := []any{ + stytch.LoadConfig, + provideStytchClient, + provideRBACPolicyService, + } + + for _, provider := range providers { + if err := container.Provide(provider); err != nil { + return fmt.Errorf("failed to provide Stytch dependency: %w", err) + } + } + + return nil +} + +func provideStytchClient(cfg *stytch.Config, log logger.Logger) (*stytch.Client, error) { + // Check for placeholder credentials (development mode) + if isPlaceholderCredentials(cfg) { + log.Warn("Stytch credentials are placeholders - Stytch client will be nil (development mode)", map[string]any{ + "project_id": cfg.ProjectID, + "message": "Organization/member management features will not work. Update STYTCH_PROJECT_ID and STYTCH_SECRET in app.env", + }) + // Return nil client for development mode - app/auth repositories should handle nil gracefully + return nil, nil + } + + return stytch.NewClient(*cfg) +} + +// isPlaceholderCredentials checks if the Stytch credentials are placeholder values. +func isPlaceholderCredentials(cfg *stytch.Config) bool { + return strings.Contains(cfg.ProjectID, "REPLACE") || + strings.Contains(cfg.Secret, "REPLACE") || + cfg.ProjectID == "" || + cfg.Secret == "" +} + +func provideRBACPolicyService( + client *stytch.Client, + redisClient redis.Client, + log logger.Logger, +) *stytch.RBACPolicyService { + // If client is nil (development mode), return nil for RBAC service too + if client == nil { + return nil + } + return stytch.NewRBACPolicyService(client, redisClient, log) +} diff --git a/go-b2b-starter/src/pkg/stytch/config.go b/go-b2b-starter/src/pkg/stytch/config.go new file mode 100644 index 0000000..d24bd14 --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/config.go @@ -0,0 +1,101 @@ +package stytch + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/viper" +) + +// Environment constants supported by the Stytch B2B API. +const ( + EnvTest = "test" + EnvLive = "live" +) + +// Config captures the runtime knobs required to communicate with Stytch. +type Config struct { + ProjectID string `mapstructure:"STYTCH_PROJECT_ID"` + Secret string `mapstructure:"STYTCH_SECRET"` + Env string `mapstructure:"STYTCH_ENV"` + BaseURL string `mapstructure:"STYTCH_BASE_URL"` + CustomDomain string `mapstructure:"STYTCH_CUSTOM_DOMAIN"` + JWKSURL string `mapstructure:"STYTCH_JWKS_URL"` + SessionDurationMinutes int32 `mapstructure:"STYTCH_SESSION_DURATION_MINUTES"` + DisableSessionVerification bool `mapstructure:"STYTCH_DISABLE_SESSION_VERIFICATION"` + OwnerRoleSlug string `mapstructure:"STYTCH_OWNER_ROLE_SLUG"` + InviteRedirectURL string `mapstructure:"STYTCH_INVITE_REDIRECT_URL"` + LoginRedirectURL string `mapstructure:"STYTCH_LOGIN_REDIRECT_URL"` + APITimeout time.Duration `mapstructure:"STYTCH_API_TIMEOUT"` +} + +// LoadConfig hydrates the Stytch configuration from app.env + process environment. +func LoadConfig() (*Config, error) { + v := viper.New() + v.SetConfigName("app") + v.SetConfigType("env") + v.AddConfigPath(".") + v.AutomaticEnv() + + // Defaults mirror the Auth0 integration to minimize surprises. + v.SetDefault("STYTCH_ENV", EnvTest) + v.SetDefault("STYTCH_SESSION_DURATION_MINUTES", 1440) // 24 hours (previously 60 minutes) + v.SetDefault("STYTCH_API_TIMEOUT", "15s") + v.SetDefault("STYTCH_DISABLE_SESSION_VERIFICATION", false) + + // Best-effort: ignore missing file, allow env-only usage + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("failed to read config: %w", err) + } + } + + var cfg *Config + if err := v.Unmarshal(&cfg); err != nil { + return cfg, fmt.Errorf("unable to decode stytch config: %w", err) + } + + cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env)) + if cfg.Env == "" { + cfg.Env = EnvTest + } + + if cfg.ProjectID == "" { + return cfg, fmt.Errorf("stytch configuration invalid: STYTCH_PROJECT_ID is required") + } + if cfg.Secret == "" { + return cfg, fmt.Errorf("stytch configuration invalid: STYTCH_SECRET is required") + } + + // Normalize timeout (viper unmarshals duration strings automatically). + if cfg.APITimeout <= 0 { + cfg.APITimeout = 15 * time.Second + } + + // Derive base URL if none supplied. + if cfg.BaseURL == "" { + switch cfg.Env { + case EnvLive: + cfg.BaseURL = "https://api.stytch.com" + default: + cfg.BaseURL = "https://test.stytch.com" + } + } + + // Custom domain overrides base URL for API + JWKS. + if cfg.CustomDomain != "" { + cfg.BaseURL = fmt.Sprintf("https://%s", strings.TrimSuffix(cfg.CustomDomain, "/")) + } + + // Derive JWKS URL if absent. + if cfg.JWKSURL == "" { + if cfg.CustomDomain != "" { + cfg.JWKSURL = fmt.Sprintf("https://%s/.well-known/jwks.json", strings.TrimSuffix(cfg.CustomDomain, "/")) + } else { + cfg.JWKSURL = fmt.Sprintf("%s/v1/b2b/sessions/jwks/%s", strings.TrimSuffix(cfg.BaseURL, "/"), cfg.ProjectID) + } + } + + return cfg, nil +} diff --git a/go-b2b-starter/src/pkg/stytch/errors.go b/go-b2b-starter/src/pkg/stytch/errors.go new file mode 100644 index 0000000..9e73b93 --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/errors.go @@ -0,0 +1,81 @@ +package stytch + +import ( + "errors" + "fmt" + + "github.com/stytchauth/stytch-go/v16/stytch/stytcherror" +) + +var ( + // ErrUnauthorized mirrors HTTP 401 responses. + ErrUnauthorized = errors.New("stytch: unauthorized") + // ErrForbidden mirrors HTTP 403 responses. + ErrForbidden = errors.New("stytch: forbidden") + // ErrNotFound mirrors HTTP 404 responses. + ErrNotFound = errors.New("stytch: resource not found") + // ErrConflict mirrors HTTP 409 responses. + ErrConflict = errors.New("stytch: conflict") + // ErrRateLimited mirrors HTTP 429 responses. + ErrRateLimited = errors.New("stytch: rate limit exceeded") + // ErrBadRequest mirrors HTTP 400 responses. + ErrBadRequest = errors.New("stytch: bad request") + // ErrInternal mirrors HTTP 5xx responses. + ErrInternal = errors.New("stytch: internal server error") + // ErrInvalidConfig surfaces configuration validation issues. + ErrInvalidConfig = errors.New("stytch: invalid configuration") + // ErrDuplicateSlug indicates organization slug already exists. + ErrDuplicateSlug = errors.New("stytch: organization slug already exists") +) + +// IsDuplicateSlugError checks if the error is a duplicate organization slug error +func IsDuplicateSlugError(err error) bool { + if err == nil { + return false + } + + var stErr *stytcherror.Error + if errors.As(err, &stErr) { + return string(stErr.ErrorType) == "organization_slug_already_used" + } + + return false +} + +// MapError inspects a returned error and maps it to one of the sentinel errors above. +func MapError(err error) error { + if err == nil { + return nil + } + + var stErr *stytcherror.Error + if errors.As(err, &stErr) { + // Check specific error types first + if string(stErr.ErrorType) == "organization_slug_already_used" { + return fmt.Errorf("%w: %s", ErrDuplicateSlug, stErr.Error()) + } + + // Then check status codes + switch stErr.StatusCode { + case 400: + return fmt.Errorf("%w: %s", ErrBadRequest, stErr.Error()) + case 401: + return fmt.Errorf("%w: %s", ErrUnauthorized, stErr.Error()) + case 403: + return fmt.Errorf("%w: %s", ErrForbidden, stErr.Error()) + case 404: + return fmt.Errorf("%w: %s", ErrNotFound, stErr.Error()) + case 409: + return fmt.Errorf("%w: %s", ErrConflict, stErr.Error()) + case 429: + return fmt.Errorf("%w: %s", ErrRateLimited, stErr.Error()) + default: + if stErr.StatusCode >= 500 { + return fmt.Errorf("%w: %s", ErrInternal, stErr.Error()) + } + return fmt.Errorf("stytch: unexpected status %d: %w", stErr.StatusCode, stErr) + } + } + + return err +} diff --git a/go-b2b-starter/src/pkg/stytch/go.mod b/go-b2b-starter/src/pkg/stytch/go.mod new file mode 100644 index 0000000..9583006 --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/go.mod @@ -0,0 +1,36 @@ +module github.com/moasq/go-b2b-starter/pkg/stytch + +go 1.25 + +require ( + github.com/spf13/viper v1.19.0 + github.com/stytchauth/stytch-go/v16 v16.40.0 + go.uber.org/dig v1.19.0 +) + +require ( + github.com/MicahParks/keyfunc/v2 v2.0.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-b2b-starter/src/pkg/stytch/go.sum b/go-b2b-starter/src/pkg/stytch/go.sum new file mode 100644 index 0000000..3a8bad1 --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/go.sum @@ -0,0 +1,55 @@ +github.com/MicahParks/keyfunc/v2 v2.0.1 h1:6FrNNvG/20gEKkjxV+5anrkq0VOF666G2zUn8lk8dgk= +github.com/MicahParks/keyfunc/v2 v2.0.1/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stytchauth/stytch-go/v16 v16.40.0 h1:xT9QyPtWi4j6rJPhkROfGCDzDeVBqvS2KQge1dv8rfs= +github.com/stytchauth/stytch-go/v16 v16.40.0/go.mod h1:b2Dj63HNogYxAwJz7l9S7aJ8k3xyFYrMOtkzdTme+tk= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-b2b-starter/src/pkg/stytch/inject.go b/go-b2b-starter/src/pkg/stytch/inject.go new file mode 100644 index 0000000..5a8891f --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/inject.go @@ -0,0 +1,32 @@ +package stytch + +import ( + "fmt" + + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" + "go.uber.org/dig" +) + +// ProvideDependencies registers Stytch package dependencies in the DI container +func ProvideDependencies(container *dig.Container) error { + // Provide Stytch client + if err := container.Provide(func(cfg Config) (*Client, error) { + return NewClient(cfg) + }); err != nil { + return fmt.Errorf("failed to provide stytch client: %w", err) + } + + // Provide RBAC policy service + if err := container.Provide(func( + client *Client, + redisClient redis.Client, + logger logger.Logger, + ) *RBACPolicyService { + return NewRBACPolicyService(client, redisClient, logger) + }); err != nil { + return fmt.Errorf("failed to provide RBAC policy service: %w", err) + } + + return nil +} diff --git a/go-b2b-starter/src/pkg/stytch/rbac_policy.go b/go-b2b-starter/src/pkg/stytch/rbac_policy.go new file mode 100644 index 0000000..d72fcc9 --- /dev/null +++ b/go-b2b-starter/src/pkg/stytch/rbac_policy.go @@ -0,0 +1,225 @@ +package stytch + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/moasq/go-b2b-starter/pkg/logger" + "github.com/moasq/go-b2b-starter/pkg/redis" + "github.com/stytchauth/stytch-go/v16/stytch/b2b/rbac" +) + +const ( + // Redis cache key for RBAC policy + rbacPolicyCacheKey = "stytch:rbac:policy" + // Cache TTL matches Stytch SDK default (5 minutes) + rbacPolicyCacheTTL = 5 * time.Minute +) + +// RBACPolicyService fetches and caches Stytch RBAC policy +type RBACPolicyService struct { + client *Client + redis redis.Client + logger logger.Logger +} + +// NewRBACPolicyService creates a new RBAC policy service +func NewRBACPolicyService( + client *Client, + redisClient redis.Client, + logger logger.Logger, +) *RBACPolicyService { + return &RBACPolicyService{ + client: client, + redis: redisClient, + logger: logger, + } +} + +// GetRolePermissions returns all permissions for a given role from Stytch RBAC policy +// Returns permissions in "resource:action" format (e.g., "invoice:create") +func (s *RBACPolicyService) GetRolePermissions(ctx context.Context, roleID string) ([]string, error) { + // Normalize role ID (remove stytch_ prefix) + normalizedRoleID := normalizeRoleID(roleID) + + // Get policy from cache or Stytch + policy, err := s.getPolicy(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get RBAC policy: %w", err) + } + + // Find role in policy + for _, role := range policy.Roles { + if strings.EqualFold(role.RoleID, normalizedRoleID) { + return s.convertPermissions(role.Permissions, policy), nil + } + } + + // Role not found in policy + return nil, nil +} + +// getPolicy fetches policy from Redis cache or Stytch API +func (s *RBACPolicyService) getPolicy(ctx context.Context) (*rbac.Policy, error) { + // Try cache first + cached, err := s.redis.Get(ctx, rbacPolicyCacheKey) + if err == nil && cached != "" { + var policy rbac.Policy + if err := json.Unmarshal([]byte(cached), &policy); err == nil { + s.logger.Debug("RBAC policy fetched from cache", logger.Fields{}) + return &policy, nil + } else { + s.logger.Warn("Failed to unmarshal cached RBAC policy, fetching from Stytch", logger.Fields{ + "error": err.Error(), + }) + } + } + + // Cache miss or error - fetch from Stytch + policy, err := s.fetchPolicyFromStytch(ctx) + if err != nil { + return nil, err + } + + // Cache the policy + s.cachePolicy(ctx, policy) + + return policy, nil +} + +// fetchPolicyFromStytch fetches RBAC policy from Stytch API +func (s *RBACPolicyService) fetchPolicyFromStytch(ctx context.Context) (*rbac.Policy, error) { + s.logger.Info("Fetching RBAC policy from Stytch", logger.Fields{}) + + resp, err := s.client.API().RBAC.Policy(ctx, &rbac.PolicyParams{}) + if err != nil { + return nil, fmt.Errorf("stytch RBAC policy API call failed: %w", err) + } + + if resp.Policy == nil { + return nil, fmt.Errorf("stytch returned empty policy") + } + + s.logger.Info("Successfully fetched RBAC policy from Stytch", logger.Fields{ + "roles_count": len(resp.Policy.Roles), + "resources_count": len(resp.Policy.Resources), + }) + + return resp.Policy, nil +} + +// cachePolicy stores policy in Redis +func (s *RBACPolicyService) cachePolicy(ctx context.Context, policy *rbac.Policy) { + data, err := json.Marshal(policy) + if err != nil { + s.logger.Warn("Failed to marshal RBAC policy for caching", logger.Fields{ + "error": err.Error(), + }) + return + } + + if err := s.redis.Set(ctx, rbacPolicyCacheKey, string(data), rbacPolicyCacheTTL); err != nil { + s.logger.Warn("Failed to cache RBAC policy in Redis", logger.Fields{ + "error": err.Error(), + }) + // Non-fatal error, continue without cache + } else { + s.logger.Debug("RBAC policy cached in Redis", logger.Fields{ + "ttl": rbacPolicyCacheTTL.String(), + }) + } +} + +// convertPermissions converts Stytch permission format to flat list, expanding wildcards +// +// Input: []PolicyRolePermission{ +// {ResourceID: "Invoice", Actions: ["view", "create"]}, +// {ResourceID: "approval", Actions: ["*"]}, +// }, policy with Resources +// +// Output: ["invoice:view", "invoice:create", "approval:view", "approval:approve", "approval:assign"] +// (wildcards expanded to all actions defined in policy for that resource) +func (s *RBACPolicyService) convertPermissions(permissions []rbac.PolicyRolePermission, policy *rbac.Policy) []string { + if len(permissions) == 0 { + return nil + } + + result := make([]string, 0, len(permissions)*5) // Estimate 5 actions per resource + + for _, perm := range permissions { + resourceID := strings.ToLower(perm.ResourceID) + + // Handle empty or invalid resource + if resourceID == "" { + continue + } + + // Expand wildcard actions using resource definitions from policy + expandedActions := s.expandWildcardActions(perm.ResourceID, perm.Actions, policy) + + // Convert each action to "resource:action" format + for _, action := range expandedActions { + if action == "" { + continue + } + actionLower := strings.ToLower(action) + result = append(result, fmt.Sprintf("%s:%s", resourceID, actionLower)) + } + } + + return result +} + +// expandWildcardActions expands wildcard (*) to all resource actions from Stytch policy +// This ensures permissions come entirely from Stytch configuration, not local code +func (s *RBACPolicyService) expandWildcardActions(resourceID string, actions []string, policy *rbac.Policy) []string { + // Check if actions contain wildcard + hasWildcard := false + for _, action := range actions { + if action == "*" { + hasWildcard = true + break + } + } + + // If no wildcard, return actions as-is + if !hasWildcard { + return actions + } + + // Find resource definition in policy to get all possible actions + for _, resource := range policy.Resources { + if strings.EqualFold(resource.ResourceID, resourceID) { + // Return all actions defined for this resource in Stytch + if len(resource.Actions) > 0 { + s.logger.Debug("Expanded wildcard permission", logger.Fields{ + "resource": resourceID, + "actions_count": len(resource.Actions), + "actions": resource.Actions, + }) + return resource.Actions + } + } + } + + // Resource not found in policy, keep wildcard as-is (shouldn't happen) + s.logger.Warn("Resource not found in policy, keeping wildcard", logger.Fields{ + "resource": resourceID, + }) + return actions +} + +// normalizeRoleID removes common prefixes from role IDs +// "stytch_member" -> "stytch_member" +// "owner" -> "owner" +// "stytch_admin" -> "stytch_admin" +func normalizeRoleID(roleID string) string { + roleID = strings.TrimSpace(roleID) + // Keep stytch_ prefix for default roles, but remove "member" suffix + roleID = strings.TrimPrefix(roleID, "member") + roleID = strings.TrimSpace(roleID) + return roleID +} diff --git a/next_b2b_starter/.dockerignore b/next_b2b_starter/.dockerignore new file mode 100644 index 0000000..db04444 --- /dev/null +++ b/next_b2b_starter/.dockerignore @@ -0,0 +1,95 @@ +# ============================================================================= +# Frontend - Docker Ignore Configuration +# ============================================================================= +# Excludes unnecessary files from Docker build context +# Reduces build time, image size, and improves security +# ============================================================================= + +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Next.js build output +.next/ +out/ +build/ +dist/ + +# Testing +coverage/ +.nyc_output/ +*.test.js +*.test.ts +*.test.tsx +*.spec.js +*.spec.ts +*.spec.tsx +__tests__/ +__mocks__/ + +# Environment files (security) +.env +.env*.local +.env.development +.env.test +.env.production.local +.env.development.local + +# Git +.git/ +.gitignore +.gitattributes + +# IDE and Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Documentation and non-essential files +*.md +!README.md +docs/ +.github/ + +# Deployment files (keep only what's needed) +deployment/ +# Note: deployments/ folder is kept for .env templates + +# Logs +logs/ +*.log + +# Misc +.cache/ +.temp/ +.tmp/ +*.pid +*.seed +*.pid.lock + +# TypeScript +*.tsbuildinfo + +# Linting +.eslintcache + +# Testing +.jest/ +playwright-report/ +test-results/ + +# Scripts (if not needed in production) +scripts/ + +# Storybook +.storybook/ +storybook-static/ + +# Turbo +.turbo/ diff --git a/next_b2b_starter/.env.example b/next_b2b_starter/.env.example new file mode 100644 index 0000000..0db8b73 --- /dev/null +++ b/next_b2b_starter/.env.example @@ -0,0 +1,47 @@ +# Database Configuration +POSTGRES_URL= +POSTGRES_PRISMA_URL= +POSTGRES_URL_NON_POOLING= +POSTGRES_USER= +POSTGRES_HOST= +POSTGRES_PASSWORD= +POSTGRES_DATABASE= + +# `openssl rand -base64 32` +# Application base URL for redirects +APP_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 +NOTIFICATION_EMAIL= +NEXT_PUBLIC_CONTACT_EMAIL= + +# Stytch B2B Authentication +STYTCH_PROJECT_ID= +STYTCH_SECRET= +STYTCH_PROJECT_ENV=test +NEXT_PUBLIC_STYTCH_PROJECT_ENV=test +NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN= +NEXT_PUBLIC_STYTCH_LOGIN_PATH=/auth +NEXT_PUBLIC_STYTCH_REDIRECT_PATH=/authenticate +NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES=43200 +STYTCH_ALLOWED_ORGANIZATION_IDS= + +# Polar Billing (sandbox defaults) +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +NEXT_PUBLIC_POLAR_PRODUCT_ID=523c6265-6eed-4ec0-b202-32e03ddd9a67 +NEXT_PUBLIC_POLAR_BUSINESS_PRODUCT_ID=523c6265-6eed-4ec0-b202-32e03ddd9a67 +NEXT_PUBLIC_POLAR_SCALE_PRODUCT_ID= +NEXT_PUBLIC_POLAR_METER_ID=74f6f057-f061-4d20-8dc0-43ff9c8704af + +# Umami Analytics +NEXT_PUBLIC_UMAMI_WEBSITE_ID=de101dd2-de68-4c6a-9b6a-7358e59a8cb0 +NEXT_PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js + +# SMTP Configuration (Email Sending) +# For Gmail: smtp.gmail.com, for Resend: smtp.resend.com, for SendGrid: smtp.sendgrid.net +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_USER= +SMTP_PASS= +SMTP_FROM= diff --git a/next_b2b_starter/.eslintrc.json b/next_b2b_starter/.eslintrc.json new file mode 100644 index 0000000..c1df76e --- /dev/null +++ b/next_b2b_starter/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} \ No newline at end of file diff --git a/next_b2b_starter/.npmrc b/next_b2b_starter/.npmrc new file mode 100644 index 0000000..8d409d8 --- /dev/null +++ b/next_b2b_starter/.npmrc @@ -0,0 +1,7 @@ +# pnpm configuration +# Allow trusted packages to run build scripts (required for sharp, Next.js image optimization, etc.) +enable-pre-post-scripts=true + +# Auto-install peer dependencies +auto-install-peers=true +strict-peer-dependencies=false diff --git a/next_b2b_starter/CLAUDE.md b/next_b2b_starter/CLAUDE.md new file mode 100644 index 0000000..4129fa3 --- /dev/null +++ b/next_b2b_starter/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**AP-Cash Frontend** - A radically simplified, advice-only accounts payable assistant. Built with minimal Next.js 15 App Router for maximum maintainability and performance. + +## Development Commands + +- **Development**: `pnpm dev` (uses Turbopack) +- **Build**: `pnpm build` +- **Production**: `pnpm start` +- **Lint**: `pnpm lint` +- **Package Manager**: `pnpm` only + +## Tech Stack & Architecture + +- **Framework**: Next.js 15 + App Router +- **Language**: TypeScript (strict mode) +- **Styling**: Tailwind CSS +- **Components**: shadcn/ui for consistent, accessible UI components +- **State Management**: Zustand for global state, React built-ins for local state +- **Philosophy**: Modern, maintainable, performant + +## Key Principles + +- **Modern Simplicity**: Use proven, well-established tools that enhance developer experience +- **Performance First**: Optimized bundle size and fast load times +- **Maintainable**: Easy to understand, modify, and scale +- **Consistent**: shadcn/ui components for design system consistency +- **Type Safe**: Strict TypeScript throughout the application + +## Project Structure + +``` +app/ +├── layout.tsx # Root layout +├── page.tsx # Homepage +├── globals.css # Tailwind imports +└── (routes)/ # App routes + +components/ +├── ui/ # shadcn/ui components +└── custom/ # Custom components + +lib/ +├── utils.ts # Utility functions +└── cn.ts # Class name utility + +stores/ +└── (zustand stores) # Global state management +``` + +## Current State + +- **Modern tech stack** with carefully selected, industry-standard tools +- **Type-safe** development with strict TypeScript +- **Component-driven** architecture with shadcn/ui +- **Optimized performance** with Next.js 15 and efficient state management + +## Development Guidelines + +- **Components**: Use shadcn/ui for UI components, create custom components when needed +- **State Management**: Use Zustand for global state, React built-ins for local component state +- **Styling**: Tailwind CSS for all styling, follow design system patterns +- **Type Safety**: Maintain strict TypeScript throughout the codebase +- **Dependencies**: Add thoughtfully - prioritize well-maintained, widely-adopted packages + +## Before Adding Any Package + +1. Ask: "Does this solve a real problem better than existing solutions?" +2. Check: "Is this package well-maintained and widely adopted?" +3. Verify: "Does this align with our tech stack and architecture?" +4. Consider: "What's the bundle size impact and maintenance overhead?" + +## Setup Instructions + +### shadcn/ui Setup +```bash +npx shadcn@latest init +npx shadcn@latest add button card input +``` + +### Zustand Installation +```bash +pnpm add zustand +``` + +## Key Files + +- **docs/PROJECT.md** - Business requirements and feature spec +- **package.json** - Project dependencies and scripts +- **tailwind.config.ts** - Tailwind configuration with design tokens +- **components.json** - shadcn/ui configuration +- **lib/utils.ts** - Shared utility functions and cn() helper + +## Architecture Notes + +- **Components**: shadcn/ui provides accessible, well-tested base components +- **State**: Zustand offers simple, boilerplate-free global state management +- **Styling**: Tailwind CSS with consistent design tokens and utility classes +- **Types**: Strict TypeScript ensures code reliability and developer experience + +The goal is to build modern, maintainable web apps using industry-standard tools that enhance productivity without unnecessary complexity. \ No newline at end of file diff --git a/next_b2b_starter/Dockerfile b/next_b2b_starter/Dockerfile new file mode 100644 index 0000000..a134ee6 --- /dev/null +++ b/next_b2b_starter/Dockerfile @@ -0,0 +1,112 @@ +# ============================================================================= +# Frontend - Production Dockerfile +# ============================================================================= +# Multi-stage build for optimized Next.js 15 deployment with SSR support +# Final image size: ~120-150MB (vs ~500MB+ without optimization) +# Node.js 22 LTS (Jod) with Alpine Linux 3.22 +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Dependencies Installation +# ----------------------------------------------------------------------------- +FROM node:22-alpine3.22 AS deps + +# Install necessary system dependencies +RUN apk add --no-cache libc6-compat + +WORKDIR /app + +# Install pnpm globally +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy package management files +COPY package.json pnpm-lock.yaml .npmrc* ./ + +# Install dependencies using pnpm +# .npmrc ensures sharp is built correctly for image optimization +RUN pnpm install --frozen-lockfile --prod=false + +# ----------------------------------------------------------------------------- +# Stage 2: Application Builder +# ----------------------------------------------------------------------------- +FROM node:22-alpine3.22 AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules + +# Copy application source +COPY . . + +# Build arguments for NEXT_PUBLIC_* environment variables +# These must be set at build time for client-side code +ARG NEXT_PUBLIC_APP_BASE_URL +ARG NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN +ARG NEXT_PUBLIC_STYTCH_PROJECT_ID +ARG NEXT_PUBLIC_STYTCH_PROJECT_ENV +ARG NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN +ARG NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN +ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID +ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL + +# Set build-time environment variables +ENV NEXT_PUBLIC_APP_BASE_URL=$NEXT_PUBLIC_APP_BASE_URL +ENV NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=$NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN +ENV NEXT_PUBLIC_STYTCH_PROJECT_ID=$NEXT_PUBLIC_STYTCH_PROJECT_ID +ENV NEXT_PUBLIC_STYTCH_PROJECT_ENV=$NEXT_PUBLIC_STYTCH_PROJECT_ENV +ENV NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN=$NEXT_PUBLIC_POLAR_API_SANDBOX_ACCESS_TOKEN +ENV NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN=$NEXT_PUBLIC_POLAR_API_PRODUCTION_ACCESS_TOKEN +ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID +ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL + +# Disable Next.js telemetry +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build the application +# next.config.ts already has output: "standalone" configured +RUN pnpm build + +# ----------------------------------------------------------------------------- +# Stage 3: Production Runtime +# ----------------------------------------------------------------------------- +FROM node:22-alpine3.22 AS runner + +WORKDIR /app + +# Set production environment +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user for security +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy public assets +COPY --from=builder /app/public ./public + +# Copy standalone build output (optimized by Next.js) +# This includes only necessary dependencies and files +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Switch to non-root user +USER nextjs + +# Expose application port +EXPOSE 3000 + +# Set runtime environment variables +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Health check for container orchestration +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start the application +# Use node directly (not npm/pnpm) for proper signal handling and graceful shutdown +CMD ["node", "server.js"] diff --git a/next_b2b_starter/README.md b/next_b2b_starter/README.md new file mode 100644 index 0000000..8bcb2e9 --- /dev/null +++ b/next_b2b_starter/README.md @@ -0,0 +1,44 @@ +# B2B SaaS Starter + + A modern B2B SaaS starter kit with Authentication, Billing, and RBAC. + + ## 🚀 Quick Start + + ```bash + ./setup.sh + cd next_b2b_starter + pnpm dev + ``` + + Visit `http://localhost:3000`. + + ## 📚 Documentation + + - **[Getting Started](./docs/01-getting-started.md)** - Setup and installation + - **[Authentication](./docs/02-authentication.md)** - How auth works + - **[Permissions](./docs/03-permissions-and-roles.md)** - RBAC system + - **[Payments](./docs/04-payments-and-billing.md)** - Subscriptions + - **[Full Documentation](./docs/README.md)** - Complete guide index + + ## 🔧 Advanced + + - **[Stytch Configuration](./STYTCH_CONFIGURATION.md)** - Advanced auth security settings + - **[Claude Guide](./CLAUDE.md)** - Development rules + + ## Features + + - **Stack**: Next.js 15, TypeScript, Tailwind, shadcn/ui + - **Auth**: Stytch B2B (passwordless) + - **Billing**: Polar.sh integration + - **State**: React Query + Zustand + + ## Project Structure + + - `app/`: Next.js App Router + - `components/`: UI Components + - `lib/`: Business logic, hooks, API clients + - `middleware.ts`: Auth protection + + ## Support + + Open an issue on GitHub for support. License: MIT. diff --git a/next_b2b_starter/STYTCH_CONFIGURATION.md b/next_b2b_starter/STYTCH_CONFIGURATION.md new file mode 100644 index 0000000..06ddb11 --- /dev/null +++ b/next_b2b_starter/STYTCH_CONFIGURATION.md @@ -0,0 +1,243 @@ +# Stytch Configuration Guide + +This document explains how to configure Stytch B2B to prevent unknown users from receiving magic link emails and creating accounts. + +## Overview + +We've implemented a custom solution to address two critical security requirements: + +1. **Prevent emails being sent to non-existent users** +2. **Block unknown email addresses from creating accounts** + +## How It Works + +### Custom Backend Validation + +Instead of using Stytch's UI component directly (which always sends emails), we've created a custom flow: + +1. **Frontend**: Custom email form in `app/auth/page.tsx` +2. **Backend API**: `/api/auth/magic-link` validates membership before sending +3. **Stytch API**: Only called if user is an existing member + +### Security Features + +✅ **Email validation**: Backend checks if email exists in any organization before sending magic link +✅ **No user enumeration**: Returns same message for existing and non-existing users +✅ **JIT provisioning blocked**: Organization settings prevent auto-creation of new members +✅ **Discovery flow restricted**: Users can only join organizations they're invited to + +## Required Stytch Dashboard Configuration + +### Step 1: Disable Self-Service Organization Creation + +1. Log into your [Stytch Dashboard](https://stytch.com/dashboard) +2. Navigate to **Frontend SDK** settings +3. Find **"Create Organizations"** toggle under **Enabled methods** +4. **Disable** this toggle + +**Result**: Users cannot create new organizations via the discovery flow + +### Step 2: Configure Organization Settings (Per Organization) + +For each organization in your Stytch project: + +1. Navigate to **Organizations** in the dashboard +2. Select your organization +3. Go to **Settings** → **Authentication** +4. Configure the following: + +```json +{ + "email_jit_provisioning": "NOT_ALLOWED", + "email_invites": "RESTRICTED", + "email_allowed_domains": ["your-company.com"] // Optional: restrict by domain +} +``` + +**What each setting does:** + +- `email_jit_provisioning: "NOT_ALLOWED"` - Prevents new members from being auto-created via magic link +- `email_invites: "RESTRICTED"` - Requires explicit invitation to join +- `email_allowed_domains` - (Optional) Only allows specific email domains + +### Step 2a: (Optional) Configure Allowed Organization IDs + +The 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: + +```bash +STYTCH_ALLOWED_ORGANIZATION_IDS=org-test-123,org-test-456 +``` + +If this variable is not set, we automatically fetch all organizations in the workspace and cache the IDs in memory. + +### Step 3: Verify API Permissions + +Ensure your Stytch API credentials have permission to: +- Search members (`organizations.members.search`) +- Send magic links (`magicLinks.email.discovery.send`) + +## Testing the Implementation + +### Test Case 1: Unknown Email + +1. Enter an email that doesn't exist in any organization +2. Click "Send magic link" +3. **Expected**: Message says "If an account exists with that email, a magic link has been sent." +4. **Verify**: No email is actually sent +5. **Check backend logs**: Should see "No members found" for the email + +### Test Case 2: Existing Member + +1. Enter an email of an existing organization member +2. Click "Send magic link" +3. **Expected**: Same message as above +4. **Verify**: Email IS sent with magic link +5. **Check inbox**: Magic link email received + +### Test Case 3: Magic Link Authentication + +1. Click the magic link from Test Case 2 +2. **Expected**: User is authenticated and redirected to dashboard +3. **Verify**: Session is created with correct organization + +### Test Case 4: Unknown User Clicks Link (if they somehow got one) + +1. If someone gets a magic link URL (e.g., from a legitimate user) +2. **Expected**: Authentication fails with error +3. **Verify**: No session is created, user cannot access dashboard + +## API Endpoint Documentation + +### POST `/api/auth/magic-link` + +Validates email and sends magic link to existing members only. + +**Request:** +```json +{ + "email": "user@company.com" +} +``` + +**Response (Success):** +```json +{ + "success": true, + "message": "If an account exists with that email, a magic link has been sent." +} +``` + +**Response (Error):** +```json +{ + "error": "Unable to process request. Please try again later." +} +``` + +**Note**: Response is the same whether user exists or not (prevents enumeration) + +## How to Add New Members + +Since self-service signup is disabled, use one of these methods: + +### Method 1: Invite via Stytch Dashboard + +1. Go to **Organizations** → Select org → **Members** +2. Click **Invite Member** +3. Enter email and assign roles +4. User receives invitation email + +### Method 2: Programmatic Invite + +```typescript +import { getStytchB2BClient } from "@/lib/auth/stytch/server"; + +const client = getStytchB2BClient(); + +await client.magicLinks.email.invite.send({ + organization_id: "org-test-...", + email_address: "newuser@company.com", + invited_by_member_id: "member-test-...", +}); +``` + +### Method 3: Create Member via API + +```typescript +await client.organizations.members.create({ + organization_id: "org-test-...", + email_address: "newuser@company.com", + name: "New User", + roles: ["member"], +}); +``` + +## Troubleshooting + +### Issue: Existing users not receiving emails + +**Check:** +1. Email is verified in Stytch +2. Member status is "active" (not "pending" or "invited") +3. Backend logs for member search results +4. Stytch API credentials are correct + +### Issue: Unknown users still getting emails + +**Check:** +1. Using `/api/auth/magic-link` endpoint (not direct Stytch SDK call) +2. Backend search is working correctly +3. No caching issues in API route + +### Issue: Users can't create organizations + +**This is expected!** Self-service organization creation is disabled. + +**Solution:** Create organizations manually via: +- Stytch Dashboard +- Stytch API programmatically + +## Environment Variables + +Required in `.env.local`: + +```bash +# Stytch B2B Authentication +STYTCH_PROJECT_ID=project-test-... +STYTCH_SECRET=secret-test-... +NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=public-token-test-... + +# Session configuration +NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES=43200 # 30 days + +# App URLs +NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_STYTCH_REDIRECT_PATH=/authenticate +``` + +## Additional Security Recommendations + +1. **Enable MFA**: Require multi-factor authentication for sensitive organizations +2. **Monitor failed attempts**: Track authentication failures in your logs +3. **Rate limiting**: Add rate limiting to `/api/auth/magic-link` endpoint +4. **Email verification**: Ensure all members have verified emails +5. **Session duration**: Keep session duration appropriate for your security requirements + +## Migration from Discovery Flow + +If you were previously using the Discovery flow with self-service signup: + +1. **Export existing members**: Get list of all current members +2. **Notify users**: Inform them that signup is now invite-only +3. **Update documentation**: Update user docs about the new auth flow +4. **Monitor support requests**: Users may try to sign up and fail + +## Questions? + +For Stytch-specific configuration questions: +- [Stytch B2B Documentation](https://stytch.com/docs/b2b) +- [Stytch Support](https://stytch.com/contact) + +For implementation questions related to this codebase: +- Review `app/api/auth/magic-link/route.ts` for backend logic +- Review `app/auth/page.tsx` for frontend implementation diff --git a/next_b2b_starter/app/api/auth/consume-magic-link/route.ts b/next_b2b_starter/app/api/auth/consume-magic-link/route.ts new file mode 100644 index 0000000..cd2cecf --- /dev/null +++ b/next_b2b_starter/app/api/auth/consume-magic-link/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStytchB2BClient } from "@/lib/auth/stytch/server"; +import { + SESSION_COOKIE_NAME, + SESSION_JWT_COOKIE_NAME, +} from "@/lib/auth/constants"; +import { + getSessionDurationMinutes, + getCookieConfig, + getSecureCookieConfig, +} from "@/lib/auth/server-constants"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const token = body?.token; + const sessionDurationMinutes = body?.sessionDurationMinutes || getSessionDurationMinutes(); + + if (!token) { + return NextResponse.json( + { success: false, error: "Magic link token is required." }, + { status: 400 } + ); + } + + const client = getStytchB2BClient(); + + const result = await client.magicLinks.authenticate({ + magic_links_token: token, + session_duration_minutes: sessionDurationMinutes, + }); + + if (!result.member_authenticated) { + return NextResponse.json( + { + success: true, + memberAuthenticated: false, + intermediateSessionToken: result.intermediate_session_token, + member: result.member, + organization: result.organization, + mfaRequired: result.mfa_required, + primaryRequired: result.primary_required, + }, + { status: 200 } + ); + } + + const response = NextResponse.json( + { + success: true, + memberAuthenticated: true, + member: result.member, + organization: result.organization, + }, + { status: 200 } + ); + + const maxAgeSeconds = sessionDurationMinutes * 60; + + if (result.session_token) { + response.cookies.set(SESSION_COOKIE_NAME, result.session_token, { + ...getSecureCookieConfig(), + maxAge: maxAgeSeconds, + }); + } + + if (result.session_jwt) { + response.cookies.set(SESSION_JWT_COOKIE_NAME, result.session_jwt, { + ...getCookieConfig(), + maxAge: maxAgeSeconds, + }); + } + + return response; + } catch (error: any) { + const errorMessage = error?.error_message || error?.message || "Unable to verify magic link."; + + return NextResponse.json( + { + success: false, + error: errorMessage, + }, + { status: error?.status_code || 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/auth/logout/route.ts b/next_b2b_starter/app/api/auth/logout/route.ts new file mode 100644 index 0000000..79db181 --- /dev/null +++ b/next_b2b_starter/app/api/auth/logout/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getStytchB2BClient } from "@/lib/auth/stytch/server"; +import { SESSION_COOKIE_NAME, SESSION_JWT_COOKIE_NAME } from "@/lib/auth/constants"; + +function resolveReturnTo(url: URL): string { + const param = url.searchParams.get("returnTo"); + if (!param) return "/"; + const trimmed = param.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return "/"; + } + return trimmed; +} + +async function handle(request: NextRequest): Promise { + const url = new URL(request.url); + const redirectPath = resolveReturnTo(url); + + const sessionToken = request.cookies.get(SESSION_COOKIE_NAME)?.value; + const sessionJwt = request.cookies.get(SESSION_JWT_COOKIE_NAME)?.value; + + if (sessionToken || sessionJwt) { + try { + if (sessionToken) { + await getStytchB2BClient().sessions.revoke({ session_token: sessionToken }); + } else if (sessionJwt) { + await getStytchB2BClient().sessions.revoke({ session_jwt: sessionJwt }); + } + } catch (error) { + // Silently fail - user is logging out anyway + } + } + + const response = NextResponse.redirect(new URL(redirectPath, url.origin)); + response.cookies.delete(SESSION_COOKIE_NAME); + response.cookies.delete(SESSION_JWT_COOKIE_NAME); + + return response; +} + +export async function GET(request: NextRequest) { + return handle(request); +} + +export async function POST(request: NextRequest) { + return handle(request); +} diff --git a/next_b2b_starter/app/api/auth/magic-link/route.ts b/next_b2b_starter/app/api/auth/magic-link/route.ts new file mode 100644 index 0000000..9acb780 --- /dev/null +++ b/next_b2b_starter/app/api/auth/magic-link/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getStytchB2BClient, + getOrganizationIdsForMemberSearch, +} from "@/lib/auth/stytch/server"; + +/** + * POST /api/auth/magic-link + * + * Validates that an email belongs to an existing member before sending a magic link. + * This prevents unknown users from receiving authentication emails. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json( + { error: "Email address is required" }, + { status: 400 } + ); + } + + const client = getStytchB2BClient(); + const organizationIds = await getOrganizationIdsForMemberSearch(); + + if (!organizationIds.length) { + console.error("[Magic Link] No organization IDs configured for member search."); + return NextResponse.json( + { + error: "Unable to process request. Please try again later.", + }, + { status: 500 } + ); + } + + // Search for members with this email across all organizations + // This checks if the user exists in ANY organization + const searchResult = await client.organizations.members.search({ + organization_ids: organizationIds, + query: { + operator: "AND", + operands: [ + { + filter_name: "member_emails", + filter_value: [email.toLowerCase()], + }, + ], + }, + }); + + // If no members found, reject without revealing this fact + if (!searchResult.members || searchResult.members.length === 0) { + // Return success to prevent user enumeration + // But don't actually send an email + return NextResponse.json({ + success: true, + message: "If an account exists with that email, a magic link has been sent.", + }); + } + + // Member exists - prepare login redirect URL + const redirectUrl = process.env.NEXT_PUBLIC_APP_BASE_URL + ? `${process.env.NEXT_PUBLIC_APP_BASE_URL}/authenticate` + : `${request.nextUrl.origin}/authenticate`; + + const memberOrganizationIds = Array.from( + new Set( + (searchResult.members ?? []) + .map((member) => member.organization_id) + .filter((orgId): orgId is string => Boolean(orgId)) + ) + ); + + if (memberOrganizationIds.length === 0) { + console.warn( + "[Magic Link] Member search succeeded but no organization IDs were returned for email:", + email + ); + return NextResponse.json({ + success: true, + message: "If an account exists with that email, a magic link has been sent.", + }); + } + + if (memberOrganizationIds.length > 1) { + console.warn( + "[Magic Link] Email is associated with multiple organizations; issuing login link for all memberships.", + { + email, + organizationIds: memberOrganizationIds, + } + ); + } + + // Send magic link for each organization the member belongs to + await Promise.all( + memberOrganizationIds.map((organizationId) => + client.magicLinks.email.loginOrSignup({ + email_address: email, + organization_id: organizationId, + login_redirect_url: redirectUrl, + }) + ) + ); + + return NextResponse.json({ + success: true, + message: "If an account exists with that email, a magic link has been sent.", + }); + } catch (error: any) { + console.error("[Magic Link] Error sending magic link:", error); + + // Return generic error to prevent user enumeration + return NextResponse.json( + { + error: "Unable to process request. Please try again later.", + details: process.env.NODE_ENV === "development" ? error.message : undefined, + }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/auth/session/refresh/route.ts b/next_b2b_starter/app/api/auth/session/refresh/route.ts new file mode 100644 index 0000000..de419ef --- /dev/null +++ b/next_b2b_starter/app/api/auth/session/refresh/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +import { getStytchB2BClient } from "@/lib/auth/stytch/server"; +import { + SESSION_COOKIE_NAME, + SESSION_JWT_COOKIE_NAME, +} from "@/lib/auth/constants"; +import { + getSessionDurationMinutes, + getCookieConfig, +} from "@/lib/auth/server-constants"; +import { isTokenExpired } from "@/lib/auth/token-utils"; + +export async function POST() { + try { + const cookieStore = await cookies(); + + // First, check if we already have a valid JWT + const existingJwt = cookieStore.get(SESSION_JWT_COOKIE_NAME)?.value ?? null; + if (existingJwt && !isTokenExpired(existingJwt)) { + console.log("[Refresh] Returning existing valid JWT"); + return NextResponse.json({ sessionJwt: existingJwt }); + } + + if (existingJwt) { + console.log("[Refresh] Existing JWT is expired, fetching new one"); + } + + // Try to get session token to exchange for JWT + const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; + + if (!sessionToken) { + console.warn("[Refresh] No session token found in cookies"); + return NextResponse.json( + { sessionJwt: null, error: "session_not_found" }, + { status: 401 } + ); + } + + console.log("[Refresh] Exchanging session token for new JWT"); + + const client = getStytchB2BClient(); + + try { + const response = await client.sessions.authenticate({ + session_token: sessionToken, + session_duration_minutes: getSessionDurationMinutes(), + }); + + const sessionJwt = (response as any)?.session_jwt ?? null; + + if (!sessionJwt) { + console.error("[Refresh] Stytch response missing session_jwt"); + return NextResponse.json( + { sessionJwt: null, error: "session_missing_jwt" }, + { status: 401 } + ); + } + + // Validate the new JWT before returning it + if (isTokenExpired(sessionJwt)) { + console.error("[Refresh] Newly issued JWT is already expired"); + return NextResponse.json( + { sessionJwt: null, error: "session_jwt_expired" }, + { status: 401 } + ); + } + + console.log("[Refresh] Successfully issued new JWT"); + + const res = NextResponse.json({ sessionJwt }); + const maxAgeSeconds = getSessionDurationMinutes() * 60; + + res.cookies.set(SESSION_JWT_COOKIE_NAME, sessionJwt, { + ...getCookieConfig(), + maxAge: maxAgeSeconds, + }); + + return res; + } catch (stytchError: any) { + console.error("[Refresh] Stytch authentication failed:", stytchError?.message || "Unknown error"); + + // Clear invalid session cookies + const response = NextResponse.json( + { + sessionJwt: null, + error: "session_invalid" + }, + { status: 401 } + ); + + // Clear the invalid cookies + response.cookies.delete(SESSION_COOKIE_NAME); + response.cookies.delete(SESSION_JWT_COOKIE_NAME); + + return response; + } + } catch (error) { + console.error("[Refresh] Unexpected error during token refresh:", error instanceof Error ? error.message : "Unknown error"); + return NextResponse.json( + { sessionJwt: null, error: "refresh_failed" }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/billing/checkout/route.ts b/next_b2b_starter/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..4fa22fb --- /dev/null +++ b/next_b2b_starter/app/api/billing/checkout/route.ts @@ -0,0 +1,237 @@ +import { NextResponse } from "next/server"; + +import { getMemberSession } from "@/lib/auth/stytch/server"; +import { getServerPermissions } from "@/lib/auth/server-permissions"; +import { getPolarClient } from "@/lib/polar/client"; +import { getDefaultPlan, getPlanById, getPlanByProductId, type PolarPlan } from "@/lib/polar/plans"; +import { getActiveSubscription } from "@/lib/polar/subscription"; + +function getAppBaseUrl(): string { + return ( + process.env.NEXT_PUBLIC_APP_BASE_URL ?? + process.env.APP_BASE_URL ?? + "http://localhost:3000" + ); +} + +export async function GET(request: Request) { + const client = getPolarClient(); + if (!client || !process.env.POLAR_ACCESS_TOKEN) { + return NextResponse.json( + { error: "Polar billing is not configured." }, + { status: 503 } + ); + } + + const session = await getMemberSession(); + if (!session?.session_jwt) { + console.info("[Polar] Checkout attempted without authentication"); + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + const permissions = await getServerPermissions(session); + const profile = permissions.profile; + if (!profile) { + console.warn("[Polar] Checkout aborted - profile unavailable"); + return NextResponse.json({ error: "Profile not available." }, { status: 401 }); + } + + if (!permissions.canManageSubscriptions) { + console.info("[Polar] Checkout forbidden - insufficient permissions", { + memberId: profile.member_id, + }); + return NextResponse.json( + { error: "You do not have access to manage subscriptions." }, + { status: 403 } + ); + } + + try { + // Fetch all products from Polar to validate the plan + const productsResponse = await client.products.list({ + isArchived: false, + }); + + const polarProducts = productsResponse.result.items; + + // Transform products to PolarPlan format + const availablePlans: PolarPlan[] = polarProducts + .map((product): PolarPlan | null => { + const price = product.prices?.[0]; + if (!price || !price.id) { + console.warn("[Polar] Product missing price, skipping", { + productId: product.id, + name: product.name, + }); + return null; + } + + const metadata = (product.metadata ?? {}) as Record; + const planId = typeof metadata.plan_id === "string" ? metadata.plan_id : product.id; + + const includedSeats = + typeof metadata.included_seats === "number" + ? metadata.included_seats + : typeof metadata.max_seats === "number" + ? metadata.max_seats + : typeof metadata.seats === "number" + ? metadata.seats + : null; + + const includedInvoices = + typeof metadata.included_invoices === "number" + ? metadata.included_invoices + : typeof metadata.invoice_limit === "number" + ? metadata.invoice_limit + : typeof metadata.invoices === "number" + ? metadata.invoices + : null; + + const benefits = + product.benefits?.map((b) => b.description).filter(Boolean) ?? []; + + const plan: PolarPlan = { + id: planId, + name: product.name, + description: product.description ?? null, + price: + price.amountType === "fixed" && price.priceAmount + ? price.priceAmount / 100 + : 0, + interval: (price.recurringInterval as "month" | "year") ?? "month", + productId: product.id, + priceId: price.id, + includedSeats, + includedInvoices, + benefits, + metadata, + }; + + return plan; + }) + .filter((plan): plan is PolarPlan => plan !== null); + + console.info("[Polar] Fetched products for checkout", { + productsCount: availablePlans.length, + productIds: availablePlans.map((p) => p.productId), + }); + + const url = new URL(request.url); + const planId = url.searchParams.get("plan"); + const products = url.searchParams + .getAll("products") + .map((product) => product.trim()) + .filter(Boolean); + + let selectedPlan = getPlanById(availablePlans, planId); + + if (selectedPlan) { + products.splice(0, products.length, selectedPlan.productId); + } + + if (products.length === 0) { + const defaultPlan = getDefaultPlan(availablePlans); + if (defaultPlan) { + selectedPlan = defaultPlan; + products.push(defaultPlan.productId); + } else { + return NextResponse.json( + { error: "No Polar product configured." }, + { status: 503 } + ); + } + } + + if (!selectedPlan && planId) { + console.warn("[Polar] Requested plan not found, defaulting to product list", { + planId, + }); + } + + if (!selectedPlan && products.length > 0) { + selectedPlan = getPlanByProductId(availablePlans, products[0]) ?? null; + } + + const organizationId = profile.organization?.organization_id ?? null; + + // Check if user already has an active subscription + // If they do, they should use the update endpoint instead + const existingSubscription = await getActiveSubscription({ + externalCustomerId: organizationId, + customerEmail: profile.email, + organizationId, + }); + + if (existingSubscription.isActive && existingSubscription.subscription) { + console.warn("[Polar] User already has active subscription, should use update endpoint", { + subscriptionId: existingSubscription.subscription.id, + currentProductId: existingSubscription.subscription.productId, + requestedPlanId: selectedPlan?.id, + }); + + return NextResponse.json( + { + error: "You already have an active subscription.", + shouldUpdate: true, + subscriptionId: existingSubscription.subscription.id, + currentProductId: existingSubscription.subscription.productId, + message: "Please use the plan switcher to upgrade or downgrade your existing subscription.", + }, + { status: 400 } + ); + } + const accountId = + typeof profile.account_id === "number" + ? String(profile.account_id) + : profile.account_id ?? null; + + const metadata: Record = {}; + if (organizationId) { + metadata.organization_id = organizationId; + } + if (accountId) { + metadata.account_id = accountId; + } + if (selectedPlan) { + metadata.plan_id = selectedPlan.id; + } + + const customerMetadata: Record = {}; + if (organizationId) { + customerMetadata.organization_id = organizationId; + } + + console.info("[Polar] Creating checkout session", { + organizationId, + email: profile.email, + products, + planId: selectedPlan?.id ?? null, + }); + + const checkout = await client.checkouts.create({ + products, + successUrl: `${getAppBaseUrl()}/dashboard?checkout_id={CHECKOUT_ID}`, + returnUrl: `${getAppBaseUrl()}/subscribe-required`, + externalCustomerId: organizationId ?? undefined, + customerEmail: profile.email, + customerName: profile.name ?? undefined, + metadata: Object.keys(metadata).length ? metadata : undefined, + customerMetadata: Object.keys(customerMetadata).length + ? customerMetadata + : undefined, + }); + + console.info("[Polar] Checkout session created", { + checkoutId: checkout.id, + url: checkout.url, + }); + + return NextResponse.redirect(checkout.url, { status: 303 }); + } catch (error) { + console.error("[Polar] Failed to create checkout session", error); + return NextResponse.json( + { error: "Failed to start Polar checkout session." }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/billing/debug/route.ts b/next_b2b_starter/app/api/billing/debug/route.ts new file mode 100644 index 0000000..c8f4fe4 --- /dev/null +++ b/next_b2b_starter/app/api/billing/debug/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; + +import { resolveCurrentSubscription } from "@/lib/polar/current-subscription"; + +/** + * Debug Endpoint - Full Polar Subscription Data + * + * GET /api/billing/debug + * + * Returns complete subscription state with all metadata for debugging. + * This endpoint prints ALL Polar.sh subscription data including: + * - Full subscription metadata + * - Customer metadata + * - Product metadata + * - Meters array + * - Trial information + * - Custom fields + * + * IMPORTANT: Only use in development/testing. Do not expose in production. + */ +export async function GET() { + // Security check - only allow in non-production environments + if (process.env.NODE_ENV === "production") { + return NextResponse.json( + { + error: "Debug endpoint disabled in production", + message: "This endpoint is only available in development environments", + }, + { status: 403 } + ); + } + + try { + const state = await resolveCurrentSubscription(); + + // Return complete subscription state with all metadata + return NextResponse.json( + { + success: true, + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV, + data: { + // Gate state + isAuthenticated: state.isAuthenticated, + isActive: state.isActive, + reason: state.reason, + status: state.status, + backendAvailable: state.backendAvailable, + backendError: state.backendError, + + // Subscription identifiers + productId: state.productId, + meterId: state.meterId, + planId: state.planId, + + // Full subscription object with all metadata + subscription: state.subscription, + + // Usage data + usage: state.usage, + + // Metadata breakdown for easy inspection + metadata: { + subscription: state.subscription?.metadata ?? null, + customFields: state.subscription?.customFieldData ?? null, + }, + + // Additional context + trial: { + start: state.subscription?.trialStart ?? null, + end: state.subscription?.trialEnd ?? null, + isActive: + state.subscription?.trialStart && + state.subscription?.trialEnd && + new Date() < new Date(state.subscription.trialEnd), + }, + + cancellation: { + cancelAtPeriodEnd: state.subscription?.cancelAtPeriodEnd ?? false, + reason: state.subscription?.customerCancellationReason ?? null, + comment: state.subscription?.customerCancellationComment ?? null, + }, + + recurringInterval: state.subscription?.recurringInterval ?? null, + }, + }, + { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store, no-cache, must-revalidate", + }, + } + ); + } catch (error) { + console.error("[Polar Debug] Failed to fetch subscription data", error); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred", + timestamp: new Date().toISOString(), + }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/billing/meters/route.ts b/next_b2b_starter/app/api/billing/meters/route.ts new file mode 100644 index 0000000..2ed78ca --- /dev/null +++ b/next_b2b_starter/app/api/billing/meters/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from "next/server"; + +import { getMemberSession } from "@/lib/auth/stytch/server"; +import { getServerPermissions } from "@/lib/auth/server-permissions"; +import { listMeters, getCustomerMeterUsage } from "@/lib/polar/usage"; +import { POLAR_METER_ID } from "@/lib/polar/config"; + +/** + * Get Meters and Usage Data + * + * GET /api/billing/meters + * + * Returns: + * - All available meters + * - Customer usage for the configured invoice meter + * - Current user's organization usage + * + * This endpoint provides secure access to Polar meters data without + * exposing API keys to the frontend. + */ +export async function GET() { + // Authentication check + const session = await getMemberSession(); + if (!session?.session_jwt) { + console.info("[Polar Meters] Unauthenticated request"); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 } + ); + } + + const permissions = await getServerPermissions(session); + if (!permissions.backendAvailable) { + console.warn("[Polar Meters] Backend unavailable", { + error: permissions.backendError, + }); + return NextResponse.json( + { + error: "Service temporarily unavailable", + details: permissions.backendError, + }, + { status: 503 } + ); + } + + const profile = permissions.profile; + if (!profile) { + console.warn("[Polar Meters] Profile unavailable"); + return NextResponse.json( + { error: "Profile not available." }, + { status: 401 } + ); + } + + if (!permissions.canManageSubscriptions) { + console.info("[Polar Meters] Forbidden - insufficient permissions", { + memberId: profile.member_id, + }); + return NextResponse.json( + { error: "You do not have access to manage subscriptions." }, + { status: 403 } + ); + } + + try { + // Fetch all meters + const meters = await listMeters(); + + // Find the invoice meter details + const invoiceMeter = meters.find((m) => m.id === POLAR_METER_ID); + + console.info("[Polar Meters] Meters data fetched successfully", { + totalMeters: meters.length, + invoiceMeterId: POLAR_METER_ID, + hasInvoiceMeter: Boolean(invoiceMeter), + organizationId: profile.organization?.organization_id, + }); + + // NOTE: Customer usage API not yet available in SDK + // When available, we can fetch per-customer usage here + + return NextResponse.json( + { + success: true, + data: { + // All available meters + meters: meters.map((m) => ({ + id: m.id, + name: m.name, + aggregation: m.aggregation, + filter: m.filter, + })), + + // Invoice meter details + invoiceMeter: invoiceMeter + ? { + id: invoiceMeter.id, + name: invoiceMeter.name, + aggregation: invoiceMeter.aggregation, + filter: invoiceMeter.filter, + } + : null, + + // Placeholders for when SDK supports customer usage endpoint + organizationUsage: null, + customerUsage: null, + }, + }, + { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "private, max-age=60", // Cache for 1 minute + }, + } + ); + } catch (error) { + console.error("[Polar Meters] Failed to fetch meters data", { + error: error instanceof Error ? error.message : String(error), + organizationId: profile.organization?.organization_id, + }); + + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to fetch meters data", + }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/billing/products/route.ts b/next_b2b_starter/app/api/billing/products/route.ts new file mode 100644 index 0000000..4e38d42 --- /dev/null +++ b/next_b2b_starter/app/api/billing/products/route.ts @@ -0,0 +1,195 @@ +import { NextResponse } from "next/server"; + +import { getMemberSession } from "@/lib/auth/stytch/server"; +import { getServerPermissions } from "@/lib/auth/server-permissions"; +import { getPolarClient } from "@/lib/polar/client"; + +export interface PolarProductResponse { + id: string; + name: string; + description: string | null; + price: number; // USD per month + interval: "month" | "year"; + productId: string; + priceId: string | null; + includedSeats: number | null; + includedInvoices: number | null; + benefits: string[]; + metadata: Record; +} + +/** + * Get Products from Polar + * + * GET /api/billing/products + * + * Returns all active products from Polar with their pricing and metadata. + * Products include plan details like included seats, invoices, and benefits. + * + * This endpoint provides secure access to Polar products without exposing API keys to the frontend. + */ +export async function GET() { + // Authentication check + const session = await getMemberSession(); + if (!session?.session_jwt) { + console.info("[Polar Products] Unauthenticated request"); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 } + ); + } + + const permissions = await getServerPermissions(session); + if (!permissions.backendAvailable) { + console.warn("[Polar Products] Backend unavailable", { + error: permissions.backendError, + }); + return NextResponse.json( + { + error: "Service temporarily unavailable", + details: permissions.backendError, + }, + { status: 503 } + ); + } + + const profile = permissions.profile; + if (!profile) { + console.warn("[Polar Products] Profile unavailable"); + return NextResponse.json( + { error: "Profile not available." }, + { status: 401 } + ); + } + + if (!permissions.canManageSubscriptions) { + console.info("[Polar Products] Forbidden - insufficient permissions", { + memberId: profile.member_id, + }); + return NextResponse.json( + { error: "You do not have access to manage subscriptions." }, + { status: 403 } + ); + } + + const client = getPolarClient(); + if (!client) { + console.warn("[Polar Products] Polar client unavailable"); + return NextResponse.json( + { error: "Billing service not configured." }, + { status: 503 } + ); + } + + try { + console.info("[Polar Products] Fetching products"); + + // Fetch all products from Polar + const response = await client.products.list({ + // Only return active, non-archived products + isArchived: false, + }); + + const products = response.result.items; + + console.info("[Polar Products] Products fetched successfully", { + count: products.length, + products: products.map((p) => ({ + id: p.id, + name: p.name, + pricesCount: p.prices?.length ?? 0, + })), + }); + + // Transform products to frontend-friendly format + const transformedProducts: PolarProductResponse[] = products.reduce( + (acc, product) => { + const price = product.prices?.[0]; + if (!price || !price.id) { + console.warn("[Polar Products] Product has no usable price", { + productId: product.id, + name: product.name, + }); + return acc; + } + + const metadata = product.metadata ?? {}; + + const includedSeats = + typeof metadata.included_seats === "number" + ? metadata.included_seats + : typeof metadata.max_seats === "number" + ? metadata.max_seats + : typeof metadata.seats === "number" + ? metadata.seats + : null; + + const includedInvoices = + typeof metadata.included_invoices === "number" + ? metadata.included_invoices + : typeof metadata.invoice_limit === "number" + ? metadata.invoice_limit + : typeof metadata.invoices === "number" + ? metadata.invoices + : null; + + const benefits = + product.benefits?.map((b) => b.description).filter(Boolean) ?? []; + + const planId = typeof metadata.plan_id === "string" ? metadata.plan_id : product.id; + + acc.push({ + id: planId, + name: product.name, + description: product.description ?? null, + price: + price.amountType === "fixed" && price.priceAmount + ? price.priceAmount / 100 + : 0, + interval: (price.recurringInterval as "month" | "year") ?? "month", + productId: product.id, + priceId: price.id, + includedSeats, + includedInvoices, + benefits, + metadata: metadata as Record, + }); + + return acc; + }, + [] as PolarProductResponse[] + ).sort((a, b) => a.price - b.price); + + return NextResponse.json( + { + success: true, + data: { + products: transformedProducts, + }, + }, + { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "private, max-age=300", // Cache for 5 minutes + }, + } + ); + } catch (error) { + console.error("[Polar Products] Failed to fetch products", { + error: error instanceof Error ? error.message : String(error), + organizationId: profile.organization?.organization_id, + }); + + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to fetch products", + }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/billing/status/route.ts b/next_b2b_starter/app/api/billing/status/route.ts new file mode 100644 index 0000000..9ae6503 --- /dev/null +++ b/next_b2b_starter/app/api/billing/status/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +import { resolveCurrentSubscription } from "@/lib/polar/current-subscription"; + +export async function GET() { + const state = await resolveCurrentSubscription(); + + if (!state.isAuthenticated) { + console.info("[Polar] /api/billing/status unauthenticated", { + reason: state.reason, + }); + return NextResponse.json( + { error: state.reason ?? "Authentication required." }, + { status: 401 } + ); + } + + if (state.reason === "INSUFFICIENT_PERMISSIONS") { + console.info("[Polar] /api/billing/status forbidden", { + reason: state.reason, + }); + return NextResponse.json( + { error: "You cannot view subscription details." }, + { status: 403 } + ); + } + + console.info("[Polar] /api/billing/status", { + isActive: state.isActive, + status: state.status, + reason: state.reason, + }); + + return NextResponse.json(state); +} diff --git a/next_b2b_starter/app/api/billing/subscription/cancel/route.ts b/next_b2b_starter/app/api/billing/subscription/cancel/route.ts new file mode 100644 index 0000000..b079c07 --- /dev/null +++ b/next_b2b_starter/app/api/billing/subscription/cancel/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from "next/server"; + +import { getMemberSession } from "@/lib/auth/stytch/server"; +import { getServerPermissions } from "@/lib/auth/server-permissions"; +import { getActiveSubscription } from "@/lib/polar/subscription"; +import { getPolarClient } from "@/lib/polar/client"; +import type { SubscriptionCancel } from "@polar-sh/sdk/models/components/subscriptioncancel"; +import type { CustomerCancellationReason } from "@polar-sh/sdk/models/components/customercancellationreason"; + +type CancelRequestPayload = { + cancelAtPeriodEnd?: boolean; + reason?: string | null; + comment?: string | null; +}; + +const ALLOWED_REASONS = new Set([ + "too_expensive", + "missing_features", + "switched_service", + "unused", + "customer_service", + "low_quality", + "too_complex", + "other", +]); + +export async function POST(request: Request) { + const client = getPolarClient(); + if (!client) { + return NextResponse.json( + { error: "Polar billing is not configured." }, + { status: 503 } + ); + } + + const session = await getMemberSession(); + if (!session?.session_jwt) { + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 } + ); + } + + const permissions = await getServerPermissions(session); + const profile = permissions.profile; + + if (!profile?.organization?.organization_id) { + return NextResponse.json( + { error: "Organization context required to manage subscriptions." }, + { status: 400 } + ); + } + + if (!permissions.canManageSubscriptions) { + console.info("[Polar] Subscription cancel forbidden - insufficient permissions", { + memberId: profile.member_id, + }); + return NextResponse.json( + { error: "You do not have access to manage subscriptions." }, + { status: 403 } + ); + } + + let payload: CancelRequestPayload | null = null; + try { + payload = await request.json(); + } catch { + payload = null; + } + + const cancelAtPeriodEnd = + typeof payload?.cancelAtPeriodEnd === "boolean" ? payload.cancelAtPeriodEnd : true; + + const reason = + typeof payload?.reason === "string" && ALLOWED_REASONS.has(payload.reason) + ? payload.reason + : undefined; + const comment = + typeof payload?.comment === "string" && payload.comment.trim().length > 0 + ? payload.comment.trim() + : undefined; + + const subscriptionResult = await getActiveSubscription({ + externalCustomerId: profile.organization.organization_id, + customerEmail: profile.email, + organizationId: profile.organization.organization_id, + }); + + const subscription = subscriptionResult.subscription; + + if (!subscription) { + return NextResponse.json( + { error: "No active subscription to update." }, + { status: 400 } + ); + } + + const subscriptionUpdatePayload: SubscriptionCancel = { + cancelAtPeriodEnd, + }; + + if (reason) { + subscriptionUpdatePayload.customerCancellationReason = reason as CustomerCancellationReason; + } + + if (comment) { + subscriptionUpdatePayload.customerCancellationComment = comment; + } + + try { + await client.subscriptions.update({ + id: subscription.id, + subscriptionUpdate: subscriptionUpdatePayload, + }); + } catch (error) { + console.error("[Polar] Failed to update subscription cancellation", error); + return NextResponse.json( + { error: "Failed to update subscription status. Please try again." }, + { status: 500 } + ); + } + + const refreshed = await getActiveSubscription({ + externalCustomerId: profile.organization.organization_id, + customerEmail: profile.email, + organizationId: profile.organization.organization_id, + }); + + return NextResponse.json({ + success: true, + cancelAtPeriodEnd, + status: refreshed.status, + subscription: refreshed.subscription + ? { + id: refreshed.subscription.id, + status: refreshed.subscription.status, + cancelAtPeriodEnd: refreshed.subscription.cancelAtPeriodEnd, + currentPeriodEnd: refreshed.subscription.currentPeriodEnd?.toISOString() ?? null, + } + : null, + }); +} diff --git a/next_b2b_starter/app/api/billing/verify-payment/route.ts b/next_b2b_starter/app/api/billing/verify-payment/route.ts new file mode 100644 index 0000000..07ee016 --- /dev/null +++ b/next_b2b_starter/app/api/billing/verify-payment/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; + +import { getMemberSession } from "@/lib/auth/stytch/server"; +import { apiClient } from "@/lib/api/api/client/api-client"; + +interface VerifyPaymentRequest { + session_id: string; +} + +interface BillingStatus { + organization_id: number; + external_id?: string; + has_active_subscription: boolean; + can_process_invoices: boolean; + invoice_count: number; + reason: string; + checked_at: string; +} + +export async function POST(request: Request) { + const session = await getMemberSession(); + if (!session?.session_jwt) { + console.info("[Billing] verify-payment attempted without authentication"); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 } + ); + } + + let body: VerifyPaymentRequest; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid request body." }, + { status: 400 } + ); + } + + if (!body.session_id || typeof body.session_id !== "string") { + return NextResponse.json( + { error: "session_id is required." }, + { status: 400 } + ); + } + + try { + console.info("[Billing] Verifying payment from checkout session", { + sessionId: body.session_id, + }); + + const billingStatus = await apiClient.post( + "/subscriptions/verify-payment", + { session_id: body.session_id } + ); + + console.info("[Billing] Payment verification successful", { + sessionId: body.session_id, + hasActiveSubscription: billingStatus.has_active_subscription, + reason: billingStatus.reason, + }); + + return NextResponse.json(billingStatus); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("[Billing] Payment verification failed", { + sessionId: body.session_id, + error: errorMessage, + }); + + // Check if it's a 404 (session not found) + if (errorMessage.includes("404")) { + return NextResponse.json( + { error: "Checkout session not found." }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: "Failed to verify payment." }, + { status: 500 } + ); + } +} diff --git a/next_b2b_starter/app/api/billing/webhook/route.ts b/next_b2b_starter/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..984ac90 --- /dev/null +++ b/next_b2b_starter/app/api/billing/webhook/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { Webhooks } from "@polar-sh/nextjs"; + +const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; + +async function handleSubscriptionEvent(eventType: string, payload: unknown) { + try { + console.info(`[Polar] ${eventType}`, { + subscriptionId: + typeof payload === "object" && payload && "id" in payload + ? (payload as { id: string }).id + : undefined, + }); + // TODO: forward to backend persistence layer when available. + } catch (error) { + console.error("[Polar] Failed to handle webhook event", error); + } +} + +export const POST = webhookSecret + ? Webhooks({ + webhookSecret, + onSubscriptionCreated: async (subscription) => { + await handleSubscriptionEvent("subscription.created", subscription); + }, + onSubscriptionUpdated: async (subscription) => { + await handleSubscriptionEvent("subscription.updated", subscription); + }, + onSubscriptionCanceled: async (subscription) => { + await handleSubscriptionEvent("subscription.canceled", subscription); + }, + onOrderPaid: async (order) => { + await handleSubscriptionEvent("order.paid", order); + }, + }) + : async () => + NextResponse.json( + { error: "Polar webhook secret not configured." }, + { status: 503 } + ); diff --git a/next_b2b_starter/app/auth/page.tsx b/next_b2b_starter/app/auth/page.tsx new file mode 100644 index 0000000..d23f9a1 --- /dev/null +++ b/next_b2b_starter/app/auth/page.tsx @@ -0,0 +1,455 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { useStytchMember } from "@stytch/nextjs/b2b"; +import { ArrowRight, CheckCircle2, Home, Inbox, Mail } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; + + +const highlights = [ + "Single workspace to review invoices, approvals, and exports.", + "Ready-made controls that plug into your existing banking stack.", + "Sessions scoped to your organization with role-aware access.", +]; + +const emailProviders = [ + { + label: "Open Gmail", + href: "https://mail.google.com/", + }, + { + label: "Open Outlook", + href: "https://outlook.office.com/mail/", + }, + { + label: "Open iCloud Mail", + href: "https://www.icloud.com/mail", + }, + { + label: "Open Yahoo Mail", + href: "https://mail.yahoo.com/", + }, +]; + +export default function AuthPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { member, isInitialized } = useStytchMember(); + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [status, setStatus] = useState<{ + type: "info" | "error" | "success"; + message: string; + } | null>(null); + const [isRedirecting, setIsRedirecting] = useState(false); + const [view, setView] = useState<"form" | "success">("form"); + const [lastSubmittedEmail, setLastSubmittedEmail] = useState(""); + const hasRedirectedRef = useRef(false); + const redirectTimeoutRef = useRef(null); + + const targetAfterLogin = useMemo(() => { + const returnTo = searchParams.get("returnTo") || "/dashboard"; + return returnTo.startsWith("/") && !returnTo.startsWith("//") + ? returnTo + : "/dashboard"; + }, [searchParams]); + + const handleAuthSuccess = useCallback(() => { + if (hasRedirectedRef.current) return; + hasRedirectedRef.current = true; + setIsRedirecting(true); + setStatus({ + type: "info", + message: "You’re signed in. Redirecting to your workspace…", + }); + router.replace(targetAfterLogin); + setTimeout(() => { + router.refresh(); + }, 150); + if (typeof window !== "undefined") { + if (redirectTimeoutRef.current !== null) { + window.clearTimeout(redirectTimeoutRef.current); + } + redirectTimeoutRef.current = window.setTimeout(() => { + window.location.assign(targetAfterLogin); + }, 1500); + } + }, [router, targetAfterLogin]); + + useEffect(() => { + if (!isInitialized) return; + if (member) { + handleAuthSuccess(); + } + }, [isInitialized, member, handleAuthSuccess]); + + useEffect(() => { + const hasMagicLinkParams = + searchParams.has("stytch_token") || + searchParams.has("token") || + searchParams.has("stytch_token_type"); + + if (hasMagicLinkParams) { + setStatus({ + type: "info", + message: "We’re verifying your sign-in link. This usually takes just a moment.", + }); + } + }, [searchParams]); + + const submitEmail = useCallback( + async ( + rawEmail: string, + options: { resetField?: boolean; stayOnSuccessView?: boolean } = {} + ) => { + const { resetField = true, stayOnSuccessView = false } = options; + const trimmedEmail = rawEmail.trim().toLowerCase(); + + if (!trimmedEmail) { + setStatus({ + type: "error", + message: "Please enter a valid email address.", + }); + return; + } + + setIsSubmitting(true); + setStatus({ + type: "info", + message: "Checking your workspace access…", + }); + if (!stayOnSuccessView) { + setView("form"); + } + + try { + const query = new URLSearchParams({ email: trimmedEmail }).toString(); + const apiBaseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api").replace(/\/$/, ""); + + const emailCheckResponse = await fetch(`${apiBaseUrl}/auth/check-email?${query}`); + + if (emailCheckResponse.status === 404) { + const errorBody = await emailCheckResponse.json().catch(() => null); + setStatus({ + type: "error", + message: + (errorBody && errorBody.message) || + "We couldn't find an account with that email. Try a different email or ask your admin to invite you.", + }); + return; + } + + if (!emailCheckResponse.ok) { + const errorBody = await emailCheckResponse.json().catch(() => null); + throw new Error( + (errorBody && errorBody.message) || + "We couldn't verify that email right now. Please try again in a moment.", + ); + } + + setStatus({ + type: "info", + message: "Sending your secure sign-in link…", + }); + + const response = await fetch("/api/auth/magic-link", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: trimmedEmail }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to send sign-in link."); + } + + setLastSubmittedEmail(trimmedEmail); + setStatus({ + type: "success", + message: "Check your email for a secure link to sign in.", + }); + setView("success"); + + if (resetField) { + setEmail(""); + } + } catch (error: any) { + console.error("Sign-in link send error:", error); + setStatus({ + type: "error", + message: + error?.message || + "Something went wrong while sending your sign-in link. Please try again.", + }); + } finally { + setIsSubmitting(false); + } + }, + [] + ); + + const handleSendMagicLink = async (e: React.FormEvent) => { + e.preventDefault(); + await submitEmail(email, { resetField: true }); + }; + + const handleResend = async () => { + if (!lastSubmittedEmail) return; + await submitEmail(lastSubmittedEmail, { + resetField: false, + stayOnSuccessView: true, + }); + }; + + useEffect(() => { + router.prefetch(targetAfterLogin); + }, [router, targetAfterLogin]); + + useEffect(() => { + return () => { + if (typeof window !== "undefined" && redirectTimeoutRef.current !== null) { + window.clearTimeout(redirectTimeoutRef.current); + redirectTimeoutRef.current = null; + } + }; + }, []); + + if (!isInitialized) { + return ( +
+
+
+

+ Checking your workspace session… +

+
+
+ ); + } + + if (isRedirecting || member) { + return ( +
+
+
+
+

+ Redirecting to your dashboard +

+

+ {(status && status.message) || + "We’re setting up your workspace now."} +

+

+ Taking longer than expected?{" "} + + Open your workspace + + . +

+
+
+
+ ); + } + + return ( +
+
+
+
+
+ + + Secure email sign-in + + + + Home + +
+
+

+ Welcome back to Your App +

+

+ Use your work email to receive a one-time, organization-aware + sign-in link. We’ll land you back where you left off as soon as + you’re authenticated. +

+
+
+
+ {highlights.map((item) => ( +
+ +

{item}

+
+ ))} +
+
+

+ Need a hand? +

+

+ Reach out at{" "} + + support@yourapp.com + {" "} + for support, or check the documentation in your workspace. +

+
+
+ + +
+
+ ); +} diff --git a/next_b2b_starter/app/authenticate/page.tsx b/next_b2b_starter/app/authenticate/page.tsx new file mode 100644 index 0000000..ee4376b --- /dev/null +++ b/next_b2b_starter/app/authenticate/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +const API_ROUTE = "/api/auth/consume-magic-link"; +const SESSION_DURATION_MINUTES = Number( + process.env.NEXT_PUBLIC_STYTCH_SESSION_DURATION_MINUTES ?? "60" +) || 60; + +const DEFAULT_DESTINATION = "/dashboard"; + +type StatusState = { + state: "verifying" | "success" | "error"; + headline: string; + message: string; +}; + +const INITIAL_STATUS: StatusState = { + state: "verifying", + headline: "We're verifying your magic link", + message: "Hang tight—this usually takes just a moment.", +}; + +function extractErrorMessage(error: unknown): string { + if (error && typeof error === "object") { + const typed = error as any; + + if (typed.error_message) { + return typed.error_message; + } + + if (typed.message) { + return typed.message; + } + } + + return "We couldn't verify that link. Please request a new magic link from the login page."; +} + +export default function AuthenticateRedirectPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState(INITIAL_STATUS); + + const hasAttemptedAuthRef = useRef(false); + + const magicLinkToken = searchParams.get("stytch_token") || searchParams.get("token"); + const tokenType = searchParams.get("stytch_token_type") || searchParams.get("token_type"); + const returnTo = searchParams.get("returnTo")?.trim() || DEFAULT_DESTINATION; + + const redirectToDestination = useCallback(() => { + router.push(returnTo); + router.refresh(); + }, [returnTo, router]); + + const exchangeMagicLink = useCallback(async () => { + if (!magicLinkToken) { + setStatus({ + state: "error", + headline: "Magic link is missing or invalid", + message: "This sign-in link is missing its token. Please request a new magic link.", + }); + return; + } + + hasAttemptedAuthRef.current = true; + setStatus(INITIAL_STATUS); + + try { + const response = await fetch(API_ROUTE, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: magicLinkToken, + tokenType: tokenType || "magic_links", + sessionDurationMinutes: SESSION_DURATION_MINUTES, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.error || "Failed to verify magic link."); + } + + if (!data?.memberAuthenticated) { + setStatus({ + state: "error", + headline: "Additional verification required", + message: "We need a bit more information to finish signing you in. Please continue from the login page.", + }); + return; + } + + setStatus({ + state: "success", + headline: "Magic link verified", + message: "You’re all set. Redirecting you to your workspace…", + }); + + redirectToDestination(); + } catch (error) { + console.error("Magic link verification failed:", error); + + setStatus({ + state: "error", + headline: "We couldn’t verify your link", + message: extractErrorMessage(error), + }); + } + }, [magicLinkToken, tokenType, redirectToDestination]); + + useEffect(() => { + if (hasAttemptedAuthRef.current) return; + void exchangeMagicLink(); + }, [exchangeMagicLink]); + + const icon = + status.state === "success" ? ( +