chore: Update dependencies and regenerate build artifacts.

This commit is contained in:
Mohammed Alquraini 2025-12-16 18:54:41 +04:00
commit b089eccc75
521 changed files with 70650 additions and 0 deletions

15
.editorconfig Normal file
View file

@ -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

101
.gitignore vendored Normal file
View file

@ -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/

21
LICENSE Normal file
View file

@ -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.

152
README.md Normal file
View file

@ -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*
```

19
SECURITY.md Normal file
View file

@ -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.

43
SETUP.md Normal file
View file

@ -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`.

BIN
docs/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

44
go-b2b-starter/.air.toml Normal file
View file

@ -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

View file

@ -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

View file

@ -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

52
go-b2b-starter/Dockerfile Normal file
View file

@ -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"]

99
go-b2b-starter/Makefile Normal file
View file

@ -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

41
go-b2b-starter/README.md Normal file
View file

@ -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 |

View file

@ -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"]

View file

@ -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:

View file

@ -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
```

View file

@ -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)
}
```

View file

@ -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 <token>` 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.

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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/

View file

@ -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/

View file

@ -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

View file

@ -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/

View file

@ -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

7
go-b2b-starter/go.mod Normal file
View file

@ -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

0
go-b2b-starter/go.sum Normal file
View file

39
go-b2b-starter/go.work Normal file
View file

@ -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
)

889
go-b2b-starter/go.work.sum Normal file
View file

@ -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=

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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=

View file

@ -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)
}

View file

@ -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{})
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}
```

View file

@ -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
}

View file

@ -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,
})
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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 ""
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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=

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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(&quota.MaxSeats),
PeriodStart: postgres.PgTimestamp(&quota.PeriodStart),
PeriodEnd: postgres.PgTimestamp(&quota.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
}

View file

@ -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
}

View file

@ -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[:])
}

View file

@ -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
}

View file

@ -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] + "..."
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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")
)

View file

@ -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
}

View file

@ -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

View file

@ -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=

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"`
}

Some files were not shown because too many files have changed in this diff Show more