Compare commits
3 Commits
035ac8ddb5
...
cf3315e8fc
| Author | SHA1 | Date | |
|---|---|---|---|
| cf3315e8fc | |||
| 48c3e15a38 | |||
| d854614a87 |
@@ -4,5 +4,11 @@
|
||||
BOOKRA_APP_ENV=staging
|
||||
BOOKRA_APP_URL=https://app.bookra.example
|
||||
BOOKRA_API_URL=https://api.bookra.example
|
||||
BOOKRA_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
|
||||
|
||||
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
BOOKRA_PADDLE_ENV=sandbox
|
||||
BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key
|
||||
BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret
|
||||
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123
|
||||
VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token
|
||||
BOOKRA_SMTP_HOST=smtp.example.com
|
||||
BOOKRA_UMAMI_API_URL=https://umami.example.com
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate API client
|
||||
run: npm run generate:api-client
|
||||
|
||||
- name: Typecheck frontend
|
||||
run: npm run lint:frontend
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build:frontend
|
||||
|
||||
go:
|
||||
name: Go - ${{ matrix.app }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
app:
|
||||
- apps/backend
|
||||
- apps/auth-service
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ matrix.app }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ${{ matrix.app }}/go.mod
|
||||
cache-dependency-path: ${{ matrix.app }}/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
- name: Build service
|
||||
run: go build ./...
|
||||
|
||||
docker:
|
||||
name: Docker publish - ${{ matrix.service.name }}
|
||||
needs:
|
||||
- frontend
|
||||
- go
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service:
|
||||
- name: backend
|
||||
context: apps/backend
|
||||
- name: auth-service
|
||||
context: apps/auth-service
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image name
|
||||
id: image
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
SERVICE_NAME: ${{ matrix.service.name }}
|
||||
run: |
|
||||
echo "repository=$(echo "${OWNER}/bookra-${SERVICE_NAME}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ steps.image.outputs.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=ref,event=branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and publish container image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.service.context }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.service.name }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.service.name }}
|
||||
@@ -1,14 +1,80 @@
|
||||
.DS_Store
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
.output/
|
||||
.solid/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
dist
|
||||
.solid
|
||||
.output
|
||||
coverage
|
||||
package-lock.json
|
||||
bin
|
||||
tmp
|
||||
*.log
|
||||
|
||||
# Go binaries and artifacts
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test and coverage
|
||||
coverage/
|
||||
*.cover
|
||||
*.cov
|
||||
*.out
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Graphify output - keep only html/json/md
|
||||
graphify-out/*
|
||||
!graphify-out/*.html
|
||||
!graphify-out/*.json
|
||||
!graphify-out/*.md
|
||||
|
||||
# Playwright
|
||||
.playwright-cli/
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# Desloppify
|
||||
desloppify-out/
|
||||
.desloppify/
|
||||
.opencode
|
||||
@@ -0,0 +1,32 @@
|
||||
# Bookra Design Context
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
- **Primary**: Small local service business owners (salons, massage therapists, clinics, repair shops, studios)
|
||||
- **Context**: Managing daily bookings, customer relationships, and business operations
|
||||
- **Job to be done**: Simple setup, reliable scheduling, clear reminders, lightweight reporting, clean SaaS billing
|
||||
- **Emotional goals**: Confidence, calm, professionalism — they want to feel in control without overwhelm
|
||||
|
||||
### Brand Personality
|
||||
- **Voice**: Calm, professional, trustworthy, quietly premium
|
||||
- **Tone**: Helpful but not chatty; structured but not rigid
|
||||
- **3-word personality**: Calm. Premium. Structured.
|
||||
- **Emotional goals**: Users should feel their business is elevated by using Bookra, not that they're wrestling with software
|
||||
|
||||
### Aesthetic Direction
|
||||
- **Visual tone**: Warm, sophisticated minimalism with intentional depth
|
||||
- **References**: Linear (clean data density), Notion (calm whitespace), Figma (refined components)
|
||||
- **Anti-references**: Generic SaaS dashboards with purple gradients, cluttered admin panels, overly playful/bootstrap aesthetics
|
||||
- **Theme**: Light mode primary, dark mode supported. Warm cream/terracotta palette — never cold blue-gray.
|
||||
- **Colors**: Deep warm terracotta accent (#a65c3e family), warm cream backgrounds, ink-colored text with warmth
|
||||
- **Fonts**:
|
||||
- Marketing/landing: Space Grotesk (headings) + Newsreader (body, editorial feel)
|
||||
- Dashboard/admin: Space Grotesk (headings) + DM Sans (body, data legibility) — dashboards need sans-serif for dense tables and metrics
|
||||
|
||||
### Design Principles
|
||||
1. **Calm density** — Dashboards are information-dense by necessity, but never cluttered. Whitespace is earned through alignment, not arbitrary padding.
|
||||
2. **Warm precision** — Every element feels intentional and warm. No cold grays. No pure black/white. Tint everything toward the terracotta warmth.
|
||||
3. **Progressive disclosure** — Start simple, reveal sophistication through interaction. Don't show every option at once.
|
||||
4. **Data dignity** — Tables, metrics, and lists are the core of the dashboard. They deserve refined typography, clear hierarchy, and thoughtful hover states.
|
||||
5. **No generic SaaS clutter** — No random gradient cards, no decorative sparklines that mean nothing, no icon+heading+text card grids repeated endlessly.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Frontend Issues Found During Production-Readiness Review
|
||||
|
||||
## Fixed During This Pass
|
||||
|
||||
- Landing pricing showed outdated EUR tiers and a 14-day trial. The product copy now uses CZK-first pricing with USD equivalents and a 30-day trial.
|
||||
- SMS reminder copy appeared in features, testimonials, and pricing. The main customer-facing copy now says email reminders instead.
|
||||
- Czech `home.step2.desc` was missing and leaked the translation key on the landing page.
|
||||
- Footer year was stale.
|
||||
|
||||
## Still Needs Frontend Polish
|
||||
|
||||
- The dashboard unauthenticated state is still a thin sign-in prompt rather than a dedicated conversion screen.
|
||||
- Some dashboard copy remains English inside the Czech locale because the new guided setup/dashboard sections are MVP product copy.
|
||||
- The mobile booking page puts slots before contact details, which is usable but creates a validation loop if users tap a slot first.
|
||||
- The pricing highlight label is hardcoded rather than localized.
|
||||
- Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured.
|
||||
@@ -5,12 +5,14 @@ Remote-first booking SaaS scaffold aligned to the `tdvorak-fullstack` profile wi
|
||||
- frontend on Vercel
|
||||
- backend on Railway
|
||||
- Neon Postgres + Neon Auth
|
||||
- Paddle billing
|
||||
- no Docker-based local runtime
|
||||
|
||||
## Workspace
|
||||
|
||||
- `apps/frontend` SolidJS frontend
|
||||
- `apps/backend` Go API
|
||||
- `apps/auth-service` auth + internal admin service
|
||||
- `packages/api-client` generated TypeScript client/types from OpenAPI
|
||||
- `packages/shared-types` shared frontend constants and helpers
|
||||
|
||||
@@ -34,10 +36,19 @@ Both apps expect remote services:
|
||||
|
||||
- Neon Postgres
|
||||
- Neon Auth
|
||||
- Stripe
|
||||
- Paddle
|
||||
|
||||
Optional extra service:
|
||||
|
||||
- `apps/auth-service` for standalone auth/admin workflows
|
||||
|
||||
See `.env.example`, `apps/frontend/.env.example`, and `apps/backend/.env.example`.
|
||||
|
||||
## CI/CD
|
||||
|
||||
- GitHub Actions CI lives in [`.github/workflows/ci.yml`](/home/tdvorak/Desktop/PROG+HTML/Bookra/.github/workflows/ci.yml:1)
|
||||
- rollout notes and platform wiring live in [docs/ci-cd.md](/home/tdvorak/Desktop/PROG+HTML/Bookra/docs/ci-cd.md:1)
|
||||
|
||||
## Backend database commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
bin
|
||||
coverage
|
||||
tmp
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,22 @@
|
||||
# Auth Service Environment Configuration
|
||||
# This service stays active for standalone auth flows and internal admin management.
|
||||
# SaaS billing is handled by apps/backend + Paddle.
|
||||
|
||||
PORT=8081
|
||||
APP_ENV=development
|
||||
|
||||
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
JWT_SECRET=change-me-in-production
|
||||
NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
|
||||
SMTP_HOST=smtp.purelymail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=noreply@example.com
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URL=http://localhost:8081/api/auth/oauth/google/callback
|
||||
@@ -0,0 +1,21 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Binary
|
||||
auth-service
|
||||
*.exe
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,38 @@
|
||||
# Build stage
|
||||
FROM golang:1.26.2-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S bookra \
|
||||
&& adduser -S -D -H -u 10001 -G bookra bookra
|
||||
|
||||
COPY --from=builder --chown=bookra:bookra /app/auth-service /app/
|
||||
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
USER bookra
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/health" >/dev/null || exit 1
|
||||
|
||||
CMD ["/app/auth-service"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Bookra Auth Service
|
||||
|
||||
Standalone auth + internal admin service for Bookra.
|
||||
|
||||
Primary responsibilities:
|
||||
|
||||
- email/password auth
|
||||
- magic-link auth
|
||||
- Google OAuth when configured
|
||||
- internal admin dashboard / remote service management
|
||||
- optional Neon JWT verification support
|
||||
|
||||
Not primary billing service:
|
||||
|
||||
- SaaS billing lives in `apps/backend`
|
||||
- Paddle config belongs in backend/frontend env
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
## Core Routes
|
||||
|
||||
- `GET /health`
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/magic-link`
|
||||
- `POST /api/auth/verify`
|
||||
- `POST /api/auth/refresh`
|
||||
- `GET /api/auth/me`
|
||||
- `GET /api/auth/providers`
|
||||
- `GET /api/auth/oauth/google`
|
||||
- `GET /api/auth/oauth/google/callback`
|
||||
- `GET /admin`
|
||||
- `GET /admin/api/config`
|
||||
- `GET /admin/api/stats`
|
||||
|
||||
See [apps/auth-service/.env.example](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/.env.example:1).
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"bookra/apps/auth-service/internal/email"
|
||||
"bookra/apps/auth-service/internal/handlers"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if os.Getenv("APP_ENV") == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
database, err := db.New(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := runMigrations(cfg.DatabaseURL); err != nil {
|
||||
log.Printf("Migration warning: %v", err)
|
||||
}
|
||||
|
||||
emailSvc := email.New(email.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.EmailFrom,
|
||||
})
|
||||
|
||||
handler, err := handlers.New(database, emailSvc, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize handlers: %v", err)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{cfg.FrontendURL, "http://localhost:3000", "http://localhost:5173"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "auth"})
|
||||
})
|
||||
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Auth service running on port %s", cfg.Port)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
}
|
||||
|
||||
func runMigrations(databaseURL string) error {
|
||||
db, err := goose.OpenDBWithDriver("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("goose set dialect: %w", err)
|
||||
}
|
||||
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
module bookra/apps/auth-service
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
github.com/stripe/stripe-go/v83 v83.2.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.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.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // 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.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // 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.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
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=
|
||||
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
|
||||
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/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.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
||||
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
|
||||
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/keyfunc/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type NeonVerifier struct {
|
||||
jwks keyfunc.Keyfunc
|
||||
expectedIssuer string
|
||||
enabled bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewNeonVerifier(neonAuthURL string) (*NeonVerifier, error) {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
|
||||
if trimmed == "" {
|
||||
return &NeonVerifier{enabled: false}, nil
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse neon auth url: %w", err)
|
||||
}
|
||||
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("create neon jwks: %w", err)
|
||||
}
|
||||
return &NeonVerifier{jwks: jwks, expectedIssuer: expectedIssuer, enabled: true, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func (v *NeonVerifier) Enabled() bool {
|
||||
return v != nil && v.enabled
|
||||
}
|
||||
|
||||
func (v *NeonVerifier) Close() {
|
||||
if v != nil && v.cancel != nil {
|
||||
v.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *NeonVerifier) Verify(tokenString string) (*Claims, error) {
|
||||
if !v.Enabled() {
|
||||
return nil, errors.New("neon auth verifier is disabled")
|
||||
}
|
||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
||||
jwt.WithIssuer(v.expectedIssuer),
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
jwt.WithAudience(v.expectedIssuer),
|
||||
jwt.WithLeeway(15*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid neon claims")
|
||||
}
|
||||
subject, _ := claims["sub"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
name, _ := claims["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = claims["display_name"].(string)
|
||||
}
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
return nil, errors.New("missing neon subject")
|
||||
}
|
||||
return &Claims{UserID: subject, Email: email, Name: name, Role: "authenticated", Type: "access"}, nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"bookra/apps/auth-service/internal/email"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenTTL = 24 * time.Hour
|
||||
refreshTokenTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *db.DB
|
||||
email *email.Service
|
||||
jwtSecret []byte
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Type string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewService(database *db.DB, emailSvc *email.Service, jwtSecret string, frontendURL string) *Service {
|
||||
return &Service{
|
||||
db: database,
|
||||
email: emailSvc,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GenerateMagicLink(ctx context.Context, emailAddr string, locale string) error {
|
||||
user, err := s.db.GetUserByEmail(ctx, emailAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
user = &db.User{
|
||||
Email: emailAddr,
|
||||
Provider: "email",
|
||||
}
|
||||
user, err = s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
token := generateRandomToken(32)
|
||||
expiresAt := time.Now().Add(15 * time.Minute)
|
||||
|
||||
if err := s.db.CreateMagicLink(ctx, token, emailAddr, user.ID, expiresAt); err != nil {
|
||||
return fmt.Errorf("create magic link: %w", err)
|
||||
}
|
||||
|
||||
magicURL := fmt.Sprintf("%s/auth/callback?token=%s", s.frontendURL, token)
|
||||
|
||||
var name string
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
|
||||
if err := s.email.SendMagicLink(emailAddr, name, magicURL, locale); err != nil {
|
||||
return fmt.Errorf("send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*TokenPair, error) {
|
||||
ml, err := s.db.GetMagicLink(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get magic link: %w", err)
|
||||
}
|
||||
|
||||
if ml == nil || ml.Used {
|
||||
return nil, fmt.Errorf("invalid or used token")
|
||||
}
|
||||
|
||||
if time.Now().After(ml.ExpiresAt) {
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
if err := s.db.MarkMagicLinkUsed(ctx, token); err != nil {
|
||||
return nil, fmt.Errorf("mark used: %w", err)
|
||||
}
|
||||
|
||||
user, err := s.db.GetUserByID(ctx, ml.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||
return nil, fmt.Errorf("update login: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) OAuthLoginOrCreate(ctx context.Context, provider, providerID, email, name string) (*TokenPair, error) {
|
||||
user, err := s.db.GetUserByProviderID(ctx, provider, providerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by provider: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check existing email: %w", err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
existing.Provider = provider
|
||||
existing.ProviderID = &providerID
|
||||
existing.Name = &name
|
||||
existing.EmailVerified = true
|
||||
if err := s.db.UpdateUser(ctx, existing); err != nil {
|
||||
return nil, fmt.Errorf("link provider: %w", err)
|
||||
}
|
||||
user = existing
|
||||
} else {
|
||||
user = &db.User{
|
||||
Email: email,
|
||||
Name: &name,
|
||||
Provider: provider,
|
||||
ProviderID: &providerID,
|
||||
EmailVerified: true,
|
||||
}
|
||||
user, err = s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||
return nil, fmt.Errorf("update login: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*TokenPair, error) {
|
||||
existing, err := s.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check existing: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, fmt.Errorf("email already registered")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
hashStr := string(hash)
|
||||
user := &db.User{
|
||||
Email: email,
|
||||
Name: &name,
|
||||
PasswordHash: &hashStr,
|
||||
Provider: "email",
|
||||
EmailVerified: false,
|
||||
}
|
||||
|
||||
user, err = s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*TokenPair, error) {
|
||||
user, err := s.db.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
if user == nil || user.PasswordHash == nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
|
||||
return nil, fmt.Errorf("update login: %w", err)
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) generateTokens(user *db.User) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
return s.generateTokensAt(user, now)
|
||||
}
|
||||
|
||||
func (s *Service) generateTokensAt(user *db.User, now time.Time) (*TokenPair, error) {
|
||||
name := ""
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
|
||||
accessTokenString, err := s.signToken(user, name, "access", now, accessTokenTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
|
||||
refreshTokenString, err := s.signToken(user, name, "refresh", now, refreshTokenTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessTokenString,
|
||||
RefreshToken: refreshTokenString,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(accessTokenTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyToken(tokenString string) (*Claims, error) {
|
||||
return s.verifyTokenOfType(tokenString, "access")
|
||||
}
|
||||
|
||||
func (s *Service) VerifyRefreshToken(tokenString string) (*Claims, error) {
|
||||
return s.verifyTokenOfType(tokenString, "refresh")
|
||||
}
|
||||
|
||||
func (s *Service) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
claims, err := s.VerifyRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &db.User{
|
||||
ID: uuid.MustParse(claims.UserID),
|
||||
Email: claims.Email,
|
||||
}
|
||||
if claims.Name != "" {
|
||||
user.Name = &claims.Name
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
storedUser, err := s.db.GetUserByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
if storedUser == nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
user = storedUser
|
||||
}
|
||||
|
||||
return s.generateTokens(user)
|
||||
}
|
||||
|
||||
func (s *Service) verifyTokenOfType(tokenString string, expectedType string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
if claims.Type != expectedType {
|
||||
return nil, fmt.Errorf("invalid token type")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func (s *Service) signToken(user *db.User, name string, tokenType string, now time.Time, ttl time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID.String(),
|
||||
Email: user.Email,
|
||||
Name: name,
|
||||
Role: "authenticated",
|
||||
Type: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "bookra-auth",
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{"bookra"},
|
||||
ID: generateRandomToken(12),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
func generateRandomToken(length int) string {
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(t *testing.T) {
|
||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
||||
name := "Token Tester"
|
||||
user := &db.User{
|
||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
||||
Email: "tester@bookra.dev",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
tokens, err := service.generateTokensAt(user, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("generate tokens: %v", err)
|
||||
}
|
||||
|
||||
accessClaims, err := service.VerifyToken(tokens.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("verify access token: %v", err)
|
||||
}
|
||||
if accessClaims.Type != "access" {
|
||||
t.Fatalf("expected access type, got %s", accessClaims.Type)
|
||||
}
|
||||
|
||||
refreshClaims, err := service.VerifyRefreshToken(tokens.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("verify refresh token: %v", err)
|
||||
}
|
||||
if refreshClaims.Type != "refresh" {
|
||||
t.Fatalf("expected refresh type, got %s", refreshClaims.Type)
|
||||
}
|
||||
|
||||
if _, err := service.VerifyToken(tokens.RefreshToken); err == nil {
|
||||
t.Fatal("expected refresh token to fail access verification")
|
||||
}
|
||||
if _, err := service.VerifyRefreshToken(tokens.AccessToken); err == nil {
|
||||
t.Fatal("expected access token to fail refresh verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensReturnsRotatedPair(t *testing.T) {
|
||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
||||
user := &db.User{
|
||||
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
|
||||
Email: "tester@bookra.dev",
|
||||
}
|
||||
|
||||
original, err := service.generateTokens(user)
|
||||
if err != nil {
|
||||
t.Fatalf("generate tokens: %v", err)
|
||||
}
|
||||
|
||||
refreshed, err := service.RefreshTokens(context.Background(), original.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh tokens: %v", err)
|
||||
}
|
||||
|
||||
if refreshed.AccessToken == original.AccessToken {
|
||||
t.Fatal("expected rotated access token")
|
||||
}
|
||||
if refreshed.RefreshToken == original.RefreshToken {
|
||||
t.Fatal("expected rotated refresh token")
|
||||
}
|
||||
if _, err := service.VerifyToken(refreshed.AccessToken); err != nil {
|
||||
t.Fatalf("verify refreshed access token: %v", err)
|
||||
}
|
||||
if _, err := service.VerifyRefreshToken(refreshed.RefreshToken); err != nil {
|
||||
t.Fatalf("verify refreshed refresh token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensRejectsInvalidToken(t *testing.T) {
|
||||
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
|
||||
|
||||
if _, err := service.RefreshTokens(context.Background(), "bad-token"); err == nil {
|
||||
t.Fatal("expected invalid refresh token error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
|
||||
"github.com/stripe/stripe-go/v83"
|
||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
||||
"github.com/stripe/stripe-go/v83/customer"
|
||||
"github.com/stripe/stripe-go/v83/subscription"
|
||||
"github.com/stripe/stripe-go/v83/webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStripeNotConfigured = errors.New("stripe is not configured")
|
||||
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
|
||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||
ErrPlanNotConfigured = errors.New("stripe plan is not configured")
|
||||
ErrCustomerMappingNotFound = errors.New("stripe customer mapping not found")
|
||||
)
|
||||
|
||||
var allowedWebhookEvents = []string{
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.resumed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
type CheckoutSession struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type SubscriptionSnapshot struct {
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
PriceID string `json:"priceId,omitempty"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethod *PaymentMethod `json:"paymentMethod,omitempty"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
||||
SyncAvailable bool `json:"syncAvailable"`
|
||||
}
|
||||
|
||||
type PaymentMethod struct {
|
||||
Brand string `json:"brand"`
|
||||
Last4 string `json:"last4"`
|
||||
}
|
||||
|
||||
type UserIdentity struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
type userCustomerMapping struct {
|
||||
CustomerID string `json:"customerId"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, database *db.DB) *Service {
|
||||
return &Service{cfg: cfg, db: database}
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
return s.noneSnapshot(), nil
|
||||
}
|
||||
|
||||
snapshot, ok, err := s.getCustomerSnapshot(ctx, mapping.CustomerID)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
snapshot = SubscriptionSnapshot{
|
||||
CustomerID: mapping.CustomerID,
|
||||
Status: "none",
|
||||
}
|
||||
}
|
||||
snapshot.CheckoutURLAvailable = s.checkoutAvailableForPlan(snapshot.PlanCode)
|
||||
snapshot.SyncAvailable = s.cfg.StripeSecretConfigured()
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, user UserIdentity, planCode string, currency string) (CheckoutSession, error) {
|
||||
priceID, resolvedPlanCode, resolvedCurrency, err := s.priceForPlan(planCode, currency)
|
||||
if err != nil {
|
||||
return CheckoutSession{}, err
|
||||
}
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return CheckoutSession{}, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
customerID, err := s.ensureCustomer(ctx, user)
|
||||
if err != nil {
|
||||
return CheckoutSession{}, err
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
Customer: stripe.String(customerID),
|
||||
ClientReferenceID: stripe.String(user.ID),
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", strings.TrimRight(s.cfg.FrontendURL, "/"))),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"user_id": user.ID,
|
||||
"plan_code": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
},
|
||||
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||
TrialPeriodDays: stripe.Int64(30),
|
||||
Metadata: map[string]string{
|
||||
"user_id": user.ID,
|
||||
"plan_code": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
checkoutSession, err := session.New(params)
|
||||
if err != nil {
|
||||
return CheckoutSession{}, err
|
||||
}
|
||||
return CheckoutSession{URL: checkoutSession.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
|
||||
mapping, ok, err := s.getCustomerMapping(ctx, userID)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
return s.noneSnapshot(), nil
|
||||
}
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return SubscriptionSnapshot{}, ErrStripeNotConfigured
|
||||
}
|
||||
return s.syncStripeDataToKV(ctx, mapping.CustomerID)
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return nil
|
||||
}
|
||||
if s.cfg.StripeWebhookSecret == "" {
|
||||
return ErrStripeWebhookMissing
|
||||
}
|
||||
if signature == "" {
|
||||
return ErrStripeSignatureMissing
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
customerID := extractCustomerID(event)
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = s.syncStripeDataToKV(ctx, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ensureCustomer(ctx context.Context, user UserIdentity) (string, error) {
|
||||
mapping, ok, err := s.getCustomerMapping(ctx, user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok && mapping.CustomerID != "" {
|
||||
return mapping.CustomerID, nil
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.CustomerParams{
|
||||
Email: stripe.String(user.Email),
|
||||
Metadata: map[string]string{
|
||||
"user_id": user.ID,
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(user.Name) != "" {
|
||||
params.Name = stripe.String(strings.TrimSpace(user.Name))
|
||||
}
|
||||
|
||||
createdCustomer, err := customer.New(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.storeCustomerMapping(ctx, user.ID, createdCustomer.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return createdCustomer.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncStripeDataToKV(ctx context.Context, customerID string) (SubscriptionSnapshot, error) {
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
||||
params.Status = stripe.String("all")
|
||||
params.AddExpand("data.default_payment_method")
|
||||
params.AddExpand("data.items.data.price")
|
||||
|
||||
iter := subscription.List(params)
|
||||
selected := (*stripe.Subscription)(nil)
|
||||
for iter.Next() {
|
||||
current := iter.Subscription()
|
||||
if selected == nil || subscriptionRank(current) > subscriptionRank(selected) {
|
||||
selected = current
|
||||
}
|
||||
}
|
||||
if iter.Err() != nil {
|
||||
return SubscriptionSnapshot{}, iter.Err()
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
snapshot := SubscriptionSnapshot{
|
||||
CustomerID: customerID,
|
||||
Status: "none",
|
||||
LastSyncedAt: &now,
|
||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
||||
}
|
||||
|
||||
if selected != nil {
|
||||
snapshot.SubscriptionID = selected.ID
|
||||
snapshot.Status = string(selected.Status)
|
||||
snapshot.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
|
||||
if len(selected.Items.Data) > 0 {
|
||||
item := selected.Items.Data[0]
|
||||
if item.Price != nil {
|
||||
snapshot.PriceID = item.Price.ID
|
||||
snapshot.PlanCode = s.planCodeForPrice(snapshot.PriceID)
|
||||
snapshot.Currency = normalizeCurrency(string(item.Price.Currency))
|
||||
}
|
||||
snapshot.CurrentPeriodStart = unixPtr(item.CurrentPeriodStart)
|
||||
snapshot.CurrentPeriodEnd = unixPtr(item.CurrentPeriodEnd)
|
||||
}
|
||||
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
|
||||
snapshot.PaymentMethod = &PaymentMethod{
|
||||
Brand: string(selected.DefaultPaymentMethod.Card.Brand),
|
||||
Last4: selected.DefaultPaymentMethod.Card.Last4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.PutKV(ctx, customerSnapshotKey(customerID), snapshot); err != nil {
|
||||
return SubscriptionSnapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Service) storeCustomerMapping(ctx context.Context, userID string, customerID string) error {
|
||||
mapping := userCustomerMapping{
|
||||
CustomerID: customerID,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
return s.db.PutKV(ctx, userCustomerKey(userID), mapping)
|
||||
}
|
||||
|
||||
func (s *Service) getCustomerMapping(ctx context.Context, userID string) (userCustomerMapping, bool, error) {
|
||||
var mapping userCustomerMapping
|
||||
ok, err := s.db.GetKV(ctx, userCustomerKey(userID), &mapping)
|
||||
if err != nil {
|
||||
return userCustomerMapping{}, false, err
|
||||
}
|
||||
if !ok || mapping.CustomerID == "" {
|
||||
return userCustomerMapping{}, false, nil
|
||||
}
|
||||
return mapping, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) getCustomerSnapshot(ctx context.Context, customerID string) (SubscriptionSnapshot, bool, error) {
|
||||
var snapshot SubscriptionSnapshot
|
||||
ok, err := s.db.GetKV(ctx, customerSnapshotKey(customerID), &snapshot)
|
||||
if err != nil {
|
||||
return SubscriptionSnapshot{}, false, err
|
||||
}
|
||||
return snapshot, ok, nil
|
||||
}
|
||||
|
||||
func (s *Service) noneSnapshot() SubscriptionSnapshot {
|
||||
return SubscriptionSnapshot{
|
||||
Status: "none",
|
||||
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
|
||||
SyncAvailable: s.cfg.StripeSecretConfigured(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string, error) {
|
||||
planCode = normalizePlanCode(strings.TrimSpace(planCode))
|
||||
if planCode == "" {
|
||||
planCode = s.defaultPlanCode()
|
||||
}
|
||||
if planCode == "" {
|
||||
return "", "", "", ErrPlanNotConfigured
|
||||
}
|
||||
resolvedCurrency := normalizeCurrency(currency)
|
||||
priceID := strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":"+resolvedCurrency])
|
||||
if priceID == "" && resolvedCurrency != "czk" {
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":czk"])
|
||||
if priceID != "" {
|
||||
resolvedCurrency = "czk"
|
||||
}
|
||||
}
|
||||
if priceID == "" {
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode])
|
||||
}
|
||||
if priceID == "" {
|
||||
switch planCode {
|
||||
case "pro":
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["growth"])
|
||||
case "business":
|
||||
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["multi-location"])
|
||||
}
|
||||
}
|
||||
if priceID == "" {
|
||||
return "", "", "", ErrPlanNotConfigured
|
||||
}
|
||||
return priceID, planCode, resolvedCurrency, nil
|
||||
}
|
||||
|
||||
func (s *Service) defaultPlanCode() string {
|
||||
for _, planCode := range []string{"pro", "monthly", "growth", "starter", "business", "multi-location"} {
|
||||
if strings.TrimSpace(s.cfg.StripePriceIDs[planCode]) != "" {
|
||||
return normalizePlanCode(planCode)
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.StripePriceIDs[normalizePlanCode(planCode)+":czk"]) != "" {
|
||||
return normalizePlanCode(planCode)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) planCodeForPrice(priceID string) string {
|
||||
for planCode, configuredPriceID := range s.cfg.StripePriceIDs {
|
||||
if strings.TrimSpace(configuredPriceID) == priceID {
|
||||
return normalizePlanCode(strings.Split(planCode, ":")[0])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) hasConfiguredPrices() bool {
|
||||
return s.defaultPlanCode() != ""
|
||||
}
|
||||
|
||||
func (s *Service) checkoutAvailableForPlan(planCode string) bool {
|
||||
if !s.cfg.StripeSecretConfigured() {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(planCode) == "" {
|
||||
return s.hasConfiguredPrices()
|
||||
}
|
||||
_, _, _, err := s.priceForPlan(planCode, "czk")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func normalizePlanCode(planCode string) string {
|
||||
switch planCode {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return planCode
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCurrency(currency string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(currency)) {
|
||||
case "usd":
|
||||
return "usd"
|
||||
default:
|
||||
return "czk"
|
||||
}
|
||||
}
|
||||
|
||||
func userCustomerKey(userID string) string {
|
||||
return "stripe:user:" + userID
|
||||
}
|
||||
|
||||
func customerSnapshotKey(customerID string) string {
|
||||
return "stripe:customer:" + customerID
|
||||
}
|
||||
|
||||
func unixPtr(value int64) *time.Time {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(value, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
func subscriptionRank(subscription *stripe.Subscription) int {
|
||||
switch subscription.Status {
|
||||
case stripe.SubscriptionStatusActive:
|
||||
return 100
|
||||
case stripe.SubscriptionStatusTrialing:
|
||||
return 90
|
||||
case stripe.SubscriptionStatusPastDue:
|
||||
return 80
|
||||
case stripe.SubscriptionStatusUnpaid:
|
||||
return 70
|
||||
case stripe.SubscriptionStatusIncomplete:
|
||||
return 60
|
||||
case stripe.SubscriptionStatusPaused:
|
||||
return 50
|
||||
case stripe.SubscriptionStatusCanceled:
|
||||
return 10
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func extractCustomerID(event stripe.Event) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := payload["customer"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
customerID, _ := value.(string)
|
||||
return customerID
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
)
|
||||
|
||||
func TestPriceForPlanUsesConfiguredPlanCodesOnly(t *testing.T) {
|
||||
service := NewService(&config.Config{
|
||||
StripePriceIDs: map[string]string{
|
||||
"monthly": "price_monthly",
|
||||
"growth": "price_growth",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
priceID, planCode, currency, err := service.priceForPlan("growth", "czk")
|
||||
if err != nil {
|
||||
t.Fatalf("price for plan: %v", err)
|
||||
}
|
||||
if priceID != "price_growth" || planCode != "pro" || currency != "czk" {
|
||||
t.Fatalf("expected pro mapping, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
||||
}
|
||||
|
||||
priceID, planCode, currency, err = service.priceForPlan("", "usd")
|
||||
if err != nil {
|
||||
t.Fatalf("default price for plan: %v", err)
|
||||
}
|
||||
if priceID != "price_monthly" || planCode != "monthly" || currency != "usd" {
|
||||
t.Fatalf("expected monthly default, got price=%q plan=%q currency=%q", priceID, planCode, currency)
|
||||
}
|
||||
|
||||
_, _, _, err = service.priceForPlan("price_attacker_controlled", "czk")
|
||||
if !errors.Is(err, ErrPlanNotConfigured) {
|
||||
t.Fatalf("expected ErrPlanNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVKeyShape(t *testing.T) {
|
||||
if got := userCustomerKey("user_123"); got != "stripe:user:user_123" {
|
||||
t.Fatalf("unexpected user key: %s", got)
|
||||
}
|
||||
if got := customerSnapshotKey("cus_123"); got != "stripe:customer:cus_123" {
|
||||
t.Fatalf("unexpected customer key: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutAvailableForPlanRequiresSecret(t *testing.T) {
|
||||
service := NewService(&config.Config{
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_pro_czk",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if service.checkoutAvailableForPlan("pro") {
|
||||
t.Fatal("expected checkout unavailable without stripe secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutAvailableForPlanRequiresConfiguredPlan(t *testing.T) {
|
||||
service := NewService(&config.Config{
|
||||
StripeSecretKey: "sk_test_123",
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_pro_czk",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if !service.checkoutAvailableForPlan("pro") {
|
||||
t.Fatal("expected pro checkout available")
|
||||
}
|
||||
if service.checkoutAvailableForPlan("business") {
|
||||
t.Fatal("expected business checkout unavailable without configured price")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
Port string
|
||||
DatabaseURL string
|
||||
FrontendURL string
|
||||
JWTSecret string
|
||||
NeonAuthURL string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
EmailFrom string
|
||||
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURL string
|
||||
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceIDs map[string]string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
port := getEnv("PORT", "8081")
|
||||
|
||||
dbURL := getEnv("DATABASE_URL", "")
|
||||
if dbURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
smtpPort, _ := strconv.Atoi(getEnv("SMTP_PORT", "465"))
|
||||
|
||||
return &Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
Port: port,
|
||||
DatabaseURL: dbURL,
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
NeonAuthURL: getEnv("NEON_AUTH_URL", ""),
|
||||
|
||||
SMTPHost: getEnv("SMTP_HOST", "smtp.purelymail.com"),
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUsername: getEnvAllowEmpty("SMTP_USERNAME", "noreply@tdvorak.dev"),
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "noreply@tdvorak.dev"),
|
||||
|
||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
|
||||
|
||||
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
||||
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripePriceIDs: map[string]string{
|
||||
"monthly": getEnv("STRIPE_PRICE_ID", ""),
|
||||
"starter": getEnv("STRIPE_STARTER_PRICE_ID", ""),
|
||||
"growth": getEnv("STRIPE_GROWTH_PRICE_ID", ""),
|
||||
"multi-location": getEnv("STRIPE_MULTI_LOCATION_PRICE_ID", ""),
|
||||
"pro": getEnv("STRIPE_PRO_PRICE_ID", ""),
|
||||
"business": getEnv("STRIPE_BUSINESS_PRICE_ID", ""),
|
||||
"starter:czk": getEnv("STRIPE_STARTER_CZK_PRICE_ID", ""),
|
||||
"starter:usd": getEnv("STRIPE_STARTER_USD_PRICE_ID", ""),
|
||||
"pro:czk": getEnv("STRIPE_PRO_CZK_PRICE_ID", ""),
|
||||
"pro:usd": getEnv("STRIPE_PRO_USD_PRICE_ID", ""),
|
||||
"business:czk": getEnv("STRIPE_BUSINESS_CZK_PRICE_ID", ""),
|
||||
"business:usd": getEnv("STRIPE_BUSINESS_USD_PRICE_ID", ""),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvAllowEmpty(key, defaultVal string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeSecretConfigured() bool {
|
||||
return strings.TrimSpace(cfg.StripeSecretKey) != ""
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeWebhookConfigured() bool {
|
||||
return strings.TrimSpace(cfg.StripeWebhookSecret) != ""
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeHasAnyPriceConfigured() bool {
|
||||
for _, priceID := range cfg.StripePriceIDs {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (cfg *Config) StripeCheckoutReady() bool {
|
||||
return cfg.StripeSecretConfigured() && cfg.StripeHasAnyPriceConfigured()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripeReadinessHelpers(t *testing.T) {
|
||||
cfg := &Config{
|
||||
StripeSecretKey: "sk_test_123",
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_123",
|
||||
},
|
||||
}
|
||||
|
||||
if !cfg.StripeSecretConfigured() {
|
||||
t.Fatal("expected secret configured")
|
||||
}
|
||||
if cfg.StripeWebhookConfigured() {
|
||||
t.Fatal("expected webhook not configured")
|
||||
}
|
||||
if !cfg.StripeHasAnyPriceConfigured() {
|
||||
t.Fatal("expected prices configured")
|
||||
}
|
||||
if !cfg.StripeCheckoutReady() {
|
||||
t.Fatal("expected checkout ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeCheckoutReadyRequiresSecretAndPrice(t *testing.T) {
|
||||
cfg := &Config{
|
||||
StripePriceIDs: map[string]string{
|
||||
"pro:czk": "price_123",
|
||||
},
|
||||
}
|
||||
if cfg.StripeCheckoutReady() {
|
||||
t.Fatal("expected checkout not ready without secret")
|
||||
}
|
||||
|
||||
cfg.StripeSecretKey = "sk_test_123"
|
||||
cfg.StripePriceIDs = map[string]string{}
|
||||
if cfg.StripeCheckoutReady() {
|
||||
t.Fatal("expected checkout not ready without price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsAuthServicePortTo8081(t *testing.T) {
|
||||
originals := map[string]string{}
|
||||
for _, key := range []string{
|
||||
"PORT",
|
||||
"DATABASE_URL",
|
||||
} {
|
||||
originals[key] = os.Getenv(key)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for key, value := range originals {
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
continue
|
||||
}
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
_ = os.Unsetenv("PORT")
|
||||
_ = os.Setenv("DATABASE_URL", "postgresql://localhost/bookra")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if cfg.Port != "8081" {
|
||||
t.Fatalf("expected default port 8081, got %s", cfg.Port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(databaseURL string) (*DB, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database config: %w", err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() {
|
||||
db.pool.Close()
|
||||
}
|
||||
|
||||
func (db *DB) Pool() *pgxpool.Pool {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
return db.pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
||||
return db.pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) error {
|
||||
_, err := db.pool.Exec(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats contains database statistics for the admin dashboard
|
||||
type Stats struct {
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
UsersToday int64 `json:"usersToday"`
|
||||
UsersThisWeek int64 `json:"usersThisWeek"`
|
||||
UsersThisMonth int64 `json:"usersThisMonth"`
|
||||
ActiveUsers7Days int64 `json:"activeUsers7Days"`
|
||||
ActiveUsers30Days int64 `json:"activeUsers30Days"`
|
||||
MagicLinksSent int64 `json:"magicLinksSent"`
|
||||
MagicLinksUsed int64 `json:"magicLinksUsed"`
|
||||
MagicLinksPending int64 `json:"magicLinksPending"`
|
||||
OAuthUsers int64 `json:"oauthUsers"`
|
||||
PasswordUsers int64 `json:"passwordUsers"`
|
||||
}
|
||||
|
||||
// GetStats returns database statistics for the admin dashboard
|
||||
func (db *DB) GetStats(ctx context.Context) (*Stats, error) {
|
||||
stats := &Stats{}
|
||||
|
||||
// Total users
|
||||
err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Users created today
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&stats.UsersToday)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Users created this week
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.UsersThisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Users created this month
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.UsersThisMonth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Active users (logged in) in last 7 days
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.ActiveUsers7Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Active users in last 30 days
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.ActiveUsers30Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Magic links sent (total)
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links`).Scan(&stats.MagicLinksSent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Magic links used
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = TRUE`).Scan(&stats.MagicLinksUsed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pending magic links
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = FALSE AND expires_at > NOW()`).Scan(&stats.MagicLinksPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// OAuth users
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE provider != 'email'`).Scan(&stats.OAuthUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Password users
|
||||
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL`).Scan(&stats.PasswordUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
PasswordHash *string `json:"-"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderID *string `json:"provider_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
type MagicLink struct {
|
||||
Token string `json:"token"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Used bool `json:"used"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
var name, passwordHash, providerID *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`, email).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &providerID,
|
||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.ProviderID = providerID
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var user User
|
||||
var name, passwordHash, providerID *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, id).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &providerID,
|
||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.ProviderID = providerID
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByProviderID(ctx context.Context, provider, providerID string) (*User, error) {
|
||||
var user User
|
||||
var name, passwordHash *string
|
||||
var lastLoginAt *time.Time
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE provider = $1 AND provider_id = $2
|
||||
`, provider, providerID).Scan(
|
||||
&user.ID, &user.Email, &name, &passwordHash,
|
||||
&user.EmailVerified, &user.Provider, &user.ProviderID,
|
||||
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.PasswordHash = passwordHash
|
||||
user.LastLoginAt = lastLoginAt
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateUser(ctx context.Context, user *User) (*User, error) {
|
||||
if user.ID == uuid.Nil {
|
||||
user.ID = uuid.Must(uuid.NewV7())
|
||||
}
|
||||
now := time.Now()
|
||||
user.CreatedAt = now
|
||||
user.UpdatedAt = now
|
||||
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified, user.Provider, user.ProviderID, now)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUser(ctx context.Context, user *User) error {
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET email = $2, name = $3, password_hash = $4, email_verified = $5,
|
||||
provider = $6, provider_id = $7, updated_at = $8, last_login_at = $9
|
||||
WHERE id = $1
|
||||
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified,
|
||||
user.Provider, user.ProviderID, user.UpdatedAt, user.LastLoginAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) CreateMagicLink(ctx context.Context, token string, email string, userID uuid.UUID, expiresAt time.Time) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO magic_links (token, user_id, email, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
`, token, userID, email, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetMagicLink(ctx context.Context, token string) (*MagicLink, error) {
|
||||
var ml MagicLink
|
||||
var userID uuid.UUID
|
||||
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT token, user_id, email, used, expires_at, created_at
|
||||
FROM magic_links
|
||||
WHERE token = $1
|
||||
`, token).Scan(&ml.Token, &userID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ml.UserID = userID
|
||||
return &ml, nil
|
||||
}
|
||||
|
||||
func (db *DB) MarkMagicLinkUsed(ctx context.Context, token string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE magic_links SET used = true WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) PutKV(ctx context.Context, key string, value any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal kv value: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.pool.Exec(ctx, `
|
||||
INSERT INTO stripe_kv (key, value, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`, key, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetKV(ctx context.Context, key string, dest any) (bool, error) {
|
||||
var payload []byte
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT value
|
||||
FROM stripe_kv
|
||||
WHERE key = $1
|
||||
`, key).Scan(&payload)
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(payload, dest); err != nil {
|
||||
return false, fmt.Errorf("unmarshal kv value: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/jordan-wright/email"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func New(config Config) *Service {
|
||||
return &Service{config: config}
|
||||
}
|
||||
|
||||
// SendMagicLink sends a magic link authentication email with proper branding
|
||||
func (s *Service) SendMagicLink(toEmail, toName, linkURL, locale string) error {
|
||||
template := MagicLinkEmail(toName, linkURL, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// SendWelcomeEmail sends a welcome email to new users
|
||||
func (s *Service) SendWelcomeEmail(toEmail, name, locale string) error {
|
||||
template := WelcomeEmail(name, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// SendBookingConfirmation sends booking confirmation to customers
|
||||
func (s *Service) SendBookingConfirmation(toEmail, customerName, businessName, serviceName, dateTime, location, locale string) error {
|
||||
template := BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// SendPasswordReset sends password reset email
|
||||
func (s *Service) SendPasswordReset(toEmail, name, resetURL, locale string) error {
|
||||
template := PasswordResetEmail(name, resetURL, locale)
|
||||
return s.sendTemplate(toEmail, template)
|
||||
}
|
||||
|
||||
// sendTemplate sends an email using the provided template
|
||||
func (s *Service) sendTemplate(toEmail string, template EmailTemplate) error {
|
||||
e := email.NewEmail()
|
||||
e.From = fmt.Sprintf("Bookra <%s>", s.config.From)
|
||||
e.To = []string{toEmail}
|
||||
e.Subject = template.Subject
|
||||
e.Text = []byte(template.Text)
|
||||
e.HTML = []byte(template.HTML)
|
||||
|
||||
return s.send(e)
|
||||
}
|
||||
|
||||
// send delivers the email via SMTP
|
||||
func (s *Service) send(e *email.Email) error {
|
||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||
|
||||
var auth smtp.Auth
|
||||
if s.config.Username != "" {
|
||||
auth = smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||
}
|
||||
|
||||
if s.config.Port == 465 {
|
||||
return e.SendWithTLS(addr, auth, &tls.Config{ServerName: s.config.Host})
|
||||
}
|
||||
|
||||
return e.Send(addr, auth)
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Bookra Design System - Warm editorial aesthetic
|
||||
// Canvas: warm cream backgrounds (#fbf9f6)
|
||||
// Ink: warm dark brown (#2a221e)
|
||||
// Accent: terracotta (#a65c3e)
|
||||
// Logo bg: #24201d, Logo text: #f7f2e8
|
||||
const (
|
||||
canvas = "#fbf9f6" // Warm cream background
|
||||
canvasSubtle = "#f5f2ed" // Slightly darker cream
|
||||
ink = "#2a221e" // Warm dark brown
|
||||
inkMuted = "#5c514a" // Muted brown
|
||||
inkSubtle = "#8b7f76" // Light muted brown
|
||||
accent = "#a65c3e" // Terracotta
|
||||
accentHover = "#8f4d33" // Darker terracotta
|
||||
accentSubtle = "#f5ebe7" // Light terracotta tint
|
||||
logoBg = "#24201d" // Logo dark brown
|
||||
logoText = "#f7f2e8" // Logo cream
|
||||
border = "#e8e2da" // Warm border
|
||||
white = "#ffffff"
|
||||
)
|
||||
|
||||
type EmailTemplate struct {
|
||||
Subject string
|
||||
HTML string
|
||||
Text string
|
||||
}
|
||||
|
||||
func MagicLinkEmail(toName, magicURL string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return magicLinkEmailCS(toName, magicURL)
|
||||
}
|
||||
return magicLinkEmailEN(toName, magicURL)
|
||||
}
|
||||
|
||||
func WelcomeEmail(name string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return welcomeEmailCS(name)
|
||||
}
|
||||
return welcomeEmailEN(name)
|
||||
}
|
||||
|
||||
func BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location)
|
||||
}
|
||||
return bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location)
|
||||
}
|
||||
|
||||
func PasswordResetEmail(name, resetURL string, locale string) EmailTemplate {
|
||||
if locale == "cs" {
|
||||
return passwordResetCS(name, resetURL)
|
||||
}
|
||||
return passwordResetEN(name, resetURL)
|
||||
}
|
||||
|
||||
func magicLinkEmailEN(toName, magicURL string) EmailTemplate {
|
||||
subject := "Your sign-in link for Bookra"
|
||||
if toName == "" {
|
||||
toName = "there"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; transition: background 0.2s; }
|
||||
.button:hover { background: %s; }
|
||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
.footer-links { margin-top: 12px; }
|
||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Calm booking software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi %s,</div>
|
||||
<div class="message">
|
||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Sign In to Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Or copy this link</div>
|
||||
<div class="link-url">%s</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
This link expires in <strong>15 minutes</strong> for security.
|
||||
</div>
|
||||
<div class="help">
|
||||
Didn't request this? You can safely ignore it — someone may have entered your email by mistake.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://bookra.tdvorak.dev/privacy">Privacy</a>
|
||||
<a href="https://bookra.tdvorak.dev/terms">Terms</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink, inkSubtle,
|
||||
ink, inkMuted,
|
||||
accent, white, accentHover,
|
||||
canvasSubtle, border, inkSubtle, inkMuted,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted, inkMuted,
|
||||
toName, magicURL, magicURL)
|
||||
|
||||
text := fmt.Sprintf(`Bookra — Sign-in Link
|
||||
|
||||
Hi %s,
|
||||
|
||||
Sign in to Bookra (link expires in 15 minutes):
|
||||
%s
|
||||
|
||||
Didn't request this? You can safely ignore this email.
|
||||
|
||||
© 2024 Bookra`, toName, magicURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func magicLinkEmailCS(toName, magicURL string) EmailTemplate {
|
||||
subject := "Váš přihlašovací odkaz do Bookra"
|
||||
if toName == "" {
|
||||
toName = "vás"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.button:hover { background: %s; }
|
||||
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
|
||||
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
.footer-links { margin-top: 12px; }
|
||||
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Klidný rezervační software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Dobrý den %s,</div>
|
||||
<div class="message">
|
||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Přihlásit se do Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
||||
<div class="link-url">%s</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
||||
</div>
|
||||
<div class="help">
|
||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://bookra.tdvorak.dev/privacy">Ochrana soukromí</a>
|
||||
<a href="https://bookra.tdvorak.dev/terms">Podmínky</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink, inkSubtle,
|
||||
ink, inkMuted,
|
||||
accent, white, accentHover,
|
||||
canvasSubtle, border, inkSubtle, inkMuted,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted, inkMuted,
|
||||
toName, magicURL, magicURL)
|
||||
|
||||
text := fmt.Sprintf(`Bookra — Přihlašovací odkaz
|
||||
|
||||
Dobrý den %s,
|
||||
|
||||
Přihlaste se do Bookra (odkaz vyprší za 15 minut):
|
||||
%s
|
||||
|
||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
||||
|
||||
© 2024 Bookra`, toName, magicURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func welcomeEmailEN(name string) EmailTemplate {
|
||||
subject := "Welcome to Bookra"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.feature:last-child { margin-bottom: 0; }
|
||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; font-family: 'Space Grotesk', sans-serif; }
|
||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
||||
.button-wrap { margin: 40px 0; text-align: center; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Welcome, %s</div>
|
||||
<div class="message">
|
||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection and buffer times</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences at your fingertips</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows with gentle notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted, canvasSubtle,
|
||||
accent, white, inkMuted,
|
||||
accent, white,
|
||||
canvas, border, inkMuted,
|
||||
name)
|
||||
|
||||
text := fmt.Sprintf(`Welcome to Bookra, %s
|
||||
|
||||
Thanks for joining. We're here to help you manage bookings with calm and clarity.
|
||||
|
||||
Get started: https://bookra.tdvorak.dev/dashboard
|
||||
|
||||
© 2024 Bookra`, name)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func welcomeEmailCS(name string) EmailTemplate {
|
||||
subject := "Vítejte v Bookra"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.feature:last-child { margin-bottom: 0; }
|
||||
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; }
|
||||
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
|
||||
.button-wrap { margin: 40px 0; text-align: center; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Vítejte, %s</div>
|
||||
<div class="message">
|
||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem a přehledem.
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Chytré plánování</strong> — Automatická detekce konfliktů</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Přehled o zákaznících</strong> — Historie a preference</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Připomenutí</strong> — Méně zapomenutých termínů</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Otevřít aplikaci</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted, canvasSubtle,
|
||||
accent, white, inkMuted,
|
||||
accent, white,
|
||||
canvas, border, inkMuted,
|
||||
name)
|
||||
|
||||
text := fmt.Sprintf(`Vítejte v Bookra, %s
|
||||
|
||||
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem.
|
||||
|
||||
Otevřít aplikaci: https://bookra.tdvorak.dev/dashboard
|
||||
|
||||
© 2024 Bookra`, name)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
||||
subject := fmt.Sprintf("Confirmed: %s with %s", serviceName, businessName)
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Confirmed</div>
|
||||
<div class="greeting">Hello %s,</div>
|
||||
<div class="message">
|
||||
Your booking with <strong>%s</strong> is confirmed.
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Service</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">When</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Where</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help">
|
||||
Need to reschedule? Contact %s directly.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
accentSubtle, accent, ink, inkMuted,
|
||||
canvasSubtle, border, inkSubtle, ink,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
text := fmt.Sprintf(`Booking Confirmed
|
||||
|
||||
Hello %s,
|
||||
|
||||
Your booking with %s is confirmed.
|
||||
|
||||
Service: %s
|
||||
When: %s
|
||||
Where: %s
|
||||
|
||||
Need to reschedule? Contact %s.
|
||||
|
||||
© 2024 Bookra`,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
|
||||
subject := fmt.Sprintf("Potvrzeno: %s v %s", serviceName, businessName)
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
|
||||
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
|
||||
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
|
||||
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Potvrzeno</div>
|
||||
<div class="greeting">Dobrý den %s,</div>
|
||||
<div class="message">
|
||||
Vaše rezervace v <strong>%s</strong> je potvrzena.
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Služba</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Termín</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Místo</div>
|
||||
<div class="detail-value">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help">
|
||||
Potřebujete přeobjednat? Kontaktujte přímo %s.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
accentSubtle, accent, ink, inkMuted,
|
||||
canvasSubtle, border, inkSubtle, ink,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
text := fmt.Sprintf(`Rezervace potvrzena
|
||||
|
||||
Dobrý den %s,
|
||||
|
||||
Vaše rezervace v %s je potvrzena.
|
||||
|
||||
Služba: %s
|
||||
Termín: %s
|
||||
Místo: %s
|
||||
|
||||
Potřebujete přeobjednat? Kontaktujte %s.
|
||||
|
||||
© 2024 Bookra`,
|
||||
customerName, businessName, serviceName, dateTime, location, businessName)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func passwordResetEN(name, resetURL string) EmailTemplate {
|
||||
subject := "Reset your Bookra password"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi %s,</div>
|
||||
<div class="message">
|
||||
We received a request to reset your password. Click below to choose a new one.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Reset Password</a>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
This link expires in <strong>1 hour</strong>.
|
||||
</div>
|
||||
<div class="help">
|
||||
Didn't request this? You can safely ignore it.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted,
|
||||
accent, white,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
name, resetURL)
|
||||
|
||||
text := fmt.Sprintf(`Reset Password — Bookra
|
||||
|
||||
Hi %s,
|
||||
|
||||
Reset your password (expires in 1 hour):
|
||||
%s
|
||||
|
||||
Didn't request this? You can safely ignore it.
|
||||
|
||||
© 2024 Bookra`, name, resetURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
|
||||
func passwordResetCS(name, resetURL string) EmailTemplate {
|
||||
subject := "Reset hesla pro Bookra"
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: %s; }
|
||||
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
|
||||
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
|
||||
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
|
||||
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
|
||||
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
|
||||
.footer-text { font-size: 14px; color: %s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Dobrý den %s,</div>
|
||||
<div class="message">
|
||||
Obdrželi jsme žádost o reset hesla. Klikněte níže pro nastavení nového.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="%s" class="button">Resetovat heslo</a>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
Tento odkaz vyprší za <strong>1 hodinu</strong>.
|
||||
</div>
|
||||
<div class="help">
|
||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject, canvas, white, canvas, border,
|
||||
logoBg, logoText, ink,
|
||||
ink, inkMuted,
|
||||
accent, white,
|
||||
accentSubtle, accent, accent,
|
||||
inkSubtle, border,
|
||||
canvas, border, inkMuted,
|
||||
name, resetURL)
|
||||
|
||||
text := fmt.Sprintf(`Reset hesla — Bookra
|
||||
|
||||
Dobrý den %s,
|
||||
|
||||
Reset hesla (vyprší za 1 hodinu):
|
||||
%s
|
||||
|
||||
Nepožádali jste o tento email? Můžete ho ignorovat.
|
||||
|
||||
© 2024 Bookra`, name, resetURL)
|
||||
|
||||
return EmailTemplate{Subject: subject, HTML: html, Text: text}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminDashboard provides a visual management interface for the auth service
|
||||
type AdminDashboard struct {
|
||||
cfg *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
|
||||
return &AdminDashboard{cfg: cfg, db: database}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers admin routes
|
||||
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
|
||||
admin := r.Group("/admin")
|
||||
{
|
||||
admin.GET("", a.RenderDashboard)
|
||||
admin.GET("/api/config", a.GetConfig)
|
||||
admin.GET("/api/prices", a.GetPrices)
|
||||
admin.GET("/api/stats", a.GetStats)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig returns current configuration (sanitized)
|
||||
func (a *AdminDashboard) GetConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"appEnv": a.cfg.AppEnv,
|
||||
"port": a.cfg.Port,
|
||||
"frontendURL": a.cfg.FrontendURL,
|
||||
"neonAuthURL": a.cfg.NeonAuthURL,
|
||||
"smtpConfigured": gin.H{
|
||||
"host": a.cfg.SMTPHost,
|
||||
"port": a.cfg.SMTPPort,
|
||||
"from": a.cfg.EmailFrom,
|
||||
},
|
||||
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
|
||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
||||
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
|
||||
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
|
||||
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPrices returns configured Stripe prices
|
||||
func (a *AdminDashboard) GetPrices(c *gin.Context) {
|
||||
prices := []gin.H{}
|
||||
|
||||
planNames := map[string]string{
|
||||
"starter": "Starter Plan",
|
||||
"pro": "Pro Plan",
|
||||
"business": "Business Plan",
|
||||
"monthly": "Monthly Plan",
|
||||
"growth": "Growth Plan (Pro alias)",
|
||||
"multi-location": "Multi-Location (Business alias)",
|
||||
}
|
||||
|
||||
currencies := []string{"czk", "usd"}
|
||||
|
||||
for planCode, priceID := range a.cfg.StripePriceIDs {
|
||||
if strings.TrimSpace(priceID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse plan:currency format
|
||||
parts := strings.Split(planCode, ":")
|
||||
displayName := planNames[planCode]
|
||||
currency := ""
|
||||
|
||||
if len(parts) == 2 {
|
||||
planCode = parts[0]
|
||||
currency = parts[1]
|
||||
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = planCode
|
||||
}
|
||||
|
||||
prices = append(prices, gin.H{
|
||||
"planCode": planCode,
|
||||
"currency": currency,
|
||||
"priceID": priceID,
|
||||
"displayName": displayName,
|
||||
"configured": true,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"prices": prices,
|
||||
"currencies": currencies,
|
||||
"stripeConfigured": a.cfg.StripeCheckoutReady(),
|
||||
"secretConfigured": a.cfg.StripeSecretConfigured(),
|
||||
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
|
||||
"pricesConfigured": len(prices) > 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats returns database statistics
|
||||
func (a *AdminDashboard) GetStats(c *gin.Context) {
|
||||
if a.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := a.db.GetStats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// RenderDashboard renders the HTML admin dashboard
|
||||
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, adminHTML)
|
||||
}
|
||||
|
||||
const adminHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookra Auth Service Admin</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--canvas: 40 25% 97%;
|
||||
--canvas-subtle: 40 20% 94%;
|
||||
--canvas-muted: 40 15% 89%;
|
||||
--ink: 25 15% 12%;
|
||||
--ink-muted: 25 10% 42%;
|
||||
--ink-subtle: 25 8% 58%;
|
||||
--accent: 17 55% 42%;
|
||||
--accent-hover: 17 60% 37%;
|
||||
--accent-subtle: 17 45% 94%;
|
||||
--success: 145 45% 38%;
|
||||
--success-subtle: 145 35% 94%;
|
||||
--error: 0 60% 52%;
|
||||
--error-subtle: 0 50% 96%;
|
||||
--border: 30 12% 86%;
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: "Newsreader", Georgia, ui-serif, serif;
|
||||
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
|
||||
color: hsl(var(--ink));
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container { padding: 2rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container { padding: 3rem; }
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1.125rem;
|
||||
color: hsl(var(--ink-muted));
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-card .icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--accent-subtle));
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card .icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
|
||||
.stat-card.success .icon svg { color: hsl(var(--success)); }
|
||||
|
||||
.stat-value {
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--ink));
|
||||
line-height: 1;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--ink-muted));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.card-header svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: hsl(var(--success-subtle));
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.status.inactive {
|
||||
background: hsl(var(--error-subtle));
|
||||
color: hsl(var(--error));
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--ink-muted));
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.env-value {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
background: hsl(var(--canvas-muted));
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
|
||||
background: hsl(var(--accent-subtle));
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
color: hsl(var(--ink-muted));
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem;
|
||||
color: hsl(var(--ink-subtle));
|
||||
}
|
||||
|
||||
.loading svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
background: hsl(var(--error-subtle));
|
||||
color: hsl(var(--error));
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: hsl(var(--ink-muted));
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.75rem;
|
||||
color: hsl(var(--ink-subtle));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>Auth Service Admin</h1>
|
||||
</div>
|
||||
<p>Monitor users, configure billing plans, and manage service health.</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">Active (7d)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">Magic Links Sent</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-value">-</div>
|
||||
<div class="stat-label">New This Week</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
<h2>Service Configuration</h2>
|
||||
</div>
|
||||
<div class="card-body" id="config-content">
|
||||
<div class="loading">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
||||
</svg>
|
||||
Loading configuration...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<h2>Billing Plans</h2>
|
||||
</div>
|
||||
<div class="card-body" id="prices-content">
|
||||
<div class="loading">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
|
||||
</svg>
|
||||
Loading plans...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<h2>API Endpoints</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Endpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
|
||||
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
|
||||
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
|
||||
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<h2>Service Overview</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Authentication</span>
|
||||
<span>Magic links, JWT, OAuth</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Billing</span>
|
||||
<span>Stripe subscriptions</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Database</span>
|
||||
<span>Neon PostgreSQL</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Email</span>
|
||||
<span>SMTP transactional</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load stats
|
||||
fetch('/admin/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const cards = document.querySelectorAll('.stat-card');
|
||||
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
|
||||
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
|
||||
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
|
||||
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('stats-grid').innerHTML =
|
||||
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
|
||||
});
|
||||
|
||||
// Load configuration
|
||||
fetch('/admin/api/config')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
let html = '<div class="info-row">' +
|
||||
'<span class="info-label">Environment</span>' +
|
||||
'<span class="env-value">' + data.appEnv + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Port</span>' +
|
||||
'<span class="env-value">' + data.port + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Neon Auth</span>' +
|
||||
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">SMTP</span>' +
|
||||
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Google OAuth</span>' +
|
||||
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="info-row">' +
|
||||
'<span class="info-label">Stripe</span>' +
|
||||
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
document.getElementById('config-content').innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('config-content').innerHTML =
|
||||
'<div class="error">Failed to load configuration</div>';
|
||||
});
|
||||
|
||||
// Load prices
|
||||
fetch('/admin/api/prices')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.prices || data.prices.length === 0) {
|
||||
document.getElementById('prices-content').innerHTML =
|
||||
'<div class="empty-state">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
|
||||
'<p>No Stripe prices configured</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
|
||||
data.prices.forEach(p => {
|
||||
html += '<tr>' +
|
||||
'<td>' + p.displayName + '</td>' +
|
||||
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
|
||||
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('prices-content').innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('prices-content').innerHTML =
|
||||
'<div class="error">Failed to load prices</div>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,513 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/auth"
|
||||
"bookra/apps/auth-service/internal/billing"
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"bookra/apps/auth-service/internal/db"
|
||||
"bookra/apps/auth-service/internal/email"
|
||||
"bookra/apps/auth-service/internal/oauth"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
authSvc *auth.Service
|
||||
neon *auth.NeonVerifier
|
||||
billingSvc *billing.Service
|
||||
google *oauth.GoogleProvider
|
||||
cfg *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type PasswordRegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
|
||||
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{
|
||||
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
|
||||
neon: neonVerifier,
|
||||
billingSvc: billing.NewService(cfg, db),
|
||||
google: oauth.NewGoogleProvider(cfg),
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
||||
// Auth API
|
||||
api := r.Group("/api/auth")
|
||||
{
|
||||
api.POST("/magic-link", h.SendMagicLink)
|
||||
api.POST("/verify", h.VerifyMagicLink)
|
||||
api.POST("/register", h.RegisterWithPassword)
|
||||
api.POST("/login", h.LoginWithPassword)
|
||||
api.POST("/refresh", h.RefreshToken)
|
||||
api.GET("/me", h.RequireAuth(), h.GetMe)
|
||||
api.POST("/logout", h.RequireAuth(), h.Logout)
|
||||
|
||||
api.GET("/providers", h.ListProviders)
|
||||
api.GET("/oauth/google", h.GoogleAuth)
|
||||
api.GET("/oauth/google/callback", h.GoogleCallback)
|
||||
}
|
||||
|
||||
// Billing API
|
||||
billingAPI := r.Group("/api/billing")
|
||||
{
|
||||
billingAPI.POST("/webhook", h.StripeWebhook)
|
||||
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
|
||||
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
|
||||
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
|
||||
billingAPI.GET("/plans", h.ListPlans)
|
||||
}
|
||||
|
||||
// Admin Dashboard (Visual Management)
|
||||
admin := NewAdminDashboard(h.cfg, h.db)
|
||||
admin.RegisterRoutes(r)
|
||||
}
|
||||
|
||||
func (h *Handler) SendMagicLink(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect locale from request: JSON body > Accept-Language header > default "en"
|
||||
locale := req.Locale
|
||||
if locale == "" {
|
||||
locale = detectLocale(c)
|
||||
}
|
||||
|
||||
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
|
||||
log.Printf("magic link failed for %s: %v", req.Email, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
|
||||
}
|
||||
|
||||
// detectLocale extracts locale from Accept-Language header
|
||||
func detectLocale(c *gin.Context) string {
|
||||
acceptLang := c.GetHeader("Accept-Language")
|
||||
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
|
||||
return "cs"
|
||||
}
|
||||
// Default to English
|
||||
return "en"
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyMagicLink(c *gin.Context) {
|
||||
var req VerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterWithPassword(c *gin.Context) {
|
||||
var req PasswordRegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already registered") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) LoginWithPassword(c *gin.Context) {
|
||||
var req PasswordLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *Handler) GetMe(c *gin.Context) {
|
||||
claims, exists := c.Get("claims")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
||||
return
|
||||
}
|
||||
|
||||
userClaims := claims.(*auth.Claims)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": userClaims.UserID,
|
||||
"email": userClaims.Email,
|
||||
"name": userClaims.Name,
|
||||
"role": userClaims.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
|
||||
func (h *Handler) ListProviders(c *gin.Context) {
|
||||
providers := []gin.H{}
|
||||
|
||||
if h.google.Enabled() {
|
||||
providers = append(providers, gin.H{
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"url": "/api/auth/oauth/google",
|
||||
})
|
||||
}
|
||||
|
||||
providers = append(providers, gin.H{
|
||||
"id": "email",
|
||||
"name": "Email Magic Link",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providers})
|
||||
}
|
||||
|
||||
func (h *Handler) GoogleAuth(c *gin.Context) {
|
||||
if !h.google.Enabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state := generateState()
|
||||
url := h.google.GetAuthURL(state)
|
||||
|
||||
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
func (h *Handler) GoogleCallback(c *gin.Context) {
|
||||
if !h.google.Enabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
expectedState, err := c.Cookie("oauth_state")
|
||||
if err != nil || state == "" || state != expectedState {
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
|
||||
return
|
||||
}
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.google.ExchangeCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
|
||||
return
|
||||
}
|
||||
|
||||
providerID, email, name := h.google.ParseUser(user)
|
||||
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
|
||||
if tokens.RefreshToken != "" {
|
||||
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSubscription(c *gin.Context) {
|
||||
claims, ok := h.claimsFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
|
||||
claims, ok := h.claimsFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
|
||||
ID: claims.UserID,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
}, req.PlanCode, req.Currency)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrPlanNotConfigured):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
|
||||
case errors.Is(err, billing.ErrStripeNotConfigured):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshSubscription(c *gin.Context) {
|
||||
claims, ok := h.claimsFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, billing.ErrStripeNotConfigured) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
// ListPlans returns available billing plans and their configuration status
|
||||
func (h *Handler) ListPlans(c *gin.Context) {
|
||||
plans := []gin.H{
|
||||
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
|
||||
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
|
||||
{"code": "business", "name": "Business", "description": "For multi-location operations"},
|
||||
}
|
||||
|
||||
// Check which plans are configured
|
||||
configured := make(map[string]bool)
|
||||
for planCode, priceID := range h.cfg.StripePriceIDs {
|
||||
if priceID != "" {
|
||||
configured[planCode] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i, plan := range plans {
|
||||
code := plan["code"].(string)
|
||||
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
|
||||
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
|
||||
plans[i] = plan
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"plans": plans,
|
||||
"stripeConfigured": h.cfg.StripeCheckoutReady(),
|
||||
"secretConfigured": h.cfg.StripeSecretConfigured(),
|
||||
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
|
||||
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
|
||||
"checkoutReady": h.cfg.StripeCheckoutReady(),
|
||||
"currencies": []string{"czk", "usd"},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) StripeWebhook(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
payload, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"received": true})
|
||||
}
|
||||
|
||||
func (h *Handler) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
tokenString := ""
|
||||
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.verifyBearerToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
|
||||
if h.neon != nil && h.neon.Enabled() {
|
||||
return h.neon.Verify(tokenString)
|
||||
}
|
||||
if h.cfg.AppEnv == "development" {
|
||||
return h.authSvc.VerifyToken(tokenString)
|
||||
}
|
||||
return nil, errors.New("neon auth is not configured")
|
||||
}
|
||||
|
||||
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
|
||||
claims, exists := c.Get("claims")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
userClaims, ok := claims.(*auth.Claims)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return userClaims, true
|
||||
}
|
||||
|
||||
func generateState() string {
|
||||
buffer := make([]byte, 24)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "state_" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
|
||||
}
|
||||
|
||||
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
|
||||
}
|
||||
|
||||
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bookra/apps/auth-service/internal/config"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
type GoogleUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
}
|
||||
|
||||
type GoogleProvider struct {
|
||||
config *oauth2.Config
|
||||
}
|
||||
|
||||
func NewGoogleProvider(cfg *config.Config) *GoogleProvider {
|
||||
if cfg.GoogleClientID == "" || cfg.GoogleClientSecret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
redirectURL := cfg.GoogleRedirectURL
|
||||
if redirectURL == "" {
|
||||
redirectURL = cfg.FrontendURL + "/auth/oauth/google/callback"
|
||||
}
|
||||
|
||||
return &GoogleProvider{
|
||||
config: &oauth2.Config{
|
||||
ClientID: cfg.GoogleClientID,
|
||||
ClientSecret: cfg.GoogleClientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
},
|
||||
Endpoint: google.Endpoint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) Enabled() bool {
|
||||
return p != nil && p.config != nil
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) GetAuthURL(state string) string {
|
||||
return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) ExchangeCode(ctx context.Context, code string) (*GoogleUser, error) {
|
||||
token, err := p.config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
|
||||
client := p.config.Client(ctx, token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch userinfo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var user GoogleUser
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("decode userinfo: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (p *GoogleProvider) ParseUser(user *GoogleUser) (providerID, email, name string) {
|
||||
return user.ID, user.Email, user.Name
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
password_hash VARCHAR(255),
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
provider VARCHAR(50) NOT NULL DEFAULT 'email',
|
||||
provider_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS magic_links (
|
||||
token VARCHAR(255) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS magic_links;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stripe_kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_kv_updated_at ON stripe_kv(updated_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_stripe_kv_updated_at;
|
||||
DROP TABLE IF EXISTS stripe_kv;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10,
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 30,
|
||||
"numReplicas": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,407 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookra Email Templates Preview</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Newsreader', Georgia, serif;
|
||||
background: #fbf9f6;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
color: #2a221e;
|
||||
}
|
||||
.container { max-width: 1400px; margin: 0 auto; }
|
||||
h1 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
text-align: center;
|
||||
color: #2a221e;
|
||||
margin-bottom: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #5c514a;
|
||||
margin-bottom: 48px;
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(42, 34, 30, 0.05);
|
||||
border: 1px solid #e8e2da;
|
||||
}
|
||||
.card-header {
|
||||
background: #fbf9f6;
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px solid #e8e2da;
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2a221e;
|
||||
}
|
||||
.card-header p {
|
||||
margin: 4px 0 0;
|
||||
color: #5c514a;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
.card-body { padding: 0; }
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: none;
|
||||
background: white;
|
||||
}
|
||||
.toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.toggle button {
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e2da;
|
||||
cursor: pointer;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
color: #5c514a;
|
||||
}
|
||||
.toggle button.active {
|
||||
background: #a65c3e;
|
||||
color: white;
|
||||
border-color: #a65c3e;
|
||||
}
|
||||
.toggle button:not(.active):hover {
|
||||
background: #f5f2ed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Bookra Email Templates</h1>
|
||||
<p class="subtitle">Warm editorial aesthetic with terracotta accents</p>
|
||||
|
||||
<div class="toggle">
|
||||
<button class="active" onclick="showLang('en')">English</button>
|
||||
<button onclick="showLang('cs')">Čeština</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="emailGrid">
|
||||
<!-- Magic Link EN -->
|
||||
<div class="card" data-lang="en">
|
||||
<div class="card-header">
|
||||
<h2>Magic Link</h2>
|
||||
<p>Passwordless authentication</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Calm booking software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Hi Sarah,</div>
|
||||
<div class="message">
|
||||
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="#" class="button">Sign In to Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Or copy this link</div>
|
||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
This link expires in <strong>15 minutes</strong> for security.
|
||||
</div>
|
||||
<div class="help">
|
||||
Didn't request this? You can safely ignore it.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Welcome EN -->
|
||||
<div class="card" data-lang="en">
|
||||
<div class="card-header">
|
||||
<h2>Welcome Email</h2>
|
||||
<p>New user onboarding</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
||||
.message { font-size: 18px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
||||
.features { background: #f5f2ed; border-radius: 12px; padding: 32px; margin: 32px 0; }
|
||||
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.feature:last-child { margin-bottom: 0; }
|
||||
.feature-icon { width: 24px; height: 24px; background: #a65c3e; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; flex-shrink: 0; }
|
||||
.feature-text { font-size: 16px; color: #5c514a; line-height: 1.5; }
|
||||
.button-wrap { margin: 40px 0; text-align: center; }
|
||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Welcome, Sarah</div>
|
||||
<div class="message">
|
||||
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
|
||||
</div>
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Customer insights</strong> — History and preferences</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="#" class="button">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Booking Confirmation EN -->
|
||||
<div class="card" data-lang="en">
|
||||
<div class="card-header">
|
||||
<h2>Booking Confirmation</h2>
|
||||
<p>Customer confirmation email</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
|
||||
.content { padding: 48px 40px; }
|
||||
.badge { display: inline-block; background: #f5ebe7; color: #a65c3e; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: "Space Grotesk", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; color: #2a221e; margin-bottom: 8px; }
|
||||
.message { font-size: 17px; color: #5c514a; margin-bottom: 32px; }
|
||||
.details { background: #f5f2ed; border-radius: 12px; padding: 28px; margin: 32px 0; }
|
||||
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid #e8e2da; }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.05em; font-family: "Space Grotesk", sans-serif; }
|
||||
.detail-value { flex: 1; font-size: 16px; color: #2a221e; font-weight: 500; }
|
||||
.help { font-size: 15px; color: #8b7f76; margin-top: 32px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Confirmed</div>
|
||||
<div class="greeting">Hello Sarah,</div>
|
||||
<div class="message">
|
||||
Your booking with <strong>Studio Ella</strong> is confirmed.
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Service</div>
|
||||
<div class="detail-value">Haircut & Styling</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">When</div>
|
||||
<div class="detail-value">Monday, April 22 at 2:00 PM</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Where</div>
|
||||
<div class="detail-value">123 Main Street, Prague 1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help">
|
||||
Need to reschedule? Contact Studio Ella directly.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Magic Link CS -->
|
||||
<div class="card" data-lang="cs" style="display:none">
|
||||
<div class="card-header">
|
||||
<h2>Magický Odkaz (CZ)</h2>
|
||||
<p>Přihlášení bez hesla</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe srcdoc='
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
|
||||
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
|
||||
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
|
||||
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
|
||||
.content { padding: 48px 40px; }
|
||||
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
|
||||
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
|
||||
.button-wrap { margin: 40px 0; }
|
||||
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
|
||||
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
|
||||
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
|
||||
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
|
||||
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
|
||||
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
|
||||
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
|
||||
.footer-text { font-size: 14px; color: #8b7f76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">B</div>
|
||||
<div class="brand">Bookra</div>
|
||||
<div class="tagline">Klidný rezervační software</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">Dobrý den Martino,</div>
|
||||
<div class="message">
|
||||
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
|
||||
</div>
|
||||
<div class="button-wrap">
|
||||
<a href="#" class="button">Přihlásit se do Bookra</a>
|
||||
</div>
|
||||
<div class="link-box">
|
||||
<div class="link-label">Nebo zkopírujte tento odkaz</div>
|
||||
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
|
||||
</div>
|
||||
<div class="expiry">
|
||||
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
|
||||
</div>
|
||||
<div class="help">
|
||||
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="footer-text">© 2024 Bookra</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showLang(lang) {
|
||||
document.querySelectorAll(".toggle button").forEach(btn => btn.classList.remove("active"));
|
||||
event.target.classList.add("active");
|
||||
document.querySelectorAll("[data-lang]").forEach(card => {
|
||||
card.style.display = card.dataset.lang === lang ? "block" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
bin
|
||||
coverage
|
||||
tmp
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -1,15 +1,82 @@
|
||||
# ============================================
|
||||
# Bookra Backend API Configuration
|
||||
# ============================================
|
||||
# Active stack:
|
||||
# - frontend signs users in with Neon Auth
|
||||
# - backend verifies Neon Auth JWTs
|
||||
# - backend serves booking + billing APIs
|
||||
# - Paddle handles SaaS billing + customer portal
|
||||
# ============================================
|
||||
|
||||
BOOKRA_APP_ENV=staging
|
||||
BOOKRA_API_PORT=8080
|
||||
BOOKRA_API_URL=http://localhost:8080
|
||||
BOOKRA_FRONTEND_URL=http://localhost:3000
|
||||
BOOKRA_DATABASE_URL=postgres://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require
|
||||
BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require
|
||||
BOOKRA_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
|
||||
|
||||
# --------------------------------------------
|
||||
# DEMO MODE (Standalone showcase mode)
|
||||
# --------------------------------------------
|
||||
# Set to true to enable permanent demo mode with in-memory data.
|
||||
# When enabled:
|
||||
# - API uses MemoryRepository with pre-populated demo data
|
||||
# - All requests are auto-authenticated as demo-owner
|
||||
# - No database connection required
|
||||
# - Perfect for showcasing the app without external dependencies
|
||||
# Note: When DEMO_MODE=false, the following are required:
|
||||
# - BOOKRA_DATABASE_URL
|
||||
# - BOOKRA_NEON_AUTH_URL (for JWT verification)
|
||||
BOOKRA_DEMO_MODE=false
|
||||
|
||||
# --------------------------------------------
|
||||
# DATABASE (Required when DEMO_MODE=false)
|
||||
# --------------------------------------------
|
||||
BOOKRA_DATABASE_URL=postgres://user:password@ep-example-pooler.region.aws.neon.tech/bookra_backend?sslmode=require
|
||||
BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.tech/bookra_backend?sslmode=require
|
||||
|
||||
# --------------------------------------------
|
||||
# AUTHENTICATION (Required when DEMO_MODE=false)
|
||||
# --------------------------------------------
|
||||
# Neon Auth base URL for JWKS verification.
|
||||
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
# Optional emergency/local fallback for non-Neon JWT verification.
|
||||
# Leave blank in normal Neon Auth setup.
|
||||
BOOKRA_AUTH_JWT_SECRET=
|
||||
|
||||
# --------------------------------------------
|
||||
# INTERNAL SERVICES
|
||||
# --------------------------------------------
|
||||
# Job runner key for internal API endpoints (reminders, notifications)
|
||||
BOOKRA_JOB_RUNNER_KEY=job_runner_secret_123
|
||||
BOOKRA_EMAIL_FROM=noreply@bookra.dev
|
||||
BOOKRA_SMS_FROM=Bookra
|
||||
BOOKRA_STRIPE_SECRET_KEY=sk_test_123
|
||||
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_123
|
||||
BOOKRA_STRIPE_STARTER_PRICE_ID=price_starter_123
|
||||
BOOKRA_STRIPE_GROWTH_PRICE_ID=price_growth_123
|
||||
BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID=price_multi_location_123
|
||||
|
||||
# --------------------------------------------
|
||||
# PADDLE BILLING (Required for live checkout/webhooks)
|
||||
# --------------------------------------------
|
||||
# BOOKRA_PADDLE_ENV must be "sandbox" or "live".
|
||||
# API key: Paddle dashboard -> Developer tools -> Authentication.
|
||||
# Webhook secret: Paddle dashboard -> Notification settings -> destination secret key.
|
||||
# Price IDs: Paddle dashboard -> Catalog -> Product price IDs.
|
||||
BOOKRA_PADDLE_ENV=sandbox
|
||||
BOOKRA_PADDLE_API_KEY=
|
||||
BOOKRA_PADDLE_WEBHOOK_SECRET=
|
||||
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=
|
||||
BOOKRA_PADDLE_STARTER_USD_PRICE_ID=
|
||||
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=
|
||||
BOOKRA_PADDLE_PRO_USD_PRICE_ID=
|
||||
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=
|
||||
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=
|
||||
|
||||
# --------------------------------------------
|
||||
# EMAIL (Optional)
|
||||
# --------------------------------------------
|
||||
# Only configure if backend needs to send reminder emails directly.
|
||||
BOOKRA_EMAIL_FROM=
|
||||
BOOKRA_SMTP_HOST=
|
||||
BOOKRA_SMTP_PORT=587
|
||||
BOOKRA_SMTP_USERNAME=
|
||||
BOOKRA_SMTP_PASSWORD=
|
||||
|
||||
# --------------------------------------------
|
||||
# ANALYTICS (Optional)
|
||||
# --------------------------------------------
|
||||
BOOKRA_UMAMI_API_URL=
|
||||
BOOKRA_UMAMI_API_KEY=
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM golang:1.26.2-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/backend ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S bookra \
|
||||
&& adduser -S -D -H -u 10001 -G bookra bookra
|
||||
|
||||
COPY --from=builder --chown=bookra:bookra /app/backend /app/
|
||||
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
USER bookra
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/healthz" >/dev/null || exit 1
|
||||
|
||||
CMD ["/app/backend"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bookra Backend
|
||||
|
||||
Go + Gin API for Bookra, designed for Railway deployment and Neon-backed persistence.
|
||||
Go + Gin API for Bookra, designed for Railway deployment with Neon Auth, Neon Postgres, and Paddle billing.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -18,11 +18,14 @@ npm run db:migrate:up
|
||||
- `BOOKRA_DATABASE_URL` Neon pooled connection
|
||||
- `BOOKRA_DATABASE_DIRECT_URL` Neon direct connection for migrations/admin tasks
|
||||
- `BOOKRA_NEON_AUTH_URL` Neon Auth base URL used for JWKS verification
|
||||
- `BOOKRA_AUTH_JWT_SECRET` optional local JWT fallback when not using Neon Auth
|
||||
- `BOOKRA_JOB_RUNNER_KEY` shared secret for remote reminder dispatch calls
|
||||
- `BOOKRA_EMAIL_FROM` sender identity for email reminders
|
||||
- `BOOKRA_SMS_FROM` sender label for future SMS reminders
|
||||
- `BOOKRA_STRIPE_SECRET_KEY` Stripe API secret
|
||||
- `BOOKRA_STRIPE_WEBHOOK_SECRET` Stripe webhook secret
|
||||
- `BOOKRA_PADDLE_ENV` billing environment: `sandbox` or `live`
|
||||
- `BOOKRA_PADDLE_API_KEY` Paddle API key
|
||||
- `BOOKRA_PADDLE_WEBHOOK_SECRET` Paddle notification destination secret
|
||||
- `BOOKRA_PADDLE_{STARTER,PRO,BUSINESS}_{CZK,USD}_PRICE_ID` Paddle price IDs
|
||||
- `BOOKRA_UMAMI_API_URL` and `BOOKRA_UMAMI_API_KEY` optional analytics integration
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -32,3 +35,40 @@ npm run db:migrate:up
|
||||
- `sqlc.yaml` is wired through `npm run db:generate`.
|
||||
- Goose migrations are wired through `npm run db:migrate:*` and use the Neon direct connection URL.
|
||||
- Reminder dispatch now runs through `POST /v1/internal/jobs/reminders/dispatch` with `X-Bookra-Job-Key`.
|
||||
|
||||
## Production Auth
|
||||
|
||||
Bookra production auth should use Neon Auth directly:
|
||||
|
||||
- frontend uses `VITE_NEON_AUTH_URL`
|
||||
- backend verifies Neon JWTs with `BOOKRA_NEON_AUTH_URL`
|
||||
- auth-service may stay deployed for standalone auth/admin workflows, but backend billing and app APIs do not depend on it
|
||||
|
||||
Trusted redirect domains in Neon Auth should include your frontend origin such as `https://bookra.eu`, plus local dev origins when needed.
|
||||
|
||||
## Paddle Setup
|
||||
|
||||
Get these values from Paddle dashboard:
|
||||
|
||||
- `BOOKRA_PADDLE_ENV`: `sandbox` for testing, `live` for production
|
||||
- `BOOKRA_PADDLE_API_KEY`: Developer tools -> Authentication
|
||||
- `BOOKRA_PADDLE_WEBHOOK_SECRET`: Notification settings -> destination secret key
|
||||
- `BOOKRA_PADDLE_*_PRICE_ID`: Catalog -> each SaaS plan recurring price ID
|
||||
|
||||
Create one recurring price per plan/currency you support:
|
||||
|
||||
- `starter` `czk`
|
||||
- `starter` `usd`
|
||||
- `pro` `czk`
|
||||
- `pro` `usd`
|
||||
- `business` `czk`
|
||||
- `business` `usd`
|
||||
|
||||
Set your webhook destination to:
|
||||
|
||||
```text
|
||||
POST /v1/webhooks/paddle
|
||||
POST /api/paddle_webhook
|
||||
```
|
||||
|
||||
Use Paddle webhook simulator for event testing.
|
||||
|
||||
@@ -29,6 +29,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("create server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
||||
@@ -3,13 +3,13 @@ module bookra/apps/backend
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/stripe/stripe-go/v83 v83.2.1
|
||||
golang.org/x/time v0.9.0
|
||||
)
|
||||
|
||||
@@ -20,12 +20,15 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/ggicci/httpin v0.20.3 // indirect
|
||||
github.com/ggicci/owl v0.8.2 // indirect
|
||||
github.com/gin-contrib/sse v1.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.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // 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
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh
|
||||
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0 h1:HJabVGWEsDyogj1Ib/ZGcMcP9Vc/e2tqIDYx0xZA+qI=
|
||||
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0/go.mod h1:nlFfZYf7MovyHTE67+u7BARbMiBdMMLXGuQMbCVm9ss=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -15,6 +17,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
|
||||
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
|
||||
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
|
||||
github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -40,6 +46,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
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/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
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=
|
||||
@@ -81,8 +89,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
|
||||
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/auth"
|
||||
@@ -30,16 +31,18 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
verifier, err := auth.NewVerifier(cfg.NeonAuthURL)
|
||||
verifier, err := auth.NewVerifier(cfg.NeonAuthURL, cfg.AuthJWTSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repository := db.NewRepository(pools)
|
||||
bookingService := bookings.NewService(repository)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
repository := db.NewRepository(pools, cfg.DemoMode)
|
||||
notificationService := notifications.NewService(cfg, repository)
|
||||
bookingService := bookings.NewService(repository, notificationService)
|
||||
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
catalogService := catalog.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||
|
||||
server := &Server{
|
||||
@@ -50,7 +53,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
AllowOrigins: []string{cfg.FrontendURL},
|
||||
AllowOrigins: allowedOrigins(cfg),
|
||||
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowCredentials: true,
|
||||
@@ -69,6 +72,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
"environment": cfg.Environment,
|
||||
"neonAuthEnabled": verifier.Enabled(),
|
||||
"apiUrl": cfg.APIURL,
|
||||
"demoMode": cfg.DemoMode,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,7 +127,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
})
|
||||
|
||||
protected := server.router.Group("/v1")
|
||||
protected.Use(auth.RequireAuth(verifier, repository))
|
||||
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
|
||||
|
||||
protected.GET("/dashboard/summary", func(c *gin.Context) {
|
||||
response, err := bookingService.DashboardSummary(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
@@ -166,7 +170,309 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
|
||||
_ = catalog.NewService()
|
||||
// ============================================
|
||||
// CATALOG API - Locations / Zones
|
||||
// ============================================
|
||||
protected.GET("/catalog/locations", func(c *gin.Context) {
|
||||
response, err := catalogService.ListLocations(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/catalog/locations", func(c *gin.Context) {
|
||||
var request domain.CreateLocationRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.CreateLocation(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/locations/:locationID", func(c *gin.Context) {
|
||||
var request domain.UpdateLocationRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrLocationNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/locations/:locationID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrLocationNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Blocked Days
|
||||
// ============================================
|
||||
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
|
||||
from := time.Now()
|
||||
to := from.AddDate(0, 3, 0) // 3 months ahead
|
||||
response, err := catalogService.ListBlockedDays(c.Request.Context(), auth.PrincipalFromContext(c), from, to)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/catalog/blocked-days", func(c *gin.Context) {
|
||||
var request domain.CreateBlockedDayRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.CreateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrInvalidBooking) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
|
||||
var request domain.UpdateBlockedDayRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Customers
|
||||
// ============================================
|
||||
protected.GET("/catalog/customers", func(c *gin.Context) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
response, err := catalogService.ListCustomers(c.Request.Context(), auth.PrincipalFromContext(c), limit, offset)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/catalog/customers", func(c *gin.Context) {
|
||||
var request domain.CreateCustomerRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.CreateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PUT("/catalog/customers/:customerID", func(c *gin.Context) {
|
||||
var request domain.UpdateCustomerRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/customers/:customerID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Working Hours
|
||||
// ============================================
|
||||
protected.GET("/catalog/working-hours", func(c *gin.Context) {
|
||||
response, err := catalogService.ListWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.PUT("/catalog/working-hours/:dayOfWeek", func(c *gin.Context) {
|
||||
dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek"))
|
||||
if err != nil || dayOfWeek < 0 || dayOfWeek > 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"})
|
||||
return
|
||||
}
|
||||
var request domain.UpdateWorkingHoursRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
err = catalogService.UpdateWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), dayOfWeek, request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER BOOKING MANAGEMENT API (Public)
|
||||
// ============================================
|
||||
server.router.GET("/v1/public/bookings/:reference", func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, bookings.ErrBookingNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
server.router.POST("/v1/public/bookings/:reference/reschedule", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
var request domain.RescheduleBookingRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
err := customerBookingService.RescheduleBooking(c.Request.Context(), c.Param("reference"), request, token)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, bookings.ErrBookingNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, bookings.ErrBookingCancelled):
|
||||
status = http.StatusConflict
|
||||
case errors.Is(err, bookings.ErrInvalidReschedule):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "rescheduled"})
|
||||
})
|
||||
server.router.POST("/v1/public/bookings/:reference/cancel", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
err := customerBookingService.CancelBooking(c.Request.Context(), c.Param("reference"), token)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, bookings.ErrBookingNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, bookings.ErrBookingCancelled):
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||
})
|
||||
|
||||
protected.GET("/billing/subscription", func(c *gin.Context) {
|
||||
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
@@ -186,7 +492,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode)
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
@@ -195,6 +501,9 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if errors.Is(err, billing.ErrPaddleNotConfigured) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -207,19 +516,41 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if errors.Is(err, billing.ErrPaddleNotConfigured) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.POST("/billing/portal", func(c *gin.Context) {
|
||||
response, err := billingService.CreatePortalSession(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrBillingMembership):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, billing.ErrBillingCustomerMissing):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, billing.ErrPaddleNotConfigured):
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
|
||||
payload, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_payload"})
|
||||
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||
})
|
||||
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
|
||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -246,6 +577,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Widget embeddable script endpoint - serves JavaScript for external sites
|
||||
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
|
||||
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
c.String(http.StatusOK, widgetJavaScript(cfg.APIURL))
|
||||
})
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
@@ -253,9 +591,185 @@ func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
if s.verifier != nil {
|
||||
s.verifier.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||
if cfg.JobRunnerKey == "" {
|
||||
return cfg.Environment == "development"
|
||||
return false
|
||||
}
|
||||
return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey
|
||||
}
|
||||
|
||||
func allowedOrigins(cfg config.Config) []string {
|
||||
origins := []string{cfg.FrontendURL}
|
||||
if cfg.Environment == "development" {
|
||||
origins = append(origins,
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:4173",
|
||||
"http://127.0.0.1:4173",
|
||||
)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(origins))
|
||||
unique := origins[:0]
|
||||
for _, origin := range origins {
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
unique = append(unique, origin)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// widgetJavaScript returns the embeddable widget script that can be included on external sites
|
||||
func widgetJavaScript(apiURL string) string {
|
||||
return `(function() {
|
||||
'use strict';
|
||||
|
||||
// Bookra Widget v1.0 - Embeddable Booking Widget
|
||||
// Usage: <script src="` + apiURL + `/v1/public/widget.js" data-tenant="your-slug" async defer></script>
|
||||
|
||||
const WIDGET_VERSION = '1.0.0';
|
||||
const WIDGET_ORIGIN = '` + apiURL + `';
|
||||
|
||||
// Configuration from script tag attributes
|
||||
const scripts = document.querySelectorAll('script[src*="widget.js"]');
|
||||
|
||||
scripts.forEach(function(script) {
|
||||
const config = {
|
||||
tenant: script.getAttribute('data-tenant') || script.getAttribute('data-tenant-slug'),
|
||||
theme: script.getAttribute('data-theme') || 'auto',
|
||||
size: script.getAttribute('data-size') || 'default',
|
||||
color: script.getAttribute('data-color') || '#a65c3e',
|
||||
position: script.getAttribute('data-position') || 'bottom-right',
|
||||
widgetId: script.getAttribute('data-widget-id') || 'bookra-widget-' + Math.random().toString(36).substr(2, 9)
|
||||
};
|
||||
|
||||
if (!config.tenant) {
|
||||
console.error('[Bookra Widget] Missing data-tenant attribute');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create widget container
|
||||
let container = document.getElementById(config.widgetId);
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = config.widgetId;
|
||||
container.className = 'bookra-widget-container';
|
||||
|
||||
// For floating widgets, append to body
|
||||
if (config.size === 'floating' || script.getAttribute('data-floating') === 'true') {
|
||||
container.style.cssText = 'position:fixed;' + getFloatingPosition(config.position) + ';z-index:9999;';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = config.theme === 'dark' || (config.theme === 'auto' && prefersDark);
|
||||
container.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
|
||||
// Build booking URL
|
||||
const bookingUrl = WIDGET_ORIGIN.replace('/api', '') + '/book/' + encodeURIComponent(config.tenant);
|
||||
|
||||
// Create widget content based on type
|
||||
const widgetType = script.getAttribute('data-type') || 'iframe';
|
||||
|
||||
switch (widgetType) {
|
||||
case 'button':
|
||||
container.innerHTML = createButtonWidget(config, bookingUrl);
|
||||
break;
|
||||
case 'modal':
|
||||
container.innerHTML = createModalWidget(config, bookingUrl);
|
||||
break;
|
||||
case 'floating':
|
||||
container.innerHTML = createFloatingWidget(config, bookingUrl);
|
||||
break;
|
||||
case 'inline-calendar':
|
||||
container.innerHTML = createCalendarWidget(config, bookingUrl, WIDGET_ORIGIN);
|
||||
break;
|
||||
default:
|
||||
container.innerHTML = createIframeWidget(config, bookingUrl);
|
||||
}
|
||||
|
||||
// Add styles
|
||||
addWidgetStyles(config.color, isDark);
|
||||
});
|
||||
|
||||
function getFloatingPosition(position) {
|
||||
switch (position) {
|
||||
case 'top-left': return 'top:20px;left:20px';
|
||||
case 'top-right': return 'top:20px;right:20px';
|
||||
case 'bottom-left': return 'bottom:20px;left:20px';
|
||||
default: return 'bottom:20px;right:20px';
|
||||
}
|
||||
}
|
||||
|
||||
function createIframeWidget(config, url) {
|
||||
const height = config.size === 'compact' ? '400px' : config.size === 'full' ? '100vh' : '760px';
|
||||
return '<iframe src="' + url + '" style="width:100%;height:' + height + ';border:none;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.1);" loading="lazy" title="Book appointment"></iframe>';
|
||||
}
|
||||
|
||||
function createButtonWidget(config, url) {
|
||||
return '<button class="bookra-widget-btn" onclick="window.open(\'' + url + '\', \'_blank\')" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:opacity 0.2s;">' +
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
|
||||
'Book appointment</button>';
|
||||
}
|
||||
|
||||
function createModalWidget(config, url) {
|
||||
const modalId = config.widgetId + '-modal';
|
||||
return '<button class="bookra-widget-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;">Book now</button>' +
|
||||
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
|
||||
'<div style="background:white;border-radius:16px;width:100%;max-width:900px;height:90vh;position:relative;overflow:hidden;">' +
|
||||
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
|
||||
'<iframe src="' + url + '" style="width:100%;height:100%;border:none;"></iframe>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function createFloatingWidget(config, url) {
|
||||
const modalId = config.widgetId + '-modal';
|
||||
return '<button class="bookra-floating-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;width:60px;height:60px;border:none;border-radius:50%;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:center;justify-content:center;transition:transform 0.2s;" onmouseover="this.style.transform=\'scale(1.1)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
|
||||
'</button>' +
|
||||
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
|
||||
'<div style="background:white;border-radius:16px;width:100%;max-width:500px;max-height:90vh;position:relative;overflow:hidden;">' +
|
||||
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
|
||||
'<iframe src="' + url + '" style="width:100%;height:80vh;border:none;"></iframe>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function createCalendarWidget(config, url, apiUrl) {
|
||||
// Placeholder for inline calendar - would fetch availability and render mini-calendar
|
||||
return '<div class="bookra-calendar-widget" style="background:white;border-radius:12px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1);">' +
|
||||
'<h3 style="margin:0 0 16px 0;font-size:18px;">Select a time</h3>' +
|
||||
'<p style="color:#666;margin:0 0 16px 0;">Loading availability...</p>' +
|
||||
'<a href="' + url + '" target="_blank" style="display:inline-block;background:' + config.color + ';color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;">View all times</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function addWidgetStyles(primaryColor, isDark) {
|
||||
if (document.getElementById('bookra-widget-styles')) return;
|
||||
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'bookra-widget-styles';
|
||||
styles.textContent = '.bookra-widget-container { font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; }' +
|
||||
'.bookra-widget-container[data-theme="dark"] iframe { filter: invert(0.95) hue-rotate(180deg); }' +
|
||||
'.bookra-floating-btn:hover { transform: scale(1.1); }' +
|
||||
'@keyframes bookra-pulse { 0%, 100% { box-shadow: 0 4px 12px rgba(0,0,0,0.15); } 50% { box-shadow: 0 4px 20px rgba(0,0,0,0.25); } }' +
|
||||
'.bookra-floating-btn { animation: bookra-pulse 2s infinite; }';
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
|
||||
// Log initialization
|
||||
console.log('[Bookra Widget] v' + WIDGET_VERSION + ' initialized for ' + scripts.length + ' widget(s)');
|
||||
})();`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestDispatchReminderJobsRequiresJobRunnerKey(t *testing.T) {
|
||||
server, err := NewServer(config.Config{
|
||||
Environment: "development",
|
||||
FrontendURL: "http://localhost:3000",
|
||||
APIURL: "http://localhost:8080",
|
||||
DemoMode: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/jobs/reminders/dispatch", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderJobsAcceptsConfiguredJobRunnerKey(t *testing.T) {
|
||||
server, err := NewServer(config.Config{
|
||||
Environment: "development",
|
||||
FrontendURL: "http://localhost:3000",
|
||||
APIURL: "http://localhost:8080",
|
||||
JobRunnerKey: "job-secret",
|
||||
DemoMode: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/jobs/reminders/dispatch", nil)
|
||||
request.Header.Set("X-Bookra-Job-Key", "job-secret")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,23 @@ import (
|
||||
|
||||
const principalContextKey = "principal"
|
||||
|
||||
func RequireAuth(verifier *Verifier, repo db.Repository) gin.HandlerFunc {
|
||||
// DemoPrincipal is the auto-authenticated user in demo mode
|
||||
var DemoPrincipal = domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "demo@bookra.dev",
|
||||
Name: "Demo User",
|
||||
Role: "owner",
|
||||
}
|
||||
|
||||
func RequireAuth(verifier *Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// In demo mode, auto-authenticate as the demo user
|
||||
if demoMode {
|
||||
c.Set(principalContextKey, DemoPrincipal)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if verifier == nil || !verifier.Enabled() {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
|
||||
return
|
||||
|
||||
@@ -16,13 +16,18 @@ type Verifier struct {
|
||||
jwks keyfunc.Keyfunc
|
||||
expectedIssuer string
|
||||
enabled bool
|
||||
localSecret []byte
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewVerifier(neonAuthURL string) (*Verifier, error) {
|
||||
trimmed := strings.TrimSpace(neonAuthURL)
|
||||
func NewVerifier(neonAuthURL string, localJWTSecret string) (*Verifier, error) {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
|
||||
if trimmed == "" {
|
||||
return &Verifier{enabled: false}, nil
|
||||
secret := strings.TrimSpace(localJWTSecret)
|
||||
return &Verifier{
|
||||
enabled: secret != "",
|
||||
localSecret: []byte(secret),
|
||||
}, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
@@ -45,6 +50,7 @@ func NewVerifier(neonAuthURL string) (*Verifier, error) {
|
||||
jwks: jwks,
|
||||
expectedIssuer: expectedIssuer,
|
||||
enabled: true,
|
||||
localSecret: []byte(strings.TrimSpace(localJWTSecret)),
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
@@ -64,6 +70,26 @@ func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
|
||||
return nil, errors.New("neon auth verifier is disabled")
|
||||
}
|
||||
|
||||
if len(v.localSecret) > 0 && v.jwks == nil {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return v.localSecret, nil
|
||||
}, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
if tokenType, _ := claims["type"].(string); tokenType != "access" {
|
||||
return nil, errors.New("invalid token type")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
|
||||
jwt.WithIssuer(v.expectedIssuer),
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
|
||||
@@ -4,48 +4,83 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/shared"
|
||||
|
||||
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stripe/stripe-go/v83"
|
||||
"github.com/stripe/stripe-go/v83/checkout/session"
|
||||
"github.com/stripe/stripe-go/v83/customer"
|
||||
"github.com/stripe/stripe-go/v83/subscription"
|
||||
"github.com/stripe/stripe-go/v83/webhook"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBillingMembership = errors.New("billing membership not found")
|
||||
ErrBillingPlanUnsupported = errors.New("billing plan is not configured")
|
||||
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
|
||||
ErrBillingCustomerMissing = errors.New("billing customer is not available")
|
||||
ErrPaddleNotConfigured = errors.New("paddle is not configured")
|
||||
ErrPaddleSignatureMissing = errors.New("paddle signature is missing")
|
||||
ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured")
|
||||
)
|
||||
|
||||
var allowedWebhookEvents = []string{
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.resumed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
"subscription.created",
|
||||
"subscription.updated",
|
||||
"subscription.activated",
|
||||
"subscription.canceled",
|
||||
"subscription.paused",
|
||||
"subscription.resumed",
|
||||
"subscription.trialing",
|
||||
"transaction.completed",
|
||||
"transaction.updated",
|
||||
"transaction.payment_failed",
|
||||
"transaction.past_due",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
client *paddle.SDK
|
||||
verifier *paddle.WebhookVerifier
|
||||
}
|
||||
|
||||
type webhookEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
CustomData map[string]any `json:"custom_data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
return &Service{cfg: cfg, repo: repo}
|
||||
service := &Service{cfg: cfg, repo: repo}
|
||||
|
||||
if strings.TrimSpace(cfg.PaddleAPIKey) != "" {
|
||||
var client *paddle.SDK
|
||||
var err error
|
||||
if cfg.PaddleEnvironment == "live" {
|
||||
client, err = paddle.New(cfg.PaddleAPIKey, paddle.WithBaseURL(paddle.ProductionBaseURL))
|
||||
} else {
|
||||
client, err = paddle.NewSandbox(cfg.PaddleAPIKey)
|
||||
}
|
||||
if err == nil {
|
||||
service.client = client
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.PaddleWebhookKey) != "" {
|
||||
service.verifier = paddle.NewWebhookVerifier(cfg.PaddleWebhookKey, paddle.VerifierWithTimestampTolerance(5*time.Minute))
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
@@ -56,80 +91,57 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
|
||||
record, err := s.repo.GetSubscriptionSnapshot(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StripeCustomerID: derefString(membership.Tenant.StripeCustomerID),
|
||||
Status: membership.Tenant.SubscriptionStatus,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantID: membership.Tenant.ID,
|
||||
BillingProvider: "paddle",
|
||||
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
}, s.cfg), nil
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string) (domain.CheckoutSessionResponse, error) {
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string) (domain.CheckoutLaunchResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CheckoutSessionResponse{}, ErrBillingMembership
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingMembership
|
||||
}
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
return domain.CheckoutLaunchResponse{}, err
|
||||
}
|
||||
|
||||
priceID := s.cfg.StripePriceIDs[planCode]
|
||||
priceID, resolvedPlanCode, resolvedCurrency := s.priceForPlan(planCode, currency)
|
||||
if priceID == "" {
|
||||
return domain.CheckoutSessionResponse{}, ErrBillingPlanUnsupported
|
||||
return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported
|
||||
}
|
||||
if !checkoutAvailable(s.cfg, resolvedPlanCode) {
|
||||
return domain.CheckoutLaunchResponse{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
mockURL := fmt.Sprintf("%s/dashboard?billing=mock-checkout&plan=%s", s.cfg.FrontendURL, planCode)
|
||||
return domain.CheckoutSessionResponse{URL: mockURL}, nil
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
customerID := derefString(membership.Tenant.StripeCustomerID)
|
||||
if customerID == "" {
|
||||
params := &stripe.CustomerParams{
|
||||
Name: stripe.String(membership.Tenant.Name),
|
||||
Email: stripe.String(principal.Email),
|
||||
Metadata: map[string]string{"tenant_id": membership.Tenant.ID, "tenant_slug": membership.Tenant.Slug},
|
||||
}
|
||||
createdCustomer, err := customer.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
customerID = createdCustomer.ID
|
||||
if err := s.repo.UpdateTenantStripeCustomerID(ctx, membership.Tenant.ID, customerID); err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
Customer: stripe.String(customerID),
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", s.cfg.FrontendURL)),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", s.cfg.FrontendURL)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
return domain.CheckoutLaunchResponse{
|
||||
PriceID: priceID,
|
||||
CustomerID: derefString(membership.Tenant.BillingCustomerID),
|
||||
CustomerEmail: strings.TrimSpace(principal.Email),
|
||||
SuccessRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success",
|
||||
CancelRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled",
|
||||
CustomData: map[string]string{
|
||||
"tenantId": membership.Tenant.ID,
|
||||
"tenantSlug": membership.Tenant.Slug,
|
||||
"userId": principal.Subject,
|
||||
"userEmail": strings.TrimSpace(principal.Email),
|
||||
"planCode": resolvedPlanCode,
|
||||
"currency": resolvedCurrency,
|
||||
"source": "bookra-dashboard",
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"tenant_id": membership.Tenant.ID,
|
||||
"plan_code": planCode,
|
||||
},
|
||||
}
|
||||
|
||||
checkoutSession, err := session.New(params)
|
||||
if err != nil {
|
||||
return domain.CheckoutSessionResponse{}, err
|
||||
}
|
||||
return domain.CheckoutSessionResponse{URL: checkoutSession.URL}, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
|
||||
@@ -140,139 +152,226 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
|
||||
}
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
customerID := derefString(membership.Tenant.StripeCustomerID)
|
||||
|
||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||
if customerID == "" {
|
||||
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StripeCustomerID: "",
|
||||
Status: "none",
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantID: membership.Tenant.ID,
|
||||
BillingProvider: "paddle",
|
||||
Status: "inactive",
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
}, s.cfg), nil
|
||||
}
|
||||
record, err := s.syncStripeData(ctx, membership.Tenant, customerID)
|
||||
if s.client == nil {
|
||||
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
|
||||
if err != nil {
|
||||
return domain.SubscriptionSnapshot{}, err
|
||||
}
|
||||
|
||||
return toSnapshot(membership.Tenant, record, s.cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return nil
|
||||
func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Principal) (domain.PortalSessionResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PortalSessionResponse{}, ErrBillingMembership
|
||||
}
|
||||
return domain.PortalSessionResponse{}, err
|
||||
}
|
||||
if signature == "" {
|
||||
return ErrStripeSignatureMissing
|
||||
if s.client == nil {
|
||||
return domain.PortalSessionResponse{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookKey)
|
||||
customerID := derefString(membership.Tenant.BillingCustomerID)
|
||||
if customerID == "" {
|
||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||
}
|
||||
|
||||
request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID}
|
||||
if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" {
|
||||
request.SubscriptionIDs = []string{subscriptionID}
|
||||
}
|
||||
|
||||
session, err := s.client.CreateCustomerPortalSession(ctx, request)
|
||||
if err != nil {
|
||||
return domain.PortalSessionResponse{}, err
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(session.URLs.General.Overview)
|
||||
if url == "" && len(session.URLs.Subscriptions) > 0 {
|
||||
url = firstNonEmpty(
|
||||
session.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod,
|
||||
session.URLs.Subscriptions[0].CancelSubscription,
|
||||
)
|
||||
}
|
||||
if url == "" {
|
||||
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
|
||||
}
|
||||
|
||||
return domain.PortalSessionResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error {
|
||||
if s.verifier == nil {
|
||||
return ErrPaddleWebhookMissing
|
||||
}
|
||||
if strings.TrimSpace(req.Header.Get("Paddle-Signature")) == "" {
|
||||
return ErrPaddleSignatureMissing
|
||||
}
|
||||
|
||||
ok, err := s.verifier.Verify(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
|
||||
if !ok {
|
||||
return errors.New("invalid paddle webhook signature")
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var event webhookEnvelope
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(allowedWebhookEvents, event.EventType) {
|
||||
return nil
|
||||
}
|
||||
|
||||
customerID := extractCustomerID(event)
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tenant, err := s.repo.GetTenantByStripeCustomerID(ctx, customerID)
|
||||
tenant, customerID, err := s.resolveWebhookTenant(ctx, event)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if tenant.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
inserted, err := s.repo.RecordStripeEvent(ctx, tenant.ID, event.ID, string(event.Type), payload)
|
||||
inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "paddle", event.EventID, event.EventType, payload)
|
||||
if err != nil || !inserted {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.syncStripeData(ctx, tenant, customerID)
|
||||
if customerID != "" && derefString(tenant.BillingCustomerID) == "" {
|
||||
if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
tenant.BillingCustomerID = &customerID
|
||||
}
|
||||
|
||||
customerID = firstNonEmpty(customerID, derefString(tenant.BillingCustomerID))
|
||||
if customerID == "" || s.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = s.syncPaddleData(ctx, tenant, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) syncStripeData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: "",
|
||||
Status: tenant.SubscriptionStatus,
|
||||
PlanCode: tenant.PlanCode,
|
||||
PriceID: s.cfg.StripePriceIDs[tenant.PlanCode],
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
func (s *Service) resolveWebhookTenant(ctx context.Context, event webhookEnvelope) (db.TenantRecord, string, error) {
|
||||
customerID := strings.TrimSpace(event.Data.CustomerID)
|
||||
if tenantID := customDataString(event.Data.CustomData, "tenantId"); tenantID != "" {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
return tenant, customerID, err
|
||||
}
|
||||
if customerID != "" {
|
||||
tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID)
|
||||
return tenant, customerID, err
|
||||
}
|
||||
return db.TenantRecord{}, customerID, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
|
||||
if s.client == nil {
|
||||
return db.BillingSnapshotRecord{}, ErrPaddleNotConfigured
|
||||
}
|
||||
|
||||
stripe.Key = s.cfg.StripeSecretKey
|
||||
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
|
||||
params.Status = stripe.String("all")
|
||||
params.AddExpand("data.default_payment_method")
|
||||
params.AddExpand("data.items.data.price")
|
||||
subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{
|
||||
CustomerID: []string{customerID},
|
||||
})
|
||||
if err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
iter := subscription.List(params)
|
||||
if iter.Err() != nil {
|
||||
return db.BillingSnapshotRecord{}, iter.Err()
|
||||
var selected *paddle.Subscription
|
||||
if err := subscriptions.Iter(ctx, func(subscription *paddle.Subscription) (bool, error) {
|
||||
if subscription == nil {
|
||||
return true, nil
|
||||
}
|
||||
if selected == nil || subscriptionRank(subscription) > subscriptionRank(selected) {
|
||||
selected = subscription
|
||||
}
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
record := db.BillingSnapshotRecord{
|
||||
TenantID: tenant.ID,
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: "",
|
||||
Status: "none",
|
||||
PlanCode: tenant.PlanCode,
|
||||
PriceID: "",
|
||||
LastSyncedAt: &now,
|
||||
TenantID: tenant.ID,
|
||||
BillingProvider: "paddle",
|
||||
BillingCustomerID: customerID,
|
||||
BillingSubscriptionID: "",
|
||||
Status: "inactive",
|
||||
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
|
||||
Currency: "czk",
|
||||
PriceID: "",
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
|
||||
if iter.Next() {
|
||||
subscriptionRecord := iter.Subscription()
|
||||
record.StripeSubscriptionID = subscriptionRecord.ID
|
||||
record.Status = string(subscriptionRecord.Status)
|
||||
record.CancelAtPeriodEnd = subscriptionRecord.CancelAtPeriodEnd
|
||||
if len(subscriptionRecord.Items.Data) > 0 {
|
||||
record.PriceID = subscriptionRecord.Items.Data[0].Price.ID
|
||||
if selected != nil {
|
||||
record.BillingSubscriptionID = selected.ID
|
||||
record.Status = normalizeSubscriptionStatus(string(selected.Status))
|
||||
record.Currency = normalizeCurrency(string(selected.CurrencyCode))
|
||||
record.CancelAtPeriodEnd = selected.ScheduledChange != nil && string(selected.ScheduledChange.Action) == "cancel"
|
||||
record.CurrentPeriodStart = parseRFC3339Ptr(timePeriodStart(selected.CurrentBillingPeriod))
|
||||
record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod))
|
||||
if len(selected.Items) > 0 {
|
||||
record.PriceID = selected.Items[0].Price.ID
|
||||
record.PlanCode = s.planCodeForPrice(record.PriceID, tenant.PlanCode)
|
||||
record.CurrentPeriodStart = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodStart)
|
||||
record.CurrentPeriodEnd = toTimePtr(subscriptionRecord.Items.Data[0].CurrentPeriodEnd)
|
||||
}
|
||||
if subscriptionRecord.DefaultPaymentMethod != nil && subscriptionRecord.DefaultPaymentMethod.Card != nil {
|
||||
record.PaymentMethodBrand = string(subscriptionRecord.DefaultPaymentMethod.Card.Brand)
|
||||
record.PaymentMethodLast4 = subscriptionRecord.DefaultPaymentMethod.Card.Last4
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.StripeSubscriptionID); err != nil {
|
||||
if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil {
|
||||
return db.BillingSnapshotRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
|
||||
if record.PlanCode == "" {
|
||||
record.PlanCode = tenant.PlanCode
|
||||
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
|
||||
} else {
|
||||
record.PlanCode = shared.NormalizePlanCode(record.PlanCode)
|
||||
}
|
||||
record.Currency = normalizeCurrency(record.Currency)
|
||||
if record.Status == "" {
|
||||
record.Status = tenant.SubscriptionStatus
|
||||
record.Status = firstNonEmpty(tenant.SubscriptionStatus, "inactive")
|
||||
}
|
||||
|
||||
customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
|
||||
|
||||
return domain.SubscriptionSnapshot{
|
||||
TenantID: tenant.ID,
|
||||
CustomerID: record.StripeCustomerID,
|
||||
SubscriptionID: record.StripeSubscriptionID,
|
||||
Provider: firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "paddle"),
|
||||
CustomerID: customerID,
|
||||
SubscriptionID: firstNonEmpty(record.BillingSubscriptionID, derefString(tenant.BillingSubscription)),
|
||||
Status: record.Status,
|
||||
PlanCode: record.PlanCode,
|
||||
Currency: record.Currency,
|
||||
PriceID: record.PriceID,
|
||||
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
|
||||
CurrentPeriodStart: record.CurrentPeriodStart,
|
||||
@@ -280,29 +379,164 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
|
||||
PaymentMethodBrand: record.PaymentMethodBrand,
|
||||
PaymentMethodLast4: record.PaymentMethodLast4,
|
||||
Entitlements: entitlementsForPlan(record.PlanCode),
|
||||
DisplayPrices: displayPricesForPlan(record.PlanCode),
|
||||
TrialDays: 30,
|
||||
LastSyncedAt: record.LastSyncedAt,
|
||||
CheckoutURLAvailable: cfg.StripePriceIDs[record.PlanCode] != "",
|
||||
CheckoutURLAvailable: checkoutAvailable(cfg, record.PlanCode),
|
||||
SyncAvailable: cfg.PaddleConfigured(),
|
||||
PortalAvailable: cfg.PaddleConfigured() && customerID != "",
|
||||
}
|
||||
}
|
||||
|
||||
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
switch planCode {
|
||||
switch shared.NormalizePlanCode(planCode) {
|
||||
case "starter":
|
||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, SMSAddonAvailable: false, AdvancedReporting: false}
|
||||
case "multi-location":
|
||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, SMSAddonAvailable: true, AdvancedReporting: true}
|
||||
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
|
||||
case "business":
|
||||
return domain.PlanEntitlements{MaxLocations: 10, MaxStaff: 30, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
||||
default:
|
||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, SMSAddonAvailable: true, AdvancedReporting: true}
|
||||
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, EmailReminders: true, AdvancedReporting: true, WidgetEmbedding: true, UmamiTracking: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
|
||||
for code, configuredPriceID := range s.cfg.StripePriceIDs {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
return code
|
||||
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
|
||||
for _, configuredPriceID := range currencies {
|
||||
if configuredPriceID != "" && configuredPriceID == priceID {
|
||||
return shared.NormalizePlanCode(planCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
return shared.NormalizePlanCode(fallback)
|
||||
}
|
||||
|
||||
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
|
||||
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
|
||||
if resolvedPlan == "" {
|
||||
resolvedPlan = "pro"
|
||||
}
|
||||
resolvedCurrency := normalizeCurrency(currency)
|
||||
if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan][resolvedCurrency]; priceID != "" {
|
||||
return priceID, resolvedPlan, resolvedCurrency
|
||||
}
|
||||
if resolvedCurrency != "czk" {
|
||||
if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan]["czk"]; priceID != "" {
|
||||
return priceID, resolvedPlan, "czk"
|
||||
}
|
||||
}
|
||||
return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd"
|
||||
}
|
||||
|
||||
func subscriptionRank(subscription *paddle.Subscription) int {
|
||||
switch subscription.Status {
|
||||
case paddle.SubscriptionStatusActive:
|
||||
return 6
|
||||
case paddle.SubscriptionStatusTrialing:
|
||||
return 5
|
||||
case paddle.SubscriptionStatusPastDue:
|
||||
return 4
|
||||
case paddle.SubscriptionStatusPaused:
|
||||
return 3
|
||||
case paddle.SubscriptionStatusCanceled:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
|
||||
switch shared.NormalizePlanCode(planCode) {
|
||||
case "starter":
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
|
||||
{Currency: "usd", AmountCents: 500, Formatted: "$5"},
|
||||
}
|
||||
case "business":
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 119900, Formatted: "1 199 Kc"},
|
||||
{Currency: "usd", AmountCents: 5000, Formatted: "$50"},
|
||||
}
|
||||
default:
|
||||
return []domain.PlanDisplayPrice{
|
||||
{Currency: "czk", AmountCents: 49900, Formatted: "499 Kc"},
|
||||
{Currency: "usd", AmountCents: 2000, Formatted: "$20"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCurrency(currency string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(currency)) {
|
||||
case "usd":
|
||||
return "usd"
|
||||
case "eur":
|
||||
return "eur"
|
||||
default:
|
||||
return "czk"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSubscriptionStatus(status string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(status)) {
|
||||
case "active", "trialing", "past_due", "paused", "canceled":
|
||||
return strings.TrimSpace(strings.ToLower(status))
|
||||
default:
|
||||
return "inactive"
|
||||
}
|
||||
}
|
||||
|
||||
func checkoutAvailable(cfg config.Config, planCode string) bool {
|
||||
if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() {
|
||||
return false
|
||||
}
|
||||
planCode = shared.NormalizePlanCode(planCode)
|
||||
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
|
||||
if strings.TrimSpace(priceID) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func customDataString(data map[string]any, key string) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := data[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseRFC3339Ptr(value string) *time.Time {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
utc := parsed.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
func timePeriodStart(period *paddle.TimePeriod) string {
|
||||
if period == nil {
|
||||
return ""
|
||||
}
|
||||
return period.StartsAt
|
||||
}
|
||||
|
||||
func timePeriodEnd(period *paddle.TimePeriod) string {
|
||||
if period == nil {
|
||||
return ""
|
||||
}
|
||||
return period.EndsAt
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
@@ -312,24 +546,11 @@ func derefString(value *string) string {
|
||||
return *value
|
||||
}
|
||||
|
||||
func toTimePtr(value int64) *time.Time {
|
||||
if value == 0 {
|
||||
return nil
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
timestamp := time.Unix(value, 0).UTC()
|
||||
return ×tamp
|
||||
}
|
||||
|
||||
func extractCustomerID(event stripe.Event) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := payload["customer"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
customerID, _ := value.(string)
|
||||
return customerID
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
func testConfig() config.Config {
|
||||
return config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
PaddleAPIKey: "pdl_sdbx_apikey_123",
|
||||
PaddleWebhookKey: "pdl_ntf_123",
|
||||
PaddlePriceMatrix: map[string]map[string]string{
|
||||
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
|
||||
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
|
||||
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
service := NewService(testConfig(), db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
@@ -25,51 +33,117 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.PlanCode != "growth" {
|
||||
t.Fatalf("expected growth, got %s", snapshot.PlanCode)
|
||||
if snapshot.PlanCode != "pro" {
|
||||
t.Fatalf("expected pro, got %s", snapshot.PlanCode)
|
||||
}
|
||||
if snapshot.Provider != "paddle" {
|
||||
t.Fatalf("expected paddle provider, got %s", snapshot.Provider)
|
||||
}
|
||||
if snapshot.Entitlements.MaxLocations != 3 {
|
||||
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutUsesMockURLWithoutStripeKey(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PaddleAPIKey = ""
|
||||
service := NewService(cfg, db.NewMemoryRepository())
|
||||
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "growth")
|
||||
if err != nil {
|
||||
t.Fatalf("create checkout: %v", err)
|
||||
}
|
||||
if response.URL == "" {
|
||||
t.Fatal("expected checkout url")
|
||||
}, "pro", "czk")
|
||||
if err != ErrPaddleNotConfigured {
|
||||
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshReturnsSnapshotWithoutStripeKey(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
},
|
||||
}, db.NewMemoryRepository())
|
||||
func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
|
||||
service := NewService(testConfig(), db.NewMemoryRepository())
|
||||
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "pro", "czk")
|
||||
if err != nil {
|
||||
t.Fatalf("create checkout: %v", err)
|
||||
}
|
||||
if response.PriceID != "pri_pro_czk" {
|
||||
t.Fatalf("expected pri_pro_czk, got %s", response.PriceID)
|
||||
}
|
||||
if response.CustomData["tenantId"] == "" {
|
||||
t.Fatal("expected tenantId in customData")
|
||||
}
|
||||
if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" {
|
||||
t.Fatal("expected redirect URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PaddleAPIKey = ""
|
||||
service := NewService(cfg, db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.Refresh(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.Status != "active" {
|
||||
t.Fatalf("expected active status, got %s", snapshot.Status)
|
||||
if err != ErrPaddleNotConfigured {
|
||||
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PaddleWebhookKey = ""
|
||||
service := NewService(cfg, db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
if snapshot.CheckoutURLAvailable {
|
||||
t.Fatal("expected checkout unavailable without webhook secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
|
||||
service := NewService(testConfig(), db.NewMemoryRepository())
|
||||
|
||||
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get subscription: %v", err)
|
||||
}
|
||||
if !snapshot.CheckoutURLAvailable {
|
||||
t.Fatal("expected checkout available when paddle is configured")
|
||||
}
|
||||
if !snapshot.PortalAvailable {
|
||||
t.Fatal("expected portal available when customer exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePortalSessionRequiresCustomer(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner")
|
||||
if err != nil {
|
||||
t.Fatalf("get membership: %v", err)
|
||||
}
|
||||
if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, ""); err != nil {
|
||||
t.Fatalf("clear billing customer: %v", err)
|
||||
}
|
||||
service := NewService(testConfig(), repo)
|
||||
|
||||
_, err = service.CreatePortalSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
})
|
||||
if err != ErrBillingCustomerMissing {
|
||||
t.Fatalf("expected ErrBillingCustomerMissing, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidReschedule = errors.New("invalid reschedule request")
|
||||
ErrBookingCancelled = errors.New("booking already cancelled")
|
||||
ErrUnauthorized = errors.New("unauthorized access")
|
||||
)
|
||||
|
||||
type CustomerService struct {
|
||||
repo db.Repository
|
||||
notifier CustomerNotifier
|
||||
}
|
||||
|
||||
type CustomerNotifier interface {
|
||||
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
|
||||
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
|
||||
}
|
||||
|
||||
func NewCustomerService(repo db.Repository, notifier CustomerNotifier) *CustomerService {
|
||||
if notifier == nil {
|
||||
notifier = &customerNoopNotifier{}
|
||||
}
|
||||
return &CustomerService{repo: repo, notifier: notifier}
|
||||
}
|
||||
|
||||
type customerNoopNotifier struct{}
|
||||
|
||||
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBookingByReference returns booking details for customer management link
|
||||
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return domain.CustomerBookingView{}, ErrBookingNotFound
|
||||
}
|
||||
|
||||
// Get tenant details for business name
|
||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||
if err != nil {
|
||||
return domain.CustomerBookingView{}, err
|
||||
}
|
||||
|
||||
return domain.CustomerBookingView{
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Service: "Service", // Would get from service ID in full implementation
|
||||
BusinessName: tenant.Name,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Location: "Location", // Would get from location ID in full implementation
|
||||
Status: booking.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RescheduleBooking allows customers to reschedule their booking
|
||||
func (s *CustomerService) RescheduleBooking(ctx context.Context, reference string, req domain.RescheduleBookingRequest, token string) error {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return ErrBookingNotFound
|
||||
}
|
||||
|
||||
if booking.Status == "cancelled" {
|
||||
return ErrBookingCancelled
|
||||
}
|
||||
|
||||
newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt)
|
||||
if err != nil {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
newEndsAt, err := time.Parse(time.RFC3339, req.NewEndsAt)
|
||||
if err != nil {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
if !newEndsAt.After(newStartsAt) {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
if newStartsAt.Before(time.Now().UTC()) {
|
||||
return ErrInvalidReschedule
|
||||
}
|
||||
|
||||
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send reschedule email (async)
|
||||
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelBooking allows customers to cancel their booking
|
||||
func (s *CustomerService) CancelBooking(ctx context.Context, reference string, token string) error {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return ErrBookingNotFound
|
||||
}
|
||||
|
||||
if booking.Status == "cancelled" {
|
||||
return ErrBookingCancelled
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send cancellation email (async)
|
||||
go s.sendCancellationEmail(booking)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStartsAt, newEndsAt time.Time) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
brandColor := brand.PrimaryColor
|
||||
if brandColor == "" {
|
||||
brandColor = "#a65c3e"
|
||||
}
|
||||
|
||||
managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference
|
||||
|
||||
emailData := notifications.BookingEmailData{
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BrandColor: brandColor,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Service: "Service",
|
||||
Location: "Location",
|
||||
Reference: booking.Reference,
|
||||
StartsAt: newStartsAt,
|
||||
EndsAt: newEndsAt,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
ManagementURL: managementURL,
|
||||
}
|
||||
|
||||
s.notifier.SendBookingReschedule(ctx, emailData)
|
||||
}
|
||||
|
||||
func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
brandColor := brand.PrimaryColor
|
||||
if brandColor == "" {
|
||||
brandColor = "#a65c3e"
|
||||
}
|
||||
|
||||
emailData := notifications.BookingEmailData{
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BrandColor: brandColor,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Service: "Service",
|
||||
Location: "Location",
|
||||
Reference: booking.Reference,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
ManagementURL: "",
|
||||
}
|
||||
|
||||
s.notifier.SendBookingCancellation(ctx, emailData)
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/shared"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -18,14 +22,34 @@ var (
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrBookingConflict = errors.New("booking conflict")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
repo db.Repository
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
type Notifier interface {
|
||||
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
|
||||
SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository, notifier Notifier) *Service {
|
||||
if notifier == nil {
|
||||
notifier = &noopNotifier{}
|
||||
}
|
||||
return &Service{repo: repo, notifier: notifier}
|
||||
}
|
||||
|
||||
type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) {
|
||||
@@ -92,6 +116,22 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
if !endsAt.After(startsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be after startsAt", ErrInvalidBooking)
|
||||
}
|
||||
if startsAt.Before(time.Now().UTC()) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
customerName := strings.TrimSpace(request.CustomerName)
|
||||
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
||||
notes := strings.TrimSpace(request.Notes)
|
||||
if len(customerName) < 2 || len(customerName) > 120 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
||||
}
|
||||
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
||||
}
|
||||
if len(notes) > 1000 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
|
||||
if err != nil {
|
||||
@@ -99,23 +139,22 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
}
|
||||
|
||||
status := "confirmed"
|
||||
if request.BookingMode == "class" && request.ClassSessionID != nil {
|
||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, startsAt.Add(-1*time.Minute), 16)
|
||||
switch request.BookingMode {
|
||||
case "appointment":
|
||||
if request.ServiceID == nil || request.ClassSessionID != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment bookings require serviceId only", ErrInvalidBooking)
|
||||
}
|
||||
service, ok, err := s.serviceForRequest(ctx, tenant.ID, *request.ServiceID)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
sessionCapacity := int32(0)
|
||||
for _, session := range classSessions {
|
||||
if session.ID == *request.ClassSessionID {
|
||||
sessionCapacity = session.Capacity
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
||||
}
|
||||
if sessionCapacity > 0 && classBookings >= sessionCapacity {
|
||||
status = "waitlisted"
|
||||
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
||||
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
|
||||
}
|
||||
} else {
|
||||
for _, booking := range existing {
|
||||
if booking.Status == "cancelled" {
|
||||
continue
|
||||
@@ -124,6 +163,26 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
return domain.CreateBookingResponse{}, ErrBookingConflict
|
||||
}
|
||||
}
|
||||
case "class":
|
||||
if request.ClassSessionID == nil || request.ServiceID != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class bookings require classSessionId only", ErrInvalidBooking)
|
||||
}
|
||||
session, ok, err := s.classSessionForRequest(ctx, tenant.ID, *request.ClassSessionID, startsAt)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: classSessionId is not available for tenant", ErrInvalidBooking)
|
||||
}
|
||||
if !sameSecond(session.StartsAt, startsAt) || !sameSecond(session.EndsAt, endsAt) {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking)
|
||||
}
|
||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||
if session.Capacity > 0 && classBookings >= session.Capacity {
|
||||
status = "waitlisted"
|
||||
}
|
||||
default:
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: bookingMode must be appointment or class", ErrInvalidBooking)
|
||||
}
|
||||
|
||||
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
@@ -133,13 +192,13 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
StaffID: request.StaffID,
|
||||
LocationID: request.LocationID,
|
||||
BookingMode: request.BookingMode,
|
||||
CustomerName: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: endsAt.UTC(),
|
||||
Status: status,
|
||||
Reference: db.Reference("BK", time.Now()),
|
||||
Notes: request.Notes,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
@@ -150,8 +209,8 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: tenant.ID,
|
||||
ClassSessionID: *request.ClassSessionID,
|
||||
CustomerName: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
Position: waitlistPosition,
|
||||
}); err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
@@ -169,6 +228,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
}
|
||||
}
|
||||
|
||||
// Send confirmation emails (async - don't fail booking if email fails)
|
||||
go s.sendBookingConfirmationEmails(tenant, created, customerName, customerEmail, startsAt, endsAt)
|
||||
|
||||
return domain.CreateBookingResponse{
|
||||
BookingID: created.ID,
|
||||
Reference: created.Reference,
|
||||
@@ -176,6 +238,65 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, customerName, customerEmail string, startsAt, endsAt time.Time) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get brand profile for business details
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
brandColor := brand.PrimaryColor
|
||||
if brandColor == "" {
|
||||
brandColor = "#a65c3e" // Default terra color
|
||||
}
|
||||
|
||||
managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference)
|
||||
|
||||
emailData := notifications.BookingEmailData{
|
||||
TenantName: tenant.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BrandColor: brandColor,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
Service: "Service", // Would lookup from booking.ServiceID in full implementation
|
||||
Location: "Location", // Would lookup from booking.LocationID in full implementation
|
||||
Reference: booking.Reference,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: endsAt,
|
||||
Timezone: tenant.Timezone,
|
||||
Locale: tenant.Locale,
|
||||
ManagementURL: managementURL,
|
||||
}
|
||||
|
||||
// Send to customer
|
||||
s.notifier.SendBookingConfirmation(ctx, emailData)
|
||||
}
|
||||
|
||||
func (s *Service) serviceForRequest(ctx context.Context, tenantID string, serviceID string) (db.ServiceRecord, bool, error) {
|
||||
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return db.ServiceRecord{}, false, err
|
||||
}
|
||||
for _, service := range services {
|
||||
if service.ID == serviceID {
|
||||
return service, true, nil
|
||||
}
|
||||
}
|
||||
return db.ServiceRecord{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, classSessionID string, startsAt time.Time) (db.ClassSessionRecord, bool, error) {
|
||||
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, startsAt.Add(-1*time.Minute), 64)
|
||||
if err != nil {
|
||||
return db.ClassSessionRecord{}, false, err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if session.ID == classSessionID {
|
||||
return session, true, nil
|
||||
}
|
||||
}
|
||||
return db.ClassSessionRecord{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
@@ -191,20 +312,61 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd)
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
|
||||
for _, booking := range upcomingRecords {
|
||||
upcoming = append(upcoming, domain.UpcomingBooking{
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Status: booking.Status,
|
||||
})
|
||||
}
|
||||
if len(upcoming) > 5 {
|
||||
upcoming = upcoming[:5]
|
||||
}
|
||||
|
||||
return domain.DashboardSummary{
|
||||
TenantName: membership.Tenant.Name,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantName: membership.Tenant.Name,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
PublicBookingURL: "/book/" + membership.Tenant.Slug,
|
||||
SetupCompletion: 100,
|
||||
KPIs: []domain.DashboardKPI{
|
||||
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
|
||||
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
|
||||
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
|
||||
},
|
||||
UpcomingBookings: upcoming,
|
||||
WidgetSnippets: widgetSnippets(membership.Tenant),
|
||||
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet {
|
||||
path := "/book/" + tenant.Slug
|
||||
return []domain.WidgetSnippet{
|
||||
{Kind: "html", Code: fmt.Sprintf(`<iframe src="%s" title="%s booking" style="width:100%%;min-height:760px;border:0;"></iframe>`, path, tenant.Name)},
|
||||
{Kind: "react", Code: fmt.Sprintf(`export function BookraWidget() { return <iframe src="%s" title="%s booking" style={{ width: "100%%", minHeight: 760, border: 0 }} />; }`, path, tenant.Name)},
|
||||
{Kind: "typescript", Code: fmt.Sprintf(`const bookraWidgetUrl = new URL("%s", window.location.origin);`, path)},
|
||||
}
|
||||
}
|
||||
|
||||
func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRecord) domain.TrackingStatus {
|
||||
brand, err := repo.GetBrandProfile(ctx, tenant.ID)
|
||||
if err != nil || strings.TrimSpace(brand.UmamiSiteID) == "" {
|
||||
return domain.TrackingStatus{Provider: "umami", Connected: false, Message: "Umami tracking is not connected."}
|
||||
}
|
||||
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
|
||||
}
|
||||
|
||||
func generateAppointmentSlots(
|
||||
tenant db.TenantRecord,
|
||||
services []db.ServiceRecord,
|
||||
@@ -318,6 +480,10 @@ func sameResource(left *string, right *string) bool {
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func sameSecond(left time.Time, right time.Time) bool {
|
||||
return left.UTC().Truncate(time.Second).Equal(right.UTC().Truncate(time.Second))
|
||||
}
|
||||
|
||||
func countClassBookings(bookings []db.BookingRecord, classSessionID string) int32 {
|
||||
var total int32
|
||||
for _, booking := range bookings {
|
||||
|
||||
@@ -2,6 +2,7 @@ package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
|
||||
func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
@@ -68,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
|
||||
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
@@ -123,9 +124,45 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil)
|
||||
|
||||
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
CustomerName: "Missing Service",
|
||||
CustomerEmail: "missing@example.com",
|
||||
StartsAt: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(25 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClassRequiresExistingSession(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil)
|
||||
missingSessionID := "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "class",
|
||||
ClassSessionID: &missingSessionID,
|
||||
CustomerName: "Missing Session",
|
||||
CustomerEmail: "missing@example.com",
|
||||
StartsAt: time.Now().UTC().Add(48 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(49 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidBooking) {
|
||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
@@ -148,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
|
||||
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
service := NewService(repo, nil)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,469 @@
|
||||
package catalog
|
||||
|
||||
type Service struct{}
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLocationNotFound = errors.New("location not found")
|
||||
ErrBlockedDayNotFound = errors.New("blocked day not found")
|
||||
ErrCustomerNotFound = errors.New("customer not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
ErrInvalidBooking = errors.New("invalid booking request")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOCATION / ZONE MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
locations := make([]domain.Location, len(records))
|
||||
for i, rec := range records {
|
||||
locations[i] = domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Type: "room", // Default type
|
||||
Capacity: 10, // Default capacity
|
||||
Timezone: rec.Timezone,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Location{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
params := db.CreateLocationParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Name: req.Name,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateLocation(ctx, params)
|
||||
if err != nil {
|
||||
return domain.Location{}, err
|
||||
}
|
||||
|
||||
return domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Type: req.Type,
|
||||
Capacity: 10,
|
||||
Timezone: rec.Timezone,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Location{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify location belongs to tenant
|
||||
loc, err := s.repo.GetLocationByID(ctx, locationID)
|
||||
if err != nil {
|
||||
return domain.Location{}, ErrLocationNotFound
|
||||
}
|
||||
if loc.TenantID != membership.Tenant.ID {
|
||||
return domain.Location{}, ErrLocationNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateLocationParams{}
|
||||
if req.Name != "" {
|
||||
params.Name = &req.Name
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateLocation(ctx, locationID, params)
|
||||
if err != nil {
|
||||
return domain.Location{}, err
|
||||
}
|
||||
|
||||
return domain.Location{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Type: req.Type,
|
||||
Capacity: 10,
|
||||
Timezone: rec.Timezone,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
loc, err := s.repo.GetLocationByID(ctx, locationID)
|
||||
if err != nil {
|
||||
return ErrLocationNotFound
|
||||
}
|
||||
if loc.TenantID != membership.Tenant.ID {
|
||||
return ErrLocationNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteLocation(ctx, locationID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BLOCKED DAYS MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blockedDays := make([]domain.BlockedDay, len(records))
|
||||
for i, rec := range records {
|
||||
blockedDays[i] = domain.BlockedDay{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Date: rec.StartsAt,
|
||||
Reason: rec.Reason,
|
||||
Type: rec.Kind,
|
||||
StaffID: rec.StaffID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
return blockedDays, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
date, err := time.Parse(time.RFC3339, req.Date)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, ErrInvalidBooking
|
||||
}
|
||||
|
||||
params := db.CreateBlockedDayParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
StaffID: req.StaffID,
|
||||
StartsAt: date,
|
||||
EndsAt: date.Add(24 * time.Hour),
|
||||
Kind: req.Type,
|
||||
Reason: req.Reason,
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateBlockedDay(ctx, params)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, err
|
||||
}
|
||||
|
||||
return domain.BlockedDay{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Date: rec.StartsAt,
|
||||
Reason: rec.Reason,
|
||||
Type: rec.Kind,
|
||||
StaffID: rec.StaffID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify blocked day belongs to tenant
|
||||
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, b := range bd {
|
||||
if b.ID == blockedDayID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return domain.BlockedDay{}, ErrBlockedDayNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateBlockedDayParams{}
|
||||
if req.Reason != "" {
|
||||
params.Reason = &req.Reason
|
||||
}
|
||||
if req.Type != "" {
|
||||
params.Kind = &req.Type
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateBlockedDay(ctx, blockedDayID, params)
|
||||
if err != nil {
|
||||
return domain.BlockedDay{}, err
|
||||
}
|
||||
|
||||
return domain.BlockedDay{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Date: rec.StartsAt,
|
||||
Reason: rec.Reason,
|
||||
Type: rec.Kind,
|
||||
StaffID: rec.StaffID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify blocked day belongs to tenant
|
||||
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, b := range bd {
|
||||
if b.ID == blockedDayID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrBlockedDayNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteBlockedDay(ctx, blockedDayID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListCustomersByTenant(ctx, membership.Tenant.ID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customers := make([]domain.Customer, len(records))
|
||||
for i, rec := range records {
|
||||
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
|
||||
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
|
||||
|
||||
customers[i] = domain.Customer{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Email: rec.Email,
|
||||
Phone: rec.Phone,
|
||||
Status: rec.Status,
|
||||
BookingsCount: bookingsCount,
|
||||
LastBookingAt: lastBooking,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Notes: "",
|
||||
}
|
||||
if rec.Notes != nil {
|
||||
customers[i].Notes = *rec.Notes
|
||||
}
|
||||
}
|
||||
return customers, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Customer{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
params := db.CreateCustomerParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Status: status,
|
||||
Notes: nil,
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateCustomer(ctx, params)
|
||||
if err != nil {
|
||||
return domain.Customer{}, err
|
||||
}
|
||||
|
||||
return domain.Customer{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Email: rec.Email,
|
||||
Phone: rec.Phone,
|
||||
Status: rec.Status,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Notes: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.Customer{}, ErrTenantMembership
|
||||
}
|
||||
|
||||
// Verify customer belongs to tenant
|
||||
cust, err := s.repo.GetCustomerByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return domain.Customer{}, ErrCustomerNotFound
|
||||
}
|
||||
if cust.TenantID != membership.Tenant.ID {
|
||||
return domain.Customer{}, ErrCustomerNotFound
|
||||
}
|
||||
|
||||
params := db.UpdateCustomerParams{}
|
||||
if req.Name != "" {
|
||||
params.Name = &req.Name
|
||||
}
|
||||
if req.Email != "" {
|
||||
params.Email = &req.Email
|
||||
}
|
||||
if req.Phone != nil {
|
||||
params.Phone = req.Phone
|
||||
}
|
||||
if req.Status != "" {
|
||||
params.Status = &req.Status
|
||||
}
|
||||
if req.Notes != "" {
|
||||
params.Notes = &req.Notes
|
||||
}
|
||||
|
||||
rec, err := s.repo.UpdateCustomer(ctx, customerID, params)
|
||||
if err != nil {
|
||||
return domain.Customer{}, err
|
||||
}
|
||||
|
||||
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
|
||||
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
|
||||
|
||||
return domain.Customer{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
Name: rec.Name,
|
||||
Email: rec.Email,
|
||||
Phone: rec.Phone,
|
||||
Status: rec.Status,
|
||||
BookingsCount: bookingsCount,
|
||||
LastBookingAt: lastBooking,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Notes: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
cust, err := s.repo.GetCustomerByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return ErrCustomerNotFound
|
||||
}
|
||||
if cust.TenantID != membership.Tenant.ID {
|
||||
return ErrCustomerNotFound
|
||||
}
|
||||
|
||||
return s.repo.DeleteCustomer(ctx, customerID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKING HOURS MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return nil, ErrTenantMembership
|
||||
}
|
||||
|
||||
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours := make([]domain.WorkingHours, len(records))
|
||||
for i, rec := range records {
|
||||
hours[i] = domain.WorkingHours{
|
||||
DayOfWeek: rec.DayOfWeek,
|
||||
Open: rec.StartsLocal,
|
||||
Close: rec.EndsLocal,
|
||||
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
|
||||
}
|
||||
}
|
||||
return hours, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
|
||||
params := db.UpdateWorkingHoursParams{}
|
||||
if req.Open != "" {
|
||||
params.StartsLocal = &req.Open
|
||||
}
|
||||
if req.Close != "" {
|
||||
params.EndsLocal = &req.Close
|
||||
}
|
||||
|
||||
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"bookra/apps/backend/internal/shared"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -14,12 +17,20 @@ type Config struct {
|
||||
DatabaseURL string
|
||||
DatabaseDirectURL string
|
||||
NeonAuthURL string
|
||||
AuthJWTSecret string
|
||||
JobRunnerKey string
|
||||
EmailFrom string
|
||||
SMSFrom string
|
||||
StripeSecretKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceIDs map[string]string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
PaddleEnvironment string
|
||||
PaddleAPIKey string
|
||||
PaddleWebhookKey string
|
||||
PaddlePriceMatrix map[string]map[string]string
|
||||
UmamiAPIURL string
|
||||
UmamiAPIKey string
|
||||
DemoMode bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -31,28 +42,130 @@ func Load() (Config, error) {
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
||||
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
||||
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
||||
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
|
||||
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
||||
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
||||
SMSFrom: valueOrDefault("BOOKRA_SMS_FROM", "Bookra"),
|
||||
StripeSecretKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SECRET_KEY")),
|
||||
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||
StripePriceIDs: map[string]string{
|
||||
"starter": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_STARTER_PRICE_ID")),
|
||||
"growth": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_GROWTH_PRICE_ID")),
|
||||
"multi-location": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID")),
|
||||
},
|
||||
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
|
||||
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
|
||||
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
|
||||
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
|
||||
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
|
||||
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
||||
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
||||
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
||||
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
|
||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
||||
}
|
||||
|
||||
if cfg.FrontendURL == "" {
|
||||
return Config{}, errors.New("BOOKRA_FRONTEND_URL is required")
|
||||
}
|
||||
if err := cfg.validateRuntimeRequirements(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (cfg Config) validateRuntimeRequirements() error {
|
||||
if cfg.Environment == "development" || cfg.Environment == "test" {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := make([]string, 0, 3)
|
||||
if cfg.DatabaseURL == "" {
|
||||
missing = append(missing, "BOOKRA_DATABASE_URL")
|
||||
}
|
||||
if cfg.NeonAuthURL == "" {
|
||||
missing = append(missing, "BOOKRA_NEON_AUTH_URL")
|
||||
}
|
||||
if cfg.JobRunnerKey == "" {
|
||||
missing = append(missing, "BOOKRA_JOB_RUNNER_KEY")
|
||||
}
|
||||
if cfg.SMTPHost == "" {
|
||||
missing = append(missing, "BOOKRA_SMTP_HOST")
|
||||
}
|
||||
if cfg.PaddleAPIKey == "" {
|
||||
missing = append(missing, "BOOKRA_PADDLE_API_KEY")
|
||||
}
|
||||
if cfg.PaddleWebhookKey == "" {
|
||||
missing = append(missing, "BOOKRA_PADDLE_WEBHOOK_SECRET")
|
||||
}
|
||||
for _, planCode := range []string{"starter", "pro", "business"} {
|
||||
if cfg.PaddlePriceMatrix[planCode]["czk"] == "" || cfg.PaddlePriceMatrix[planCode]["usd"] == "" {
|
||||
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
|
||||
missing = append(missing, "BOOKRA_PADDLE_"+envPlan+"_CZK_PRICE_ID")
|
||||
missing = append(missing, "BOOKRA_PADDLE_"+envPlan+"_USD_PRICE_ID")
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("%s required when BOOKRA_APP_ENV=%s", strings.Join(uniqueStrings(missing), ", "), cfg.Environment)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg Config) PaddleConfigured() bool {
|
||||
return strings.TrimSpace(cfg.PaddleAPIKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) PaddleWebhookConfigured() bool {
|
||||
return strings.TrimSpace(cfg.PaddleWebhookKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
|
||||
planCode = shared.NormalizePlanCode(planCode)
|
||||
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
|
||||
}
|
||||
|
||||
func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||
matrix := map[string]map[string]string{
|
||||
"starter": {},
|
||||
"pro": {},
|
||||
"business": {},
|
||||
}
|
||||
for _, planCode := range []string{"starter", "pro", "business"} {
|
||||
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
|
||||
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_CZK_PRICE_ID"))
|
||||
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_USD_PRICE_ID"))
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
func normalizePaddleEnvironment(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "live", "production":
|
||||
return "live"
|
||||
default:
|
||||
return "sandbox"
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDefault(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolFromEnv(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
value = strings.ToLower(value)
|
||||
return value == "true" || value == "1" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPaddleCheckoutConfigured(t *testing.T) {
|
||||
cfg := Config{
|
||||
PaddleAPIKey: "pdl_sdbx_apikey_123",
|
||||
PaddleWebhookKey: "pdl_ntf_123",
|
||||
PaddlePriceMatrix: map[string]map[string]string{
|
||||
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
|
||||
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
|
||||
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
|
||||
},
|
||||
}
|
||||
|
||||
if !cfg.PaddleCheckoutConfigured("pro") {
|
||||
t.Fatal("expected pro checkout configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaddleCheckoutConfiguredRequiresWebhook(t *testing.T) {
|
||||
cfg := Config{
|
||||
PaddleAPIKey: "pdl_sdbx_apikey_123",
|
||||
PaddlePriceMatrix: map[string]map[string]string{
|
||||
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.PaddleCheckoutConfigured("pro") {
|
||||
t.Fatal("expected checkout disabled without webhook key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
|
||||
var record BillingSnapshotRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, COALESCE(currency, 'czk'), price_id,
|
||||
cancel_at_period_end, current_period_start, current_period_end,
|
||||
payment_method_brand, payment_method_last4, last_synced_at
|
||||
FROM billing_snapshots
|
||||
WHERE tenant_id = $1
|
||||
`, tenantID).Scan(
|
||||
&record.TenantID,
|
||||
&record.BillingProvider,
|
||||
&record.BillingCustomerID,
|
||||
&record.BillingSubscriptionID,
|
||||
&record.Status,
|
||||
&record.PlanCode,
|
||||
&record.Currency,
|
||||
&record.PriceID,
|
||||
&record.CancelAtPeriodEnd,
|
||||
&record.CurrentPeriodStart,
|
||||
&record.CurrentPeriodEnd,
|
||||
&record.PaymentMethodBrand,
|
||||
&record.PaymentMethodLast4,
|
||||
&record.LastSyncedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO billing_snapshots (
|
||||
tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, currency, price_id,
|
||||
cancel_at_period_end, current_period_start, current_period_end,
|
||||
payment_method_brand, payment_method_last4, last_synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
billing_provider = EXCLUDED.billing_provider,
|
||||
billing_customer_id = EXCLUDED.billing_customer_id,
|
||||
billing_subscription_id = EXCLUDED.billing_subscription_id,
|
||||
status = EXCLUDED.status,
|
||||
plan_code = EXCLUDED.plan_code,
|
||||
currency = EXCLUDED.currency,
|
||||
price_id = EXCLUDED.price_id,
|
||||
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
|
||||
current_period_start = EXCLUDED.current_period_start,
|
||||
current_period_end = EXCLUDED.current_period_end,
|
||||
payment_method_brand = EXCLUDED.payment_method_brand,
|
||||
payment_method_last4 = EXCLUDED.payment_method_last4,
|
||||
last_synced_at = EXCLUDED.last_synced_at,
|
||||
updated_at = now()
|
||||
`, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
|
||||
firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
|
||||
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error) {
|
||||
result, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, now())
|
||||
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING
|
||||
`, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.RowsAffected() == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, starts_at, ends_at, status, reference
|
||||
FROM bookings
|
||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||
ORDER BY starts_at ASC
|
||||
`, tenantID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []BookingRecord
|
||||
for rows.Next() {
|
||||
var record BookingRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.ServiceID,
|
||||
&record.ClassSessionID,
|
||||
&record.StaffID,
|
||||
&record.LocationID,
|
||||
&record.CustomerName,
|
||||
&record.CustomerEmail,
|
||||
&record.StartsAt,
|
||||
&record.EndsAt,
|
||||
&record.Status,
|
||||
&record.Reference,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
||||
var created CreatedBooking
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO bookings (
|
||||
tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
booking_mode, customer_name, customer_email, starts_at, ends_at,
|
||||
status, reference, notes
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING id, reference, status
|
||||
`,
|
||||
params.TenantID,
|
||||
params.ServiceID,
|
||||
params.ClassSessionID,
|
||||
params.StaffID,
|
||||
params.LocationID,
|
||||
params.BookingMode,
|
||||
params.CustomerName,
|
||||
params.CustomerEmail,
|
||||
params.StartsAt,
|
||||
params.EndsAt,
|
||||
params.Status,
|
||||
params.Reference,
|
||||
params.Notes,
|
||||
).Scan(&created.ID, &created.Reference, &created.Status)
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
|
||||
rj.booking_id, rj.channel, rj.scheduled_for,
|
||||
b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status
|
||||
FROM reminder_jobs rj
|
||||
INNER JOIN bookings b ON b.id = rj.booking_id
|
||||
INNER JOIN tenants t ON t.id = rj.tenant_id
|
||||
WHERE rj.status = 'pending' AND rj.scheduled_for <= $1
|
||||
ORDER BY rj.scheduled_for ASC
|
||||
LIMIT $2
|
||||
`, dueBefore, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []ReminderJobRecord
|
||||
for rows.Next() {
|
||||
var record ReminderJobRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.TenantName,
|
||||
&record.Locale,
|
||||
&record.Timezone,
|
||||
&record.BookingID,
|
||||
&record.Channel,
|
||||
&record.ScheduledFor,
|
||||
&record.CustomerName,
|
||||
&record.CustomerEmail,
|
||||
&record.Reference,
|
||||
&record.StartsAt,
|
||||
&record.Status,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE reminder_jobs
|
||||
SET status = $2, dispatched_at = $3
|
||||
WHERE id = $1
|
||||
`, reminderJobID, status, dispatchedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_delivery_logs (
|
||||
tenant_id, reminder_job_id, channel, provider, recipient,
|
||||
delivery_status, external_id, error_message
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''))
|
||||
`, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient,
|
||||
params.Status, params.ExternalID, params.ErrorMessage)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
|
||||
var metrics DashboardMetrics
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count,
|
||||
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count,
|
||||
COALESCE(
|
||||
ROUND(
|
||||
100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed')
|
||||
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
|
||||
),
|
||||
0
|
||||
)::integer AS utilization_percent
|
||||
FROM bookings
|
||||
WHERE tenant_id = $1
|
||||
`, tenantID, startsAt, endsAt).Scan(
|
||||
&metrics.BookingsCount,
|
||||
&metrics.CancellationsCount,
|
||||
&metrics.UtilizationPercent,
|
||||
)
|
||||
return metrics, err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, timezone, created_at
|
||||
FROM locations
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []LocationRecord
|
||||
for rows.Next() {
|
||||
var rec LocationRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
|
||||
var rec LocationRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, name, timezone, created_at
|
||||
FROM locations
|
||||
WHERE id = $1
|
||||
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
|
||||
var rec LocationRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO locations (tenant_id, name, timezone)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, tenant_id, name, timezone, created_at
|
||||
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
|
||||
var rec LocationRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
UPDATE locations
|
||||
SET name = COALESCE($2, name),
|
||||
timezone = COALESCE($3, timezone),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, tenant_id, name, timezone, created_at
|
||||
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) DeleteLocation(ctx context.Context, locationID string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, locationID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
|
||||
FROM availability_exceptions
|
||||
WHERE tenant_id = $1 AND starts_at <= $3 AND ends_at >= $2
|
||||
ORDER BY starts_at
|
||||
`, tenantID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []BlockedDayRecord
|
||||
for rows.Next() {
|
||||
var rec BlockedDayRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
|
||||
var rec BlockedDayRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO availability_exceptions (tenant_id, staff_id, starts_at, ends_at, kind, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
|
||||
`, params.TenantID, params.StaffID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
|
||||
var rec BlockedDayRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
UPDATE availability_exceptions
|
||||
SET starts_at = COALESCE($2, starts_at),
|
||||
ends_at = COALESCE($3, ends_at),
|
||||
kind = COALESCE($4, kind),
|
||||
reason = COALESCE($5, reason)
|
||||
WHERE id = $1
|
||||
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
|
||||
`, blockedDayID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM availability_exceptions WHERE id = $1`, blockedDayID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local
|
||||
FROM availability_rules
|
||||
WHERE tenant_id = $1 AND staff_id IS NULL
|
||||
ORDER BY day_of_week
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []WorkingHoursRecord
|
||||
for rows.Next() {
|
||||
var rec WorkingHoursRecord
|
||||
if err := rows.Scan(&rec.TenantID, &rec.StaffID, &rec.DayOfWeek, &rec.StartsLocal, &rec.EndsLocal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE availability_rules
|
||||
SET starts_local = COALESCE($3, starts_local),
|
||||
ends_local = COALESCE($4, ends_local)
|
||||
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
|
||||
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
|
||||
FROM services
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []ServiceRecord
|
||||
for rows.Next() {
|
||||
var record ServiceRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Name,
|
||||
&record.DurationMinutes,
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
|
||||
FROM availability_rules
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY day_of_week ASC, starts_local ASC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []AvailabilityRuleRecord
|
||||
for rows.Next() {
|
||||
var record AvailabilityRuleRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.StaffID,
|
||||
&record.DayOfWeek,
|
||||
&record.StartsLocal,
|
||||
&record.EndsLocal,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity
|
||||
FROM class_sessions cs
|
||||
INNER JOIN class_templates ct ON ct.id = cs.template_id
|
||||
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
|
||||
ORDER BY cs.starts_at ASC
|
||||
LIMIT $3
|
||||
`, tenantID, from, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []ClassSessionRecord
|
||||
for rows.Next() {
|
||||
var record ClassSessionRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.TemplateID,
|
||||
&record.LocationID,
|
||||
&record.Title,
|
||||
&record.StartsAt,
|
||||
&record.EndsAt,
|
||||
&record.Capacity,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (r *PGRepository) GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) {
|
||||
var record TenantRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
|
||||
FROM tenants
|
||||
WHERE slug = $1
|
||||
`, slug).Scan(
|
||||
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
|
||||
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
|
||||
&record.BillingCustomerID, &record.BillingSubscription,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error) {
|
||||
var record TenantRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
|
||||
FROM tenants
|
||||
WHERE id = $1
|
||||
`, tenantID).Scan(
|
||||
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
|
||||
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
|
||||
&record.BillingCustomerID, &record.BillingSubscription,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) {
|
||||
var record TenantRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
|
||||
FROM tenants
|
||||
WHERE billing_customer_id = $1
|
||||
`, customerID).Scan(
|
||||
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
|
||||
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
|
||||
&record.BillingCustomerID, &record.BillingSubscription,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO users (id, neon_subject, email, display_name)
|
||||
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, ''))
|
||||
ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name)
|
||||
`, subject, email, displayName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var tenantID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO tenants (id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 'starter', 'inactive', '')
|
||||
RETURNING id
|
||||
`, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan(&tenantID)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
|
||||
VALUES ($1, $2, NULLIF($3,''), NULLIF($4,''), NULLIF($5,''))
|
||||
`, tenantID, params.BrandName, params.SiteURL, params.LogoURL, params.PrimaryColor)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
|
||||
locationID := ""
|
||||
if params.LocationName != "" {
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO locations (id, tenant_id, name, timezone)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3)
|
||||
RETURNING id
|
||||
`, tenantID, params.LocationName, params.Timezone).Scan(&locationID)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.ServiceName != "" && params.DurationMinutes > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO services (id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 0)
|
||||
`, tenantID, params.ServiceName, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, block := range params.AvailabilityBlocks {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO availability_rules (id, tenant_id, day_of_week, starts_local, ends_local)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4)
|
||||
`, tenantID, block.DayOfWeek, block.StartsLocal, block.EndsLocal)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, invite := range params.TeamInvites {
|
||||
_, _ = tx.Exec(ctx, `
|
||||
INSERT INTO team_invites (tenant_id, email, role, expires_at)
|
||||
VALUES ($1, $2, $3, now() + interval '7 days')
|
||||
`, tenantID, invite.Email, invite.Role)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO users (id, neon_subject, email, display_name)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3)
|
||||
ON CONFLICT (neon_subject) DO NOTHING
|
||||
`, params.Subject, params.Subject+"@users.bookra.invalid", "")
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
|
||||
SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now()
|
||||
FROM users u WHERE u.neon_subject = $2
|
||||
ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner'
|
||||
RETURNING id
|
||||
`, tenantID, params.Subject).Scan(&userID)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return TenantMembershipRecord{}, err
|
||||
}
|
||||
|
||||
return TenantMembershipRecord{
|
||||
Tenant: TenantRecord{
|
||||
ID: tenantID,
|
||||
Slug: params.Slug,
|
||||
Name: params.Name,
|
||||
Preset: params.Preset,
|
||||
Locale: params.Locale,
|
||||
Timezone: params.Timezone,
|
||||
},
|
||||
UserID: userID,
|
||||
Role: "owner",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error) {
|
||||
var record BrandProfileRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
|
||||
FROM brand_profiles WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&record.TenantID, &record.Name, &record.SiteURL, &record.LogoURL, &record.PrimaryColor, &record.UmamiSiteID)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
|
||||
var record TenantMembershipRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
|
||||
t.billing_provider, t.billing_customer_id, t.billing_subscription_id,
|
||||
tm.user_neon_subject, tm.role
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
JOIN users u ON u.neon_subject = tm.user_neon_subject
|
||||
WHERE u.id = $1
|
||||
ORDER BY tm.joined_at DESC
|
||||
LIMIT 1
|
||||
`, userID).Scan(
|
||||
&record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset,
|
||||
&record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus,
|
||||
&record.Tenant.BillingProvider, &record.Tenant.BillingCustomerID, &record.Tenant.BillingSubscription,
|
||||
&record.UserID, &record.Role,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE tenants
|
||||
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
`, tenantID, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE tenants
|
||||
SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now()
|
||||
WHERE id = $1
|
||||
`, tenantID, planCode, subscriptionStatus, subscriptionID)
|
||||
return err
|
||||
}
|
||||
@@ -15,30 +15,93 @@ type DashboardKPI struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UpcomingBooking struct {
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type WidgetSnippet struct {
|
||||
Kind string `json:"kind"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type TrackingStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
Connected bool `json:"connected"`
|
||||
SiteID string `json:"siteId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type DashboardSummary struct {
|
||||
TenantName string `json:"tenantName"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode"`
|
||||
KPIs []DashboardKPI `json:"kpis"`
|
||||
TenantName string `json:"tenantName"`
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode"`
|
||||
PublicBookingURL string `json:"publicBookingUrl"`
|
||||
SetupCompletion int `json:"setupCompletion"`
|
||||
KPIs []DashboardKPI `json:"kpis"`
|
||||
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
|
||||
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
|
||||
Tracking TrackingStatus `json:"tracking"`
|
||||
}
|
||||
|
||||
type BrandProfile struct {
|
||||
Name string `json:"name"`
|
||||
SiteURL string `json:"siteUrl,omitempty"`
|
||||
LogoURL string `json:"logoUrl,omitempty"`
|
||||
PrimaryColor string `json:"primaryColor,omitempty"`
|
||||
}
|
||||
|
||||
type TenantBootstrap struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
CurrentUser Principal `json:"currentUser"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
PlanCode string `json:"planCode,omitempty"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
Brand BrandProfile `json:"brand"`
|
||||
CurrentUser Principal `json:"currentUser"`
|
||||
}
|
||||
|
||||
type TeamInviteRequest struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type AvailabilityBlockRequest struct {
|
||||
DayOfWeek int `json:"dayOfWeek"`
|
||||
StartsLocal string `json:"startsLocal"`
|
||||
EndsLocal string `json:"endsLocal"`
|
||||
Busy bool `json:"busy,omitempty"`
|
||||
}
|
||||
|
||||
type BookingDefaultsRequest struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
|
||||
BufferAfterMinutes int `json:"bufferAfterMinutes"`
|
||||
CancelWindowHours int `json:"cancelWindowHours"`
|
||||
}
|
||||
|
||||
type OnboardTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Preset string `json:"preset"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
Brand BrandProfile `json:"brand"`
|
||||
LocationName string `json:"locationName"`
|
||||
BookingDefaults BookingDefaultsRequest `json:"bookingDefaults"`
|
||||
AvailabilityBlocks []AvailabilityBlockRequest `json:"availabilityBlocks"`
|
||||
TeamInvites []TeamInviteRequest `json:"teamInvites"`
|
||||
}
|
||||
|
||||
type TimeSlot struct {
|
||||
@@ -83,32 +146,56 @@ type CreateBookingResponse struct {
|
||||
type PlanEntitlements struct {
|
||||
MaxLocations int `json:"maxLocations"`
|
||||
MaxStaff int `json:"maxStaff"`
|
||||
SMSAddonAvailable bool `json:"smsAddonAvailable"`
|
||||
EmailReminders bool `json:"emailReminders"`
|
||||
AdvancedReporting bool `json:"advancedReporting"`
|
||||
WidgetEmbedding bool `json:"widgetEmbedding"`
|
||||
UmamiTracking bool `json:"umamiTracking"`
|
||||
}
|
||||
|
||||
type PlanDisplayPrice struct {
|
||||
Currency string `json:"currency"`
|
||||
AmountCents int `json:"amountCents"`
|
||||
Formatted string `json:"formatted"`
|
||||
}
|
||||
|
||||
type SubscriptionSnapshot struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
CustomerID string `json:"customerId"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode"`
|
||||
PriceID string `json:"priceId"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
|
||||
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
|
||||
Entitlements PlanEntitlements `json:"entitlements"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable,omitempty"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Provider string `json:"provider"`
|
||||
CustomerID string `json:"customerId"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Status string `json:"status"`
|
||||
PlanCode string `json:"planCode"`
|
||||
Currency string `json:"currency"`
|
||||
PriceID string `json:"priceId"`
|
||||
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
|
||||
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
|
||||
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
|
||||
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
|
||||
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
|
||||
Entitlements PlanEntitlements `json:"entitlements"`
|
||||
DisplayPrices []PlanDisplayPrice `json:"displayPrices"`
|
||||
TrialDays int `json:"trialDays"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
||||
SyncAvailable bool `json:"syncAvailable"`
|
||||
PortalAvailable bool `json:"portalAvailable"`
|
||||
}
|
||||
|
||||
type CheckoutSessionRequest struct {
|
||||
PlanCode string `json:"planCode"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutSessionResponse struct {
|
||||
type CheckoutLaunchResponse struct {
|
||||
PriceID string `json:"priceId"`
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||
SuccessRedirectURL string `json:"successRedirectUrl"`
|
||||
CancelRedirectURL string `json:"cancelRedirectUrl"`
|
||||
CustomData map[string]string `json:"customData"`
|
||||
}
|
||||
|
||||
type PortalSessionResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
@@ -121,3 +208,166 @@ type DispatchReminderJobsResponse struct {
|
||||
SentCount int `json:"sentCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOCATION / ZONE MODELS
|
||||
// ============================================
|
||||
|
||||
type Location struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // room, private, hall, etc.
|
||||
Capacity int `json:"capacity"`
|
||||
Timezone string `json:"timezone"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreateLocationRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Capacity int `json:"capacity"`
|
||||
}
|
||||
|
||||
type UpdateLocationRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Capacity int `json:"capacity,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS
|
||||
// ============================================
|
||||
|
||||
type BlockedDay struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Date time.Time `json:"date"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreateBlockedDayRequest struct {
|
||||
Date string `json:"date" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
Type string `json:"type" binding:"required"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateBlockedDayRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER MODELS
|
||||
// ============================================
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status"` // active, inactive, vip
|
||||
BookingsCount int `json:"bookingsCount"`
|
||||
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type CreateCustomerRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"` // defaults to active
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCustomerRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER BOOKING MANAGEMENT MODELS
|
||||
// ============================================
|
||||
|
||||
type CustomerBookingView struct {
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service"`
|
||||
BusinessName string `json:"businessName"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type RescheduleBookingRequest struct {
|
||||
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
|
||||
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type CancelBookingRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKING HOURS MODELS
|
||||
// ============================================
|
||||
|
||||
type WorkingHours struct {
|
||||
DayOfWeek int `json:"dayOfWeek"` // 0=Sunday, 1=Monday, etc.
|
||||
Open string `json:"open"` // HH:MM format
|
||||
Close string `json:"close"` // HH:MM format
|
||||
IsOpen bool `json:"isOpen"`
|
||||
}
|
||||
|
||||
type UpdateWorkingHoursRequest struct {
|
||||
Open string `json:"open,omitempty"`
|
||||
Close string `json:"close,omitempty"`
|
||||
IsOpen *bool `json:"isOpen,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EMAIL TEMPLATE MODELS
|
||||
// ============================================
|
||||
|
||||
type EmailTemplate struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
|
||||
Subject string `json:"subject"`
|
||||
BodyHTML string `json:"bodyHtml"`
|
||||
BodyText string `json:"bodyText"`
|
||||
IsEnabled bool `json:"isEnabled"`
|
||||
}
|
||||
|
||||
type SendEmailRequest struct {
|
||||
To string `json:"to" binding:"required,email"`
|
||||
Subject string `json:"subject" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
Data map[string]string `json:"data,omitempty"` // Template variables
|
||||
}
|
||||
|
||||
type EmailNotification struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
BookingID string `json:"bookingId,omitempty"`
|
||||
Channel string `json:"channel"` // email, sms
|
||||
Type string `json:"type"` // confirmation, reminder, cancellation
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"` // pending, sent, failed
|
||||
SentAt *time.Time `json:"sentAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
)
|
||||
|
||||
type EmailType string
|
||||
|
||||
const (
|
||||
EmailTypeConfirmation EmailType = "confirmation"
|
||||
EmailTypeReminder EmailType = "reminder"
|
||||
EmailTypeReschedule EmailType = "reschedule"
|
||||
EmailTypeCancellation EmailType = "cancellation"
|
||||
EmailTypeBusinessNotify EmailType = "business_notify"
|
||||
)
|
||||
|
||||
type BookingEmailData struct {
|
||||
Type EmailType
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
BusinessEmail string
|
||||
BusinessPhone string
|
||||
BusinessAddress string
|
||||
BrandColor string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
Service string
|
||||
Location string
|
||||
Reference string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Timezone string
|
||||
Locale string
|
||||
Notes string
|
||||
ManagementURL string
|
||||
AddToCalendarURL string
|
||||
}
|
||||
|
||||
func RenderEmailMessage(data BookingEmailData) EmailMessage {
|
||||
subject := renderSubject(data)
|
||||
htmlBody := renderHTMLBody(data)
|
||||
textBody := renderTextBody(data)
|
||||
|
||||
return EmailMessage{
|
||||
From: data.BusinessEmail,
|
||||
To: data.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: textBody,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
func renderSubject(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Potvrzení rezervace %s - %s", data.Reference, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf("Booking Confirmation %s - %s", data.Reference, data.TenantName)
|
||||
case EmailTypeReminder:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Připomínka: Máte rezervaci zítra v %s", localizedTime)
|
||||
}
|
||||
return fmt.Sprintf("Reminder: You have a booking tomorrow at %s", localizedTime)
|
||||
case EmailTypeReschedule:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Vaše rezervace byla přesunuta - %s", data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("Your booking has been rescheduled - %s", data.Reference)
|
||||
case EmailTypeCancellation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Vaše rezervace byla zrušena - %s", data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("Your booking has been cancelled - %s", data.Reference)
|
||||
case EmailTypeBusinessNotify:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf("Nová rezervace od %s - %s", data.CustomerName, data.Reference)
|
||||
}
|
||||
return fmt.Sprintf("New booking from %s - %s", data.CustomerName, data.Reference)
|
||||
default:
|
||||
return "Booking Update"
|
||||
}
|
||||
}
|
||||
|
||||
func renderTextBody(data BookingEmailData) string {
|
||||
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
|
||||
|
||||
switch data.Type {
|
||||
case EmailTypeConfirmation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla potvrzena.
|
||||
|
||||
Detaily rezervace:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace navštivte: %s
|
||||
|
||||
Děkujeme,
|
||||
%s
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been confirmed.
|
||||
|
||||
Booking Details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
Manage your booking at: %s
|
||||
|
||||
Thank you,
|
||||
%s
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
|
||||
|
||||
case EmailTypeReminder:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
připomínáme vám zítřejší rezervaci.
|
||||
|
||||
- Služba: %s
|
||||
- Čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
This is a reminder for your booking tomorrow.
|
||||
|
||||
- Service: %s
|
||||
- Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
Manage booking: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
|
||||
case EmailTypeReschedule:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla přesunuta na nový termín.
|
||||
|
||||
Nové detaily:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Místo: %s
|
||||
- Reference: %s
|
||||
|
||||
Pro správu rezervace: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been rescheduled.
|
||||
|
||||
New details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Location: %s
|
||||
- Reference: %s
|
||||
|
||||
Manage booking: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
|
||||
|
||||
case EmailTypeCancellation:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Dobrý den %s,
|
||||
|
||||
vaše rezervace byla zrušena.
|
||||
|
||||
Zrušená rezervace:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Reference: %s
|
||||
|
||||
Pokud jste rezervaci nezrušili vy, kontaktujte nás: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||
}
|
||||
return fmt.Sprintf(`Hello %s,
|
||||
|
||||
Your booking has been cancelled.
|
||||
|
||||
Cancelled booking:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Reference: %s
|
||||
|
||||
If you didn't cancel this, please contact us: %s
|
||||
|
||||
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
|
||||
|
||||
case EmailTypeBusinessNotify:
|
||||
if data.Locale == "cs" {
|
||||
return fmt.Sprintf(`Nová rezervace od %s
|
||||
|
||||
Detaily:
|
||||
- Služba: %s
|
||||
- Datum a čas: %s
|
||||
- Reference: %s
|
||||
- Email: %s
|
||||
|
||||
Spravovat v administraci: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
}
|
||||
return fmt.Sprintf(`New booking from %s
|
||||
|
||||
Details:
|
||||
- Service: %s
|
||||
- Date & Time: %s
|
||||
- Reference: %s
|
||||
- Email: %s
|
||||
|
||||
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
|
||||
|
||||
default:
|
||||
return "Booking update"
|
||||
}
|
||||
}
|
||||
|
||||
func renderHTMLBody(data BookingEmailData) string {
|
||||
// For now, return simple HTML version. In production, this would use proper HTML templates
|
||||
textBody := renderTextBody(data)
|
||||
// Simple conversion: wrap paragraphs in <p> tags and preserve line breaks
|
||||
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
|
||||
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
|
||||
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", data.BrandColor, data.TenantName)
|
||||
|
||||
// Convert text to simple HTML
|
||||
paragraphs := splitParagraphs(textBody)
|
||||
for _, p := range paragraphs {
|
||||
if len(p) > 0 {
|
||||
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Add management button
|
||||
if data.ManagementURL != "" {
|
||||
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", data.ManagementURL, data.BrandColor)
|
||||
}
|
||||
|
||||
html += "</div></body></html>"
|
||||
return html
|
||||
}
|
||||
|
||||
func formatLocalizedTime(t time.Time, timezone, locale string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
localTime := t.In(loc)
|
||||
if locale == "cs" {
|
||||
return localTime.Format("15:04")
|
||||
}
|
||||
return localTime.Format("3:04 PM")
|
||||
}
|
||||
|
||||
func formatLocalizedDateTime(t time.Time, timezone, locale string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
localTime := t.In(loc)
|
||||
if locale == "cs" {
|
||||
return localTime.Format("02.01.2006 15:04")
|
||||
}
|
||||
return localTime.Format("Jan 02, 2006 3:04 PM")
|
||||
}
|
||||
|
||||
func splitParagraphs(text string) []string {
|
||||
var paragraphs []string
|
||||
current := ""
|
||||
for _, line := range splitLines(text) {
|
||||
trimmed := trimSpace(line)
|
||||
if trimmed == "" {
|
||||
if current != "" {
|
||||
paragraphs = append(paragraphs, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
if current != "" {
|
||||
current += " "
|
||||
}
|
||||
current += trimmed
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
paragraphs = append(paragraphs, current)
|
||||
}
|
||||
return paragraphs
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r' || s[start] == '\n') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r' || s[end-1] == '\n') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
// RenderReminderEmail renders the legacy reminder email from a job record
|
||||
func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
data := BookingEmailData{
|
||||
Type: EmailTypeReminder,
|
||||
TenantName: job.TenantName,
|
||||
TenantSlug: "", // Not available in job record
|
||||
CustomerName: job.CustomerName,
|
||||
CustomerEmail: job.CustomerEmail,
|
||||
Reference: job.Reference,
|
||||
StartsAt: job.StartsAt,
|
||||
Timezone: job.Timezone,
|
||||
Locale: job.Locale,
|
||||
Service: "Service", // Legacy
|
||||
Location: "Location", // Legacy
|
||||
}
|
||||
return RenderEmailMessage(data)
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
@@ -23,36 +26,30 @@ type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
}
|
||||
|
||||
type SMSMessage struct {
|
||||
From string
|
||||
To string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
type EmailProvider interface {
|
||||
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
emailProvider EmailProvider
|
||||
smsProvider SMSProvider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
emailProvider := EmailProvider(noopEmailProvider{})
|
||||
if cfg.SMTPHost != "" {
|
||||
emailProvider = smtpEmailProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
emailProvider: noopEmailProvider{},
|
||||
smsProvider: noopSMSProvider{},
|
||||
emailProvider: emailProvider,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
@@ -86,15 +83,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
case "sms":
|
||||
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
|
||||
if sendErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = sendErr.Error()
|
||||
} else {
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
default:
|
||||
status = "failed"
|
||||
errorMessage = ErrUnsupportedChannel.Error()
|
||||
@@ -103,8 +91,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
if provider == "unknown" {
|
||||
if job.Channel == "email" {
|
||||
provider = "noop-email"
|
||||
} else if job.Channel == "sms" {
|
||||
provider = "noop-sms"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,23 +120,53 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return EmailMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
// SendBookingConfirmation sends a booking confirmation email to the customer
|
||||
func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeConfirmation
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return SMSMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Text: fmt.Sprintf("%s: %s", subject, body),
|
||||
// SendBookingReschedule sends a reschedule notification email to the customer
|
||||
func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeReschedule
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendBookingCancellation sends a cancellation notification email to the customer
|
||||
func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmailData) error {
|
||||
if data.BusinessEmail == "" {
|
||||
data.BusinessEmail = s.cfg.EmailFrom
|
||||
}
|
||||
data.Type = EmailTypeCancellation
|
||||
msg := RenderEmailMessage(data)
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendBusinessNotification sends a notification to the business about a new booking
|
||||
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
|
||||
if businessEmail == "" {
|
||||
return nil // Skip if no business email configured
|
||||
}
|
||||
data.Type = EmailTypeBusinessNotify
|
||||
msg := RenderEmailMessage(data)
|
||||
msg.To = businessEmail
|
||||
_, err := s.emailProvider.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
return RenderReminderEmail(from, job)
|
||||
}
|
||||
|
||||
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
|
||||
@@ -190,9 +206,6 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
|
||||
}
|
||||
|
||||
func reminderRecipient(job db.ReminderJobRecord) string {
|
||||
if job.Channel == "email" {
|
||||
return job.CustomerEmail
|
||||
}
|
||||
return job.CustomerEmail
|
||||
}
|
||||
|
||||
@@ -208,14 +221,67 @@ func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (Delivery
|
||||
}, nil
|
||||
}
|
||||
|
||||
type noopSMSProvider struct{}
|
||||
type smtpEmailProvider struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
|
||||
func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
|
||||
if strings.TrimSpace(message.To) == "" {
|
||||
return DeliveryReceipt{Provider: "smtp"}, errors.New("missing email recipient")
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(p.cfg.SMTPHost)
|
||||
if host == "" {
|
||||
return DeliveryReceipt{Provider: "smtp"}, errors.New("smtp host is not configured")
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(host, strings.TrimSpace(p.cfg.SMTPPort))
|
||||
var auth smtp.Auth
|
||||
if p.cfg.SMTPUsername != "" {
|
||||
auth = smtp.PlainAuth("", p.cfg.SMTPUsername, p.cfg.SMTPPassword, host)
|
||||
}
|
||||
|
||||
// Build multipart email with both plain text and HTML
|
||||
var payload string
|
||||
if message.HTML != "" {
|
||||
boundary := "BOOKRA-BOUNDARY"
|
||||
payload = strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", message.From),
|
||||
fmt.Sprintf("To: %s", message.To),
|
||||
fmt.Sprintf("Subject: %s", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary),
|
||||
"",
|
||||
fmt.Sprintf("--%s", boundary),
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Text,
|
||||
"",
|
||||
fmt.Sprintf("--%s", boundary),
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
message.HTML,
|
||||
"",
|
||||
fmt.Sprintf("--%s--", boundary),
|
||||
}, "\r\n")
|
||||
} else {
|
||||
payload = strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", message.From),
|
||||
fmt.Sprintf("To: %s", message.To),
|
||||
fmt.Sprintf("Subject: %s", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Text,
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
if err := smtp.SendMail(address, auth, message.From, []string{message.To}, []byte(payload)); err != nil {
|
||||
return DeliveryReceipt{Provider: "smtp"}, err
|
||||
}
|
||||
|
||||
return DeliveryReceipt{
|
||||
Provider: "noop-sms",
|
||||
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
|
||||
Provider: "smtp",
|
||||
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
Environment: "development",
|
||||
EmailFrom: "noreply@bookra.dev",
|
||||
SMSFrom: "Bookra",
|
||||
}, repo)
|
||||
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package shared
|
||||
|
||||
import "strings"
|
||||
|
||||
// NormalizePlanCode canonicalizes plan codes from various sources
|
||||
// (Paddle checkout, webhook payloads, database records) into stable
|
||||
// internal identifiers used across the billing and tenancy domains.
|
||||
func NormalizePlanCode(planCode string) string {
|
||||
switch strings.TrimSpace(planCode) {
|
||||
case "growth":
|
||||
return "pro"
|
||||
case "multi-location":
|
||||
return "business"
|
||||
default:
|
||||
return strings.TrimSpace(planCode)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/shared"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -52,12 +53,15 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
OnboardingCompleted: true,
|
||||
Brand: s.brandProfile(ctx, membership.Tenant),
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
@@ -79,6 +83,21 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
preset := strings.TrimSpace(request.Preset)
|
||||
locale := strings.TrimSpace(request.Locale)
|
||||
timezone := strings.TrimSpace(request.Timezone)
|
||||
locationName := strings.TrimSpace(request.LocationName)
|
||||
brand := request.Brand
|
||||
if strings.TrimSpace(brand.Name) == "" {
|
||||
brand.Name = name
|
||||
}
|
||||
defaults := request.BookingDefaults
|
||||
if strings.TrimSpace(defaults.ServiceName) == "" {
|
||||
defaults.ServiceName = "First appointment"
|
||||
}
|
||||
if defaults.DurationMinutes == 0 {
|
||||
defaults.DurationMinutes = 60
|
||||
}
|
||||
if defaults.CancelWindowHours == 0 {
|
||||
defaults.CancelWindowHours = 24
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(name) < 2 || len(name) > 80:
|
||||
@@ -91,18 +110,43 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case timezone == "":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case len(locationName) > 120:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.DurationMinutes < 15 || defaults.DurationMinutes > 480:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.BufferBeforeMinutes < 0 || defaults.BufferBeforeMinutes > 180:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.BufferAfterMinutes < 0 || defaults.BufferAfterMinutes > 180:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case defaults.CancelWindowHours < 1 || defaults.CancelWindowHours > 720:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
if err := validateAvailabilityBlocks(request.AvailabilityBlocks); err != nil {
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
|
||||
Subject: principal.Subject,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Preset: preset,
|
||||
Locale: locale,
|
||||
Timezone: timezone,
|
||||
Subject: principal.Subject,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Preset: preset,
|
||||
Locale: locale,
|
||||
Timezone: timezone,
|
||||
BrandName: strings.TrimSpace(brand.Name),
|
||||
SiteURL: strings.TrimSpace(brand.SiteURL),
|
||||
LogoURL: strings.TrimSpace(brand.LogoURL),
|
||||
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
|
||||
LocationName: locationName,
|
||||
ServiceName: strings.TrimSpace(defaults.ServiceName),
|
||||
DurationMinutes: defaults.DurationMinutes,
|
||||
BufferBeforeMinutes: defaults.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: defaults.BufferAfterMinutes,
|
||||
CancelWindowHours: defaults.CancelWindowHours,
|
||||
AvailabilityBlocks: toAvailabilityBlocks(request.AvailabilityBlocks),
|
||||
TeamInvites: toTeamInvites(request.TeamInvites),
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
@@ -113,12 +157,20 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
|
||||
OnboardingCompleted: true,
|
||||
Brand: domain.BrandProfile{
|
||||
Name: strings.TrimSpace(brand.Name),
|
||||
SiteURL: strings.TrimSpace(brand.SiteURL),
|
||||
LogoURL: strings.TrimSpace(brand.LogoURL),
|
||||
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
|
||||
},
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
@@ -127,3 +179,85 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile {
|
||||
brand, err := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return domain.BrandProfile{Name: tenant.Name}
|
||||
}
|
||||
return domain.BrandProfile{
|
||||
Name: firstNonEmpty(brand.Name, tenant.Name),
|
||||
SiteURL: brand.SiteURL,
|
||||
LogoURL: brand.LogoURL,
|
||||
PrimaryColor: brand.PrimaryColor,
|
||||
}
|
||||
}
|
||||
|
||||
func validateAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) error {
|
||||
for _, block := range blocks {
|
||||
if block.DayOfWeek < 0 || block.DayOfWeek > 6 {
|
||||
return ErrInvalidOnboarding
|
||||
}
|
||||
starts, err := time.Parse("15:04", block.StartsLocal)
|
||||
if err != nil {
|
||||
starts, err = time.Parse("15:04:05", block.StartsLocal)
|
||||
}
|
||||
if err != nil {
|
||||
return ErrInvalidOnboarding
|
||||
}
|
||||
ends, err := time.Parse("15:04", block.EndsLocal)
|
||||
if err != nil {
|
||||
ends, err = time.Parse("15:04:05", block.EndsLocal)
|
||||
}
|
||||
if err != nil || !ends.After(starts) {
|
||||
return ErrInvalidOnboarding
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) []db.AvailabilityBlockRecord {
|
||||
records := make([]db.AvailabilityBlockRecord, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
records = append(records, db.AvailabilityBlockRecord{
|
||||
DayOfWeek: block.DayOfWeek,
|
||||
StartsLocal: normalizeClock(block.StartsLocal),
|
||||
EndsLocal: normalizeClock(block.EndsLocal),
|
||||
Busy: block.Busy,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func toTeamInvites(invites []domain.TeamInviteRequest) []db.TeamInviteRecord {
|
||||
records := make([]db.TeamInviteRecord, 0, len(invites))
|
||||
for _, invite := range invites {
|
||||
email := strings.TrimSpace(strings.ToLower(invite.Email))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(invite.Role)
|
||||
if role == "" {
|
||||
role = "staff"
|
||||
}
|
||||
records = append(records, db.TeamInviteRecord{Email: email, Role: role})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func normalizeClock(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == len("15:04") {
|
||||
return value + ":00"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ INSERT INTO tenants (
|
||||
'studio',
|
||||
'cs',
|
||||
'Europe/Prague',
|
||||
'growth',
|
||||
'pro',
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
|
||||
|
||||
ALTER TABLE billing_snapshots
|
||||
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
|
||||
|
||||
ALTER TABLE subscription_events
|
||||
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'stripe_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN stripe_customer_id TO billing_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'stripe_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'stripe_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN stripe_customer_id TO billing_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'stripe_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'subscription_events' AND column_name = 'stripe_event_id'
|
||||
) THEN
|
||||
ALTER TABLE subscription_events RENAME COLUMN stripe_event_id TO billing_provider_event_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_stripe_event_id_key;
|
||||
ALTER TABLE subscription_events
|
||||
ADD CONSTRAINT subscription_events_provider_event_key UNIQUE (billing_provider, billing_provider_event_id);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_provider_event_key;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'subscription_events' AND column_name = 'billing_provider_event_id'
|
||||
) THEN
|
||||
ALTER TABLE subscription_events RENAME COLUMN billing_provider_event_id TO stripe_event_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'billing_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'billing_snapshots' AND column_name = 'billing_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE billing_snapshots RENAME COLUMN billing_customer_id TO stripe_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'billing_subscription_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tenants' AND column_name = 'billing_customer_id'
|
||||
) THEN
|
||||
ALTER TABLE tenants RENAME COLUMN billing_customer_id TO stripe_customer_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE subscription_events DROP COLUMN IF EXISTS billing_provider;
|
||||
ALTER TABLE billing_snapshots DROP COLUMN IF EXISTS billing_provider;
|
||||
ALTER TABLE tenants DROP COLUMN IF EXISTS billing_provider;
|
||||
ALTER TABLE subscription_events
|
||||
ADD CONSTRAINT subscription_events_stripe_event_id_key UNIQUE (stripe_event_id);
|
||||
@@ -0,0 +1,61 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE billing_snapshots
|
||||
ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'czk';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brand_profiles (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
site_url text,
|
||||
logo_url text,
|
||||
primary_color text,
|
||||
umami_site_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
cancel_window_hours integer NOT NULL DEFAULT 24,
|
||||
onboarding_completed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_invites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'staff',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, email)
|
||||
);
|
||||
|
||||
UPDATE tenants SET plan_code = 'pro' WHERE plan_code = 'growth';
|
||||
UPDATE tenants SET plan_code = 'business' WHERE plan_code = 'multi-location';
|
||||
UPDATE billing_snapshots SET plan_code = 'pro' WHERE plan_code = 'growth';
|
||||
UPDATE billing_snapshots SET plan_code = 'business' WHERE plan_code = 'multi-location';
|
||||
|
||||
INSERT INTO brand_profiles (tenant_id, name)
|
||||
SELECT id, name
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
|
||||
INSERT INTO tenant_settings (tenant_id, onboarding_completed)
|
||||
SELECT id, true
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
UPDATE tenants SET plan_code = 'growth' WHERE plan_code = 'pro';
|
||||
UPDATE tenants SET plan_code = 'multi-location' WHERE plan_code = 'business';
|
||||
UPDATE billing_snapshots SET plan_code = 'growth' WHERE plan_code = 'pro';
|
||||
UPDATE billing_snapshots SET plan_code = 'multi-location' WHERE plan_code = 'business';
|
||||
|
||||
DROP TABLE IF EXISTS team_invites;
|
||||
DROP TABLE IF EXISTS tenant_settings;
|
||||
DROP TABLE IF EXISTS brand_profiles;
|
||||
|
||||
ALTER TABLE billing_snapshots
|
||||
DROP COLUMN IF EXISTS currency;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- Create customers table for customer management
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL,
|
||||
phone text,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_tenant ON customers (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers (tenant_id, email);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_status ON customers (tenant_id, status);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS customers;
|
||||
@@ -4,7 +4,7 @@ info:
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Remote-first booking API for Bookra. The Go backend owns business rules,
|
||||
scheduling logic, tenant isolation, and Stripe-backed plan enforcement.
|
||||
scheduling logic, tenant isolation, and Paddle-backed plan enforcement.
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
tags:
|
||||
@@ -155,15 +155,17 @@ paths:
|
||||
$ref: "#/components/schemas/CheckoutSessionRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Hosted Stripe checkout session
|
||||
description: Paddle checkout launch payload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CheckoutSessionResponse"
|
||||
$ref: "#/components/schemas/CheckoutLaunchResponse"
|
||||
"400":
|
||||
description: Invalid request
|
||||
"401":
|
||||
description: Unauthorized
|
||||
"503":
|
||||
description: Billing provider unavailable
|
||||
/v1/billing/refresh:
|
||||
post:
|
||||
tags: [Billing]
|
||||
@@ -179,10 +181,31 @@ paths:
|
||||
$ref: "#/components/schemas/SubscriptionSnapshot"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
/v1/webhooks/stripe:
|
||||
"503":
|
||||
description: Billing provider unavailable
|
||||
/v1/billing/portal:
|
||||
post:
|
||||
tags: [Billing]
|
||||
operationId: handleStripeWebhook
|
||||
operationId: createBillingPortalSession
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Paddle customer portal session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PortalSessionResponse"
|
||||
"400":
|
||||
description: Billing customer missing
|
||||
"401":
|
||||
description: Unauthorized
|
||||
"503":
|
||||
description: Billing provider unavailable
|
||||
/v1/webhooks/paddle:
|
||||
post:
|
||||
tags: [Billing]
|
||||
operationId: handlePaddleWebhook
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -238,7 +261,7 @@ components:
|
||||
type: boolean
|
||||
PublicConfig:
|
||||
type: object
|
||||
required: [environment, neonAuthEnabled]
|
||||
required: [environment, neonAuthEnabled, apiUrl, demoMode]
|
||||
properties:
|
||||
environment:
|
||||
type: string
|
||||
@@ -247,6 +270,8 @@ components:
|
||||
apiUrl:
|
||||
type: string
|
||||
format: uri
|
||||
demoMode:
|
||||
type: boolean
|
||||
TimeSlot:
|
||||
type: object
|
||||
required: [startsAt, endsAt, mode]
|
||||
@@ -354,20 +379,84 @@ components:
|
||||
type: string
|
||||
DashboardSummary:
|
||||
type: object
|
||||
required: [tenantName, locale, timezone, planCode, kpis]
|
||||
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]
|
||||
properties:
|
||||
tenantName:
|
||||
type: string
|
||||
tenantSlug:
|
||||
type: string
|
||||
locale:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
planCode:
|
||||
type: string
|
||||
publicBookingUrl:
|
||||
type: string
|
||||
setupCompletion:
|
||||
type: integer
|
||||
kpis:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DashboardKPI"
|
||||
upcomingBookings:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UpcomingBooking"
|
||||
widgetSnippets:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WidgetSnippet"
|
||||
tracking:
|
||||
$ref: "#/components/schemas/TrackingStatus"
|
||||
UpcomingBooking:
|
||||
type: object
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
customerName:
|
||||
type: string
|
||||
customerEmail:
|
||||
type: string
|
||||
startsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
status:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
WidgetSnippet:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
TrackingStatus:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
connected:
|
||||
type: boolean
|
||||
siteId:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
BrandProfile:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
siteUrl:
|
||||
type: string
|
||||
logoUrl:
|
||||
type: string
|
||||
primaryColor:
|
||||
type: string
|
||||
TenantBootstrap:
|
||||
type: object
|
||||
required: [tenantId, tenantName, preset, locale, timezone, currentUser]
|
||||
@@ -377,6 +466,8 @@ components:
|
||||
format: uuid
|
||||
tenantName:
|
||||
type: string
|
||||
tenantSlug:
|
||||
type: string
|
||||
preset:
|
||||
type: string
|
||||
locale:
|
||||
@@ -385,6 +476,10 @@ components:
|
||||
type: string
|
||||
planCode:
|
||||
type: string
|
||||
onboardingCompleted:
|
||||
type: boolean
|
||||
brand:
|
||||
$ref: "#/components/schemas/BrandProfile"
|
||||
currentUser:
|
||||
type: object
|
||||
required: [subject, role]
|
||||
@@ -414,25 +509,84 @@ components:
|
||||
enum: [cs, en]
|
||||
timezone:
|
||||
type: string
|
||||
brand:
|
||||
$ref: "#/components/schemas/BrandProfile"
|
||||
locationName:
|
||||
type: string
|
||||
bookingDefaults:
|
||||
$ref: "#/components/schemas/BookingDefaultsRequest"
|
||||
availabilityBlocks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AvailabilityBlockRequest"
|
||||
teamInvites:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TeamInviteRequest"
|
||||
BookingDefaultsRequest:
|
||||
type: object
|
||||
properties:
|
||||
serviceName:
|
||||
type: string
|
||||
durationMinutes:
|
||||
type: integer
|
||||
bufferBeforeMinutes:
|
||||
type: integer
|
||||
bufferAfterMinutes:
|
||||
type: integer
|
||||
cancelWindowHours:
|
||||
type: integer
|
||||
AvailabilityBlockRequest:
|
||||
type: object
|
||||
required: [dayOfWeek, startsLocal, endsLocal]
|
||||
properties:
|
||||
dayOfWeek:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 7
|
||||
startsLocal:
|
||||
type: string
|
||||
example: "09:00"
|
||||
endsLocal:
|
||||
type: string
|
||||
example: "17:00"
|
||||
busy:
|
||||
type: boolean
|
||||
TeamInviteRequest:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
role:
|
||||
type: string
|
||||
PlanEntitlements:
|
||||
type: object
|
||||
required: [maxLocations, maxStaff, smsAddonAvailable, advancedReporting]
|
||||
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
||||
properties:
|
||||
maxLocations:
|
||||
type: integer
|
||||
maxStaff:
|
||||
type: integer
|
||||
smsAddonAvailable:
|
||||
emailReminders:
|
||||
type: boolean
|
||||
widgetEmbedding:
|
||||
type: boolean
|
||||
umamiTracking:
|
||||
type: boolean
|
||||
advancedReporting:
|
||||
type: boolean
|
||||
SubscriptionSnapshot:
|
||||
type: object
|
||||
required: [tenantId, customerId, subscriptionId, status, planCode, priceId, cancelAtPeriodEnd, entitlements]
|
||||
required: [tenantId, provider, customerId, subscriptionId, status, planCode, currency, priceId, cancelAtPeriodEnd, entitlements, displayPrices, trialDays, checkoutUrlAvailable, syncAvailable, portalAvailable]
|
||||
properties:
|
||||
tenantId:
|
||||
type: string
|
||||
format: uuid
|
||||
provider:
|
||||
type: string
|
||||
example: paddle
|
||||
customerId:
|
||||
type: string
|
||||
subscriptionId:
|
||||
@@ -441,6 +595,9 @@ components:
|
||||
type: string
|
||||
planCode:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd, eur]
|
||||
priceId:
|
||||
type: string
|
||||
cancelAtPeriodEnd:
|
||||
@@ -459,20 +616,65 @@ components:
|
||||
type: string
|
||||
entitlements:
|
||||
$ref: "#/components/schemas/PlanEntitlements"
|
||||
displayPrices:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PlanDisplayPrice"
|
||||
trialDays:
|
||||
type: integer
|
||||
lastSyncedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checkoutUrlAvailable:
|
||||
type: boolean
|
||||
syncAvailable:
|
||||
type: boolean
|
||||
portalAvailable:
|
||||
type: boolean
|
||||
CheckoutSessionRequest:
|
||||
type: object
|
||||
required: [planCode]
|
||||
properties:
|
||||
planCode:
|
||||
type: string
|
||||
enum: [starter, growth, multi-location]
|
||||
CheckoutSessionResponse:
|
||||
enum: [starter, pro, business]
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd]
|
||||
PlanDisplayPrice:
|
||||
type: object
|
||||
required: [currency, amountCents, formatted]
|
||||
properties:
|
||||
currency:
|
||||
type: string
|
||||
enum: [czk, usd]
|
||||
amountCents:
|
||||
type: integer
|
||||
formatted:
|
||||
type: string
|
||||
CheckoutLaunchResponse:
|
||||
type: object
|
||||
required: [priceId, successRedirectUrl, cancelRedirectUrl, customData]
|
||||
properties:
|
||||
priceId:
|
||||
type: string
|
||||
customerId:
|
||||
type: string
|
||||
customerEmail:
|
||||
type: string
|
||||
format: email
|
||||
successRedirectUrl:
|
||||
type: string
|
||||
format: uri
|
||||
cancelRedirectUrl:
|
||||
type: string
|
||||
format: uri
|
||||
customData:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
PortalSessionResponse:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10,
|
||||
"healthcheckPath": "/healthz",
|
||||
"healthcheckTimeout": 30,
|
||||
"numReplicas": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -1,5 +1,5 @@
|
||||
-- name: GetTenantBySlug :one
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id, created_at, updated_at
|
||||
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_customer_id, billing_subscription_id, created_at, updated_at
|
||||
FROM tenants
|
||||
WHERE slug = $1;
|
||||
|
||||
@@ -8,4 +8,3 @@ SELECT tenant_id, user_id, role, created_at
|
||||
FROM tenant_users
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
VITE_BOOKRA_APP_ENV=staging
|
||||
VITE_BOOKRA_API_URL=http://localhost:8080
|
||||
VITE_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
|
||||
# Primary production auth path: Neon Auth.
|
||||
VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
|
||||
VITE_PADDLE_ENV=sandbox
|
||||
VITE_PADDLE_CLIENT_TOKEN=
|
||||
VITE_DEFAULT_LOCALE=cs
|
||||
|
||||
# Demo Mode: Set to true to show demo UI indicators
|
||||
VITE_BOOKRA_DEMO_MODE=false
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Bookra is a remote-first booking SaaS for local service businesses."
|
||||
/>
|
||||
<title>Bookra</title>
|
||||
<meta name="description" content="Bookra - Calm booking software for salons, clinics, and local service businesses. Simple setup, reliable scheduling, clear reminders." />
|
||||
<meta name="theme-color" content="#f6f4ee" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1a1816" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Bookra — Calm Booking Software</title>
|
||||
|
||||
<!-- Preconnect to Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!-- Inter font with display optimization -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;450;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-canvas text-ink">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@bookra/api-client": "0.1.0",
|
||||
"@bookra/shared-types": "0.1.0",
|
||||
"@neondatabase/neon-js": "^0.2.0-beta.1",
|
||||
"@paddle/paddle-js": "^1.3.2",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 94 KiB |