chore: Update dependencies and regenerate build artifacts.
This commit is contained in:
commit
b089eccc75
521 changed files with 70650 additions and 0 deletions
15
.editorconfig
Normal file
15
.editorconfig
Normal 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
101
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
152
README.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Production SaaS Starter Kit
|
||||
|
||||
[](https://goreportcard.com/report/github.com/moasq/production-saas-starter)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
**The high-performance, self-hosted alternative to Indie Hacker boilerplates.**
|
||||
|
||||
**Modular Monolith. Hexagonal Architecture. Enterprise practices for every scale.**
|
||||
|
||||

|
||||
|
||||
## 🔓 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.
|
||||
|
||||

|
||||
|
||||
```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
19
SECURITY.md
Normal 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
43
SETUP.md
Normal 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
BIN
docs/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
44
go-b2b-starter/.air.toml
Normal file
44
go-b2b-starter/.air.toml
Normal 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
|
||||
44
go-b2b-starter/.dockerignore
Normal file
44
go-b2b-starter/.dockerignore
Normal 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
|
||||
33
go-b2b-starter/.gitlab-ci.yml
Normal file
33
go-b2b-starter/.gitlab-ci.yml
Normal 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
52
go-b2b-starter/Dockerfile
Normal 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
99
go-b2b-starter/Makefile
Normal 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
41
go-b2b-starter/README.md
Normal 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 |
|
||||
14
go-b2b-starter/deps/Dockerfile
Normal file
14
go-b2b-starter/deps/Dockerfile
Normal 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"]
|
||||
51
go-b2b-starter/deps/docker-compose.yml
Normal file
51
go-b2b-starter/deps/docker-compose.yml
Normal 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:
|
||||
61
go-b2b-starter/docs/01-architecture.md
Normal file
61
go-b2b-starter/docs/01-architecture.md
Normal 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
|
||||
```
|
||||
62
go-b2b-starter/docs/02-adding-a-module.md
Normal file
62
go-b2b-starter/docs/02-adding-a-module.md
Normal 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)
|
||||
}
|
||||
```
|
||||
51
go-b2b-starter/docs/03-api-and-auth.md
Normal file
51
go-b2b-starter/docs/03-api-and-auth.md
Normal 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.
|
||||
109
go-b2b-starter/docs/README.md
Normal file
109
go-b2b-starter/docs/README.md
Normal 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)
|
||||
396
go-b2b-starter/docs/api-development.md
Normal file
396
go-b2b-starter/docs/api-development.md
Normal 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
|
||||
238
go-b2b-starter/docs/architecture.md
Normal file
238
go-b2b-starter/docs/architecture.md
Normal 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)
|
||||
305
go-b2b-starter/docs/authentication.md
Normal file
305
go-b2b-starter/docs/authentication.md
Normal 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
|
||||
251
go-b2b-starter/docs/billing.md
Normal file
251
go-b2b-starter/docs/billing.md
Normal 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/
|
||||
319
go-b2b-starter/docs/database.md
Normal file
319
go-b2b-starter/docs/database.md
Normal 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/
|
||||
199
go-b2b-starter/docs/event-bus.md
Normal file
199
go-b2b-starter/docs/event-bus.md
Normal 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
|
||||
222
go-b2b-starter/docs/file-manager.md
Normal file
222
go-b2b-starter/docs/file-manager.md
Normal 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/
|
||||
82
go-b2b-starter/example.env
Normal file
82
go-b2b-starter/example.env
Normal 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
7
go-b2b-starter/go.mod
Normal 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
0
go-b2b-starter/go.sum
Normal file
39
go-b2b-starter/go.work
Normal file
39
go-b2b-starter/go.work
Normal 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
889
go-b2b-starter/go.work.sum
Normal 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=
|
||||
28
go-b2b-starter/scripts/migrate_down.sh
Executable file
28
go-b2b-starter/scripts/migrate_down.sh
Executable 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
|
||||
27
go-b2b-starter/scripts/migrate_up.sh
Executable file
27
go-b2b-starter/scripts/migrate_up.sh
Executable 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
|
||||
48
go-b2b-starter/scripts/run_tests_with_coverage.sh
Executable file
48
go-b2b-starter/scripts/run_tests_with_coverage.sh
Executable 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
|
||||
13
go-b2b-starter/src/api/cmd/init.go
Normal file
13
go-b2b-starter/src/api/cmd/init.go
Normal 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)
|
||||
}
|
||||
}
|
||||
175
go-b2b-starter/src/api/example_cognitive/handler.go
Normal file
175
go-b2b-starter/src/api/example_cognitive/handler.go
Normal 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)
|
||||
}
|
||||
27
go-b2b-starter/src/api/example_cognitive/provider.go
Normal file
27
go-b2b-starter/src/api/example_cognitive/provider.go
Normal 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
|
||||
}
|
||||
50
go-b2b-starter/src/api/example_cognitive/routes.go
Normal file
50
go-b2b-starter/src/api/example_cognitive/routes.go
Normal 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)
|
||||
}
|
||||
173
go-b2b-starter/src/api/example_documents/handler.go
Normal file
173
go-b2b-starter/src/api/example_documents/handler.go
Normal 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)
|
||||
}
|
||||
27
go-b2b-starter/src/api/example_documents/provider.go
Normal file
27
go-b2b-starter/src/api/example_documents/provider.go
Normal 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
|
||||
}
|
||||
48
go-b2b-starter/src/api/example_documents/routes.go
Normal file
48
go-b2b-starter/src/api/example_documents/routes.go
Normal 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)
|
||||
}
|
||||
20
go-b2b-starter/src/api/example_resource/go.mod
Normal file
20
go-b2b-starter/src/api/example_resource/go.mod
Normal 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
|
||||
394
go-b2b-starter/src/api/example_resource/handler.go
Normal file
394
go-b2b-starter/src/api/example_resource/handler.go
Normal 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)
|
||||
}
|
||||
27
go-b2b-starter/src/api/example_resource/provider.go
Normal file
27
go-b2b-starter/src/api/example_resource/provider.go
Normal 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
|
||||
}
|
||||
63
go-b2b-starter/src/api/example_resource/routes.go
Normal file
63
go-b2b-starter/src/api/example_resource/routes.go
Normal 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)
|
||||
}
|
||||
115
go-b2b-starter/src/api/go.mod
Normal file
115
go-b2b-starter/src/api/go.mod
Normal 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
|
||||
183
go-b2b-starter/src/api/go.sum
Normal file
183
go-b2b-starter/src/api/go.sum
Normal 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=
|
||||
302
go-b2b-starter/src/api/organizations/account_handler.go
Normal file
302
go-b2b-starter/src/api/organizations/account_handler.go
Normal 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)
|
||||
}
|
||||
340
go-b2b-starter/src/api/organizations/member_handler.go
Normal file
340
go-b2b-starter/src/api/organizations/member_handler.go
Normal 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{})
|
||||
}
|
||||
166
go-b2b-starter/src/api/organizations/organization_handler.go
Normal file
166
go-b2b-starter/src/api/organizations/organization_handler.go
Normal 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)
|
||||
}
|
||||
65
go-b2b-starter/src/api/organizations/provider.go
Normal file
65
go-b2b-starter/src/api/organizations/provider.go
Normal 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
|
||||
}
|
||||
102
go-b2b-starter/src/api/organizations/routes.go
Normal file
102
go-b2b-starter/src/api/organizations/routes.go
Normal 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)
|
||||
}
|
||||
113
go-b2b-starter/src/api/provider.go
Normal file
113
go-b2b-starter/src/api/provider.go
Normal 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
|
||||
}
|
||||
164
go-b2b-starter/src/api/rbac/handler.go
Normal file
164
go-b2b-starter/src/api/rbac/handler.go
Normal 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)
|
||||
}
|
||||
46
go-b2b-starter/src/api/rbac/provider.go
Normal file
46
go-b2b-starter/src/api/rbac/provider.go
Normal 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
|
||||
}
|
||||
64
go-b2b-starter/src/api/rbac/routes.go
Normal file
64
go-b2b-starter/src/api/rbac/routes.go
Normal 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)
|
||||
}
|
||||
189
go-b2b-starter/src/api/subscriptions/handler.go
Normal file
189
go-b2b-starter/src/api/subscriptions/handler.go
Normal 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)
|
||||
}
|
||||
18
go-b2b-starter/src/api/subscriptions/provider.go
Normal file
18
go-b2b-starter/src/api/subscriptions/provider.go
Normal 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)
|
||||
}
|
||||
31
go-b2b-starter/src/api/subscriptions/routes.go
Normal file
31
go-b2b-starter/src/api/subscriptions/routes.go
Normal 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)
|
||||
}
|
||||
470
go-b2b-starter/src/app/billing/README.md
Normal file
470
go-b2b-starter/src/app/billing/README.md
Normal 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
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
58
go-b2b-starter/src/app/billing/app/services/module.go
Normal file
58
go-b2b-starter/src/app/billing/app/services/module.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
24
go-b2b-starter/src/app/billing/cmd/init.go
Normal file
24
go-b2b-starter/src/app/billing/cmd/init.go
Normal 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
|
||||
}
|
||||
31
go-b2b-starter/src/app/billing/cmd/provider.go
Normal file
31
go-b2b-starter/src/app/billing/cmd/provider.go
Normal 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
|
||||
}
|
||||
26
go-b2b-starter/src/app/billing/domain/errors.go
Normal file
26
go-b2b-starter/src/app/billing/domain/errors.go
Normal 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")
|
||||
)
|
||||
25
go-b2b-starter/src/app/billing/domain/repository.go
Normal file
25
go-b2b-starter/src/app/billing/domain/repository.go
Normal 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)
|
||||
}
|
||||
97
go-b2b-starter/src/app/billing/domain/types.go
Normal file
97
go-b2b-starter/src/app/billing/domain/types.go
Normal 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
|
||||
}
|
||||
76
go-b2b-starter/src/app/billing/go.mod
Normal file
76
go-b2b-starter/src/app/billing/go.mod
Normal 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
|
||||
)
|
||||
198
go-b2b-starter/src/app/billing/go.sum
Normal file
198
go-b2b-starter/src/app/billing/go.sum
Normal 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=
|
||||
117
go-b2b-starter/src/app/billing/infra/adapters/status_provider.go
Normal file
117
go-b2b-starter/src/app/billing/infra/adapters/status_provider.go
Normal 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
|
||||
}
|
||||
333
go-b2b-starter/src/app/billing/infra/polar/polar_adapter.go
Normal file
333
go-b2b-starter/src/app/billing/infra/polar/polar_adapter.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/moasq/go-b2b-starter/app/billing/domain"
|
||||
"github.com/moasq/go-b2b-starter/pkg/db/postgres"
|
||||
sqlc "github.com/moasq/go-b2b-starter/pkg/db/postgres/sqlc/gen"
|
||||
"github.com/moasq/go-b2b-starter/pkg/db/adapters"
|
||||
)
|
||||
|
||||
type subscriptionRepository struct {
|
||||
store adapters.SubscriptionStore
|
||||
}
|
||||
|
||||
// NewSubscriptionRepository creates a new subscription repository
|
||||
func NewSubscriptionRepository(store adapters.SubscriptionStore) domain.SubscriptionRepository {
|
||||
return &subscriptionRepository{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) GetSubscriptionByOrgID(ctx context.Context, organizationID int32) (*domain.Subscription, error) {
|
||||
result, err := r.store.GetSubscriptionByOrgID(ctx, organizationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrSubscriptionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get subscription: %w", err)
|
||||
}
|
||||
|
||||
return r.mapToDomainSubscription(&result), nil
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) UpsertSubscription(ctx context.Context, subscription *domain.Subscription) (*domain.Subscription, error) {
|
||||
// Marshal metadata to JSONB
|
||||
metadataJSON, err := json.Marshal(subscription.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
params := sqlc.UpsertSubscriptionParams{
|
||||
OrganizationID: subscription.OrganizationID,
|
||||
ExternalCustomerID: subscription.ExternalCustomerID,
|
||||
SubscriptionID: subscription.SubscriptionID,
|
||||
SubscriptionStatus: subscription.SubscriptionStatus,
|
||||
ProductID: subscription.ProductID,
|
||||
ProductName: postgres.PgText(&subscription.ProductName),
|
||||
PlanName: postgres.PgText(&subscription.PlanName),
|
||||
CurrentPeriodStart: postgres.PgTimestamp(&subscription.CurrentPeriodStart),
|
||||
CurrentPeriodEnd: postgres.PgTimestamp(&subscription.CurrentPeriodEnd),
|
||||
CancelAtPeriodEnd: postgres.PgBool(&subscription.CancelAtPeriodEnd),
|
||||
CanceledAt: postgres.PgTimestamp(subscription.CanceledAt),
|
||||
Metadata: metadataJSON,
|
||||
}
|
||||
|
||||
result, err := r.store.UpsertSubscription(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upsert subscription: %w", err)
|
||||
}
|
||||
|
||||
return r.mapToDomainSubscription(&result), nil
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) DeleteSubscription(ctx context.Context, organizationID int32) error {
|
||||
if err := r.store.DeleteSubscription(ctx, organizationID); err != nil {
|
||||
return fmt.Errorf("failed to delete subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) GetQuotaByOrgID(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) {
|
||||
result, err := r.store.GetQuotaByOrgID(ctx, organizationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrQuotaNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get quota: %w", err)
|
||||
}
|
||||
|
||||
return r.mapToDomainQuota(&result), nil
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) UpsertQuota(ctx context.Context, quota *domain.QuotaTracking) (*domain.QuotaTracking, error) {
|
||||
params := sqlc.UpsertQuotaParams{
|
||||
OrganizationID: quota.OrganizationID,
|
||||
InvoiceCount: quota.InvoiceCount,
|
||||
MaxSeats: postgres.PgInt4("a.MaxSeats),
|
||||
PeriodStart: postgres.PgTimestamp("a.PeriodStart),
|
||||
PeriodEnd: postgres.PgTimestamp("a.PeriodEnd),
|
||||
}
|
||||
|
||||
result, err := r.store.UpsertQuota(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upsert quota: %w", err)
|
||||
}
|
||||
|
||||
return r.mapToDomainQuota(&result), nil
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) DecrementInvoiceCount(ctx context.Context, organizationID int32) (*domain.QuotaTracking, error) {
|
||||
result, err := r.store.DecrementInvoiceCount(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrement invoice count: %w", err)
|
||||
}
|
||||
|
||||
return r.mapToDomainQuota(&result), nil
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) GetQuotaStatus(ctx context.Context, organizationID int32) (*domain.QuotaStatus, error) {
|
||||
result, err := r.store.GetQuotaStatus(ctx, organizationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrSubscriptionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get quota status: %w", err)
|
||||
}
|
||||
|
||||
return r.mapToDomainQuotaStatus(&result), nil
|
||||
}
|
||||
|
||||
// Mapping functions
|
||||
|
||||
func (r *subscriptionRepository) mapToDomainSubscription(s *sqlc.SubscriptionBillingSubscription) *domain.Subscription {
|
||||
var metadata map[string]any
|
||||
if len(s.Metadata) > 0 {
|
||||
json.Unmarshal(s.Metadata, &metadata)
|
||||
}
|
||||
|
||||
subscription := &domain.Subscription{
|
||||
ID: s.ID,
|
||||
OrganizationID: s.OrganizationID,
|
||||
ExternalCustomerID: s.ExternalCustomerID,
|
||||
SubscriptionID: s.SubscriptionID,
|
||||
SubscriptionStatus: s.SubscriptionStatus,
|
||||
ProductID: s.ProductID,
|
||||
ProductName: postgres.StringFromPgText(s.ProductName),
|
||||
PlanName: postgres.StringFromPgText(s.PlanName),
|
||||
CurrentPeriodStart: s.CurrentPeriodStart.Time,
|
||||
CurrentPeriodEnd: s.CurrentPeriodEnd.Time,
|
||||
Metadata: metadata,
|
||||
CreatedAt: s.CreatedAt.Time,
|
||||
UpdatedAt: s.UpdatedAt.Time,
|
||||
}
|
||||
|
||||
// Handle nullable fields
|
||||
if s.CancelAtPeriodEnd.Valid {
|
||||
subscription.CancelAtPeriodEnd = s.CancelAtPeriodEnd.Bool
|
||||
}
|
||||
if s.CanceledAt.Valid {
|
||||
subscription.CanceledAt = &s.CanceledAt.Time
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) mapToDomainQuota(q *sqlc.SubscriptionBillingQuotaTracking) *domain.QuotaTracking {
|
||||
quota := &domain.QuotaTracking{
|
||||
ID: q.ID,
|
||||
OrganizationID: q.OrganizationID,
|
||||
InvoiceCount: q.InvoiceCount,
|
||||
MaxSeats: postgres.Int32FromPgInt4(q.MaxSeats),
|
||||
PeriodStart: q.PeriodStart.Time,
|
||||
PeriodEnd: q.PeriodEnd.Time,
|
||||
CreatedAt: q.CreatedAt.Time,
|
||||
UpdatedAt: q.UpdatedAt.Time,
|
||||
}
|
||||
|
||||
// Handle nullable LastSyncedAt
|
||||
if q.LastSyncedAt.Valid {
|
||||
quota.LastSyncedAt = &q.LastSyncedAt.Time
|
||||
}
|
||||
|
||||
return quota
|
||||
}
|
||||
|
||||
func (r *subscriptionRepository) mapToDomainQuotaStatus(qs *sqlc.GetQuotaStatusRow) *domain.QuotaStatus {
|
||||
status := &domain.QuotaStatus{
|
||||
SubscriptionStatus: qs.SubscriptionStatus,
|
||||
CurrentPeriodStart: qs.CurrentPeriodStart.Time,
|
||||
CurrentPeriodEnd: qs.CurrentPeriodEnd.Time,
|
||||
InvoiceCount: qs.InvoiceCount,
|
||||
CanProcessInvoice: qs.CanProcessInvoice,
|
||||
}
|
||||
|
||||
// Handle nullable fields
|
||||
if qs.CancelAtPeriodEnd.Valid {
|
||||
status.CancelAtPeriodEnd = qs.CancelAtPeriodEnd.Bool
|
||||
}
|
||||
if qs.MaxSeats.Valid {
|
||||
status.MaxSeats = qs.MaxSeats.Int32
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[:])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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] + "..."
|
||||
}
|
||||
43
go-b2b-starter/src/app/example_cognitive/cmd/init.go
Normal file
43
go-b2b-starter/src/app/example_cognitive/cmd/init.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
134
go-b2b-starter/src/app/example_cognitive/domain/entity.go
Normal file
134
go-b2b-starter/src/app/example_cognitive/domain/entity.go
Normal 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"`
|
||||
}
|
||||
32
go-b2b-starter/src/app/example_cognitive/domain/errors.go
Normal file
32
go-b2b-starter/src/app/example_cognitive/domain/errors.go
Normal 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")
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
35
go-b2b-starter/src/app/example_cognitive/go.mod
Normal file
35
go-b2b-starter/src/app/example_cognitive/go.mod
Normal 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
|
||||
77
go-b2b-starter/src/app/example_cognitive/go.sum
Normal file
77
go-b2b-starter/src/app/example_cognitive/go.sum
Normal 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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
95
go-b2b-starter/src/app/example_cognitive/module.go
Normal file
95
go-b2b-starter/src/app/example_cognitive/module.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue