Compare commits

...

3 Commits

Author SHA1 Message Date
Tomas Dvorak cf3315e8fc cleanup
CI / Frontend (push) Successful in 11m7s
CI / Go - apps/auth-service (push) Failing after 8s
CI / Go - apps/backend (push) Failing after 2s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
2026-05-05 09:48:15 +02:00
Tomas Dvorak 48c3e15a38 cleanup 2026-05-05 09:48:07 +02:00
Tomas Dvorak d854614a87 docs: add paddle migration spec 2026-04-21 10:16:36 +02:00
211 changed files with 230653 additions and 1477 deletions
+8 -2
View File
@@ -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
+129
View File
@@ -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 }}
+76 -10
View File
@@ -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
+32
View File
@@ -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.
+16
View File
@@ -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.
+12 -1
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
+22
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
# Environment variables
.env
# Binary
auth-service
*.exe
# Go
vendor/
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
+38
View File
@@ -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"]
+42
View File
@@ -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).
+121
View File
@@ -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
}
+59
View File
@@ -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
)
+146
View File
@@ -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=
+79
View File
@@ -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
}
+333
View File
@@ -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")
}
}
+113
View File
@@ -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)
}
}
+140
View File
@@ -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
}
+224
View File
@@ -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
+14
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+407
View File
@@ -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&apos;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&apos;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 &amp; 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>
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
+77 -10
View File
@@ -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=
+33
View File
@@ -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"]
+44 -4
View File
@@ -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.
+1
View File
@@ -29,6 +29,7 @@ func main() {
if err != nil {
log.Fatalf("create server: %v", err)
}
defer server.Close()
httpServer := &http.Server{
Addr: ":" + cfg.Port,
+4 -1
View File
@@ -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
+8 -2
View File
@@ -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=
+529 -15
View File
@@ -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)');
})();`
}
+55
View File
@@ -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())
}
}
+16 -1
View File
@@ -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
+29 -3
View File
@@ -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"}),
+383 -162
View File
@@ -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
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,
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,
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
}
if signature == "" {
return ErrStripeSignatureMissing
return domain.PortalSessionResponse{}, err
}
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,
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 err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
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
}
subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{
CustomerID: []string{customerID},
})
if err != nil {
return db.BillingSnapshotRecord{}, err
}
return record, nil
var selected *paddle.Subscription
if err := subscriptions.Iter(ctx, func(subscription *paddle.Subscription) (bool, error) {
if subscription == nil {
return true, nil
}
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)
if iter.Err() != nil {
return db.BillingSnapshotRecord{}, iter.Err()
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,
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 {
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
return code
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 &timestamp
}
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
}
+107 -33
View File
@@ -9,13 +9,21 @@ import (
"bookra/apps/backend/internal/domain"
)
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
service := NewService(config.Config{
func testConfig() config.Config {
return config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
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)
}
+186 -20
View File
@@ -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
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)
}
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)
}
if sessionCapacity > 0 && classBookings >= sessionCapacity {
status = "waitlisted"
}
} 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,
TenantSlug: membership.Tenant.Slug,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
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 {
+41 -4
View File
@@ -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 {
+465 -3
View File
@@ -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)
}
+125 -12
View File
@@ -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")
}
}
+72
View File
@@ -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
}
+173
View File
@@ -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
}
+152
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -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()
}
+210
View File
@@ -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
}
+253 -3
View File
@@ -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"`
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"`
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"`
Brand BrandProfile `json:"brand"`
LocationName string `json:"locationName"`
BookingDefaults BookingDefaultsRequest `json:"bookingDefaults"`
AvailabilityBlocks []AvailabilityBlockRequest `json:"availabilityBlocks"`
TeamInvites []TeamInviteRequest `json:"teamInvites"`
}
type TimeSlot struct {
@@ -83,16 +146,26 @@ 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"`
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"`
@@ -100,15 +173,29 @@ type SubscriptionSnapshot struct {
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,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)
}
+112 -46
View File
@@ -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)
+17
View File
@@ -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)
}
}
+136 -2
View File
@@ -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"
@@ -54,10 +55,13 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
return domain.TenantBootstrap{
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: membership.Tenant.PlanCode,
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,10 +110,23 @@ 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,
@@ -103,6 +135,18 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
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
@@ -115,10 +159,18 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
return domain.TenantBootstrap{
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: membership.Tenant.PlanCode,
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 ""
}
+1 -1
View File
@@ -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;
+214 -12
View File
@@ -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:
+14
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+1 -2
View File
@@ -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;
+6 -1
View File
@@ -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
+12 -9
View File
@@ -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>
+1
View File
@@ -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"
},
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 284 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 289 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 281 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 290 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 282 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 288 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 280 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 287 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 290 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 280 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

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