first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:01:36 +02:00
commit 035ac8ddb5
61 changed files with 6600 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
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
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
+34
View File
@@ -0,0 +1,34 @@
# Bookra Backend
Go + Gin API for Bookra, designed for Railway deployment and Neon-backed persistence.
## Commands
```bash
go run ./cmd/api
go build ./...
npm run db:generate
npm run db:migrate:status
npm run db:migrate:up
```
## Environment
- `BOOKRA_FRONTEND_URL` allowed browser origin
- `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_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
## Notes
- Auth verification is isolated in `internal/auth`.
- OpenAPI lives in `openapi/bookra.openapi.yaml`.
- SQL migrations live in `migrations/`.
- `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`.
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"bookra/apps/backend/internal/api"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
pools, err := db.NewPools(cfg)
if err != nil {
log.Fatalf("create database pools: %v", err)
}
defer pools.Close()
server, err := api.NewServer(cfg, pools)
if err != nil {
log.Fatalf("create server: %v", err)
}
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: server.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("bookra api listening on :%s (%s)", cfg.Port, cfg.Environment)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
module bookra/apps/backend
go 1.26.2
require (
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
)
require (
github.com/MicahParks/jwkset v0.11.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/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/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
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+114
View File
@@ -0,0 +1,114 @@
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/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/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.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/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/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=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/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=
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"errors"
"io"
"net/http"
"time"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/catalog"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/tenancy"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
type Server struct {
router *gin.Engine
cfg config.Config
pools *db.Pools
verifier *auth.Verifier
}
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
verifier, err := auth.NewVerifier(cfg.NeonAuthURL)
if err != nil {
return nil, err
}
repository := db.NewRepository(pools)
bookingService := bookings.NewService(repository)
tenantService := tenancy.NewService(repository)
billingService := billing.NewService(cfg, repository)
notificationService := notifications.NewService(cfg, repository)
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
}
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
AllowOrigins: []string{cfg.FrontendURL},
AllowHeaders: []string{"Authorization", "Content-Type"},
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowCredentials: true,
}), httpx.SecurityHeaders())
server.router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"environment": cfg.Environment,
"databaseConfigured": pools.DatabaseConfigured(),
})
})
server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL,
})
})
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrTenantNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) {
var request struct {
TenantSlug string `json:"tenantSlug" binding:"required"`
BookingMode string `json:"bookingMode" binding:"required"`
ServiceID *string `json:"serviceId"`
ClassSessionID *string `json:"classSessionId"`
StaffID *string `json:"staffId"`
LocationID *string `json:"locationId"`
CustomerName string `json:"customerName" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required,email"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt" binding:"required"`
EndsAt string `json:"endsAt" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := bookingService.Create(c.Request.Context(), domain.CreateBookingRequest(request))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrInvalidBooking):
status = http.StatusBadRequest
case errors.Is(err, bookings.ErrTenantNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected := server.router.Group("/v1")
protected.Use(auth.RequireAuth(verifier, repository))
protected.GET("/dashboard/summary", func(c *gin.Context) {
response, err := bookingService.DashboardSummary(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/tenants/bootstrap", func(c *gin.Context) {
response, err := tenantService.Bootstrap(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/tenants/onboard", func(c *gin.Context) {
var request domain.OnboardTenantRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.Onboard(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, tenancy.ErrInvalidOnboarding):
status = http.StatusBadRequest
case errors.Is(err, tenancy.ErrTenantAlreadyProvisioned), errors.Is(err, tenancy.ErrTenantSlugTaken):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
_ = catalog.NewService()
protected.GET("/billing/subscription", func(c *gin.Context) {
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/billing/checkout", func(c *gin.Context) {
var request domain.CheckoutSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/billing/refresh", func(c *gin.Context) {
response, err := billingService.Refresh(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
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"})
return
}
if err := billingService.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var request domain.DispatchReminderJobsRequest
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := notificationService.DispatchDue(c.Request.Context(), request.Limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
return server, nil
}
func (s *Server) Handler() http.Handler {
return s.router
}
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
if cfg.JobRunnerKey == "" {
return cfg.Environment == "development"
}
return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey
}
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"net/http"
"strings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/gin-gonic/gin"
)
const principalContextKey = "principal"
func RequireAuth(verifier *Verifier, repo db.Repository) gin.HandlerFunc {
return func(c *gin.Context) {
if verifier == nil || !verifier.Enabled() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
return
}
header := c.GetHeader("Authorization")
tokenString, ok := strings.CutPrefix(header, "Bearer ")
if !ok || strings.TrimSpace(tokenString) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing_bearer_token"})
return
}
claims, err := verifier.Verify(strings.TrimSpace(tokenString))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
subject, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
if name == "" {
name, _ = claims["display_name"].(string)
}
role, _ := claims["role"].(string)
if role == "" {
role = "authenticated"
}
if strings.TrimSpace(subject) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"})
return
}
if repo != nil {
if err := repo.EnsureUserIdentity(c.Request.Context(), subject, email, name); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "identity_sync_failed"})
return
}
}
c.Set(principalContextKey, domain.Principal{
Subject: subject,
Email: email,
Name: name,
Role: role,
})
c.Next()
}
}
func PrincipalFromContext(c *gin.Context) domain.Principal {
value, ok := c.Get(principalContextKey)
if !ok {
return domain.Principal{}
}
principal, _ := value.(domain.Principal)
return principal
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Verifier struct {
jwks keyfunc.Keyfunc
expectedIssuer string
enabled bool
cancel context.CancelFunc
}
func NewVerifier(neonAuthURL string) (*Verifier, error) {
trimmed := strings.TrimSpace(neonAuthURL)
if trimmed == "" {
return &Verifier{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 jwks: %w", err)
}
return &Verifier{
jwks: jwks,
expectedIssuer: expectedIssuer,
enabled: true,
cancel: cancel,
}, nil
}
func (v *Verifier) Enabled() bool {
return v.enabled
}
func (v *Verifier) Close() {
if v.cancel != nil {
v.cancel()
}
}
func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, 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 token claims")
}
return claims, nil
}
+335
View File
@@ -0,0 +1,335 @@
package billing
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"time"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"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")
)
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
repo db.Repository
}
func NewService(cfg config.Config, repo db.Repository) *Service {
return &Service{cfg: cfg, repo: repo}
}
func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SubscriptionSnapshot{}, ErrBillingMembership
}
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,
}, 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) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.CheckoutSessionResponse{}, ErrBillingMembership
}
return domain.CheckoutSessionResponse{}, err
}
priceID := s.cfg.StripePriceIDs[planCode]
if priceID == "" {
return domain.CheckoutSessionResponse{}, ErrBillingPlanUnsupported
}
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),
},
},
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
}
func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SubscriptionSnapshot{}, ErrBillingMembership
}
return domain.SubscriptionSnapshot{}, err
}
customerID := derefString(membership.Tenant.StripeCustomerID)
if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
StripeCustomerID: "",
Status: "none",
PlanCode: membership.Tenant.PlanCode,
}, s.cfg), nil
}
record, err := s.syncStripeData(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
}
if signature == "" {
return ErrStripeSignatureMissing
}
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookKey)
if err != nil {
return err
}
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
return nil
}
customerID := extractCustomerID(event)
if customerID == "" {
return nil
}
tenant, err := s.repo.GetTenantByStripeCustomerID(ctx, customerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
inserted, err := s.repo.RecordStripeEvent(ctx, tenant.ID, event.ID, string(event.Type), payload)
if err != nil || !inserted {
return err
}
_, err = s.syncStripeData(ctx, tenant, customerID)
return err
}
func (s *Service) syncStripeData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) {
if s.cfg.StripeSecretKey == "" {
now := time.Now().UTC()
record := db.BillingSnapshotRecord{
TenantID: tenant.ID,
StripeCustomerID: customerID,
StripeSubscriptionID: "",
Status: tenant.SubscriptionStatus,
PlanCode: tenant.PlanCode,
PriceID: s.cfg.StripePriceIDs[tenant.PlanCode],
LastSyncedAt: &now,
}
if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
return db.BillingSnapshotRecord{}, err
}
return record, nil
}
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()
}
now := time.Now().UTC()
record := db.BillingSnapshotRecord{
TenantID: tenant.ID,
StripeCustomerID: customerID,
StripeSubscriptionID: "",
Status: "none",
PlanCode: tenant.PlanCode,
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
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 {
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
}
if record.Status == "" {
record.Status = tenant.SubscriptionStatus
}
return domain.SubscriptionSnapshot{
TenantID: tenant.ID,
CustomerID: record.StripeCustomerID,
SubscriptionID: record.StripeSubscriptionID,
Status: record.Status,
PlanCode: record.PlanCode,
PriceID: record.PriceID,
CancelAtPeriodEnd: record.CancelAtPeriodEnd,
CurrentPeriodStart: record.CurrentPeriodStart,
CurrentPeriodEnd: record.CurrentPeriodEnd,
PaymentMethodBrand: record.PaymentMethodBrand,
PaymentMethodLast4: record.PaymentMethodLast4,
Entitlements: entitlementsForPlan(record.PlanCode),
LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: cfg.StripePriceIDs[record.PlanCode] != "",
}
}
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch 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}
default:
return domain.PlanEntitlements{MaxLocations: 3, MaxStaff: 10, SMSAddonAvailable: true, AdvancedReporting: true}
}
}
func (s *Service) planCodeForPrice(priceID string, fallback string) string {
for code, configuredPriceID := range s.cfg.StripePriceIDs {
if configuredPriceID != "" && configuredPriceID == priceID {
return code
}
}
return fallback
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func toTimePtr(value int64) *time.Time {
if value == 0 {
return nil
}
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
}
@@ -0,0 +1,75 @@
package billing
import (
"context"
"testing"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
},
}, 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.PlanCode != "growth" {
t.Fatalf("expected growth, got %s", snapshot.PlanCode)
}
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())
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")
}
}
func TestRefreshReturnsSnapshotWithoutStripeKey(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
},
}, 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)
}
}
+344
View File
@@ -0,0 +1,344 @@
package bookings
import (
"context"
"errors"
"fmt"
"sort"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/jackc/pgx/v5"
)
var (
ErrTenantNotFound = errors.New("tenant not found")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflict")
ErrTenantMembership = errors.New("tenant membership not found")
)
type Service struct {
repo db.Repository
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) {
tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PublicAvailability{}, ErrTenantNotFound
}
return domain.PublicAvailability{}, err
}
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
if err != nil {
return domain.PublicAvailability{}, err
}
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID)
if err != nil {
return domain.PublicAvailability{}, err
}
windowStart := time.Now().UTC()
windowEnd := windowStart.AddDate(0, 0, 7)
existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd)
if err != nil {
return domain.PublicAvailability{}, err
}
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8)
if err != nil {
return domain.PublicAvailability{}, err
}
slots := make([]domain.TimeSlot, 0, 8)
slots = append(slots, generateAppointmentSlots(tenant, services, rules, existingBookings)...)
slots = append(slots, generateClassSlots(classSessions, existingBookings)...)
sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt })
if len(slots) > 8 {
slots = slots[:8]
}
return domain.PublicAvailability{
TenantSlug: tenant.Slug,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
Slots: slots,
}, nil
}
func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) {
tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.CreateBookingResponse{}, ErrTenantNotFound
}
return domain.CreateBookingResponse{}, err
}
startsAt, err := time.Parse(time.RFC3339, request.StartsAt)
if err != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking)
}
endsAt, err := time.Parse(time.RFC3339, request.EndsAt)
if err != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be RFC3339", ErrInvalidBooking)
}
if !endsAt.After(startsAt) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be after startsAt", ErrInvalidBooking)
}
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
if err != nil {
return domain.CreateBookingResponse{}, err
}
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)
if err != nil {
return domain.CreateBookingResponse{}, err
}
sessionCapacity := int32(0)
for _, session := range classSessions {
if session.ID == *request.ClassSessionID {
sessionCapacity = session.Capacity
break
}
}
if sessionCapacity > 0 && classBookings >= sessionCapacity {
status = "waitlisted"
}
} else {
for _, booking := range existing {
if booking.Status == "cancelled" {
continue
}
if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) {
return domain.CreateBookingResponse{}, ErrBookingConflict
}
}
}
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenant.ID,
ServiceID: request.ServiceID,
ClassSessionID: request.ClassSessionID,
StaffID: request.StaffID,
LocationID: request.LocationID,
BookingMode: request.BookingMode,
CustomerName: request.CustomerName,
CustomerEmail: request.CustomerEmail,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: status,
Reference: db.Reference("BK", time.Now()),
Notes: request.Notes,
})
if err != nil {
return domain.CreateBookingResponse{}, err
}
if status == "waitlisted" && request.ClassSessionID != nil {
waitlistPosition := int(countClassBookings(existing, *request.ClassSessionID)) + 1
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: tenant.ID,
ClassSessionID: *request.ClassSessionID,
CustomerName: request.CustomerName,
CustomerEmail: request.CustomerEmail,
Position: waitlistPosition,
}); err != nil {
return domain.CreateBookingResponse{}, err
}
}
if scheduledFor, ok := reminderSchedule(startsAt); ok {
if err := s.repo.CreateReminderJob(ctx, db.ReminderJobParams{
TenantID: tenant.ID,
BookingID: created.ID,
Channel: "email",
ScheduledFor: scheduledFor,
}); err != nil {
return domain.CreateBookingResponse{}, err
}
}
return domain.CreateBookingResponse{
BookingID: created.ID,
Reference: created.Reference,
Status: created.Status,
}, 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 {
if errors.Is(err, pgx.ErrNoRows) {
return domain.DashboardSummary{}, ErrTenantMembership
}
return domain.DashboardSummary{}, err
}
now := time.Now().UTC()
weekEnd := now.AddDate(0, 0, 7)
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
if err != nil {
return domain.DashboardSummary{}, err
}
return domain.DashboardSummary{
TenantName: membership.Tenant.Name,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
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)},
},
}, nil
}
func generateAppointmentSlots(
tenant db.TenantRecord,
services []db.ServiceRecord,
rules []db.AvailabilityRuleRecord,
existing []db.BookingRecord,
) []domain.TimeSlot {
if len(services) == 0 || len(rules) == 0 {
return nil
}
location, err := time.LoadLocation(tenant.Timezone)
if err != nil {
location = time.UTC
}
service := services[0]
now := time.Now().In(location)
var slots []domain.TimeSlot
for dayOffset := 0; dayOffset < 7 && len(slots) < 6; dayOffset++ {
day := now.AddDate(0, 0, dayOffset)
for _, rule := range rules {
if int(day.Weekday()) != rule.DayOfWeek {
continue
}
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
if err != nil {
continue
}
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
if err != nil {
continue
}
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
duration := time.Duration(service.DurationMinutes) * time.Minute
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
if slotStart.Before(now.Add(2 * time.Hour)) {
continue
}
slotEnd := slotStart.Add(duration)
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
continue
}
serviceID := service.ID
slots = append(slots, domain.TimeSlot{
ServiceID: &serviceID,
StaffID: rule.StaffID,
StartsAt: slotStart.UTC().Format(time.RFC3339),
EndsAt: slotEnd.UTC().Format(time.RFC3339),
Mode: "appointment",
Label: service.Name,
})
if len(slots) >= 6 {
break
}
}
}
}
return slots
}
func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot {
slots := make([]domain.TimeSlot, 0, len(classSessions))
for _, session := range classSessions {
remaining := session.Capacity - countClassBookings(existing, session.ID)
if remaining < 0 {
remaining = 0
}
classSessionID := session.ID
locationID := session.LocationID
slots = append(slots, domain.TimeSlot{
ClassSessionID: &classSessionID,
LocationID: locationID,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
Mode: "class",
Label: session.Title,
RemainingCapacity: &remaining,
})
}
return slots
}
func collides(bookings []db.BookingRecord, staffID *string, locationID *string, startsAt time.Time, endsAt time.Time) bool {
for _, booking := range bookings {
if booking.Status == "cancelled" || booking.Status == "waitlisted" {
continue
}
if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) {
continue
}
if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) {
return true
}
}
return false
}
func sameResource(left *string, right *string) bool {
if left == nil || right == nil {
return false
}
return *left == *right
}
func countClassBookings(bookings []db.BookingRecord, classSessionID string) int32 {
var total int32
for _, booking := range bookings {
if booking.ClassSessionID == nil {
continue
}
if *booking.ClassSessionID == classSessionID && booking.Status == "confirmed" {
total++
}
}
return total
}
func reminderSchedule(startsAt time.Time) (time.Time, bool) {
now := time.Now().UTC()
switch {
case startsAt.After(now.Add(25 * time.Hour)):
return startsAt.Add(-24 * time.Hour), true
case startsAt.After(now.Add(3 * time.Hour)):
return startsAt.Add(-2 * time.Hour), true
default:
return time.Time{}, false
}
}
@@ -0,0 +1,191 @@
package bookings
import (
"context"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
first, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "First",
CustomerEmail: "first@example.com",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err != nil {
t.Fatalf("first create: %v", err)
}
if first.Status != "confirmed" {
t.Fatalf("expected confirmed, got %s", first.Status)
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "Second",
CustomerEmail: "second@example.com",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err == nil {
t.Fatal("expected conflict error")
}
if err != ErrBookingConflict {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("availability: %v", err)
}
var classSlot domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "class" {
classSlot = slot
break
}
}
if classSlot.ClassSessionID == nil {
t.Fatal("expected class slot")
}
for i := 0; i < 4; i++ {
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: classSlot.ClassSessionID,
LocationID: classSlot.LocationID,
CustomerName: "Capacity",
CustomerEmail: "capacity@example.com",
StartsAt: classSlot.StartsAt,
EndsAt: classSlot.EndsAt,
})
if err != nil {
t.Fatalf("create within capacity: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed within capacity, got %s", response.Status)
}
}
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: classSlot.ClassSessionID,
LocationID: classSlot.LocationID,
CustomerName: "Waitlist",
CustomerEmail: "waitlist@example.com",
StartsAt: classSlot.StartsAt,
EndsAt: classSlot.EndsAt,
})
if err != nil {
t.Fatalf("create waitlist: %v", err)
}
if response.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", response.Status)
}
}
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) == 0 {
t.Fatal("expected slots")
}
for _, slot := range availability.Slots {
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
if startsAt.Before(time.Now().UTC().Add(90 * time.Minute)) {
t.Fatalf("expected upcoming slot, got %s", slot.StartsAt)
}
}
}
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "Reminder",
CustomerEmail: "reminder@example.com",
StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339),
})
if err != nil {
t.Fatalf("create: %v", err)
}
reminders, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(365*24*time.Hour), 10)
if err != nil {
t.Fatalf("list reminder jobs: %v", err)
}
if len(reminders) == 0 {
t.Fatal("expected reminder job to be scheduled")
}
}
+7
View File
@@ -0,0 +1,7 @@
package catalog
type Service struct{}
func NewService() *Service {
return &Service{}
}
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"errors"
"os"
"strings"
)
type Config struct {
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
JobRunnerKey string
EmailFrom string
SMSFrom string
StripeSecretKey string
StripeWebhookKey string
StripePriceIDs map[string]string
}
func Load() (Config, error) {
cfg := Config{
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
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")),
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")),
},
}
if cfg.FrontendURL == "" {
return Config{}, errors.New("BOOKRA_FRONTEND_URL is required")
}
return cfg, nil
}
func valueOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
+56
View File
@@ -0,0 +1,56 @@
package db
import (
"context"
"time"
"bookra/apps/backend/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
)
type Pools struct {
App *pgxpool.Pool
Direct *pgxpool.Pool
}
func NewPools(cfg config.Config) (*Pools, error) {
pools := &Pools{}
if cfg.DatabaseURL != "" {
pool, err := connect(cfg.DatabaseURL)
if err != nil {
return nil, err
}
pools.App = pool
}
if cfg.DatabaseDirectURL != "" {
pool, err := connect(cfg.DatabaseDirectURL)
if err != nil {
return nil, err
}
pools.Direct = pool
}
return pools, nil
}
func (p *Pools) Close() {
if p.App != nil {
p.App.Close()
}
if p.Direct != nil {
p.Direct.Close()
}
}
func (p *Pools) DatabaseConfigured() bool {
return p.App != nil || p.Direct != nil
}
func connect(databaseURL string) (*pgxpool.Pool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return pgxpool.New(ctx, databaseURL)
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
package domain
import "time"
type Principal struct {
Subject string `json:"subject"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Role string `json:"role"`
}
type DashboardKPI struct {
Code string `json:"code"`
Label string `json:"label"`
Value string `json:"value"`
}
type DashboardSummary struct {
TenantName string `json:"tenantName"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
PlanCode string `json:"planCode"`
KPIs []DashboardKPI `json:"kpis"`
}
type TenantBootstrap struct {
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
Preset string `json:"preset"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
PlanCode string `json:"planCode,omitempty"`
CurrentUser Principal `json:"currentUser"`
}
type OnboardTenantRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Preset string `json:"preset"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
}
type TimeSlot struct {
ServiceID *string `json:"serviceId,omitempty"`
ClassSessionID *string `json:"classSessionId,omitempty"`
StaffID *string `json:"staffId,omitempty"`
LocationID *string `json:"locationId,omitempty"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
Mode string `json:"mode"`
Label string `json:"label"`
RemainingCapacity *int32 `json:"remainingCapacity,omitempty"`
}
type PublicAvailability struct {
TenantSlug string `json:"tenantSlug"`
Timezone string `json:"timezone"`
Locale string `json:"locale"`
Slots []TimeSlot `json:"slots"`
}
type CreateBookingRequest struct {
TenantSlug string `json:"tenantSlug"`
BookingMode string `json:"bookingMode"`
ServiceID *string `json:"serviceId,omitempty"`
ClassSessionID *string `json:"classSessionId,omitempty"`
StaffID *string `json:"staffId,omitempty"`
LocationID *string `json:"locationId,omitempty"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
}
type CreateBookingResponse struct {
BookingID string `json:"bookingId"`
Reference string `json:"reference"`
Status string `json:"status"`
}
type PlanEntitlements struct {
MaxLocations int `json:"maxLocations"`
MaxStaff int `json:"maxStaff"`
SMSAddonAvailable bool `json:"smsAddonAvailable"`
AdvancedReporting bool `json:"advancedReporting"`
}
type SubscriptionSnapshot struct {
TenantID string `json:"tenantId"`
CustomerID string `json:"customerId"`
SubscriptionID string `json:"subscriptionId"`
Status string `json:"status"`
PlanCode string `json:"planCode"`
PriceID string `json:"priceId"`
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
Entitlements PlanEntitlements `json:"entitlements"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
CheckoutURLAvailable bool `json:"checkoutUrlAvailable,omitempty"`
}
type CheckoutSessionRequest struct {
PlanCode string `json:"planCode"`
}
type CheckoutSessionResponse struct {
URL string `json:"url"`
}
type DispatchReminderJobsRequest struct {
Limit int `json:"limit,omitempty"`
}
type DispatchReminderJobsResponse struct {
ProcessedCount int `json:"processedCount"`
SentCount int `json:"sentCount"`
FailedCount int `json:"failedCount"`
}
+91
View File
@@ -0,0 +1,91 @@
package httpx
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Content-Security-Policy", "default-src 'self'; connect-src 'self' https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; base-uri 'self'; form-action 'self'")
c.Next()
}
}
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
limit rate.Limit
burst int
}
func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter {
return &RateLimiter{
visitors: make(map[string]*visitor),
limit: limit,
burst: burst,
}
}
func (r *RateLimiter) Middleware() gin.HandlerFunc {
go r.cleanupLoop()
return func(c *gin.Context) {
ip := c.ClientIP()
if ip == "" {
ip = "unknown"
}
limiter := r.getVisitor(ip)
if !limiter.Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate_limited"})
return
}
c.Next()
}
}
func (r *RateLimiter) getVisitor(key string) *rate.Limiter {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.visitors[key]
if !ok {
entry = &visitor{
limiter: rate.NewLimiter(r.limit, r.burst),
lastSeen: time.Now(),
}
r.visitors[key] = entry
return entry.limiter
}
entry.lastSeen = time.Now()
return entry.limiter
}
func (r *RateLimiter) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
r.mu.Lock()
cutoff := time.Now().Add(-15 * time.Minute)
for key, entry := range r.visitors {
if entry.lastSeen.Before(cutoff) {
delete(r.visitors, key)
}
}
r.mu.Unlock()
}
}
@@ -0,0 +1,221 @@
package notifications
import (
"context"
"errors"
"fmt"
"time"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var ErrUnsupportedChannel = errors.New("unsupported notification channel")
type DeliveryReceipt struct {
Provider string
ExternalID string
}
type EmailMessage struct {
From string
To string
Subject string
Text string
}
type SMSMessage struct {
From string
To string
Text 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 {
return &Service{
cfg: cfg,
repo: repo,
emailProvider: noopEmailProvider{},
smsProvider: noopSMSProvider{},
now: func() time.Time { return time.Now().UTC() },
}
}
func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchReminderJobsResponse, error) {
if limit <= 0 {
limit = 25
}
jobs, err := s.repo.ListDueReminderJobs(ctx, s.now(), limit)
if err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
response := domain.DispatchReminderJobsResponse{}
for _, job := range jobs {
response.ProcessedCount++
status := "sent"
provider := "unknown"
externalID := ""
errorMessage := ""
switch job.Channel {
case "email":
receipt, sendErr := s.emailProvider.Send(ctx, renderEmailMessage(s.cfg.EmailFrom, job))
if sendErr != nil {
status = "failed"
errorMessage = sendErr.Error()
} else {
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()
}
if provider == "unknown" {
if job.Channel == "email" {
provider = "noop-email"
} else if job.Channel == "sms" {
provider = "noop-sms"
}
}
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, status, s.now()); err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
if err := s.repo.CreateNotificationDeliveryLog(ctx, db.NotificationDeliveryLogParams{
TenantID: job.TenantID,
ReminderJobID: job.ID,
Channel: job.Channel,
Provider: provider,
Recipient: reminderRecipient(job),
Status: status,
ExternalID: externalID,
ErrorMessage: errorMessage,
}); err != nil {
return domain.DispatchReminderJobsResponse{}, err
}
if status == "sent" {
response.SentCount++
} else {
response.FailedCount++
}
}
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,
}
}
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),
}
}
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
startLabel := localizedStartsAt(job)
if job.Locale == "cs" {
return "Pripominka rezervace Bookra", fmt.Sprintf(
"Dobry den %s,\n\npripominame rezervaci %s u %s na %s.\n\nReference: %s\n",
job.CustomerName,
job.Reference,
job.TenantName,
startLabel,
job.Reference,
)
}
return "Bookra booking reminder", fmt.Sprintf(
"Hello %s,\n\nthis is a reminder for booking %s with %s at %s.\n\nReference: %s\n",
job.CustomerName,
job.Reference,
job.TenantName,
startLabel,
job.Reference,
)
}
func localizedStartsAt(job db.ReminderJobRecord) string {
location, err := time.LoadLocation(job.Timezone)
if err != nil {
location = time.UTC
}
localStartsAt := job.StartsAt.In(location)
if job.Locale == "cs" {
return localStartsAt.Format("02.01.2006 15:04")
}
return localStartsAt.Format("Jan 02, 2006 15:04")
}
func reminderRecipient(job db.ReminderJobRecord) string {
if job.Channel == "email" {
return job.CustomerEmail
}
return job.CustomerEmail
}
type noopEmailProvider struct{}
func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
if message.To == "" {
return DeliveryReceipt{Provider: "noop-email"}, errors.New("missing email recipient")
}
return DeliveryReceipt{
Provider: "noop-email",
ExternalID: fmt.Sprintf("noop-email-%d", time.Now().UnixNano()),
}, nil
}
type noopSMSProvider struct{}
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
if message.To == "" {
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
}
return DeliveryReceipt{
Provider: "noop-sms",
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
}, nil
}
@@ -0,0 +1,93 @@
package notifications
import (
"context"
"testing"
"time"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
repo := db.NewMemoryRepository()
startsAt := time.Now().UTC().Add(26 * time.Hour)
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingMode: "appointment",
CustomerName: "Reminder Customer",
CustomerEmail: "reminder@example.com",
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: "confirmed",
Reference: "BK-TEST-REMINDER",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingID: created.ID,
Channel: "email",
ScheduledFor: time.Now().UTC().Add(-time.Minute),
}); err != nil {
t.Fatalf("create reminder job: %v", err)
}
service := NewService(config.Config{
Environment: "development",
EmailFrom: "noreply@bookra.dev",
SMSFrom: "Bookra",
}, repo)
response, err := service.DispatchDue(context.Background(), 10)
if err != nil {
t.Fatalf("dispatch due: %v", err)
}
if response.ProcessedCount != 1 {
t.Fatalf("expected processed count 1, got %d", response.ProcessedCount)
}
if response.SentCount != 1 {
t.Fatalf("expected sent count 1, got %d", response.SentCount)
}
if response.FailedCount != 0 {
t.Fatalf("expected failed count 0, got %d", response.FailedCount)
}
pending, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(time.Hour), 10)
if err != nil {
t.Fatalf("list due reminder jobs: %v", err)
}
if len(pending) != 0 {
t.Fatalf("expected no pending jobs after dispatch, got %d", len(pending))
}
}
func TestDispatchDueFailsUnknownChannel(t *testing.T) {
repo := db.NewMemoryRepository()
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
BookingID: "booking-unknown-channel",
Channel: "push",
ScheduledFor: time.Now().UTC().Add(-time.Minute),
}); err != nil {
t.Fatalf("create reminder job: %v", err)
}
service := NewService(config.Config{Environment: "development"}, repo)
response, err := service.DispatchDue(context.Background(), 10)
if err != nil {
t.Fatalf("dispatch due: %v", err)
}
if response.ProcessedCount != 1 || response.FailedCount != 1 {
t.Fatalf("expected one failed job, got %+v", response)
}
}
func TestDispatchRequestContractShape(t *testing.T) {
request := domain.DispatchReminderJobsRequest{Limit: 20}
if request.Limit != 20 {
t.Fatalf("expected limit 20, got %d", request.Limit)
}
}
+129
View File
@@ -0,0 +1,129 @@
package tenancy
import (
"context"
"errors"
"regexp"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrTenantAlreadyProvisioned = errors.New("tenant already provisioned for user")
ErrInvalidOnboarding = errors.New("invalid onboarding request")
ErrTenantSlugTaken = errors.New("tenant slug is already in use")
)
var tenantSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
type Service struct {
repo db.Repository
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TenantBootstrap{
TenantID: "",
TenantName: "",
Preset: "",
Locale: "cs",
Timezone: "Europe/Prague",
CurrentUser: domain.Principal{
Subject: principal.Subject,
Email: principal.Email,
Name: principal.Name,
Role: principal.Role,
},
}, nil
}
return domain.TenantBootstrap{}, err
}
return domain.TenantBootstrap{
TenantID: membership.Tenant.ID,
TenantName: membership.Tenant.Name,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
CurrentUser: domain.Principal{
Subject: principal.Subject,
Email: principal.Email,
Name: principal.Name,
Role: membership.Role,
},
}, nil
}
func (s *Service) Onboard(ctx context.Context, principal domain.Principal, request domain.OnboardTenantRequest) (domain.TenantBootstrap, error) {
if _, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject); err == nil {
return domain.TenantBootstrap{}, ErrTenantAlreadyProvisioned
} else if !errors.Is(err, pgx.ErrNoRows) {
return domain.TenantBootstrap{}, err
}
name := strings.TrimSpace(request.Name)
slug := strings.TrimSpace(request.Slug)
preset := strings.TrimSpace(request.Preset)
locale := strings.TrimSpace(request.Locale)
timezone := strings.TrimSpace(request.Timezone)
switch {
case len(name) < 2 || len(name) > 80:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case len(slug) < 3 || len(slug) > 48 || !tenantSlugPattern.MatchString(slug):
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case preset != "salon" && preset != "clinic" && preset != "massage" && preset != "repair" && preset != "studio":
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case locale != "cs" && locale != "en":
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case timezone == "":
return domain.TenantBootstrap{}, ErrInvalidOnboarding
}
if _, err := time.LoadLocation(timezone); err != nil {
return domain.TenantBootstrap{}, ErrInvalidOnboarding
}
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
Subject: principal.Subject,
Name: name,
Slug: slug,
Preset: preset,
Locale: locale,
Timezone: timezone,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return domain.TenantBootstrap{}, ErrTenantSlugTaken
}
return domain.TenantBootstrap{}, err
}
return domain.TenantBootstrap{
TenantID: membership.Tenant.ID,
TenantName: membership.Tenant.Name,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
CurrentUser: domain.Principal{
Subject: principal.Subject,
Email: principal.Email,
Name: principal.Name,
Role: membership.Role,
},
}, nil
}
@@ -0,0 +1,108 @@
package tenancy
import (
"context"
"testing"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestBootstrapResolvesMembershipAfterIdentitySync(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
if err := repo.EnsureUserIdentity(context.Background(), "neon-user-123", "owner@bookra.dev", "Neon Owner"); err != nil {
t.Fatalf("ensure user identity: %v", err)
}
bootstrap, err := service.Bootstrap(context.Background(), domain.Principal{
Subject: "neon-user-123",
Email: "owner@bookra.dev",
Name: "Neon Owner",
Role: "authenticated",
})
if err != nil {
t.Fatalf("bootstrap: %v", err)
}
if bootstrap.TenantID == "" {
t.Fatal("expected tenant id")
}
if bootstrap.CurrentUser.Role != "owner" {
t.Fatalf("expected owner role, got %s", bootstrap.CurrentUser.Role)
}
if bootstrap.CurrentUser.Name != "Neon Owner" {
t.Fatalf("expected synced name, got %s", bootstrap.CurrentUser.Name)
}
}
func TestBootstrapReturnsShellWhenMembershipMissing(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
bootstrap, err := service.Bootstrap(context.Background(), domain.Principal{
Subject: "unassigned-user",
Email: "new@bookra.dev",
Name: "New User",
Role: "authenticated",
})
if err != nil {
t.Fatalf("bootstrap without membership: %v", err)
}
if bootstrap.TenantID != "" {
t.Fatalf("expected empty tenant id, got %s", bootstrap.TenantID)
}
if bootstrap.CurrentUser.Subject != "unassigned-user" {
t.Fatalf("expected subject passthrough, got %s", bootstrap.CurrentUser.Subject)
}
}
func TestOnboardCreatesTenantForAuthenticatedUser(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
bootstrap, err := service.Onboard(context.Background(), domain.Principal{
Subject: "fresh-user",
Email: "fresh@bookra.dev",
Name: "Fresh User",
Role: "authenticated",
}, domain.OnboardTenantRequest{
Name: "Fresh Studio",
Slug: "fresh-studio",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
})
if err != nil {
t.Fatalf("onboard: %v", err)
}
if bootstrap.TenantName != "Fresh Studio" {
t.Fatalf("expected tenant name, got %s", bootstrap.TenantName)
}
if bootstrap.CurrentUser.Role != "owner" {
t.Fatalf("expected owner role, got %s", bootstrap.CurrentUser.Role)
}
}
func TestOnboardRejectsInvalidSlug(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
_, err := service.Onboard(context.Background(), domain.Principal{
Subject: "fresh-user",
Email: "fresh@bookra.dev",
Name: "Fresh User",
Role: "authenticated",
}, domain.OnboardTenantRequest{
Name: "Fresh Studio",
Slug: "bad slug",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
})
if err != ErrInvalidOnboarding {
t.Fatalf("expected invalid onboarding, got %v", err)
}
}
+175
View File
@@ -0,0 +1,175 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE TABLE IF NOT EXISTS tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
preset text NOT NULL,
locale text NOT NULL DEFAULT 'cs',
timezone text NOT NULL DEFAULT 'Europe/Prague',
plan_code text NOT NULL DEFAULT 'starter',
subscription_status text NOT NULL DEFAULT 'trialing',
stripe_customer_id text,
stripe_subscription_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY,
email text NOT NULL,
display_name text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_users (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE IF NOT EXISTS locations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
timezone text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staff (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
location_id uuid REFERENCES locations(id) ON DELETE SET NULL,
display_name text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS services (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
duration_minutes integer NOT NULL,
buffer_before_minutes integer NOT NULL DEFAULT 0,
buffer_after_minutes integer NOT NULL DEFAULT 0,
price_cents integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS class_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title text NOT NULL,
duration_minutes integer NOT NULL,
capacity integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS class_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
template_id uuid NOT NULL REFERENCES class_templates(id) ON DELETE CASCADE,
location_id uuid REFERENCES locations(id) ON DELETE SET NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
capacity integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS availability_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id uuid REFERENCES staff(id) ON DELETE CASCADE,
day_of_week integer NOT NULL,
starts_local time NOT NULL,
ends_local time NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS availability_exceptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id uuid REFERENCES staff(id) ON DELETE CASCADE,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
kind text NOT NULL,
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS bookings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
service_id uuid REFERENCES services(id) ON DELETE SET NULL,
class_session_id uuid REFERENCES class_sessions(id) ON DELETE SET NULL,
staff_id uuid REFERENCES staff(id) ON DELETE SET NULL,
location_id uuid REFERENCES locations(id) ON DELETE SET NULL,
booking_mode text NOT NULL,
customer_name text NOT NULL,
customer_email text NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
status text NOT NULL,
reference text NOT NULL UNIQUE,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS waitlist_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
class_session_id uuid NOT NULL REFERENCES class_sessions(id) ON DELETE CASCADE,
customer_name text NOT NULL,
customer_email text NOT NULL,
position integer NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS reminder_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
booking_id uuid REFERENCES bookings(id) ON DELETE CASCADE,
channel text NOT NULL,
scheduled_for timestamptz NOT NULL,
dispatched_at timestamptz,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS subscription_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
stripe_event_id text NOT NULL UNIQUE,
event_type text NOT NULL,
payload jsonb NOT NULL,
processed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_bookings_tenant_time ON bookings (tenant_id, starts_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_tenant_time ON class_sessions (tenant_id, starts_at DESC);
CREATE INDEX IF NOT EXISTS idx_reminder_jobs_pending ON reminder_jobs (scheduled_for) WHERE status = 'pending';
-- +goose Down
DROP TABLE IF EXISTS subscription_events;
DROP TABLE IF EXISTS reminder_jobs;
DROP TABLE IF EXISTS waitlist_entries;
DROP TABLE IF EXISTS bookings;
DROP TABLE IF EXISTS availability_exceptions;
DROP TABLE IF EXISTS availability_rules;
DROP TABLE IF EXISTS class_sessions;
DROP TABLE IF EXISTS class_templates;
DROP TABLE IF EXISTS services;
DROP TABLE IF EXISTS staff;
DROP TABLE IF EXISTS locations;
DROP TABLE IF EXISTS tenant_users;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants;
+109
View File
@@ -0,0 +1,109 @@
-- +goose Up
INSERT INTO tenants (
id, slug, name, preset, locale, timezone, plan_code, subscription_status
) VALUES (
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'studio-atelier',
'Studio Atelier',
'studio',
'cs',
'Europe/Prague',
'growth',
'active'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO users (id, email, display_name)
VALUES (
'11111111-1111-1111-1111-111111111111',
'owner@bookra.dev',
'Bookra Demo Owner'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES (
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'11111111-1111-1111-1111-111111111111',
'owner'
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
INSERT INTO locations (id, tenant_id, name, timezone)
VALUES (
'659f1cc0-a850-46d6-b3b8-cb15d55d8daf',
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'Prague Studio',
'Europe/Prague'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO staff (id, tenant_id, location_id, display_name)
VALUES (
'6936c444-c7d0-4a7d-b596-a9b72d2f4fc0',
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'659f1cc0-a850-46d6-b3b8-cb15d55d8daf',
'Lenka Atelier'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO services (id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES (
'd5d76a61-3d49-467c-8dd4-bf61ee754e39',
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'Signature treatment',
60,
0,
15,
120000
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO class_templates (id, tenant_id, title, duration_minutes, capacity)
VALUES (
'd13fe5fd-727f-4d69-bfd8-47f1b92a2cf7',
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'Small group mobility class',
60,
4
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO class_sessions (id, tenant_id, template_id, location_id, starts_at, ends_at, capacity)
VALUES (
'4bf74c12-44dd-45ca-86bb-b104f16f2435',
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'd13fe5fd-727f-4d69-bfd8-47f1b92a2cf7',
'659f1cc0-a850-46d6-b3b8-cb15d55d8daf',
now() + interval '2 days',
now() + interval '2 days' + interval '1 hour',
4
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO availability_rules (tenant_id, staff_id, day_of_week, starts_local, ends_local)
SELECT
'5d6b3551-0a3e-4b86-bdf0-e9df20a47148',
'6936c444-c7d0-4a7d-b596-a9b72d2f4fc0',
weekday,
'09:00:00',
'17:00:00'
FROM (VALUES (1), (2), (3), (4), (5)) AS weekdays(weekday)
WHERE NOT EXISTS (
SELECT 1
FROM availability_rules ar
WHERE ar.tenant_id = '5d6b3551-0a3e-4b86-bdf0-e9df20a47148'
AND ar.staff_id = '6936c444-c7d0-4a7d-b596-a9b72d2f4fc0'
AND ar.day_of_week = weekdays.weekday
);
-- +goose Down
DELETE FROM availability_rules WHERE tenant_id = '5d6b3551-0a3e-4b86-bdf0-e9df20a47148';
DELETE FROM class_sessions WHERE id = '4bf74c12-44dd-45ca-86bb-b104f16f2435';
DELETE FROM class_templates WHERE id = 'd13fe5fd-727f-4d69-bfd8-47f1b92a2cf7';
DELETE FROM services WHERE id = 'd5d76a61-3d49-467c-8dd4-bf61ee754e39';
DELETE FROM staff WHERE id = '6936c444-c7d0-4a7d-b596-a9b72d2f4fc0';
DELETE FROM locations WHERE id = '659f1cc0-a850-46d6-b3b8-cb15d55d8daf';
DELETE FROM tenant_users WHERE tenant_id = '5d6b3551-0a3e-4b86-bdf0-e9df20a47148' AND user_id = '11111111-1111-1111-1111-111111111111';
DELETE FROM users WHERE id = '11111111-1111-1111-1111-111111111111';
DELETE FROM tenants WHERE id = '5d6b3551-0a3e-4b86-bdf0-e9df20a47148';
@@ -0,0 +1,23 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS billing_snapshots (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
stripe_customer_id text NOT NULL DEFAULT '',
stripe_subscription_id text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'none',
plan_code text NOT NULL DEFAULT 'starter',
price_id text NOT NULL DEFAULT '',
cancel_at_period_end boolean NOT NULL DEFAULT false,
current_period_start timestamptz,
current_period_end timestamptz,
payment_method_brand text NOT NULL DEFAULT '',
payment_method_last4 text NOT NULL DEFAULT '',
last_synced_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_subscription_events_processed ON subscription_events (processed_at);
-- +goose Down
DROP INDEX IF EXISTS idx_subscription_events_processed;
DROP TABLE IF EXISTS billing_snapshots;
@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS notification_delivery_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
reminder_job_id uuid REFERENCES reminder_jobs(id) ON DELETE SET NULL,
channel text NOT NULL,
provider text NOT NULL,
recipient text NOT NULL,
delivery_status text NOT NULL,
external_id text,
error_message text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_tenant_time
ON notification_delivery_logs (tenant_id, created_at DESC);
-- +goose Down
DROP INDEX IF EXISTS idx_notification_delivery_logs_tenant_time;
DROP TABLE IF EXISTS notification_delivery_logs;
@@ -0,0 +1,18 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN IF NOT EXISTS neon_subject text;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_neon_subject
ON users (neon_subject)
WHERE neon_subject IS NOT NULL;
UPDATE users
SET neon_subject = 'demo-owner',
updated_at = now()
WHERE email = 'owner@bookra.dev'
AND neon_subject IS NULL;
-- +goose Down
DROP INDEX IF EXISTS idx_users_neon_subject;
ALTER TABLE users
DROP COLUMN IF EXISTS neon_subject;
+498
View File
@@ -0,0 +1,498 @@
openapi: 3.0.3
info:
title: Bookra API
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.
servers:
- url: http://localhost:8080
tags:
- name: Health
- name: Public Booking
- name: Dashboard
- name: Tenant
- name: Billing
- name: Jobs
paths:
/healthz:
get:
tags: [Health]
operationId: getHealth
responses:
"200":
description: Service health
content:
application/json:
schema:
$ref: "#/components/schemas/HealthResponse"
/v1/meta/config:
get:
tags: [Health]
operationId: getPublicConfig
responses:
"200":
description: Public runtime configuration
content:
application/json:
schema:
$ref: "#/components/schemas/PublicConfig"
/v1/public/tenants/{tenantSlug}/availability:
get:
tags: [Public Booking]
operationId: getPublicAvailability
parameters:
- in: path
name: tenantSlug
required: true
schema:
type: string
responses:
"200":
description: Availability for a tenant booking page
content:
application/json:
schema:
$ref: "#/components/schemas/PublicAvailabilityResponse"
/v1/public/bookings:
post:
tags: [Public Booking]
operationId: createBooking
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBookingRequest"
responses:
"201":
description: Booking created
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBookingResponse"
/v1/dashboard/summary:
get:
tags: [Dashboard]
operationId: getDashboardSummary
security:
- bearerAuth: []
responses:
"200":
description: Tenant dashboard summary
content:
application/json:
schema:
$ref: "#/components/schemas/DashboardSummary"
"401":
description: Unauthorized
/v1/tenants/bootstrap:
get:
tags: [Tenant]
operationId: getTenantBootstrap
security:
- bearerAuth: []
responses:
"200":
description: Tenant bootstrap payload for the authenticated user
content:
application/json:
schema:
$ref: "#/components/schemas/TenantBootstrap"
"401":
description: Unauthorized
/v1/tenants/onboard:
post:
tags: [Tenant]
operationId: onboardTenant
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardTenantRequest"
responses:
"201":
description: Tenant created for authenticated user
content:
application/json:
schema:
$ref: "#/components/schemas/TenantBootstrap"
"400":
description: Invalid request
"401":
description: Unauthorized
"409":
description: Conflict
/v1/billing/subscription:
get:
tags: [Billing]
operationId: getSubscriptionSnapshot
security:
- bearerAuth: []
responses:
"200":
description: Current subscription snapshot and entitlements
content:
application/json:
schema:
$ref: "#/components/schemas/SubscriptionSnapshot"
"401":
description: Unauthorized
/v1/billing/checkout:
post:
tags: [Billing]
operationId: createBillingCheckout
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CheckoutSessionRequest"
responses:
"200":
description: Hosted Stripe checkout session
content:
application/json:
schema:
$ref: "#/components/schemas/CheckoutSessionResponse"
"400":
description: Invalid request
"401":
description: Unauthorized
/v1/billing/refresh:
post:
tags: [Billing]
operationId: refreshSubscriptionSnapshot
security:
- bearerAuth: []
responses:
"200":
description: Refreshed snapshot
content:
application/json:
schema:
$ref: "#/components/schemas/SubscriptionSnapshot"
"401":
description: Unauthorized
/v1/webhooks/stripe:
post:
tags: [Billing]
operationId: handleStripeWebhook
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: Webhook accepted
/v1/internal/jobs/reminders/dispatch:
post:
tags: [Jobs]
operationId: dispatchReminderJobs
security:
- jobRunnerKey: []
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/DispatchReminderJobsRequest"
responses:
"200":
description: Reminder jobs dispatched
content:
application/json:
schema:
$ref: "#/components/schemas/DispatchReminderJobsResponse"
"401":
description: Unauthorized
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
jobRunnerKey:
type: apiKey
in: header
name: X-Bookra-Job-Key
schemas:
HealthResponse:
type: object
required: [status, environment]
properties:
status:
type: string
example: ok
environment:
type: string
example: staging
databaseConfigured:
type: boolean
PublicConfig:
type: object
required: [environment, neonAuthEnabled]
properties:
environment:
type: string
neonAuthEnabled:
type: boolean
apiUrl:
type: string
format: uri
TimeSlot:
type: object
required: [startsAt, endsAt, mode]
properties:
serviceId:
type: string
format: uuid
nullable: true
classSessionId:
type: string
format: uuid
nullable: true
staffId:
type: string
format: uuid
nullable: true
locationId:
type: string
format: uuid
nullable: true
startsAt:
type: string
format: date-time
endsAt:
type: string
format: date-time
mode:
type: string
enum: [appointment, class]
label:
type: string
remainingCapacity:
type: integer
nullable: true
PublicAvailabilityResponse:
type: object
required: [tenantSlug, timezone, locale, slots]
properties:
tenantSlug:
type: string
timezone:
type: string
locale:
type: string
enum: [cs, en]
slots:
type: array
items:
$ref: "#/components/schemas/TimeSlot"
CreateBookingRequest:
type: object
required: [tenantSlug, bookingMode, customerName, customerEmail, startsAt, endsAt]
properties:
tenantSlug:
type: string
bookingMode:
type: string
enum: [appointment, class]
serviceId:
type: string
format: uuid
classSessionId:
type: string
format: uuid
staffId:
type: string
format: uuid
locationId:
type: string
format: uuid
customerName:
type: string
customerEmail:
type: string
format: email
notes:
type: string
startsAt:
type: string
format: date-time
endsAt:
type: string
format: date-time
CreateBookingResponse:
type: object
required: [bookingId, reference, status]
properties:
bookingId:
type: string
format: uuid
reference:
type: string
status:
type: string
enum: [confirmed, waitlisted]
DashboardKPI:
type: object
required: [code, label, value]
properties:
code:
type: string
label:
type: string
value:
type: string
DashboardSummary:
type: object
required: [tenantName, locale, timezone, planCode, kpis]
properties:
tenantName:
type: string
locale:
type: string
timezone:
type: string
planCode:
type: string
kpis:
type: array
items:
$ref: "#/components/schemas/DashboardKPI"
TenantBootstrap:
type: object
required: [tenantId, tenantName, preset, locale, timezone, currentUser]
properties:
tenantId:
type: string
format: uuid
tenantName:
type: string
preset:
type: string
locale:
type: string
timezone:
type: string
planCode:
type: string
currentUser:
type: object
required: [subject, role]
properties:
subject:
type: string
email:
type: string
format: email
name:
type: string
role:
type: string
OnboardTenantRequest:
type: object
required: [name, slug, preset, locale, timezone]
properties:
name:
type: string
slug:
type: string
preset:
type: string
enum: [salon, clinic, massage, repair, studio]
locale:
type: string
enum: [cs, en]
timezone:
type: string
PlanEntitlements:
type: object
required: [maxLocations, maxStaff, smsAddonAvailable, advancedReporting]
properties:
maxLocations:
type: integer
maxStaff:
type: integer
smsAddonAvailable:
type: boolean
advancedReporting:
type: boolean
SubscriptionSnapshot:
type: object
required: [tenantId, customerId, subscriptionId, status, planCode, priceId, cancelAtPeriodEnd, entitlements]
properties:
tenantId:
type: string
format: uuid
customerId:
type: string
subscriptionId:
type: string
status:
type: string
planCode:
type: string
priceId:
type: string
cancelAtPeriodEnd:
type: boolean
currentPeriodStart:
type: string
format: date-time
nullable: true
currentPeriodEnd:
type: string
format: date-time
nullable: true
paymentMethodBrand:
type: string
paymentMethodLast4:
type: string
entitlements:
$ref: "#/components/schemas/PlanEntitlements"
lastSyncedAt:
type: string
format: date-time
nullable: true
checkoutUrlAvailable:
type: boolean
CheckoutSessionRequest:
type: object
required: [planCode]
properties:
planCode:
type: string
enum: [starter, growth, multi-location]
CheckoutSessionResponse:
type: object
required: [url]
properties:
url:
type: string
format: uri
DispatchReminderJobsRequest:
type: object
properties:
limit:
type: integer
minimum: 1
maximum: 200
DispatchReminderJobsResponse:
type: object
required: [processedCount, sentCount, failedCount]
properties:
processedCount:
type: integer
sentCount:
type: integer
failedCount:
type: integer
+7
View File
@@ -0,0 +1,7 @@
-- name: ListUpcomingBookingsByTenant :many
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, booking_mode, customer_name, customer_email, starts_at, ends_at, status, reference, notes, created_at, updated_at
FROM bookings
WHERE tenant_id = $1 AND starts_at >= now()
ORDER BY starts_at ASC
LIMIT $2;
+11
View File
@@ -0,0 +1,11 @@
-- name: GetTenantBySlug :one
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id, created_at, updated_at
FROM tenants
WHERE slug = $1;
-- name: ListTenantUsers :many
SELECT tenant_id, user_id, role, created_at
FROM tenant_users
WHERE tenant_id = $1
ORDER BY created_at ASC;
+16
View File
@@ -0,0 +1,16 @@
version: "2"
sql:
- engine: "postgresql"
schema:
- "./migrations"
queries:
- "./sql"
gen:
go:
package: "dbgen"
out: "./internal/db/dbgen"
sql_package: "pgx/v5"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: true