mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Shared naming reference only.
|
||||||
|
# Put concrete values into app-specific env files.
|
||||||
|
|
||||||
|
BOOKRA_APP_ENV=staging
|
||||||
|
BOOKRA_APP_URL=https://app.bookra.example
|
||||||
|
BOOKRA_API_URL=https://api.bookra.example
|
||||||
|
BOOKRA_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
|
||||||
|
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.solid
|
||||||
|
.output
|
||||||
|
coverage
|
||||||
|
package-lock.json
|
||||||
|
bin
|
||||||
|
tmp
|
||||||
|
*.log
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Bookra
|
||||||
|
|
||||||
|
Remote-first booking SaaS scaffold aligned to the `tdvorak-fullstack` profile with these overrides:
|
||||||
|
|
||||||
|
- frontend on Vercel
|
||||||
|
- backend on Railway
|
||||||
|
- Neon Postgres + Neon Auth
|
||||||
|
- no Docker-based local runtime
|
||||||
|
|
||||||
|
## Workspace
|
||||||
|
|
||||||
|
- `apps/frontend` SolidJS frontend
|
||||||
|
- `apps/backend` Go API
|
||||||
|
- `packages/api-client` generated TypeScript client/types from OpenAPI
|
||||||
|
- `packages/shared-types` shared frontend constants and helpers
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Both apps expect remote services:
|
||||||
|
|
||||||
|
- Neon Postgres
|
||||||
|
- Neon Auth
|
||||||
|
- Stripe
|
||||||
|
|
||||||
|
See `.env.example`, `apps/frontend/.env.example`, and `apps/backend/.env.example`.
|
||||||
|
|
||||||
|
## Backend database commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate:status
|
||||||
|
npm run db:migrate:up
|
||||||
|
```
|
||||||
|
|
||||||
|
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell.
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ×tamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCustomerID(event stripe.Event) string {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := payload["customer"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
customerID, _ := value.(string)
|
||||||
|
return customerID
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
type Service struct{}
|
||||||
|
|
||||||
|
func NewService() *Service {
|
||||||
|
return &Service{}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
VITE_BOOKRA_APP_ENV=staging
|
||||||
|
VITE_BOOKRA_API_URL=http://localhost:8080
|
||||||
|
VITE_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
|
||||||
|
VITE_DEFAULT_LOCALE=cs
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Bookra is a remote-first booking SaaS for local service businesses."
|
||||||
|
/>
|
||||||
|
<title>Bookra</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="bg-canvas text-ink">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@bookra/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bookra/api-client": "0.1.0",
|
||||||
|
"@bookra/shared-types": "0.1.0",
|
||||||
|
"@neondatabase/neon-js": "^0.2.0-beta.1",
|
||||||
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"solid-js": "^1.9.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vite-plugin-solid": "^2.11.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Route } from "@solidjs/router";
|
||||||
|
import { AuthProvider } from "./providers/auth-provider";
|
||||||
|
import { I18nProvider } from "./providers/i18n-provider";
|
||||||
|
import { Shell } from "./components/shell";
|
||||||
|
import { DashboardRoute } from "./routes/dashboard-route";
|
||||||
|
import { HomeRoute } from "./routes/home-route";
|
||||||
|
import { PublicBookingRoute } from "./routes/public-booking-route";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<Shell>
|
||||||
|
<Route path="/" component={HomeRoute} />
|
||||||
|
<Route path="/dashboard" component={DashboardRoute} />
|
||||||
|
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
|
||||||
|
</Shell>
|
||||||
|
</AuthProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { ParentComponent, Show } from "solid-js";
|
||||||
|
import { useAuth } from "../providers/auth-provider";
|
||||||
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
|
||||||
|
export const Shell: ParentComponent = (props) => {
|
||||||
|
const auth = useAuth();
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<header class="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||||
|
<A class="text-lg font-semibold tracking-tight" href="/">
|
||||||
|
Bookra
|
||||||
|
</A>
|
||||||
|
<nav class="flex items-center gap-3 text-sm text-slate">
|
||||||
|
<A class="rounded-full border border-black/10 px-4 py-2 hover:border-ember" href="/dashboard">
|
||||||
|
{i18n.t("nav.dashboard")}
|
||||||
|
</A>
|
||||||
|
<A class="rounded-full border border-black/10 px-4 py-2 hover:border-ember" href="/book/studio-atelier">
|
||||||
|
{i18n.t("nav.booking")}
|
||||||
|
</A>
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-black/10 px-4 py-2 hover:border-pine"
|
||||||
|
onClick={() => i18n.toggleLocale()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{i18n.locale().toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<Show
|
||||||
|
when={auth.session()}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
class="rounded-full bg-ink px-4 py-2 text-canvas"
|
||||||
|
onClick={() => void auth.signInDemo()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{i18n.t("auth.signIn")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-black/10 px-4 py-2"
|
||||||
|
onClick={() => void auth.signOut()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{i18n.t("auth.signOut")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-7xl px-6 pb-16">{props.children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import createClient from "openapi-fetch";
|
||||||
|
import type { paths } from "@bookra/api-client/generated/types";
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export const apiClient = createClient<paths>({
|
||||||
|
baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type AuthSession = {
|
||||||
|
session?: {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
token?: string;
|
||||||
|
expiresAt?: string | Date;
|
||||||
|
};
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { render } from "solid-js/web";
|
||||||
|
import { Router } from "@solidjs/router";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
render(
|
||||||
|
() => (
|
||||||
|
<Router>
|
||||||
|
<App />
|
||||||
|
</Router>
|
||||||
|
),
|
||||||
|
document.getElementById("root")!,
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
ParentComponent,
|
||||||
|
useContext,
|
||||||
|
} from "solid-js";
|
||||||
|
import { createAuthClient } from "@neondatabase/neon-js/auth";
|
||||||
|
import type { AuthSession } from "../lib/types";
|
||||||
|
|
||||||
|
const neonAuthUrl = import.meta.env.VITE_NEON_AUTH_URL ?? "";
|
||||||
|
const authClient = neonAuthUrl ? createAuthClient(neonAuthUrl) : null;
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
session: () => AuthSession | null;
|
||||||
|
loading: () => boolean;
|
||||||
|
getToken: () => Promise<string | null>;
|
||||||
|
signInDemo: () => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>();
|
||||||
|
|
||||||
|
export const AuthProvider: ParentComponent = (props) => {
|
||||||
|
const [session, setSession] = createSignal<AuthSession | null>(null);
|
||||||
|
const [loading, setLoading] = createSignal(true);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (!authClient) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authClient.getSession();
|
||||||
|
setSession((response?.data as unknown as AuthSession | undefined) ?? null);
|
||||||
|
} catch {
|
||||||
|
setSession(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
session,
|
||||||
|
loading,
|
||||||
|
async getToken() {
|
||||||
|
if (!authClient) return null;
|
||||||
|
return session()?.session?.token ?? null;
|
||||||
|
},
|
||||||
|
async signInDemo() {
|
||||||
|
if (!authClient) {
|
||||||
|
setSession({
|
||||||
|
user: {
|
||||||
|
id: "demo-owner",
|
||||||
|
email: "owner@bookra.dev",
|
||||||
|
name: "Bookra Demo Owner",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: "demo-session",
|
||||||
|
userId: "demo-owner",
|
||||||
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await authClient.signIn.email({
|
||||||
|
email: "owner@bookra.dev",
|
||||||
|
password: "bookra-demo-password",
|
||||||
|
});
|
||||||
|
const response = await authClient.getSession();
|
||||||
|
setSession((response?.data as unknown as AuthSession | undefined) ?? null);
|
||||||
|
},
|
||||||
|
async signOut() {
|
||||||
|
if (!authClient) return;
|
||||||
|
await authClient.signOut();
|
||||||
|
setSession(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("AuthProvider is missing from the component tree.");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
ParentComponent,
|
||||||
|
useContext,
|
||||||
|
} from "solid-js";
|
||||||
|
import { defaultLocale, locales } from "@bookra/shared-types";
|
||||||
|
import type { Locale } from "@bookra/shared-types";
|
||||||
|
|
||||||
|
const dictionaries = {
|
||||||
|
cs: {
|
||||||
|
"nav.booking": "Veřejná rezervace",
|
||||||
|
"nav.dashboard": "Aplikace",
|
||||||
|
"auth.signIn": "Přihlásit",
|
||||||
|
"auth.signOut": "Odhlásit",
|
||||||
|
"home.eyebrow": "Bookra",
|
||||||
|
"home.title": "Klidný rezervační software pro lokální služby.",
|
||||||
|
"home.body":
|
||||||
|
"Root je marketingový vstup do produktu. Hlavní aplikace začíná v dashboardu a veřejná rezervace zůstává oddělená pro zákazníky.",
|
||||||
|
"home.primary": "Otevřít aplikaci",
|
||||||
|
"home.secondary": "Zobrazit veřejnou rezervaci",
|
||||||
|
"home.appLabel": "Hlavní vstup",
|
||||||
|
"home.appTitle": "/dashboard",
|
||||||
|
"home.appBody":
|
||||||
|
"Majitelé a tým pokračují přímo do aplikace, kde řeší dashboard, billing, tenant bootstrap a provoz.",
|
||||||
|
"home.publicLabel": "Veřejný tok",
|
||||||
|
"home.publicTitle": "/book/:tenantSlug",
|
||||||
|
"home.publicBody":
|
||||||
|
"Customer-facing booking flow zůstává mimo aplikaci, aby byl rychlý, čistý a bez interního šumu.",
|
||||||
|
"dashboard.title": "Owner dashboard",
|
||||||
|
"dashboard.body":
|
||||||
|
"Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
|
||||||
|
"dashboard.kpi.bookings": "Bookings this week",
|
||||||
|
"dashboard.kpi.cancellations": "Cancellations",
|
||||||
|
"dashboard.kpi.utilization": "Utilization",
|
||||||
|
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
|
||||||
|
"dashboard.bootstrap": "Tenant bootstrap",
|
||||||
|
"dashboard.previewMode": "Preview mode",
|
||||||
|
"dashboard.billing": "Billing",
|
||||||
|
"dashboard.checkout": "Open checkout",
|
||||||
|
"dashboard.refreshBilling": "Refresh billing",
|
||||||
|
"dashboard.plan": "Plan",
|
||||||
|
"dashboard.status": "Status",
|
||||||
|
"dashboard.entitlements": "Entitlements",
|
||||||
|
"dashboard.onboarding.title": "Vytvořit pracovní prostor",
|
||||||
|
"dashboard.onboarding.body":
|
||||||
|
"Tento účet ještě nemá tenant membership. Vytvořte první workspace a pokračujte do aplikace.",
|
||||||
|
"dashboard.onboarding.name": "Název firmy",
|
||||||
|
"dashboard.onboarding.slug": "Slug",
|
||||||
|
"dashboard.onboarding.preset": "Preset",
|
||||||
|
"dashboard.onboarding.locale": "Locale",
|
||||||
|
"dashboard.onboarding.timezone": "Timezone",
|
||||||
|
"dashboard.onboarding.submit": "Vytvořit workspace",
|
||||||
|
"dashboard.onboarding.pending": "Vytvářím workspace...",
|
||||||
|
"booking.title": "Public booking page",
|
||||||
|
"booking.body":
|
||||||
|
"The public experience stays light: availability, slot confirmation, and a clear fallback for guest booking or account-based booking later.",
|
||||||
|
"booking.empty": "Live availability will load from the Railway API when the tenant is configured.",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
"nav.booking": "Public booking",
|
||||||
|
"nav.dashboard": "App",
|
||||||
|
"auth.signIn": "Sign in",
|
||||||
|
"auth.signOut": "Sign out",
|
||||||
|
"home.eyebrow": "Bookra",
|
||||||
|
"home.title": "Calm booking software for local service businesses.",
|
||||||
|
"home.body":
|
||||||
|
"The root is the marketing entry to the product. The main app starts in the dashboard, while public booking stays separate for customers.",
|
||||||
|
"home.primary": "Open app",
|
||||||
|
"home.secondary": "View public booking",
|
||||||
|
"home.appLabel": "Main app entry",
|
||||||
|
"home.appTitle": "/dashboard",
|
||||||
|
"home.appBody":
|
||||||
|
"Owners and staff move directly into the app for dashboard, billing, tenant bootstrap, and day-to-day operations.",
|
||||||
|
"home.publicLabel": "Public flow",
|
||||||
|
"home.publicTitle": "/book/:tenantSlug",
|
||||||
|
"home.publicBody":
|
||||||
|
"The customer-facing booking flow stays outside the app so it remains focused, fast, and free of internal noise.",
|
||||||
|
"dashboard.title": "Owner dashboard",
|
||||||
|
"dashboard.body":
|
||||||
|
"Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
|
||||||
|
"dashboard.kpi.bookings": "Bookings this week",
|
||||||
|
"dashboard.kpi.cancellations": "Cancellations",
|
||||||
|
"dashboard.kpi.utilization": "Utilization",
|
||||||
|
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
|
||||||
|
"dashboard.bootstrap": "Tenant bootstrap",
|
||||||
|
"dashboard.previewMode": "Preview mode",
|
||||||
|
"dashboard.billing": "Billing",
|
||||||
|
"dashboard.checkout": "Open checkout",
|
||||||
|
"dashboard.refreshBilling": "Refresh billing",
|
||||||
|
"dashboard.plan": "Plan",
|
||||||
|
"dashboard.status": "Status",
|
||||||
|
"dashboard.entitlements": "Entitlements",
|
||||||
|
"dashboard.onboarding.title": "Create workspace",
|
||||||
|
"dashboard.onboarding.body":
|
||||||
|
"This account does not have a tenant membership yet. Create the first workspace and continue into the app.",
|
||||||
|
"dashboard.onboarding.name": "Business name",
|
||||||
|
"dashboard.onboarding.slug": "Slug",
|
||||||
|
"dashboard.onboarding.preset": "Preset",
|
||||||
|
"dashboard.onboarding.locale": "Locale",
|
||||||
|
"dashboard.onboarding.timezone": "Timezone",
|
||||||
|
"dashboard.onboarding.submit": "Create workspace",
|
||||||
|
"dashboard.onboarding.pending": "Creating workspace...",
|
||||||
|
"booking.title": "Public booking page",
|
||||||
|
"booking.body":
|
||||||
|
"The public experience stays light: availability, slot confirmation, and a clear fallback for guest booking or account-based booking later.",
|
||||||
|
"booking.empty": "Live availability will load from the Railway API when the tenant is configured.",
|
||||||
|
},
|
||||||
|
} satisfies Record<Locale, Record<string, string>>;
|
||||||
|
|
||||||
|
type I18nContextValue = {
|
||||||
|
locale: () => Locale;
|
||||||
|
t: (key: string) => string;
|
||||||
|
toggleLocale: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue>();
|
||||||
|
|
||||||
|
export const I18nProvider: ParentComponent = (props) => {
|
||||||
|
const initial = (import.meta.env.VITE_DEFAULT_LOCALE as Locale | undefined) ?? defaultLocale;
|
||||||
|
const [locale, setLocale] = createSignal<Locale>(locales.includes(initial) ? initial : defaultLocale);
|
||||||
|
const dictionary = createMemo(() => dictionaries[locale()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider
|
||||||
|
value={{
|
||||||
|
locale,
|
||||||
|
t(key: string) {
|
||||||
|
return dictionary()[key as keyof (typeof dictionaries)[Locale]] ?? key;
|
||||||
|
},
|
||||||
|
toggleLocale() {
|
||||||
|
setLocale((value) => (value === "cs" ? "en" : "cs"));
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("I18nProvider is missing from the component tree.");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { For, Show, createEffect, createResource, createSignal } from "solid-js";
|
||||||
|
import type { components } from "@bookra/api-client/generated/types";
|
||||||
|
import { locales, tenantPresets } from "@bookra/shared-types";
|
||||||
|
import { apiClient } from "../lib/api-client";
|
||||||
|
import { useAuth } from "../providers/auth-provider";
|
||||||
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
|
||||||
|
export function DashboardRoute() {
|
||||||
|
const i18n = useI18n();
|
||||||
|
const auth = useAuth();
|
||||||
|
const [token] = createResource(() => auth.session()?.session?.id, () => auth.getToken());
|
||||||
|
const [summary, { refetch: refetchSummary }] = createResource(token, async (bearer) => {
|
||||||
|
if (!bearer) return null;
|
||||||
|
const response = await apiClient.GET("/v1/dashboard/summary", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${bearer}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data ?? null;
|
||||||
|
});
|
||||||
|
const [bootstrap, { refetch: refetchBootstrap }] = createResource(token, async (bearer) => {
|
||||||
|
if (!bearer) return null;
|
||||||
|
const response = await apiClient.GET("/v1/tenants/bootstrap", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${bearer}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data ?? null;
|
||||||
|
});
|
||||||
|
const [billing, { refetch: refetchBilling }] = createResource(token, async (bearer) => {
|
||||||
|
if (!bearer) return null;
|
||||||
|
const response = await apiClient.GET("/v1/billing/subscription", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${bearer}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data ?? null;
|
||||||
|
});
|
||||||
|
const [billingMessage, setBillingMessage] = createSignal<string | null>(null);
|
||||||
|
const [onboardingMessage, setOnboardingMessage] = createSignal<string | null>(null);
|
||||||
|
const [submittingOnboarding, setSubmittingOnboarding] = createSignal(false);
|
||||||
|
const [businessName, setBusinessName] = createSignal("");
|
||||||
|
const [slug, setSlug] = createSignal("");
|
||||||
|
const [preset, setPreset] = createSignal<(typeof tenantPresets)[number]>("studio");
|
||||||
|
const [selectedLocale, setSelectedLocale] = createSignal<(typeof locales)[number]>(i18n.locale());
|
||||||
|
const [timezone, setTimezone] = createSignal(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Prague",
|
||||||
|
);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const name = auth.session()?.user?.name;
|
||||||
|
if (!businessName() && name) {
|
||||||
|
setBusinessName(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (slug()) return;
|
||||||
|
const source = businessName().trim().toLowerCase();
|
||||||
|
if (!source) return;
|
||||||
|
setSlug(
|
||||||
|
source
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewKPIs = [
|
||||||
|
{ code: "bookings_this_week", label: i18n.t("dashboard.kpi.bookings"), value: "124" },
|
||||||
|
{ code: "cancellations", label: i18n.t("dashboard.kpi.cancellations"), value: "11" },
|
||||||
|
{ code: "utilization", label: i18n.t("dashboard.kpi.utilization"), value: "82%" },
|
||||||
|
] satisfies components["schemas"]["DashboardKPI"][];
|
||||||
|
const resolvedSummary = () =>
|
||||||
|
summary.latest ??
|
||||||
|
({
|
||||||
|
tenantName: "Studio Atelier",
|
||||||
|
locale: "cs",
|
||||||
|
timezone: "Europe/Prague",
|
||||||
|
planCode: "growth",
|
||||||
|
kpis: previewKPIs,
|
||||||
|
} satisfies components["schemas"]["DashboardSummary"]);
|
||||||
|
const resolvedBootstrap = () =>
|
||||||
|
(!token() || !bootstrap.latest) ?
|
||||||
|
({
|
||||||
|
tenantId: "preview",
|
||||||
|
tenantName: "Studio Atelier",
|
||||||
|
preset: "studio",
|
||||||
|
locale: "cs",
|
||||||
|
timezone: "Europe/Prague",
|
||||||
|
currentUser: {
|
||||||
|
subject: auth.session()?.user?.id ?? "preview-user",
|
||||||
|
role: "owner",
|
||||||
|
},
|
||||||
|
} satisfies components["schemas"]["TenantBootstrap"]) :
|
||||||
|
bootstrap.latest;
|
||||||
|
const resolvedBilling = () =>
|
||||||
|
billing.latest ??
|
||||||
|
({
|
||||||
|
tenantId: resolvedBootstrap().tenantId,
|
||||||
|
customerId: "cus_demo_bookra",
|
||||||
|
subscriptionId: "",
|
||||||
|
status: "none",
|
||||||
|
planCode: resolvedSummary().planCode,
|
||||||
|
priceId: "",
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
entitlements: {
|
||||||
|
maxLocations: 3,
|
||||||
|
maxStaff: 10,
|
||||||
|
smsAddonAvailable: true,
|
||||||
|
advancedReporting: true,
|
||||||
|
},
|
||||||
|
checkoutUrlAvailable: true,
|
||||||
|
} satisfies components["schemas"]["SubscriptionSnapshot"]);
|
||||||
|
const checkoutPlanCode = () => {
|
||||||
|
const code = resolvedBilling().planCode;
|
||||||
|
return code === "starter" || code === "multi-location" ? code : "growth";
|
||||||
|
};
|
||||||
|
const hasTenant = () => Boolean(bootstrap.latest?.tenantId);
|
||||||
|
|
||||||
|
const openCheckout = async () => {
|
||||||
|
if (!token()) {
|
||||||
|
setBillingMessage("Billing checkout needs a Neon Auth JWT.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await apiClient.POST("/v1/billing/checkout", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token()}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
planCode: checkoutPlanCode(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.data?.url) {
|
||||||
|
window.location.href = response.data.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBillingMessage("Unable to create checkout session.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshBilling = async () => {
|
||||||
|
if (!token()) {
|
||||||
|
setBillingMessage("Billing refresh needs a Neon Auth JWT.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apiClient.POST("/v1/billing/refresh", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setBillingMessage("Billing snapshot refreshed.");
|
||||||
|
void refetchBilling();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitOnboarding = async () => {
|
||||||
|
if (!token()) {
|
||||||
|
setOnboardingMessage("Workspace creation needs a Neon Auth JWT.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmittingOnboarding(true);
|
||||||
|
setOnboardingMessage(null);
|
||||||
|
const response = await apiClient.POST("/v1/tenants/onboard", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token()}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: businessName().trim(),
|
||||||
|
slug: slug().trim(),
|
||||||
|
preset: preset(),
|
||||||
|
locale: selectedLocale(),
|
||||||
|
timezone: timezone().trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSubmittingOnboarding(false);
|
||||||
|
if (response.error) {
|
||||||
|
setOnboardingMessage(`Workspace creation failed: ${String(response.error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOnboardingMessage("Workspace created.");
|
||||||
|
void refetchBootstrap();
|
||||||
|
void refetchSummary();
|
||||||
|
void refetchBilling();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class="space-y-8 py-10">
|
||||||
|
<div class="rounded-panel border border-black/10 bg-white/70 p-8 shadow-card">
|
||||||
|
<h1 class="text-3xl font-semibold tracking-tight">{i18n.t("dashboard.title")}</h1>
|
||||||
|
<p class="mt-3 max-w-3xl text-base leading-7 text-slate">{i18n.t("dashboard.body")}</p>
|
||||||
|
<Show when={!token.loading && !token()}>
|
||||||
|
<div class="mt-5 rounded-panel border border-dashed border-black/10 bg-canvas/70 px-4 py-3 text-sm text-slate">
|
||||||
|
{i18n.t("dashboard.authRequired")}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={token() && bootstrap.latest && !hasTenant()}>
|
||||||
|
<article class="rounded-panel border border-black/10 bg-white/70 p-6 shadow-card">
|
||||||
|
<div class="max-w-2xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate">{i18n.t("dashboard.bootstrap")}</p>
|
||||||
|
<h2 class="mt-3 text-2xl font-semibold tracking-tight">{i18n.t("dashboard.onboarding.title")}</h2>
|
||||||
|
<p class="mt-2 text-sm leading-7 text-slate">{i18n.t("dashboard.onboarding.body")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-2 text-sm text-slate">
|
||||||
|
<span>{i18n.t("dashboard.onboarding.name")}</span>
|
||||||
|
<input class="w-full rounded-panel border border-black/10 bg-white px-4 py-3" value={businessName()} onInput={(event) => setBusinessName(event.currentTarget.value)} />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2 text-sm text-slate">
|
||||||
|
<span>{i18n.t("dashboard.onboarding.slug")}</span>
|
||||||
|
<input class="w-full rounded-panel border border-black/10 bg-white px-4 py-3" value={slug()} onInput={(event) => setSlug(event.currentTarget.value.toLowerCase())} />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2 text-sm text-slate">
|
||||||
|
<span>{i18n.t("dashboard.onboarding.preset")}</span>
|
||||||
|
<select class="w-full rounded-panel border border-black/10 bg-white px-4 py-3" value={preset()} onInput={(event) => setPreset(event.currentTarget.value as (typeof tenantPresets)[number])}>
|
||||||
|
<For each={tenantPresets}>
|
||||||
|
{(item) => <option value={item}>{item}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2 text-sm text-slate">
|
||||||
|
<span>{i18n.t("dashboard.onboarding.locale")}</span>
|
||||||
|
<select class="w-full rounded-panel border border-black/10 bg-white px-4 py-3" value={selectedLocale()} onInput={(event) => setSelectedLocale(event.currentTarget.value as (typeof locales)[number])}>
|
||||||
|
<For each={locales}>
|
||||||
|
{(item) => <option value={item}>{item}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2 text-sm text-slate md:col-span-2">
|
||||||
|
<span>{i18n.t("dashboard.onboarding.timezone")}</span>
|
||||||
|
<input class="w-full rounded-panel border border-black/10 bg-white px-4 py-3" value={timezone()} onInput={(event) => setTimezone(event.currentTarget.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas disabled:opacity-50"
|
||||||
|
disabled={submittingOnboarding()}
|
||||||
|
onClick={() => void submitOnboarding()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{submittingOnboarding() ? i18n.t("dashboard.onboarding.pending") : i18n.t("dashboard.onboarding.submit")}
|
||||||
|
</button>
|
||||||
|
<Show when={onboardingMessage()}>
|
||||||
|
<p class="text-sm text-pine">{onboardingMessage()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Show>
|
||||||
|
<Show when={!token() || hasTenant()}>
|
||||||
|
<div class="grid gap-4 md:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<article class="rounded-panel border border-black/10 bg-pine p-6 text-canvas shadow-card">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-canvas/70">{i18n.t("dashboard.bootstrap")}</p>
|
||||||
|
<div class="mt-4 space-y-3 text-sm leading-7 text-canvas/85">
|
||||||
|
<p><span class="font-semibold">Tenant:</span> {resolvedBootstrap().tenantName}</p>
|
||||||
|
<p><span class="font-semibold">Preset:</span> {resolvedBootstrap().preset}</p>
|
||||||
|
<p><span class="font-semibold">Locale:</span> {resolvedBootstrap().locale}</p>
|
||||||
|
<p><span class="font-semibold">Timezone:</span> {resolvedBootstrap().timezone}</p>
|
||||||
|
<p><span class="font-semibold">Role:</span> {resolvedBootstrap().currentUser.role}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-panel border border-black/10 bg-white/70 p-6 shadow-card">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate">{i18n.t("dashboard.previewMode")}</p>
|
||||||
|
<p class="mt-4 text-sm leading-7 text-slate">
|
||||||
|
The dashboard hydrates from the Railway API when a Neon Auth JWT is available. Without one, it renders the same contract shape in preview mode.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<article class="rounded-panel border border-black/10 bg-white/70 p-6 shadow-card">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate">{i18n.t("dashboard.billing")}</p>
|
||||||
|
<h2 class="mt-3 text-2xl font-semibold tracking-tight">
|
||||||
|
{i18n.t("dashboard.plan")}: {resolvedBilling().planCode}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm leading-7 text-slate">
|
||||||
|
{i18n.t("dashboard.status")}: {resolvedBilling().status}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-7 text-slate">
|
||||||
|
{i18n.t("dashboard.entitlements")}: {resolvedBilling().entitlements.maxLocations} locations,{" "}
|
||||||
|
{resolvedBilling().entitlements.maxStaff} staff, SMS{" "}
|
||||||
|
{resolvedBilling().entitlements.smsAddonAvailable ? "enabled" : "disabled"}.
|
||||||
|
</p>
|
||||||
|
<Show when={billingMessage()}>
|
||||||
|
<p class="mt-3 text-sm text-pine">{billingMessage()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas"
|
||||||
|
onClick={() => void openCheckout()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{i18n.t("dashboard.checkout")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-black/10 px-5 py-3 text-sm font-medium"
|
||||||
|
onClick={() => void refreshBilling()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{i18n.t("dashboard.refreshBilling")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<For each={resolvedSummary().kpis}>
|
||||||
|
{(kpi) => (
|
||||||
|
<article class="rounded-panel border border-black/10 bg-white/70 p-6 shadow-card">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate">{kpi.label}</p>
|
||||||
|
<p class="mt-4 text-4xl font-semibold tracking-tight">{kpi.value}</p>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
|
||||||
|
export function HomeRoute() {
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class="grid gap-8 py-10 lg:grid-cols-[1.4fr_0.8fr]">
|
||||||
|
<div class="rounded-panel border border-black/10 bg-white/70 p-8 shadow-card backdrop-blur">
|
||||||
|
<p class="mb-4 text-xs uppercase tracking-[0.24em] text-slate">{i18n.t("home.eyebrow")}</p>
|
||||||
|
<h1 class="max-w-3xl text-4xl font-semibold tracking-tight text-ink md:text-6xl">
|
||||||
|
{i18n.t("home.title")}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 max-w-2xl text-lg leading-8 text-slate">{i18n.t("home.body")}</p>
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<A class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas" href="/dashboard">
|
||||||
|
{i18n.t("home.primary")}
|
||||||
|
</A>
|
||||||
|
<A class="rounded-full border border-black/10 px-5 py-3 text-sm font-medium" href="/book/studio-atelier">
|
||||||
|
{i18n.t("home.secondary")}
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="rounded-panel border border-black/10 bg-pine p-6 text-canvas shadow-card">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-canvas/70">{i18n.t("home.appLabel")}</p>
|
||||||
|
<p class="mt-4 text-2xl font-semibold">{i18n.t("home.appTitle")}</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-canvas/80">
|
||||||
|
{i18n.t("home.appBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-panel border border-black/10 bg-white/70 p-6 shadow-card">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate">{i18n.t("home.publicLabel")}</p>
|
||||||
|
<p class="mt-4 text-2xl font-semibold">{i18n.t("home.publicTitle")}</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate">{i18n.t("home.publicBody")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useParams } from "@solidjs/router";
|
||||||
|
import { For, Show, createResource, createSignal } from "solid-js";
|
||||||
|
import { apiClient } from "../lib/api-client";
|
||||||
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
|
import type { components } from "@bookra/api-client/generated/types";
|
||||||
|
|
||||||
|
export function PublicBookingRoute() {
|
||||||
|
const params = useParams();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const tenantSlug = () => params.tenantSlug ?? "studio-atelier";
|
||||||
|
const [bookingResult, setBookingResult] = createSignal<string | null>(null);
|
||||||
|
const [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
|
||||||
|
const [availability, { refetch }] = createResource(() =>
|
||||||
|
apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
|
||||||
|
params: {
|
||||||
|
path: {
|
||||||
|
tenantSlug: tenantSlug(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
|
||||||
|
setSubmittingSlot(slot.startsAt);
|
||||||
|
setBookingResult(null);
|
||||||
|
|
||||||
|
const response = await apiClient.POST("/v1/public/bookings", {
|
||||||
|
body: {
|
||||||
|
tenantSlug: tenantSlug(),
|
||||||
|
bookingMode: slot.mode,
|
||||||
|
serviceId: slot.serviceId ?? undefined,
|
||||||
|
classSessionId: slot.classSessionId ?? undefined,
|
||||||
|
staffId: slot.staffId ?? undefined,
|
||||||
|
locationId: slot.locationId ?? undefined,
|
||||||
|
customerName: "Demo Customer",
|
||||||
|
customerEmail: "customer@bookra.dev",
|
||||||
|
startsAt: slot.startsAt,
|
||||||
|
endsAt: slot.endsAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = response as { data?: components["schemas"]["CreateBookingResponse"]; error?: unknown };
|
||||||
|
if (payload.error) {
|
||||||
|
setBookingResult(`Booking failed: ${String(payload.error)}`);
|
||||||
|
} else if (payload.data) {
|
||||||
|
setBookingResult(`Created ${payload.data.status} booking ${payload.data.reference}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmittingSlot(null);
|
||||||
|
void refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class="grid gap-8 py-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div class="rounded-panel border border-black/10 bg-white/70 p-8 shadow-card">
|
||||||
|
<h1 class="text-3xl font-semibold tracking-tight">{i18n.t("booking.title")}</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-base leading-7 text-slate">{i18n.t("booking.body")}</p>
|
||||||
|
<Show when={bookingResult()}>
|
||||||
|
<div class="mt-6 rounded-panel border border-pine/20 bg-pine/10 px-4 py-3 text-sm text-pine">
|
||||||
|
{bookingResult()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="mt-8 grid gap-3">
|
||||||
|
<Show
|
||||||
|
when={availability.latest?.data?.slots?.length}
|
||||||
|
fallback={
|
||||||
|
<div class="rounded-panel border border-dashed border-black/10 bg-canvas/70 p-5 text-sm leading-6 text-slate">
|
||||||
|
{i18n.t("booking.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={availability.latest?.data?.slots ?? []}>
|
||||||
|
{(slot) => (
|
||||||
|
<article class="rounded-panel border border-black/10 bg-canvas/70 p-5">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate">{slot.mode}</p>
|
||||||
|
<h2 class="mt-2 text-xl font-semibold">{slot.label ?? "Bookable slot"}</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate">
|
||||||
|
{new Date(slot.startsAt).toLocaleString()} - {new Date(slot.endsAt).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
<Show when={typeof slot.remainingCapacity === "number"}>
|
||||||
|
<p class="mt-2 text-sm text-pine">Remaining capacity: {slot.remainingCapacity}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas disabled:opacity-50"
|
||||||
|
disabled={submittingSlot() === slot.startsAt}
|
||||||
|
onClick={() => void bookSlot(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{submittingSlot() === slot.startsAt ? "Booking..." : "Book demo slot"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-panel border border-black/10 bg-pine p-8 text-canvas shadow-card">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-canvas/70">Tenant slug</p>
|
||||||
|
<p class="mt-4 text-2xl font-semibold">{tenantSlug()}</p>
|
||||||
|
<p class="mt-4 text-sm leading-7 text-canvas/80">
|
||||||
|
This route is ready to consume live availability from the Go API once Neon-backed tenant data is configured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(157, 92, 61, 0.12), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.64), transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
canvas: "#f6f4ee",
|
||||||
|
ink: "#171614",
|
||||||
|
mist: "#ded7cb",
|
||||||
|
ember: "#9d5c3d",
|
||||||
|
pine: "#26413c",
|
||||||
|
slate: "#42515b",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 20px 60px rgba(23, 22, 20, 0.08)",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
panel: "1.5rem",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["'IBM Plex Sans'", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/shell.tsx","./src/lib/api-client.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import solidPlugin from "vite-plugin-solid";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [solidPlugin()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 4173,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "bookra",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/frontend",
|
||||||
|
"packages/api-client",
|
||||||
|
"packages/shared-types"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:frontend": "npm run dev --workspace @bookra/frontend",
|
||||||
|
"dev:backend": "cd apps/backend && go run ./cmd/api",
|
||||||
|
"build:frontend": "npm run build --workspace @bookra/frontend",
|
||||||
|
"build:backend": "cd apps/backend && go build ./...",
|
||||||
|
"db:generate": "cd apps/backend && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate -f sqlc.yaml",
|
||||||
|
"db:migrate:up": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" up",
|
||||||
|
"db:migrate:status": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/goose@v3.24.1 -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" status",
|
||||||
|
"lint:frontend": "npm run lint --workspace @bookra/frontend",
|
||||||
|
"generate:api-client": "npm run generate --workspace @bookra/api-client",
|
||||||
|
"verify": "npm run generate:api-client && npm run build:frontend && npm run build:backend"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@bookra/api-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./generated/types": "./src/generated/types.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "node ./scripts/generate.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"openapi-fetch": "^0.13.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"openapi-typescript": "^7.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import openapiTS, { astToString } from "openapi-typescript";
|
||||||
|
|
||||||
|
const rootDir = path.resolve(import.meta.dirname, "../../..");
|
||||||
|
const specPath = path.resolve(rootDir, "apps/backend/openapi/bookra.openapi.yaml");
|
||||||
|
const outputPath = path.resolve(import.meta.dirname, "../src/generated/types.ts");
|
||||||
|
|
||||||
|
const ast = await openapiTS(pathToFileURL(specPath), {
|
||||||
|
alphabetize: true,
|
||||||
|
});
|
||||||
|
const schema = astToString(ast);
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await fs.writeFile(outputPath, `${schema}\n`, "utf8");
|
||||||
|
console.log(`Generated API types to ${outputPath}`);
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
export interface paths {
|
||||||
|
"/healthz": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getHealth"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/billing/checkout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["createBillingCheckout"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/billing/refresh": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["refreshSubscriptionSnapshot"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/billing/subscription": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getSubscriptionSnapshot"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/dashboard/summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getDashboardSummary"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/internal/jobs/reminders/dispatch": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["dispatchReminderJobs"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/meta/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getPublicConfig"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/public/bookings": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["createBooking"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/public/tenants/{tenantSlug}/availability": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getPublicAvailability"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/tenants/bootstrap": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getTenantBootstrap"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/tenants/onboard": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["onboardTenant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/webhooks/stripe": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["handleStripeWebhook"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
CheckoutSessionRequest: {
|
||||||
|
/** @enum {string} */
|
||||||
|
planCode: "starter" | "growth" | "multi-location";
|
||||||
|
};
|
||||||
|
CheckoutSessionResponse: {
|
||||||
|
/** Format: uri */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
CreateBookingRequest: {
|
||||||
|
/** @enum {string} */
|
||||||
|
bookingMode: "appointment" | "class";
|
||||||
|
/** Format: uuid */
|
||||||
|
classSessionId?: string;
|
||||||
|
/** Format: email */
|
||||||
|
customerEmail: string;
|
||||||
|
customerName: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
endsAt: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
locationId?: string;
|
||||||
|
notes?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
serviceId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
staffId?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
startsAt: string;
|
||||||
|
tenantSlug: string;
|
||||||
|
};
|
||||||
|
CreateBookingResponse: {
|
||||||
|
/** Format: uuid */
|
||||||
|
bookingId: string;
|
||||||
|
reference: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
status: "confirmed" | "waitlisted";
|
||||||
|
};
|
||||||
|
DashboardKPI: {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
DashboardSummary: {
|
||||||
|
kpis: components["schemas"]["DashboardKPI"][];
|
||||||
|
locale: string;
|
||||||
|
planCode: string;
|
||||||
|
tenantName: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
DispatchReminderJobsRequest: {
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
DispatchReminderJobsResponse: {
|
||||||
|
failedCount: number;
|
||||||
|
processedCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
};
|
||||||
|
HealthResponse: {
|
||||||
|
databaseConfigured?: boolean;
|
||||||
|
/** @example staging */
|
||||||
|
environment: string;
|
||||||
|
/** @example ok */
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
OnboardTenantRequest: {
|
||||||
|
/** @enum {string} */
|
||||||
|
locale: "cs" | "en";
|
||||||
|
name: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
preset: "salon" | "clinic" | "massage" | "repair" | "studio";
|
||||||
|
slug: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
PlanEntitlements: {
|
||||||
|
advancedReporting: boolean;
|
||||||
|
maxLocations: number;
|
||||||
|
maxStaff: number;
|
||||||
|
smsAddonAvailable: boolean;
|
||||||
|
};
|
||||||
|
PublicAvailabilityResponse: {
|
||||||
|
/** @enum {string} */
|
||||||
|
locale: "cs" | "en";
|
||||||
|
slots: components["schemas"]["TimeSlot"][];
|
||||||
|
tenantSlug: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
PublicConfig: {
|
||||||
|
/** Format: uri */
|
||||||
|
apiUrl?: string;
|
||||||
|
environment: string;
|
||||||
|
neonAuthEnabled: boolean;
|
||||||
|
};
|
||||||
|
SubscriptionSnapshot: {
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
checkoutUrlAvailable?: boolean;
|
||||||
|
/** Format: date-time */
|
||||||
|
currentPeriodEnd?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
currentPeriodStart?: string | null;
|
||||||
|
customerId: string;
|
||||||
|
entitlements: components["schemas"]["PlanEntitlements"];
|
||||||
|
/** Format: date-time */
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
paymentMethodBrand?: string;
|
||||||
|
paymentMethodLast4?: string;
|
||||||
|
planCode: string;
|
||||||
|
priceId: string;
|
||||||
|
status: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
tenantId: string;
|
||||||
|
};
|
||||||
|
TenantBootstrap: {
|
||||||
|
currentUser: {
|
||||||
|
/** Format: email */
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
role: string;
|
||||||
|
subject: string;
|
||||||
|
};
|
||||||
|
locale: string;
|
||||||
|
planCode?: string;
|
||||||
|
preset: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
TimeSlot: {
|
||||||
|
/** Format: uuid */
|
||||||
|
classSessionId?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
endsAt: string;
|
||||||
|
label?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
locationId?: string | null;
|
||||||
|
/** @enum {string} */
|
||||||
|
mode: "appointment" | "class";
|
||||||
|
remainingCapacity?: number | null;
|
||||||
|
/** Format: uuid */
|
||||||
|
serviceId?: string | null;
|
||||||
|
/** Format: uuid */
|
||||||
|
staffId?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
startsAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
getHealth: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Service health */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HealthResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createBillingCheckout: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CheckoutSessionRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Hosted Stripe checkout session */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CheckoutSessionResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
refreshSubscriptionSnapshot: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Refreshed snapshot */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SubscriptionSnapshot"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getSubscriptionSnapshot: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Current subscription snapshot and entitlements */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SubscriptionSnapshot"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getDashboardSummary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Tenant dashboard summary */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DashboardSummary"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
dispatchReminderJobs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DispatchReminderJobsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Reminder jobs dispatched */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DispatchReminderJobsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getPublicConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Public runtime configuration */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PublicConfig"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createBooking: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateBookingRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Booking created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateBookingResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getPublicAvailability: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tenantSlug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Availability for a tenant booking page */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PublicAvailabilityResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getTenantBootstrap: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Tenant bootstrap payload for the authenticated user */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TenantBootstrap"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
onboardTenant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["OnboardTenantRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Tenant created for authenticated user */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TenantBootstrap"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
handleStripeWebhook: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Webhook accepted */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./generated/types";
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@bookra/shared-types",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const locales = ["cs", "en"] as const;
|
||||||
|
export type Locale = (typeof locales)[number];
|
||||||
|
export const defaultLocale: Locale = "cs";
|
||||||
|
|
||||||
|
export const tenantPresets = [
|
||||||
|
"salon",
|
||||||
|
"clinic",
|
||||||
|
"massage",
|
||||||
|
"repair",
|
||||||
|
"studio",
|
||||||
|
] as const;
|
||||||
|
export type TenantPreset = (typeof tenantPresets)[number];
|
||||||
|
|
||||||
|
export const planCodes = ["starter", "growth", "multi-location"] as const;
|
||||||
|
export type PlanCode = (typeof planCodes)[number];
|
||||||
|
|
||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
# Bookra
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
- `[done]` Milestone 1: Remote-first monorepo scaffold completed
|
||||||
|
- SolidJS frontend shell
|
||||||
|
- Go + Gin backend shell
|
||||||
|
- OpenAPI-driven TS client generation
|
||||||
|
- Neon Auth adapter boundary
|
||||||
|
- remote-first env layout for Vercel, Railway, and Neon
|
||||||
|
- `[done]` Milestone 2: Repository-backed booking baseline completed
|
||||||
|
- DB/in-memory repository boundary added
|
||||||
|
- public availability now generated through backend service logic
|
||||||
|
- public booking creation now goes through real service validation
|
||||||
|
- appointment conflict detection implemented
|
||||||
|
- class capacity and waitlist fallback implemented
|
||||||
|
- backend tests added for booking and slot behavior
|
||||||
|
- frontend public booking page can submit a demo booking
|
||||||
|
- `[done/partial]` Milestone 3: Tenant bootstrap, dashboard hydration, and Neon-authenticated API flows
|
||||||
|
- tenant bootstrap endpoint now resolves through repository-backed membership lookup
|
||||||
|
- dashboard summary endpoint now resolves through repository-backed metrics lookup
|
||||||
|
- frontend dashboard now hydrates from API when a Neon Auth JWT is available
|
||||||
|
- frontend dashboard degrades to contract-shaped preview mode when auth is unavailable
|
||||||
|
- Neon Auth token acquisition remains adapter-isolated and still needs production validation against live Neon Auth
|
||||||
|
- `[done/partial]` Milestone 4: Stripe subscription sync and entitlement baseline completed
|
||||||
|
- normalized billing snapshot model added to backend and OpenAPI contract
|
||||||
|
- backend-owned subscription state now syncs through one overwrite path
|
||||||
|
- Stripe checkout session endpoint added
|
||||||
|
- Stripe webhook endpoint added with signature verification and event filtering
|
||||||
|
- billing snapshot refresh endpoint added
|
||||||
|
- plan entitlements now derive from normalized plan state
|
||||||
|
- dashboard now surfaces billing status, entitlements, and checkout actions
|
||||||
|
- mock/no-key fallback still works for local development
|
||||||
|
- `[done/partial]` Milestone 5: Reminder jobs, notification delivery abstractions, and remote job-runner baseline completed
|
||||||
|
- booking creation now schedules reminder jobs for qualifying upcoming bookings
|
||||||
|
- notification service now dispatches due jobs through replaceable provider interfaces
|
||||||
|
- localized reminder copy now renders with tenant locale/timezone context
|
||||||
|
- notification delivery log persistence added
|
||||||
|
- internal reminder dispatch endpoint added for Railway-style scheduled execution
|
||||||
|
- shared job-runner key added for remote job execution
|
||||||
|
- current providers are noop-safe abstractions; real email/SMS vendors still need production integration
|
||||||
|
- `[done/partial]` Milestone 6: Production auth completion and tenant identity sync baseline completed
|
||||||
|
- verified Neon JWTs now sync app-side users automatically on protected requests
|
||||||
|
- app-side membership resolution now maps by `users.neon_subject` instead of assuming JWT subject equals DB primary key
|
||||||
|
- frontend auth provider now reads JWT from the Neon session contract instead of a fallback token hook
|
||||||
|
- bootstrap payload now returns user identity fields needed for authenticated shells
|
||||||
|
- Neon subject schema migration added for production databases
|
||||||
|
- live Neon Auth / staging validation still needs to be completed against real infrastructure
|
||||||
|
- `[done/partial]` Milestone 7: First-tenant onboarding flow added to the dashboard
|
||||||
|
- authenticated users without membership now see a real workspace creation flow in `/dashboard`
|
||||||
|
- protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone
|
||||||
|
- onboarding now creates the tenant, owner membership, and first default location
|
||||||
|
- dashboard can transition from empty bootstrap shell to a provisioned tenant state
|
||||||
|
- real post-onboarding entity management screens are still missing
|
||||||
|
- `[next]` Milestone 8: Catalog management, live staging validation, and operational hardening
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||||
|
|
||||||
|
The product is multi-tenant, calm in scope, and optimized for production quality rather than a fast prototype.
|
||||||
|
|
||||||
|
## Product Direction
|
||||||
|
Bookra uses one unified booking core with tenant presets.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- one platform for appointments and simple classes
|
||||||
|
- different business presets for salon, clinic, repair, massage, sports/studio
|
||||||
|
- shared scheduling, reminders, dashboard, and billing logic
|
||||||
|
- no industry-specific forked products in v1
|
||||||
|
|
||||||
|
## Launch Scope
|
||||||
|
### Market
|
||||||
|
- Czech Republic first
|
||||||
|
- English included at launch
|
||||||
|
- EU-friendly hosting and data handling defaults
|
||||||
|
|
||||||
|
### Supported booking models
|
||||||
|
- Appointments
|
||||||
|
- Capacity-based classes
|
||||||
|
|
||||||
|
### Explicitly out of scope for v1
|
||||||
|
- Customer payments for bookings
|
||||||
|
- Deposits or prepaid services
|
||||||
|
- Memberships, passes, or package accounting
|
||||||
|
- Regulated healthcare workflows
|
||||||
|
- Medical records or sensitive health data
|
||||||
|
- Marketplace/discovery layer
|
||||||
|
|
||||||
|
## Customer Experience
|
||||||
|
- Public booking page per business
|
||||||
|
- Guest booking supported by default
|
||||||
|
- Optional customer accounts for history and faster rebooking
|
||||||
|
- Confirmation, cancellation, and reschedule flow
|
||||||
|
- Czech and English UI from day one
|
||||||
|
- Timezone-aware booking and reminders
|
||||||
|
|
||||||
|
## Business Experience
|
||||||
|
Each business can manage:
|
||||||
|
- multiple locations
|
||||||
|
- multiple staff members
|
||||||
|
- services
|
||||||
|
- class sessions
|
||||||
|
- business hours and exceptions
|
||||||
|
- buffer times
|
||||||
|
- blackout periods
|
||||||
|
- reminder settings
|
||||||
|
- branding basics
|
||||||
|
- dashboard metrics
|
||||||
|
|
||||||
|
Owner dashboard should show:
|
||||||
|
- bookings this week
|
||||||
|
- cancellations
|
||||||
|
- utilization
|
||||||
|
- simple revenue forecast based on scheduled services
|
||||||
|
|
||||||
|
## Core Scheduling Rules
|
||||||
|
The booking engine must support:
|
||||||
|
- multi-tenant isolation
|
||||||
|
- staff and location assignment
|
||||||
|
- conflict detection
|
||||||
|
- buffer times
|
||||||
|
- timezone-safe date handling
|
||||||
|
- waitlists
|
||||||
|
- class capacity limits
|
||||||
|
- exception-based availability rules
|
||||||
|
|
||||||
|
Appointments and classes should share the same scheduling foundation where possible.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
v1 reminder strategy:
|
||||||
|
- email is first-class
|
||||||
|
- SMS is optional and treated as a paid add-on
|
||||||
|
- SMS must be abstracted behind a provider interface
|
||||||
|
- Czech-first provider support is preferred, with room for generic fallback providers later
|
||||||
|
|
||||||
|
## Billing
|
||||||
|
Bookra uses Stripe for B2B SaaS subscription billing.
|
||||||
|
|
||||||
|
Stripe scope in v1:
|
||||||
|
- tenant subscription plans
|
||||||
|
- plan upgrades/downgrades
|
||||||
|
- add-ons such as SMS or expanded limits
|
||||||
|
- webhook-based subscription state sync
|
||||||
|
|
||||||
|
Stripe is not used for end-customer booking checkout in v1.
|
||||||
|
|
||||||
|
## Remote-First Architecture
|
||||||
|
Bookra follows the `tdvorak-fullstack` profile with remote deployment overrides.
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
- Frontend: SolidJS + Vite + TypeScript + Tailwind
|
||||||
|
- Backend: Go + Gin
|
||||||
|
- Database: Neon Postgres + sqlc + goose
|
||||||
|
- Auth: Neon Auth
|
||||||
|
- API contract: OpenAPI as source of truth
|
||||||
|
- Frontend deployment: Vercel
|
||||||
|
- Backend deployment: Railway
|
||||||
|
- Local development: direct app processes against remote services
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
- modular monolith first
|
||||||
|
- monorepo layout
|
||||||
|
- generated TypeScript API client from OpenAPI
|
||||||
|
- strong tenant boundaries
|
||||||
|
- background jobs for reminders and waitlist handling
|
||||||
|
- no Docker Compose and no self-hosted infra path in v1
|
||||||
|
|
||||||
|
Suggested layout:
|
||||||
|
- `/apps/frontend`
|
||||||
|
- `/apps/backend`
|
||||||
|
- `/packages/api-client`
|
||||||
|
- `/packages/shared-types`
|
||||||
|
|
||||||
|
## Auth And Identity
|
||||||
|
- Neon Auth handles sign-up, sign-in, sessions, and browser auth state
|
||||||
|
- frontend owns browser auth flows
|
||||||
|
- Go backend verifies Neon Auth JWTs through JWKS
|
||||||
|
- backend treats Neon `sub` as the authenticated principal
|
||||||
|
- backend now syncs `neon_subject` into app-side `users` records on authenticated requests
|
||||||
|
- JWT usage is isolated to frontend-to-backend API calls across domains
|
||||||
|
- Neon-specific auth code must stay behind replaceable adapters because Neon Auth is beta
|
||||||
|
|
||||||
|
## Database And Environments
|
||||||
|
- Neon is the only database platform
|
||||||
|
- pooled connections for application traffic
|
||||||
|
- direct connections for migrations and admin tasks
|
||||||
|
- staging and production only in v1
|
||||||
|
- no per-PR backend preview environments in v1
|
||||||
|
|
||||||
|
## Main Domain Model
|
||||||
|
Key entities:
|
||||||
|
- tenants
|
||||||
|
- tenant users
|
||||||
|
- customer accounts
|
||||||
|
- locations
|
||||||
|
- staff
|
||||||
|
- services
|
||||||
|
- class templates
|
||||||
|
- class sessions
|
||||||
|
- availability rules
|
||||||
|
- availability exceptions
|
||||||
|
- bookings
|
||||||
|
- waitlist entries
|
||||||
|
- reminder jobs
|
||||||
|
- subscription accounts
|
||||||
|
- subscription events
|
||||||
|
|
||||||
|
## v1 Delivery Phases
|
||||||
|
### Phase 1
|
||||||
|
- monorepo setup
|
||||||
|
- Neon Auth integration shell
|
||||||
|
- tenant foundation
|
||||||
|
- locations, staff, services
|
||||||
|
- i18n foundation
|
||||||
|
- OpenAPI baseline
|
||||||
|
- status: completed
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
- appointment booking flow
|
||||||
|
- availability rules
|
||||||
|
- conflict detection
|
||||||
|
- cancellation and rescheduling
|
||||||
|
- timezone-safe scheduling
|
||||||
|
- status: in progress
|
||||||
|
- current implementation:
|
||||||
|
- public availability endpoint returns generated appointment and class slots
|
||||||
|
- public booking creation validates tenant existence, timestamps, conflicts, and class capacity
|
||||||
|
- frontend public route can trigger booking creation from live slots
|
||||||
|
- repository pattern supports Neon-backed persistence with memory fallback
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
- class sessions
|
||||||
|
- capacity limits
|
||||||
|
- waitlists
|
||||||
|
- email reminders
|
||||||
|
- dashboard metrics
|
||||||
|
- status: materially implemented
|
||||||
|
- current implementation:
|
||||||
|
- class sessions appear in public availability
|
||||||
|
- class capacity and waitlist status are enforced at booking time
|
||||||
|
- dashboard metrics endpoint is repository-backed but still minimal in scope
|
||||||
|
- booking creation now schedules reminder jobs
|
||||||
|
- due reminder jobs can now be dispatched through the notifications service
|
||||||
|
- delivery logging now exists for reminder execution
|
||||||
|
|
||||||
|
### Phase 4
|
||||||
|
- Stripe subscriptions
|
||||||
|
- SMS add-on support
|
||||||
|
- plan enforcement
|
||||||
|
- hardening, logging, and operational tooling
|
||||||
|
- status: in progress
|
||||||
|
- current implementation:
|
||||||
|
- billing snapshot persistence added
|
||||||
|
- Stripe checkout route added
|
||||||
|
- Stripe webhook route added
|
||||||
|
- subscription refresh route added
|
||||||
|
- entitlement mapping is now available to the frontend
|
||||||
|
- API edge now includes security headers and public route rate limiting
|
||||||
|
- remote job-runner endpoint added for reminder execution
|
||||||
|
- sqlc and goose commands are now wired into repeatable npm scripts
|
||||||
|
|
||||||
|
## Current Implementation Notes
|
||||||
|
- Backend is no longer static-demo-only for booking flows.
|
||||||
|
- The repository layer supports:
|
||||||
|
- Neon-backed pgx access when `BOOKRA_DATABASE_URL` is configured
|
||||||
|
- deterministic in-memory fallback when Neon is not configured
|
||||||
|
- OpenAPI remains the frontend/backend contract source.
|
||||||
|
- Frontend public booking flow is now interactive, not just presentational.
|
||||||
|
- Route structure is now explicit:
|
||||||
|
- `/` is the primary marketing landing page
|
||||||
|
- `/dashboard` is the main application entry
|
||||||
|
- `/book/:tenantSlug` stays public and customer-facing
|
||||||
|
- Neon Auth remains intentionally isolated behind adapters because the managed product is still beta.
|
||||||
|
- Billing now follows a backend-owned snapshot model rather than trusting incremental webhook payloads.
|
||||||
|
- Reminder delivery now has a working runtime path instead of just a queued schema.
|
||||||
|
- sqlc generation and goose migration execution are now runnable through workspace scripts.
|
||||||
|
- Protected backend requests now perform automatic app-side identity sync before tenant membership resolution.
|
||||||
|
- `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users.
|
||||||
|
|
||||||
|
## Current Gaps
|
||||||
|
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
|
||||||
|
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
|
||||||
|
- First-time onboarding exists, but there are still no CRUD screens for locations, staff, services, or schedules after workspace creation.
|
||||||
|
- Stripe lifecycle sync is implemented structurally, but still needs live environment validation against real products, price IDs, and webhook signatures.
|
||||||
|
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
|
||||||
|
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
|
||||||
|
|
||||||
|
## Next Build Targets
|
||||||
|
- replace preview-mode dashboard hydration with fully validated Neon Auth JWT flow
|
||||||
|
- sync Neon Auth identities into tenant membership records and onboarding bootstrap
|
||||||
|
- validate Stripe checkout/webhooks and reminder dispatch against live staging infrastructure
|
||||||
|
- replace noop notification providers with the first production email transport and a gated SMS transport
|
||||||
|
- add real app management screens for services, staff, locations, and availability after onboarding
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
- calm, premium, structured UX
|
||||||
|
- production-grade architecture
|
||||||
|
- no generic SaaS clutter
|
||||||
|
- deterministic scheduling behavior
|
||||||
|
- minimal operational complexity in v1
|
||||||
|
- build for maintainability, not feature sprawl
|
||||||
|
|
||||||
|
## Success Criteria For v1
|
||||||
|
Bookra is successful when a small business can:
|
||||||
|
- create its business profile
|
||||||
|
- configure staff, locations, and services
|
||||||
|
- publish a booking page
|
||||||
|
- accept bookings without double-booking
|
||||||
|
- send reminders reliably
|
||||||
|
- manage cancellations and waitlists
|
||||||
|
- view simple business health metrics
|
||||||
|
- subscribe to a paid plan through Stripe
|
||||||
|
|
||||||
|
## Final Positioning
|
||||||
|
Bookra is not an ERP, not a medical system, and not a marketplace.
|
||||||
|
|
||||||
|
It is a focused booking operating layer for small local service businesses that need:
|
||||||
|
- simple setup
|
||||||
|
- reliable scheduling
|
||||||
|
- clear reminders
|
||||||
|
- lightweight reporting
|
||||||
|
- clean SaaS billing
|
||||||
Reference in New Issue
Block a user