diff --git a/.env.example b/.env.example index cf8e312..64d04a5 100644 --- a/.env.example +++ b/.env.example @@ -2,33 +2,31 @@ # 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://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth +BOOKRA_FRONTEND_URL=https://app.bookra.example +BOOKRA_NEON_AUTH_URL=https://ep-example.neonauth.region.aws.neon.tech/neondb/auth -# Stripe billing (preferred) -BOOKRA_STRIPE_SECRET_KEY=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 +# Stripe billing (the only billing provider) +BOOKRA_STRIPE_API_KEY=sk_test_xxx BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx # Stripe Monthly Prices -BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI -BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk -BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD -BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n -BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E -BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi +BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_xxx -# Stripe Yearly Prices (17% discount) -BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_1TVAlqGrjyNQaOSGNiZQ5tEx -BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_1TVAnSGrjyNQaOSGTHQHrgv3 -BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_1TVAmVGrjyNQaOSGWDgWqYvb -BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k -BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd -BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua +# Stripe Yearly Prices +BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_xxx -# Stripe SMS prices — one per market/currency (create as standard prices, not metered) -# These are used for monthly aggregate billing (invoice item at month end) -# Czech: 1.50 CZK per SMS | US: ~$0.065 per SMS | EUR: ~€0.060 | GBP: ~£0.050 | PLN: ~0.27zł +# Stripe SMS prices — one per currency (standard prices, not metered) BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_xxx BOOKRA_STRIPE_SMS_EUR_PRICE_ID= @@ -39,35 +37,6 @@ BOOKRA_STRIPE_SMS_PLN_PRICE_ID= BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key BOOKRA_SMSMANAGER_BASE_URL=https://api.smsmngr.com/v2 -# Legacy price IDs (fallback) -BOOKRA_STRIPE_STARTER_CZK_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI -BOOKRA_STRIPE_STARTER_USD_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk -BOOKRA_STRIPE_PRO_CZK_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD -BOOKRA_STRIPE_PRO_USD_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n -BOOKRA_STRIPE_BUSINESS_CZK_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E -BOOKRA_STRIPE_BUSINESS_USD_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi - -VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51TV6GOGrjyNQaOSGddY1BS14H63e1uYgZUgPe25WhP7yMxmZxMprN3U3scnsmKGBmREzHLrhJlVSEiErimNwVtuR00TRAcHaJi - # Admin credentials (for platform management) BOOKRA_ADMIN_EMAIL=admin@example.com -BOOKRA_ADMIN_KEY=your-secure-admin-key-here - -# Paddle billing (fallback) -BOOKRA_PADDLE_ENV=sandbox -BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key -BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret -BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=pri_starter_czk_123 -BOOKRA_PADDLE_STARTER_USD_PRICE_ID=pri_starter_usd_123 -BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123 -BOOKRA_PADDLE_PRO_USD_PRICE_ID=pri_pro_usd_123 -BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=pri_business_czk_123 -BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=pri_business_usd_123 -VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token - -BOOKRA_SMTP_HOST=smtp.example.com -BOOKRA_UMAMI_API_URL=https://umami.example.com - -# Sentry (optional) -VITE_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352 -BOOKRA_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352 +BOOKRA_ADMIN_KEY=replace-with-secure-random-key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d406f3..d94d039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,6 @@ jobs: matrix: app: - apps/backend - - apps/auth-service defaults: run: working-directory: ${{ matrix.app }} @@ -83,8 +82,6 @@ jobs: service: - name: backend context: apps/backend - - name: auth-service - context: apps/auth-service steps: - name: Checkout uses: actions/checkout@v5 diff --git a/.gitignore b/.gitignore index 67a12dd..6080f85 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,11 @@ playwright/.cache/ # Desloppify desloppify-out/ .desloppify/ -.opencode \ No newline at end of file +.opencode +# Local deploy env files (contain live secrets — never commit) +*.env +!*.env.example + +# Analysis output +graphify-out/ +output/ diff --git a/AGENTS.md b/AGENTS.md index 3ca1196..26a3b2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ## Database - Uses PostgreSQL via Neon (pooled URL for app, direct URL for migrations) - Migrations with Goose: `npm run db:migrate:up` -- SQLC for typed queries: `npm run db:generate` +- Handwritten pgx repositories (sqlc tooling removed); migrations: `npm run db:migrate:up` ## SMS Feature diff --git a/FRONTEND_ISSUES.md b/FRONTEND_ISSUES.md index c5c6ee1..5341793 100644 --- a/FRONTEND_ISSUES.md +++ b/FRONTEND_ISSUES.md @@ -27,3 +27,15 @@ - Many dashboard inline i18n ternaries remain (~280). Systematic extraction to `i18n.t()` keys is an ongoing task. - Registration cannot be fully customer-tested locally until Neon Auth environment variables are configured. + +## Fixed This Session (platform audit pass) + +- Billing checkout sends `planCode`/`currency`/`billingInterval` and reads `checkoutUrl` (was `{interval}`/`url` — broken end-to-end). Plan selector added to the billing page. +- Billing page now reads normalized backend fields (`status`, `planCode`, `trialDays`, `maxBookingsMonth`). +- Zones page rewired to real catalog APIs: locations CRUD, blocked days CRUD, working-hours persistence — all three tabs now functional. +- Customers page loads `GET /v1/catalog/customers` instead of hardcoded mock rows (demo mode keeps sample data). +- Settings email-template editor now loads saved templates via `GET /v1/tenants/email-templates` and saves with `isEnabled`. +- Dead code removed: `dashboard-route.tsx`, `dashboard-route.tsx.backup`, `lib/paddle.ts`, `lib/stripe.ts`, `@paddle/paddle-js`, `@stripe/stripe-js`. +- Demo bootstrap slug aligned with backend demo tenant (`studio-atelier`). +- Paddle mentions replaced by Stripe in legal + i18n copy. +- `@neondatabase/neon-js` bumped to 0.7.0-beta — `npm audit` now reports 0 vulnerabilities. diff --git a/README.md b/README.md index 2b32121..da023d6 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,17 @@ # Bookra -Remote-first booking SaaS scaffold aligned to the `tdvorak-fullstack` profile with these overrides: +Remote-first booking SaaS aligned to the `tdvorak-fullstack` profile with these overrides: - frontend on Vercel - backend on Railway - Neon Postgres + Neon Auth -- Paddle billing +- Stripe billing (checkout, customer portal, webhooks, SMS usage invoices) - no Docker-based local runtime ## Workspace - `apps/frontend` SolidJS frontend - `apps/backend` Go API -- `apps/auth-service` auth + internal admin service - `packages/api-client` generated TypeScript client/types from OpenAPI - `packages/shared-types` shared frontend constants and helpers @@ -36,14 +35,17 @@ Both apps expect remote services: - Neon Postgres - Neon Auth -- Paddle - -Optional extra service: - -- `apps/auth-service` for standalone auth/admin workflows +- Stripe See `.env.example`, `apps/frontend/.env.example`, and `apps/backend/.env.example`. +### Demo mode + +Set `BOOKRA_DEMO_MODE=true` to run the backend with an in-memory repository and +pre-populated demo data — no database, Neon Auth, or Stripe required. All +requests are authenticated as the `demo-owner` principal. Pair with +`VITE_BOOKRA_DEMO_MODE=true` on the frontend. + ## CI/CD - GitHub Actions CI lives in [`.github/workflows/ci.yml`](/home/tdvorak/Desktop/PROG+HTML/Bookra/.github/workflows/ci.yml:1) @@ -52,13 +54,27 @@ 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. +The API client types are regenerated from the OpenAPI spec: + +```bash +npm run generate:api-client +``` + +## Verification + +```bash +npm run verify +``` + +Runs API-client generation, frontend typecheck, backend tests, and both builds. +Run this before pushing. + ## Brand Colors Bookra uses a sophisticated color system designed for modern booking interfaces with support for both light and dark themes. @@ -90,7 +106,7 @@ Bookra uses a sophisticated color system designed for modern booking interfaces ### Shadow System - **Shadow XS** (`--shadow-xs`): 0 1px 2px 0 rgba(0, 0, 0, 0.05) -- **Shadow SM** (`--shadow-sm`): 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) +- **Shadow SM** (`--shadow-sm`): 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.06) - **Shadow MD** (`--shadow-md`): 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) - **Shadow LG** (`--shadow-lg`): 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05) - **Shadow XL** (`--shadow-xl`): 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d86842c..61af88e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -5,7 +5,7 @@ # - frontend signs users in with Neon Auth # - backend verifies Neon Auth JWTs # - backend serves booking + billing APIs -# - Paddle handles SaaS billing + customer portal +# - Stripe handles SaaS billing + customer portal # ============================================ BOOKRA_APP_ENV=staging @@ -37,33 +37,47 @@ BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.t # AUTHENTICATION (Required when DEMO_MODE=false) # -------------------------------------------- # Neon Auth base URL for JWKS verification. -BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth -# Optional emergency/local fallback for non-Neon JWT verification. -# Leave blank in normal Neon Auth setup. +BOOKRA_NEON_AUTH_URL=https://ep-example.neonauth.region.aws.neon.tech/neondb/auth +# Local JWT fallback secret for the password-auth endpoints (/v1/auth/*). +# Required when DEMO_MODE=false if you use local auth. BOOKRA_AUTH_JWT_SECRET= # -------------------------------------------- # INTERNAL SERVICES # -------------------------------------------- # Job runner key for internal API endpoints (reminders, notifications) -BOOKRA_JOB_RUNNER_KEY=job_runner_secret_123 +BOOKRA_JOB_RUNNER_KEY=replace-with-secure-random-key # -------------------------------------------- -# PADDLE BILLING (Required for live checkout/webhooks) +# STRIPE BILLING (Required for live checkout/webhooks) # -------------------------------------------- -# BOOKRA_PADDLE_ENV must be "sandbox" or "live". -# API key: Paddle dashboard -> Developer tools -> Authentication. -# Webhook secret: Paddle dashboard -> Notification settings -> destination secret key. -# Price IDs: Paddle dashboard -> Catalog -> Product price IDs. -BOOKRA_PADDLE_ENV=sandbox -BOOKRA_PADDLE_API_KEY= -BOOKRA_PADDLE_WEBHOOK_SECRET= -BOOKRA_PADDLE_STARTER_CZK_PRICE_ID= -BOOKRA_PADDLE_STARTER_USD_PRICE_ID= -BOOKRA_PADDLE_PRO_CZK_PRICE_ID= -BOOKRA_PADDLE_PRO_USD_PRICE_ID= -BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID= -BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID= +# Secret API key + webhook signing secret from the Stripe dashboard. +# Price IDs: Stripe dashboard -> Product catalog -> Price IDs. +BOOKRA_STRIPE_API_KEY=sk_test_xxx +BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx + +# Monthly subscription price IDs +BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_xxx + +# Yearly subscription price IDs +BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_xxx +BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_xxx + +# SMS usage prices (standard prices — usage is aggregated monthly into invoices) +BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx +BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_xxx +BOOKRA_STRIPE_SMS_EUR_PRICE_ID= +BOOKRA_STRIPE_SMS_GBP_PRICE_ID= +BOOKRA_STRIPE_SMS_PLN_PRICE_ID= # -------------------------------------------- # EMAIL (Optional) @@ -75,8 +89,25 @@ BOOKRA_SMTP_PORT=587 BOOKRA_SMTP_USERNAME= BOOKRA_SMTP_PASSWORD= +# -------------------------------------------- +# SMS Manager.cz (Optional — SMS add-on) +# -------------------------------------------- +BOOKRA_SMSMANAGER_API_KEY= +BOOKRA_SMSMANAGER_BASE_URL=https://api.smsmngr.com/v2 + # -------------------------------------------- # ANALYTICS (Optional) # -------------------------------------------- BOOKRA_UMAMI_API_URL= BOOKRA_UMAMI_API_KEY= + +# -------------------------------------------- +# MONITORING (Optional) +# -------------------------------------------- +BOOKRA_SENTRY_DSN= + +# -------------------------------------------- +# ADMIN (Optional — platform admin API) +# -------------------------------------------- +BOOKRA_ADMIN_EMAIL= +BOOKRA_ADMIN_KEY= diff --git a/apps/backend/README.md b/apps/backend/README.md index a5de229..470fe0b 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,13 +1,13 @@ # Bookra Backend -Go + Gin API for Bookra, designed for Railway deployment with Neon Auth, Neon Postgres, and Paddle billing. +Go + Gin API for Bookra, designed for Railway deployment with Neon Auth, Neon Postgres, and Stripe billing. ## Commands ```bash go run ./cmd/api go build ./... -npm run db:generate +go test ./... npm run db:migrate:status npm run db:migrate:up ``` @@ -18,57 +18,64 @@ npm run db:migrate:up - `BOOKRA_DATABASE_URL` Neon pooled connection - `BOOKRA_DATABASE_DIRECT_URL` Neon direct connection for migrations/admin tasks - `BOOKRA_NEON_AUTH_URL` Neon Auth base URL used for JWKS verification -- `BOOKRA_AUTH_JWT_SECRET` optional local JWT fallback when not using Neon Auth -- `BOOKRA_JOB_RUNNER_KEY` shared secret for remote reminder dispatch calls +- `BOOKRA_AUTH_JWT_SECRET` local JWT secret for the password-auth endpoints +- `BOOKRA_JOB_RUNNER_KEY` shared secret for remote job-runner calls - `BOOKRA_EMAIL_FROM` sender identity for email reminders -- `BOOKRA_PADDLE_ENV` billing environment: `sandbox` or `live` -- `BOOKRA_PADDLE_API_KEY` Paddle API key -- `BOOKRA_PADDLE_WEBHOOK_SECRET` Paddle notification destination secret -- `BOOKRA_PADDLE_{STARTER,PRO,BUSINESS}_{CZK,USD}_PRICE_ID` Paddle price IDs +- `BOOKRA_SMTP_*` optional SMTP transport for email delivery +- `BOOKRA_STRIPE_API_KEY` Stripe secret API key +- `BOOKRA_STRIPE_WEBHOOK_SECRET` Stripe webhook signing secret +- `BOOKRA_STRIPE_{STARTER,PRO,BUSINESS}_{CZK,USD}_{MONTHLY,YEARLY}_PRICE_ID` Stripe price IDs +- `BOOKRA_STRIPE_SMS_{CZK,USD,...}_PRICE_ID` Stripe SMS usage price IDs +- `BOOKRA_SMSMANAGER_API_KEY` optional SMS Manager.cz key - `BOOKRA_UMAMI_API_URL` and `BOOKRA_UMAMI_API_KEY` optional analytics integration +- `BOOKRA_ADMIN_EMAIL` and `BOOKRA_ADMIN_KEY` optional platform admin API credentials +- `BOOKRA_SENTRY_DSN` optional error monitoring + +See `.env.example` for the full list. ## 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`. +- SQL migrations live in `migrations/` (goose). +- Repositories are handwritten pgx in `internal/db/`; there is no generated query layer. - 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`. +- Reminder dispatch runs through `POST /v1/internal/jobs/reminders/dispatch` with `X-Bookra-Job-Key`. +- `BOOKRA_DEMO_MODE=true` swaps the repository for deterministic in-memory demo data and authenticates all requests as `demo-owner`. ## Production Auth -Bookra production auth should use Neon Auth directly: +Bookra production auth uses Neon Auth directly: - frontend uses `VITE_NEON_AUTH_URL` - backend verifies Neon JWTs with `BOOKRA_NEON_AUTH_URL` -- auth-service may stay deployed for standalone auth/admin workflows, but backend billing and app APIs do not depend on it Trusted redirect domains in Neon Auth should include your frontend origin such as `https://bookra.eu`, plus local dev origins when needed. -## Paddle Setup +## Stripe Setup -Get these values from Paddle dashboard: +Get these values from the Stripe dashboard: -- `BOOKRA_PADDLE_ENV`: `sandbox` for testing, `live` for production -- `BOOKRA_PADDLE_API_KEY`: Developer tools -> Authentication -- `BOOKRA_PADDLE_WEBHOOK_SECRET`: Notification settings -> destination secret key -- `BOOKRA_PADDLE_*_PRICE_ID`: Catalog -> each SaaS plan recurring price ID +- `BOOKRA_STRIPE_API_KEY`: Developers -> API keys -> secret key +- `BOOKRA_STRIPE_WEBHOOK_SECRET`: Developers -> Webhooks -> destination signing secret +- `BOOKRA_STRIPE_*_PRICE_ID`: Product catalog -> recurring price IDs -Create one recurring price per plan/currency you support: +Create one recurring price per plan/currency/interval you support: -- `starter` `czk` -- `starter` `usd` -- `pro` `czk` -- `pro` `usd` -- `business` `czk` -- `business` `usd` +- `starter` / `pro` / `business` × `czk` / `usd` × `monthly` / `yearly` -Set your webhook destination to: +Set the webhook destination to: ```text -POST /v1/webhooks/paddle -POST /api/paddle_webhook +POST /v1/webhooks/stripe ``` -Use Paddle webhook simulator for event testing. +Subscribe to at least: `checkout.session.completed`, `customer.subscription.created`, +`customer.subscription.updated`, `customer.subscription.deleted`, +`invoice.payment_succeeded`, `invoice.payment_failed`. + +For local testing: + +```bash +stripe listen --forward-to http://localhost:8080/v1/webhooks/stripe +``` diff --git a/apps/backend/cmd/api/main.go b/apps/backend/cmd/api/main.go index d0e449e..a921b3c 100644 --- a/apps/backend/cmd/api/main.go +++ b/apps/backend/cmd/api/main.go @@ -25,8 +25,8 @@ func initSentry(cfg config.Config) { Dsn: cfg.SentryDSN, Environment: cfg.Environment, Release: "bookra@1.0.0", - // Set TracesSampleRate to 1.0 to capture 100% of transactions for testing - TracesSampleRate: 1.0, + // 10% trace sampling keeps production volumes sane. + TracesSampleRate: 0.1, }) if err != nil { log.Fatalf("Sentry initialization failed: %v", err) diff --git a/apps/backend/go.mod b/apps/backend/go.mod index b7fdbc1..51210da 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -4,13 +4,14 @@ go 1.26.2 require ( github.com/MicahParks/keyfunc/v3 v3.8.0 - github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0 + github.com/getsentry/sentry-go v0.46.2 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/v81 v81.0.0 + golang.org/x/crypto v0.48.0 golang.org/x/time v0.9.0 ) @@ -21,16 +22,12 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/getsentry/sentry-go v0.46.2 // indirect - github.com/ggicci/httpin v0.20.3 // indirect - github.com/ggicci/owl v0.8.2 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -47,7 +44,6 @@ require ( 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 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 0a48db5..229defa 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -2,8 +2,6 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds= github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= -github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0 h1:HJabVGWEsDyogj1Ib/ZGcMcP9Vc/e2tqIDYx0xZA+qI= -github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0/go.mod h1:nlFfZYf7MovyHTE67+u7BARbMiBdMMLXGuQMbCVm9ss= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -19,16 +17,14 @@ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCK github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= -github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY= -github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA= -github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA= -github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4= github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 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-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 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= @@ -48,8 +44,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -60,8 +54,6 @@ 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/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= -github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= 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= @@ -75,6 +67,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G 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/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= @@ -101,6 +97,8 @@ 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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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= diff --git a/apps/backend/internal/admin/service.go b/apps/backend/internal/admin/service.go index 4789d42..3617fb7 100644 --- a/apps/backend/internal/admin/service.go +++ b/apps/backend/internal/admin/service.go @@ -6,8 +6,8 @@ import ( "errors" "net/http" "strings" - "time" + "bookra/apps/backend/internal/auth" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" @@ -21,9 +21,9 @@ var ( ) type Service struct { - repo db.Repository - adminEmail string - adminKey string + repo db.Repository + adminEmail string + adminKey string } func NewService(repo db.Repository, adminEmail, adminKey string) *Service { @@ -52,20 +52,39 @@ func (s *Service) ValidateAdminLogin(email, key string) bool { return emailMatch && keyMatch } -// RequireAdmin is middleware that checks for admin authentication -// It supports two modes: -// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access) -// 2. Session-based auth where the user has role "admin" or "superadmin" -func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc { +const adminActorKey = "adminActor" + +// ActorFromContext returns the admin identity recorded by RequireAdmin. +func ActorFromContext(c *gin.Context) string { + value, ok := c.Get(adminActorKey) + if !ok { + return "" + } + actor, _ := value.(string) + return actor +} + +// RequireAdmin is middleware that checks for admin authentication. +// It supports three modes: +// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access) +// 2. Bearer tokens verified against the auth verifier, where the token subject +// maps to a user with role "admin" or "superadmin" +// 3. Demo mode, where the demo owner is treated as admin +func RequireAdmin(adminSvc *Service, verifier *auth.Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc { return func(c *gin.Context) { + if demoMode { + c.Set(adminActorKey, auth.DemoPrincipal.Subject) + c.Next() + return + } + // Check for admin header credentials (direct admin login) adminEmail := c.GetHeader("X-Admin-Email") adminKey := c.GetHeader("X-Admin-Key") - if adminEmail != "" && adminKey != "" { + if adminEmail != "" || adminKey != "" { if adminSvc.ValidateAdminLogin(adminEmail, adminKey) { - c.Set("isAdmin", true) - c.Set("adminMode", "credentials") + c.Set(adminActorKey, adminEmail) c.Next() return } @@ -74,31 +93,39 @@ func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Cont } // Check for Bearer token with admin role - auth := c.GetHeader("Authorization") - if auth != "" && strings.HasPrefix(auth, "Bearer ") { - // The auth middleware should have already validated the token - // and set the user info in context - userID, exists := c.Get("userID") - if !exists { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string)) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"}) - return - } - - if isAdmin { - c.Set("isAdmin", true) - c.Set("adminMode", "session") - c.Next() - return - } + tokenString, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") + if !ok || strings.TrimSpace(tokenString) == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) + return + } + if verifier == nil || !verifier.Enabled() { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"}) + return } - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) + claims, err := verifier.Verify(strings.TrimSpace(tokenString)) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"}) + return + } + subject, _ := claims["sub"].(string) + if strings.TrimSpace(subject) == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"}) + return + } + + isAdmin, err := repo.IsAdminSubject(c.Request.Context(), subject) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"}) + return + } + if !isAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) + return + } + + c.Set(adminActorKey, subject) + c.Next() } } @@ -110,11 +137,11 @@ func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardS } return domain.AdminDashboardStats{ - TotalTenants: stats.TotalTenants, - TotalUsers: stats.TotalUsers, - ActiveSubscriptions: stats.ActiveSubscriptions, - TrialSubscriptions: stats.TrialSubscriptions, - BookingsThisMonth: stats.BookingsThisMonth, + TotalTenants: stats.TotalTenants, + TotalUsers: stats.TotalUsers, + ActiveSubscriptions: stats.ActiveSubscriptions, + TrialSubscriptions: stats.TrialSubscriptions, + BookingsThisMonth: stats.BookingsThisMonth, RevenueThisMonthCents: stats.RevenueThisMonth, }, nil } @@ -144,12 +171,12 @@ func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.A for i, t := range tenants { result.Tenants[i] = domain.AdminTenant{ - ID: t.ID, - Slug: t.Slug, - Name: t.Name, - PlanCode: t.PlanCode, + ID: t.ID, + Slug: t.Slug, + Name: t.Name, + PlanCode: t.PlanCode, SubscriptionStatus: t.SubscriptionStatus, - BillingProvider: t.BillingProvider, + BillingProvider: t.BillingProvider, } } @@ -198,8 +225,8 @@ func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.Adm func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error { // Validate role validRoles := map[string]bool{ - "user": true, - "admin": true, + "user": true, + "admin": true, "superadmin": true, } if !validRoles[newRole] { @@ -224,20 +251,9 @@ func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, }) } -// SyncTenantSubscription manually syncs a tenant's subscription from Stripe -func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error { - // This will be called from the billing service - return nil -} - func stringPtrToStr(s *string) string { if s == nil { return "" } return *s } - -func init() { - // Ensure time package is imported - _ = time.Now() -} diff --git a/apps/backend/internal/admin/service_test.go b/apps/backend/internal/admin/service_test.go new file mode 100644 index 0000000..ded6391 --- /dev/null +++ b/apps/backend/internal/admin/service_test.go @@ -0,0 +1,117 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "testing" + + "bookra/apps/backend/internal/auth" + "bookra/apps/backend/internal/db" + + "github.com/gin-gonic/gin" +) + +func newTestRouter(t *testing.T, adminSvc *Service, verifier *auth.Verifier, repo db.Repository, demoMode bool) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(RequireAdmin(adminSvc, verifier, repo, demoMode)) + r.GET("/v1/admin/stats", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"actor": ActorFromContext(c)}) + }) + return r +} + +func request(t *testing.T, r *gin.Engine, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/admin/stats", nil) + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestRequireAdminRejectsUnauthenticated(t *testing.T) { + repo := db.NewMemoryRepository() + svc := NewService(repo, "admin@example.com", "secret-key") + verifier, _ := auth.NewVerifier("", "jwt-secret") + defer verifier.Close() + r := newTestRouter(t, svc, verifier, repo, false) + + if w := request(t, r, nil); w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestRequireAdminRejectsInvalidHeaderCreds(t *testing.T) { + repo := db.NewMemoryRepository() + svc := NewService(repo, "admin@example.com", "secret-key") + verifier, _ := auth.NewVerifier("", "jwt-secret") + defer verifier.Close() + r := newTestRouter(t, svc, verifier, repo, false) + + w := request(t, r, map[string]string{ + "X-Admin-Email": "admin@example.com", + "X-Admin-Key": "wrong-key", + }) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestRequireAdminAcceptsHeaderCreds(t *testing.T) { + repo := db.NewMemoryRepository() + svc := NewService(repo, "admin@example.com", "secret-key") + verifier, _ := auth.NewVerifier("", "jwt-secret") + defer verifier.Close() + r := newTestRouter(t, svc, verifier, repo, false) + + w := request(t, r, map[string]string{ + "X-Admin-Email": "admin@example.com", + "X-Admin-Key": "secret-key", + }) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestRequireAdminVerifiesBearerTokenAndRole(t *testing.T) { + repo := db.NewMemoryRepository() + secret := "jwt-secret" + svc := NewService(repo, "admin@example.com", "secret-key") + verifier, _ := auth.NewVerifier("", secret) + defer verifier.Close() + r := newTestRouter(t, svc, verifier, repo, false) + + // A locally issued token for a non-admin subject must be rejected even though + // the token itself is valid. + authSvc := auth.NewService(repo, secret) + _, tokens, err := authSvc.RegisterWithPassword(t.Context(), "user@example.com", "password123", "Regular User") + if err != nil { + t.Fatalf("register: %v", err) + } + w := request(t, r, map[string]string{"Authorization": "Bearer " + tokens.AccessToken}) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin subject, got %d", w.Code) + } + + // A forged bearer string must be rejected outright. + w = request(t, r, map[string]string{"Authorization": "Bearer not-a-token"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for invalid token, got %d", w.Code) + } +} + +func TestRequireAdminDemoMode(t *testing.T) { + repo := db.NewMemoryRepository() + svc := NewService(repo, "", "") + verifier, _ := auth.NewVerifier("", "jwt-secret") + defer verifier.Close() + r := newTestRouter(t, svc, verifier, repo, true) + + if w := request(t, r, nil); w.Code != http.StatusOK { + t.Fatalf("expected 200 in demo mode, got %d", w.Code) + } +} diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index eedd199..9a4fbc4 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -49,8 +49,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { repository := db.NewRepository(pools, cfg.DemoMode) notificationService := notifications.NewService(cfg, repository) - bookingService := bookings.NewService(repository, notificationService) - customerBookingService := bookings.NewCustomerService(repository, notificationService) + bookingService := bookings.NewService(repository, notificationService, cfg.FrontendURL) + customerBookingService := bookings.NewCustomerService(repository, notificationService, cfg.FrontendURL) tenantService := tenancy.NewService(repository) billingService := billing.NewService(cfg, repository) catalogService := catalog.NewService(repository, billingService, notificationService) @@ -73,9 +73,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{ AllowOrigins: allowedOrigins(cfg), - AllowHeaders: []string{"Authorization", "Content-Type"}, - AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{"Authorization", "Content-Type", "X-Admin-Email", "X-Admin-Key"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowCredentials: true, + MaxAge: 12 * time.Hour, }), httpx.SecurityHeaders()) server.router.GET("/healthz", func(c *gin.Context) { @@ -86,24 +87,24 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { }) }) - // Test endpoint for Sentry - server.router.GET("/debug/sentry", func(c *gin.Context) { - sentry.CaptureMessage("Test message from Bookra API") - c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"}) - }) - - // Test endpoint for billing - server.router.GET("/debug/billing-test", func(c *gin.Context) { - if billingService != nil { - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "billingService": "initialized", - "message": "Billing service is working", - }) - } else { - c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"}) - } - }) + if cfg.Environment == "development" || cfg.Environment == "test" { + // Test endpoints, only exposed outside production + server.router.GET("/debug/sentry", func(c *gin.Context) { + sentry.CaptureMessage("Test message from Bookra API") + c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"}) + }) + server.router.GET("/debug/billing-test", func(c *gin.Context) { + if billingService != nil { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "billingService": "initialized", + "message": "Billing service is working", + }) + } else { + c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"}) + } + }) + } server.router.GET("/v1/meta/config", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -119,6 +120,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { // AUTH API // ============================================ authGroup := server.router.Group("/v1/auth") + authGroup.Use(publicRateLimiter.Middleware()) { authGroup.POST("/register", func(c *gin.Context) { var request struct { @@ -255,7 +257,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { // ADMIN API // ============================================ adminGroup := server.router.Group("/v1/admin") - adminGroup.Use(admin.RequireAdmin(adminService, authService)) + adminGroup.Use(admin.RequireAdmin(adminService, verifier, repository, cfg.DemoMode)) { adminGroup.GET("/stats", func(c *gin.Context) { stats, err := adminService.GetDashboardStats(c.Request.Context()) @@ -294,10 +296,9 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } - adminUserID, _ := c.Get("userID") err := adminService.UpdateUserRole( c.Request.Context(), - adminUserID.(string), + admin.ActorFromContext(c), c.Param("userID"), request.Role, c.ClientIP(), @@ -433,6 +434,45 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusCreated, response) }) + protected.PUT("/tenants/brand", func(c *gin.Context) { + var request domain.UpdateBrandRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := tenantService.UpdateBrand(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.GET("/tenants/email-templates", func(c *gin.Context) { + response, err := tenantService.ListEmailTemplates(c.Request.Context(), auth.PrincipalFromContext(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"templates": response}) + }) + protected.PUT("/tenants/email-template", func(c *gin.Context) { + var request domain.UpdateEmailTemplateRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := tenantService.UpdateEmailTemplate(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, tenancy.ErrInvalidOnboarding) { + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + // ============================================ // CATALOG API - Locations / Zones // ============================================ @@ -501,6 +541,29 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, gin.H{"status": "deleted"}) }) + protected.POST("/catalog/bookings", func(c *gin.Context) { + var request domain.DashboardBookingRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.CreateBooking(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidBooking): + status = http.StatusBadRequest + case errors.Is(err, catalog.ErrBookingConflict): + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, response) + }) + // ============================================ // CATALOG API - Blocked Days // ============================================ @@ -691,8 +754,11 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token) if err != nil { status := http.StatusInternalServerError - if errors.Is(err, bookings.ErrBookingNotFound) { + switch { + case errors.Is(err, bookings.ErrBookingNotFound): status = http.StatusNotFound + case errors.Is(err, bookings.ErrUnauthorized): + status = http.StatusForbidden } c.JSON(status, gin.H{"error": err.Error()}) return @@ -712,10 +778,14 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { switch { case errors.Is(err, bookings.ErrBookingNotFound): status = http.StatusNotFound + case errors.Is(err, bookings.ErrUnauthorized): + status = http.StatusForbidden case errors.Is(err, bookings.ErrBookingCancelled): status = http.StatusConflict case errors.Is(err, bookings.ErrInvalidReschedule): status = http.StatusBadRequest + case errors.Is(err, bookings.ErrBookingConflict): + status = http.StatusConflict } c.JSON(status, gin.H{"error": err.Error()}) return @@ -730,6 +800,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { switch { case errors.Is(err, bookings.ErrBookingNotFound): status = http.StatusNotFound + case errors.Is(err, bookings.ErrUnauthorized): + status = http.StatusForbidden case errors.Is(err, bookings.ErrBookingCancelled): status = http.StatusConflict } @@ -766,7 +838,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { if errors.Is(err, billing.ErrBillingPlanUnsupported) { status = http.StatusBadRequest } - if errors.Is(err, billing.ErrPaddleNotConfigured) { + if errors.Is(err, billing.ErrStripeNotConfigured) { status = http.StatusServiceUnavailable } c.JSON(status, gin.H{"error": err.Error()}) @@ -781,7 +853,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { if errors.Is(err, billing.ErrBillingMembership) { status = http.StatusNotFound } - if errors.Is(err, billing.ErrPaddleNotConfigured) { + if errors.Is(err, billing.ErrStripeNotConfigured) { status = http.StatusServiceUnavailable } c.JSON(status, gin.H{"error": err.Error()}) @@ -798,7 +870,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { status = http.StatusNotFound case errors.Is(err, billing.ErrBillingCustomerMissing): status = http.StatusBadRequest - case errors.Is(err, billing.ErrPaddleNotConfigured): + case errors.Is(err, billing.ErrStripeNotConfigured): status = http.StatusServiceUnavailable } c.JSON(status, gin.H{"error": err.Error()}) @@ -942,13 +1014,6 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, response) }) - server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) { - if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "accepted"}) - }) server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) { if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -956,13 +1021,6 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusOK, gin.H{"status": "accepted"}) }) - server.router.POST("/api/paddle_webhook", func(c *gin.Context) { - if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "accepted"}) - }) server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) { if !authorizeJobRunner(c, cfg) { diff --git a/apps/backend/internal/auth/neon.go b/apps/backend/internal/auth/neon.go index a0ab849..92bfc4a 100644 --- a/apps/backend/internal/auth/neon.go +++ b/apps/backend/internal/auth/neon.go @@ -65,29 +65,42 @@ func (v *Verifier) Close() { } } +func (v *Verifier) verifyLocal(tokenString string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return v.localSecret, nil + }, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second)) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token claims") + } + if tokenType, _ := claims["type"].(string); tokenType != "access" { + return nil, errors.New("invalid token type") + } + return claims, nil +} + func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) { if !v.enabled { return nil, errors.New("neon auth verifier is disabled") } - if len(v.localSecret) > 0 && v.jwks == nil { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return v.localSecret, nil - }, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second)) - if err != nil { + if len(v.localSecret) > 0 { + claims, err := v.verifyLocal(tokenString) + if err == nil { + return claims, nil + } + if v.jwks == nil { return nil, err } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok || !token.Valid { - return nil, errors.New("invalid token claims") - } - if tokenType, _ := claims["type"].(string); tokenType != "access" { - return nil, errors.New("invalid token type") - } - return claims, nil + } + if v.jwks == nil { + return nil, errors.New("no token verification mechanism configured") } token, err := jwt.Parse(tokenString, v.jwks.Keyfunc, diff --git a/apps/backend/internal/auth/service.go b/apps/backend/internal/auth/service.go index 8c0b60f..40f4098 100644 --- a/apps/backend/internal/auth/service.go +++ b/apps/backend/internal/auth/service.go @@ -20,6 +20,8 @@ const ( refreshTokenTTL = 30 * 24 * time.Hour magicLinkTTL = 15 * time.Minute passwordResetTTL = 30 * time.Minute + tokenIssuer = "bookra-auth" + tokenAudience = "bookra" ) var ( @@ -227,7 +229,7 @@ func (s *Service) ValidateToken(tokenString string) (*Claims, error) { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return s.jwtSecret, nil - }) + }, jwt.WithIssuer(tokenIssuer), jwt.WithAudience(tokenAudience)) if err != nil { return nil, err @@ -266,6 +268,8 @@ func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPai Role: role, Type: "access", RegisteredClaims: jwt.RegisteredClaims{ + Issuer: tokenIssuer, + Audience: jwt.ClaimStrings{tokenAudience}, ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), @@ -285,6 +289,8 @@ func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPai Role: role, Type: "refresh", RegisteredClaims: jwt.RegisteredClaims{ + Issuer: tokenIssuer, + Audience: jwt.ClaimStrings{tokenAudience}, ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), diff --git a/apps/backend/internal/auth/service_test.go b/apps/backend/internal/auth/service_test.go new file mode 100644 index 0000000..1297c0e --- /dev/null +++ b/apps/backend/internal/auth/service_test.go @@ -0,0 +1,69 @@ +package auth + +import ( + "context" + "testing" + + "bookra/apps/backend/internal/db" +) + +func TestLocalTokensVerifyWithIssuerAndAudience(t *testing.T) { + repo := db.NewMemoryRepository() + secret := "test-secret-that-is-long-enough" + svc := NewService(repo, secret) + + _, tokens, err := svc.RegisterWithPassword(context.Background(), "user@example.com", "password123", "Test User") + if err != nil { + t.Fatalf("register: %v", err) + } + + verifier, err := NewVerifier("", secret) + if err != nil { + t.Fatalf("create verifier: %v", err) + } + defer verifier.Close() + + claims, err := verifier.Verify(tokens.AccessToken) + if err != nil { + t.Fatalf("verify access token: %v", err) + } + if iss, _ := claims["iss"].(string); iss != tokenIssuer { + t.Fatalf("expected issuer %s, got %v", tokenIssuer, claims["iss"]) + } + if sub, _ := claims["sub"].(string); sub == "" { + t.Fatal("expected non-empty sub") + } +} + +func TestLocalVerifierRejectsTamperedAndWrongTypeTokens(t *testing.T) { + repo := db.NewMemoryRepository() + secret := "test-secret-that-is-long-enough" + svc := NewService(repo, secret) + + _, tokens, err := svc.RegisterWithPassword(context.Background(), "user@example.com", "password123", "Test User") + if err != nil { + t.Fatalf("register: %v", err) + } + + verifier, err := NewVerifier("", secret) + if err != nil { + t.Fatalf("create verifier: %v", err) + } + defer verifier.Close() + + // Refresh tokens carry type=refresh and must not authenticate API calls. + if _, err := verifier.Verify(tokens.RefreshToken); err == nil { + t.Fatal("expected refresh token to be rejected as access token") + } + + // Token signed with a different secret must fail. + other := NewService(repo, "different-secret") + otherUser, otherTokens, err := other.RegisterWithPassword(context.Background(), "other@example.com", "password123", "Other") + if err != nil { + t.Fatalf("register other: %v", err) + } + _ = otherUser + if _, err := verifier.Verify(otherTokens.AccessToken); err == nil { + t.Fatal("expected token signed with a different secret to be rejected") + } +} diff --git a/apps/backend/internal/billing/service.go b/apps/backend/internal/billing/service.go index 107d91d..9c3b45e 100644 --- a/apps/backend/internal/billing/service.go +++ b/apps/backend/internal/billing/service.go @@ -16,7 +16,6 @@ import ( "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/shared" - paddle "github.com/PaddleHQ/paddle-go-sdk/v5" "github.com/jackc/pgx/v5" "github.com/stripe/stripe-go/v81" portalsession "github.com/stripe/stripe-go/v81/billingportal/session" @@ -30,28 +29,11 @@ var ( ErrBillingMembership = errors.New("billing membership not found") ErrBillingPlanUnsupported = errors.New("billing plan is not configured") ErrBillingCustomerMissing = errors.New("billing customer is not available") - ErrPaddleNotConfigured = errors.New("paddle is not configured") - ErrPaddleSignatureMissing = errors.New("paddle signature is missing") - ErrPaddleWebhookMissing = errors.New("paddle webhook secret is not configured") ErrStripeNotConfigured = errors.New("stripe is not configured") ErrStripeSignatureMissing = errors.New("stripe signature is missing") ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured") ) -var allowedPaddleWebhookEvents = []string{ - "subscription.created", - "subscription.updated", - "subscription.activated", - "subscription.canceled", - "subscription.paused", - "subscription.resumed", - "subscription.trialing", - "transaction.completed", - "transaction.updated", - "transaction.payment_failed", - "transaction.past_due", -} - var allowedStripeWebhookEvents = []stripe.EventType{ stripe.EventTypeCheckoutSessionCompleted, stripe.EventTypeCustomerSubscriptionCreated, @@ -64,48 +46,15 @@ var allowedStripeWebhookEvents = []stripe.EventType{ } type Service struct { - cfg config.Config - repo db.Repository - client *paddle.SDK - verifier *paddle.WebhookVerifier - stripeEnabled bool -} - -type webhookEnvelope struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - Data struct { - ID string `json:"id"` - CustomerID string `json:"customer_id"` - SubscriptionID string `json:"subscription_id"` - CustomData map[string]any `json:"custom_data"` - } `json:"data"` + cfg config.Config + repo db.Repository } func NewService(cfg config.Config, repo db.Repository) *Service { - service := &Service{cfg: cfg, repo: repo} - - // Initialize Paddle client - if strings.TrimSpace(cfg.PaddleAPIKey) != "" { - var client *paddle.SDK - var err error - if cfg.PaddleEnvironment == "live" { - client, err = paddle.New(cfg.PaddleAPIKey, paddle.WithBaseURL(paddle.ProductionBaseURL)) - } else { - client, err = paddle.NewSandbox(cfg.PaddleAPIKey) - } - if err == nil { - service.client = client - } - } - - // Initialize Stripe if strings.TrimSpace(cfg.StripeAPIKey) != "" { stripe.Key = cfg.StripeAPIKey - service.stripeEnabled = true } - - return service + return &Service{cfg: cfg, repo: repo} } // GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement) @@ -122,7 +71,7 @@ func (s *Service) GetEntitlements(ctx context.Context, tenantID string) (domain. } func (s *Service) GetSubscription(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.SubscriptionSnapshot{}, ErrBillingMembership @@ -135,7 +84,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa if errors.Is(err, pgx.ErrNoRows) { return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ TenantID: membership.Tenant.ID, - BillingProvider: s.cfg.BillingProvider(), + BillingProvider: "stripe", Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"), PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), Currency: "czk", @@ -148,26 +97,23 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa } func (s *Service) CreateCheckoutSession(ctx context.Context, principal domain.Principal, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.CheckoutLaunchResponse{}, ErrBillingMembership } return domain.CheckoutLaunchResponse{}, err } + if !s.cfg.StripeConfigured() { + return domain.CheckoutLaunchResponse{}, ErrStripeNotConfigured + } // Default to monthly if not specified if billingInterval == "" { billingInterval = "monthly" } - // Prefer Stripe if configured - if s.cfg.StripeConfigured() { - return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval) - } - - // Fall back to Paddle - return s.createPaddleCheckoutSession(ctx, principal, membership, planCode, currency) + return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval) } func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) { @@ -191,7 +137,7 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err) } customerID = cust.ID - if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, customerID); err != nil { + if err := s.repo.UpdateTenantBillingCustomerID(ctx, membership.Tenant.ID, "stripe", customerID); err != nil { return domain.CheckoutLaunchResponse{}, err } } @@ -241,35 +187,8 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom }, nil } -func (s *Service) createPaddleCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string) (domain.CheckoutLaunchResponse, error) { - priceID, resolvedPlanCode, resolvedCurrency := s.paddlePriceForPlan(planCode, currency) - if priceID == "" { - return domain.CheckoutLaunchResponse{}, ErrBillingPlanUnsupported - } - if !checkoutAvailable(s.cfg, resolvedPlanCode) { - return domain.CheckoutLaunchResponse{}, ErrPaddleNotConfigured - } - - return domain.CheckoutLaunchResponse{ - PriceID: priceID, - CustomerID: derefString(membership.Tenant.BillingCustomerID), - CustomerEmail: strings.TrimSpace(principal.Email), - SuccessRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=success", - CancelRedirectURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard?billing=cancelled", - CustomData: map[string]string{ - "tenantId": membership.Tenant.ID, - "tenantSlug": membership.Tenant.Slug, - "userId": principal.Subject, - "userEmail": strings.TrimSpace(principal.Email), - "planCode": resolvedPlanCode, - "currency": resolvedCurrency, - "source": "bookra-dashboard", - }, - }, nil -} - func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (domain.SubscriptionSnapshot, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.SubscriptionSnapshot{}, ErrBillingMembership @@ -281,36 +200,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma if customerID == "" { return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ TenantID: membership.Tenant.ID, - BillingProvider: s.cfg.BillingProvider(), + BillingProvider: "stripe", Status: "inactive", PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), Currency: "czk", }, s.cfg), nil } - // Prefer Stripe if configured - if s.cfg.StripeConfigured() { - record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID) - if err != nil { - return domain.SubscriptionSnapshot{}, err - } - return toSnapshot(membership.Tenant, record, s.cfg), nil + if !s.cfg.StripeConfigured() { + return domain.SubscriptionSnapshot{}, ErrStripeNotConfigured } - // Fall back to Paddle - if s.client == nil { - return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured - } - record, err := s.syncPaddleData(ctx, membership.Tenant, customerID) + record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID) if err != nil { return domain.SubscriptionSnapshot{}, err } - return toSnapshot(membership.Tenant, record, s.cfg), nil } func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Principal) (domain.PortalSessionResponse, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PortalSessionResponse{}, ErrBillingMembership @@ -322,14 +231,11 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin if customerID == "" { return domain.PortalSessionResponse{}, ErrBillingCustomerMissing } - - // Prefer Stripe if configured - if s.cfg.StripeConfigured() { - return s.createStripePortalSession(customerID) + if !s.cfg.StripeConfigured() { + return domain.PortalSessionResponse{}, ErrStripeNotConfigured } - // Fall back to Paddle - return s.createPaddlePortalSession(ctx, membership, customerID) + return s.createStripePortalSession(customerID) } func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) { @@ -344,51 +250,6 @@ func (s *Service) createStripePortalSession(customerID string) (domain.PortalSes return domain.PortalSessionResponse{URL: sess.URL}, nil } -func (s *Service) createPaddlePortalSession(ctx context.Context, membership db.TenantMembershipRecord, customerID string) (domain.PortalSessionResponse, error) { - if s.client == nil { - return domain.PortalSessionResponse{}, ErrPaddleNotConfigured - } - - request := &paddle.CreateCustomerPortalSessionRequest{CustomerID: customerID} - if subscriptionID := derefString(membership.Tenant.BillingSubscription); subscriptionID != "" { - request.SubscriptionIDs = []string{subscriptionID} - } - - sess, err := s.client.CreateCustomerPortalSession(ctx, request) - if err != nil { - return domain.PortalSessionResponse{}, err - } - - url := strings.TrimSpace(sess.URLs.General.Overview) - if url == "" && len(sess.URLs.Subscriptions) > 0 { - url = firstNonEmpty( - sess.URLs.Subscriptions[0].UpdateSubscriptionPaymentMethod, - sess.URLs.Subscriptions[0].CancelSubscription, - ) - } - if url == "" { - return domain.PortalSessionResponse{}, ErrBillingCustomerMissing - } - - return domain.PortalSessionResponse{URL: url}, nil -} - -func (s *Service) HandleWebhook(ctx context.Context, req *http.Request) error { - // Detect provider based on signature header - stripeSig := req.Header.Get("Stripe-Signature") - paddleSig := req.Header.Get("Paddle-Signature") - - if stripeSig != "" { - return s.handleStripeWebhook(ctx, req) - } - - if paddleSig != "" { - return s.handlePaddleWebhook(ctx, req) - } - - return errors.New("missing webhook signature header") -} - func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error { return s.handleStripeWebhook(ctx, req) } @@ -397,6 +258,9 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er if s.cfg.StripeWebhookKey == "" { return ErrStripeWebhookMissing } + if strings.TrimSpace(req.Header.Get("Stripe-Signature")) == "" { + return ErrStripeSignatureMissing + } body, err := io.ReadAll(req.Body) if err != nil { @@ -433,7 +297,7 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er return err } if customerID != "" && derefString(tenant.BillingCustomerID) == "" { - if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil { + if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, "stripe", customerID); err != nil { return err } tenant.BillingCustomerID = &customerID @@ -476,141 +340,6 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er return err } -func (s *Service) handlePaddleWebhook(ctx context.Context, req *http.Request) error { - if s.verifier == nil { - return ErrPaddleWebhookMissing - } - if strings.TrimSpace(req.Header.Get("Paddle-Signature")) == "" { - return ErrPaddleSignatureMissing - } - - ok, err := s.verifier.Verify(req) - if err != nil { - return err - } - if !ok { - return errors.New("invalid paddle webhook signature") - } - - payload, err := io.ReadAll(req.Body) - if err != nil { - return err - } - - var event webhookEnvelope - if err := json.Unmarshal(payload, &event); err != nil { - return err - } - if !slices.Contains(allowedPaddleWebhookEvents, event.EventType) { - return nil - } - - tenant, customerID, err := s.resolveWebhookTenant(ctx, event) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil - } - return err - } - if tenant.ID == "" { - return nil - } - - inserted, err := s.repo.RecordBillingEvent(ctx, tenant.ID, "paddle", event.EventID, event.EventType, payload) - if err != nil || !inserted { - return err - } - - if customerID != "" && derefString(tenant.BillingCustomerID) == "" { - if err := s.repo.UpdateTenantBillingCustomerID(ctx, tenant.ID, customerID); err != nil { - return err - } - tenant.BillingCustomerID = &customerID - } - - customerID = firstNonEmpty(customerID, derefString(tenant.BillingCustomerID)) - if customerID == "" || s.client == nil { - return nil - } - - _, err = s.syncPaddleData(ctx, tenant, customerID) - return err -} - -func (s *Service) resolveWebhookTenant(ctx context.Context, event webhookEnvelope) (db.TenantRecord, string, error) { - customerID := strings.TrimSpace(event.Data.CustomerID) - if tenantID := customDataString(event.Data.CustomData, "tenantId"); tenantID != "" { - tenant, err := s.repo.GetTenantByID(ctx, tenantID) - return tenant, customerID, err - } - if customerID != "" { - tenant, err := s.repo.GetTenantByBillingCustomerID(ctx, customerID) - return tenant, customerID, err - } - return db.TenantRecord{}, customerID, pgx.ErrNoRows -} - -func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, customerID string) (db.BillingSnapshotRecord, error) { - if s.client == nil { - return db.BillingSnapshotRecord{}, ErrPaddleNotConfigured - } - - subscriptions, err := s.client.ListSubscriptions(ctx, &paddle.ListSubscriptionsRequest{ - CustomerID: []string{customerID}, - }) - if err != nil { - return db.BillingSnapshotRecord{}, err - } - - var selected *paddle.Subscription - if err := subscriptions.Iter(ctx, func(subscription *paddle.Subscription) (bool, error) { - if subscription == nil { - return true, nil - } - if selected == nil || subscriptionRank(subscription) > subscriptionRank(selected) { - selected = subscription - } - return true, nil - }); err != nil { - return db.BillingSnapshotRecord{}, err - } - - now := time.Now().UTC() - record := db.BillingSnapshotRecord{ - TenantID: tenant.ID, - BillingProvider: "paddle", - BillingCustomerID: customerID, - BillingSubscriptionID: "", - Status: "inactive", - PlanCode: shared.NormalizePlanCode(tenant.PlanCode), - Currency: "czk", - PriceID: "", - LastSyncedAt: &now, - } - - if selected != nil { - record.BillingSubscriptionID = selected.ID - record.Status = normalizeSubscriptionStatus(string(selected.Status)) - record.Currency = normalizeCurrency(string(selected.CurrencyCode)) - record.CancelAtPeriodEnd = selected.ScheduledChange != nil && string(selected.ScheduledChange.Action) == "cancel" - record.CurrentPeriodStart = parseRFC3339Ptr(timePeriodStart(selected.CurrentBillingPeriod)) - record.CurrentPeriodEnd = parseRFC3339Ptr(timePeriodEnd(selected.CurrentBillingPeriod)) - if len(selected.Items) > 0 { - record.PriceID = selected.Items[0].Price.ID - record.PlanCode = s.paddlePlanCodeForPrice(record.PriceID, tenant.PlanCode) - } - } - - 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.BillingSubscriptionID); err != nil { - return db.BillingSnapshotRecord{}, err - } - - return record, nil -} - // syncStripeDataToKV is the core sync function following the KV sync model. // It fetches full subscription state from Stripe and stores it in the database. // This function is called after checkout success and on every relevant webhook event. @@ -669,7 +398,7 @@ func (s *Service) syncStripeDataToKV(ctx context.Context, tenant db.TenantRecord 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.BillingSubscriptionID); err != nil { + if err := s.repo.UpdateTenantBillingState(ctx, tenant.ID, "stripe", record.PlanCode, record.Status, record.BillingSubscriptionID); err != nil { return db.BillingSnapshotRecord{}, err } @@ -732,11 +461,7 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con } customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID)) - provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider()) - - syncAvailable := cfg.BillingConfigured() - portalAvailable := cfg.BillingConfigured() && customerID != "" - checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode) + provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "stripe") return domain.SubscriptionSnapshot{ TenantID: tenant.ID, @@ -761,9 +486,9 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con return 0 }(), LastSyncedAt: record.LastSyncedAt, - CheckoutURLAvailable: checkoutAvailable, - SyncAvailable: syncAvailable, - PortalAvailable: portalAvailable, + CheckoutURLAvailable: billingCheckoutAvailable(cfg, record.PlanCode), + SyncAvailable: cfg.StripeConfigured(), + PortalAvailable: cfg.StripeConfigured() && customerID != "", } } @@ -812,17 +537,6 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements { } } -func (s *Service) paddlePlanCodeForPrice(priceID string, fallback string) string { - for planCode, currencies := range s.cfg.PaddlePriceMatrix { - for _, configuredPriceID := range currencies { - if configuredPriceID != "" && configuredPriceID == priceID { - return shared.NormalizePlanCode(planCode) - } - } - } - return shared.NormalizePlanCode(fallback) -} - func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string { for planCode, currencies := range s.cfg.StripePriceMatrix { for _, configuredPriceID := range currencies { @@ -834,23 +548,6 @@ func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string return shared.NormalizePlanCode(fallback) } -func (s *Service) paddlePriceForPlan(planCode string, currency string) (string, string, string) { - resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode)) - if resolvedPlan == "" { - resolvedPlan = "pro" - } - resolvedCurrency := normalizeCurrency(currency) - if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan][resolvedCurrency]; priceID != "" { - return priceID, resolvedPlan, resolvedCurrency - } - if resolvedCurrency != "czk" { - if priceID := s.cfg.PaddlePriceMatrix[resolvedPlan]["czk"]; priceID != "" { - return priceID, resolvedPlan, "czk" - } - } - return s.cfg.PaddlePriceMatrix[resolvedPlan]["usd"], resolvedPlan, "usd" -} - func (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) { resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode)) if resolvedPlan == "" { @@ -893,23 +590,6 @@ func (s *Service) stripePriceForPlan(planCode string, currency string, billingIn return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk" } -func subscriptionRank(subscription *paddle.Subscription) int { - switch subscription.Status { - case paddle.SubscriptionStatusActive: - return 6 - case paddle.SubscriptionStatusTrialing: - return 5 - case paddle.SubscriptionStatusPastDue: - return 4 - case paddle.SubscriptionStatusPaused: - return 3 - case paddle.SubscriptionStatusCanceled: - return 2 - default: - return 1 - } -} - func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice { switch shared.NormalizePlanCode(planCode) { case "starter": @@ -944,21 +624,12 @@ func normalizeCurrency(currency string) string { } } -func normalizeSubscriptionStatus(status string) string { - switch strings.TrimSpace(strings.ToLower(status)) { - case "active", "trialing", "past_due", "paused", "canceled": - return strings.TrimSpace(strings.ToLower(status)) - default: - return "inactive" - } -} - -func checkoutAvailable(cfg config.Config, planCode string) bool { - if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() { +func billingCheckoutAvailable(cfg config.Config, planCode string) bool { + if !cfg.StripeConfigured() || !cfg.StripeWebhookConfigured() { return false } planCode = shared.NormalizePlanCode(planCode) - for _, priceID := range cfg.PaddlePriceMatrix[planCode] { + for _, priceID := range cfg.StripePriceMatrix[planCode] { if strings.TrimSpace(priceID) != "" { return true } @@ -966,72 +637,6 @@ func checkoutAvailable(cfg config.Config, planCode string) bool { return false } -func billingCheckoutAvailable(cfg config.Config, planCode string) bool { - planCode = shared.NormalizePlanCode(planCode) - - // Prefer Stripe - if cfg.StripeConfigured() && cfg.StripeWebhookConfigured() { - for _, priceID := range cfg.StripePriceMatrix[planCode] { - if strings.TrimSpace(priceID) != "" { - return true - } - } - } - - // Fall back to Paddle - if cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() { - for _, priceID := range cfg.PaddlePriceMatrix[planCode] { - if strings.TrimSpace(priceID) != "" { - return true - } - } - } - - return false -} - -func customDataString(data map[string]any, key string) string { - if data == nil { - return "" - } - value, ok := data[key] - if !ok { - return "" - } - switch typed := value.(type) { - case string: - return strings.TrimSpace(typed) - default: - return "" - } -} - -func parseRFC3339Ptr(value string) *time.Time { - if strings.TrimSpace(value) == "" { - return nil - } - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - return nil - } - utc := parsed.UTC() - return &utc -} - -func timePeriodStart(period *paddle.TimePeriod) string { - if period == nil { - return "" - } - return period.StartsAt -} - -func timePeriodEnd(period *paddle.TimePeriod) string { - if period == nil { - return "" - } - return period.EndsAt -} - func derefString(value *string) string { if value == nil { return "" diff --git a/apps/backend/internal/billing/service_test.go b/apps/backend/internal/billing/service_test.go index 4ec2f67..89b03e5 100644 --- a/apps/backend/internal/billing/service_test.go +++ b/apps/backend/internal/billing/service_test.go @@ -12,12 +12,12 @@ import ( func testConfig() config.Config { return config.Config{ FrontendURL: "http://localhost:3000", - PaddleAPIKey: "pdl_sdbx_apikey_123", - PaddleWebhookKey: "pdl_ntf_123", - PaddlePriceMatrix: map[string]map[string]string{ - "starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"}, - "pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"}, - "business": {"czk": "pri_business_czk", "usd": "pri_business_usd"}, + StripeAPIKey: "sk_test_123", + StripeWebhookKey: "whsec_test_123", + StripePriceMatrix: map[string]map[string]string{ + "starter": {"czk": "price_starter_czk", "usd": "price_starter_usd"}, + "pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"}, + "business": {"czk": "price_business_czk", "usd": "price_business_usd"}, }, } } @@ -36,66 +36,59 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) { if snapshot.PlanCode != "pro" { t.Fatalf("expected pro, got %s", snapshot.PlanCode) } - if snapshot.Provider != "paddle" { - t.Fatalf("expected paddle provider, got %s", snapshot.Provider) + if snapshot.Provider != "stripe" { + t.Fatalf("expected stripe provider, got %s", snapshot.Provider) } if snapshot.Entitlements.MaxLocations != 3 { t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations) } } -func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) { +func TestCreateCheckoutRequiresStripeConfig(t *testing.T) { cfg := testConfig() - cfg.PaddleAPIKey = "" + cfg.StripeAPIKey = "" service := NewService(cfg, db.NewMemoryRepository()) response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{ Subject: "demo-owner", Email: "owner@bookra.dev", }, "pro", "czk", "monthly") - if err != ErrPaddleNotConfigured { - t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err) + if err != ErrStripeNotConfigured { + t.Fatalf("expected ErrStripeNotConfigured, got response=%v err=%v", response, err) } } -func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) { - service := NewService(testConfig(), db.NewMemoryRepository()) +func TestCreateCheckoutRejectsUnconfiguredPlan(t *testing.T) { + cfg := testConfig() + cfg.StripePriceMatrix["pro"] = map[string]string{} + service := NewService(cfg, db.NewMemoryRepository()) - response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{ + _, err := service.CreateCheckoutSession(context.Background(), domain.Principal{ Subject: "demo-owner", Email: "owner@bookra.dev", }, "pro", "czk", "monthly") - if err != nil { - t.Fatalf("create checkout: %v", err) - } - if response.PriceID != "pri_pro_czk" { - t.Fatalf("expected pri_pro_czk, got %s", response.PriceID) - } - if response.CustomData["tenantId"] == "" { - t.Fatal("expected tenantId in customData") - } - if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" { - t.Fatal("expected redirect URLs") + if err != ErrBillingPlanUnsupported { + t.Fatalf("expected ErrBillingPlanUnsupported, got %v", err) } } -func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) { +func TestRefreshRequiresStripeKeyWhenCustomerExists(t *testing.T) { cfg := testConfig() - cfg.PaddleAPIKey = "" + cfg.StripeAPIKey = "" service := NewService(cfg, db.NewMemoryRepository()) snapshot, err := service.Refresh(context.Background(), domain.Principal{ Subject: "demo-owner", Email: "owner@bookra.dev", }) - if err != ErrPaddleNotConfigured { - t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err) + if err != ErrStripeNotConfigured { + t.Fatalf("expected ErrStripeNotConfigured, got snapshot=%v err=%v", snapshot, err) } } func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) { cfg := testConfig() - cfg.PaddleWebhookKey = "" + cfg.StripeWebhookKey = "" service := NewService(cfg, db.NewMemoryRepository()) snapshot, err := service.GetSubscription(context.Background(), domain.Principal{ @@ -110,7 +103,7 @@ func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) { } } -func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) { +func TestGetSubscriptionEnablesCheckoutWhenStripeConfigured(t *testing.T) { service := NewService(testConfig(), db.NewMemoryRepository()) snapshot, err := service.GetSubscription(context.Background(), domain.Principal{ @@ -121,7 +114,7 @@ func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) { t.Fatalf("get subscription: %v", err) } if !snapshot.CheckoutURLAvailable { - t.Fatal("expected checkout available when paddle is configured") + t.Fatal("expected checkout available when stripe is configured") } if !snapshot.PortalAvailable { t.Fatal("expected portal available when customer exists") @@ -130,11 +123,11 @@ func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) { func TestCreatePortalSessionRequiresCustomer(t *testing.T) { repo := db.NewMemoryRepository() - membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner") + membership, err := repo.GetTenantMembershipBySubject(context.Background(), "demo-owner") if err != nil { t.Fatalf("get membership: %v", err) } - if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, ""); err != nil { + if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, "stripe", ""); err != nil { t.Fatalf("clear billing customer: %v", err) } service := NewService(testConfig(), repo) diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 46328fc..a8c8ac2 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -2,7 +2,10 @@ package bookings import ( "context" + "crypto/subtle" "errors" + "fmt" + "strings" "time" "bookra/apps/backend/internal/db" @@ -17,8 +20,9 @@ var ( ) type CustomerService struct { - repo db.Repository - notifier CustomerNotifier + repo db.Repository + notifier CustomerNotifier + frontendURL string } type CustomerNotifier interface { @@ -26,11 +30,14 @@ type CustomerNotifier interface { SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error } -func NewCustomerService(repo db.Repository, notifier CustomerNotifier) *CustomerService { +func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendURL string) *CustomerService { if notifier == nil { notifier = &customerNoopNotifier{} } - return &CustomerService{repo: repo, notifier: notifier} + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + return &CustomerService{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")} } type customerNoopNotifier struct{} @@ -43,29 +50,70 @@ func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data return nil } +// authorize compares the caller-supplied management token against the stored +// per-booking token in constant time. Bookings without a token (created before +// the feature) cannot be managed through the public endpoints. +func authorize(booking db.BookingRecord, token string) error { + if booking.ManageToken == "" || token == "" { + return ErrUnauthorized + } + if subtle.ConstantTimeCompare([]byte(token), []byte(booking.ManageToken)) != 1 { + return ErrUnauthorized + } + return nil +} + +func (s *CustomerService) managementURL(booking db.BookingRecord) string { + return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken) +} + +func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) { + if booking.ServiceID != nil { + if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil { + for _, service := range services { + if service.ID == *booking.ServiceID { + serviceName = service.Name + break + } + } + } + } + if booking.LocationID != nil { + if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil { + locationName = location.Name + } + } + return serviceName, locationName +} + // GetBookingByReference returns booking details for customer management link func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) { booking, err := s.repo.GetBookingByReference(ctx, reference) if err != nil { return domain.CustomerBookingView{}, ErrBookingNotFound } + if err := authorize(booking, token); err != nil { + return domain.CustomerBookingView{}, err + } - // Get tenant details for business name tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) if err != nil { return domain.CustomerBookingView{}, err } + serviceName, locationName := s.descriptors(ctx, booking) + return domain.CustomerBookingView{ Reference: booking.Reference, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, - Service: "Service", // Would get from service ID in full implementation + Service: serviceName, BusinessName: tenant.Name, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, - Location: "Location", // Would get from location ID in full implementation + Location: locationName, Status: booking.Status, + Notes: booking.Notes, }, nil } @@ -75,6 +123,9 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin if err != nil { return ErrBookingNotFound } + if err := authorize(booking, token); err != nil { + return err + } if booking.Status == "cancelled" { return ErrBookingCancelled @@ -98,6 +149,20 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin return ErrInvalidReschedule } + // Re-check resource conflicts at the requested window, excluding self. + existing, err := s.repo.ListBookingsByTenantBetween(ctx, booking.TenantID, newStartsAt, newEndsAt) + if err != nil { + return err + } + for _, other := range existing { + if other.ID == booking.ID || other.Status == "cancelled" || other.Status == "waitlisted" { + continue + } + if resourcesCollide(other, booking.StaffID, booking.LocationID) { + return ErrBookingConflict + } + } + if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil { return err } @@ -114,6 +179,9 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t if err != nil { return ErrBookingNotFound } + if err := authorize(booking, token); err != nil { + return err + } if booking.Status == "cancelled" { return ErrBookingCancelled @@ -144,22 +212,23 @@ func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStart brandColor = "#a65c3e" } - managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference + serviceName, locationName := s.descriptors(ctx, booking) emailData := notifications.BookingEmailData{ + TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, - Service: "Service", - Location: "Location", + Service: serviceName, + Location: locationName, Reference: booking.Reference, StartsAt: newStartsAt, EndsAt: newEndsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, - ManagementURL: managementURL, + ManagementURL: s.managementURL(booking), } s.notifier.SendBookingReschedule(ctx, emailData) @@ -180,14 +249,17 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) { brandColor = "#a65c3e" } + serviceName, locationName := s.descriptors(ctx, booking) + emailData := notifications.BookingEmailData{ + TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, - Service: "Service", - Location: "Location", + Service: serviceName, + Location: locationName, Reference: booking.Reference, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, diff --git a/apps/backend/internal/bookings/customer_service_test.go b/apps/backend/internal/bookings/customer_service_test.go new file mode 100644 index 0000000..cdd5cf8 --- /dev/null +++ b/apps/backend/internal/bookings/customer_service_test.go @@ -0,0 +1,113 @@ +package bookings + +import ( + "context" + "testing" + "time" + + "bookra/apps/backend/internal/db" + "bookra/apps/backend/internal/domain" +) + +func newBookingFixture(t *testing.T, repo *db.MemoryRepository) db.BookingRecord { + t.Helper() + created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + CustomerName: "Test Customer", + CustomerEmail: "customer@example.com", + StartsAt: time.Now().Add(24 * time.Hour), + EndsAt: time.Now().Add(25 * time.Hour), + Status: "confirmed", + Reference: "BOK-TEST-1", + ManageToken: "secret-manage-token", + }) + if err != nil { + t.Fatalf("create booking: %v", err) + } + booking, err := repo.GetBookingByReference(context.Background(), created.Reference) + if err != nil { + t.Fatalf("get booking: %v", err) + } + return booking +} + +func TestGetBookingRequiresManageToken(t *testing.T) { + repo := db.NewMemoryRepository() + booking := newBookingFixture(t, repo) + svc := NewCustomerService(repo, nil, "http://localhost:3000") + + if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, ""); err != ErrUnauthorized { + t.Fatalf("expected ErrUnauthorized for missing token, got %v", err) + } + if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized { + t.Fatalf("expected ErrUnauthorized for wrong token, got %v", err) + } + view, err := svc.GetBookingByReference(context.Background(), booking.Reference, "secret-manage-token") + if err != nil { + t.Fatalf("expected authorized view, got %v", err) + } + if view.Reference != booking.Reference { + t.Fatalf("expected reference %s, got %s", booking.Reference, view.Reference) + } +} + +func TestRescheduleRequiresManageToken(t *testing.T) { + repo := db.NewMemoryRepository() + booking := newBookingFixture(t, repo) + svc := NewCustomerService(repo, nil, "http://localhost:3000") + + req := domain.RescheduleBookingRequest{ + NewStartsAt: time.Now().Add(48 * time.Hour).Format(time.RFC3339), + NewEndsAt: time.Now().Add(49 * time.Hour).Format(time.RFC3339), + } + if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, ""); err != ErrUnauthorized { + t.Fatalf("expected ErrUnauthorized, got %v", err) + } + if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, "wrong"); err != ErrUnauthorized { + t.Fatalf("expected ErrUnauthorized, got %v", err) + } +} + +func TestCancelRequiresManageToken(t *testing.T) { + repo := db.NewMemoryRepository() + booking := newBookingFixture(t, repo) + svc := NewCustomerService(repo, nil, "http://localhost:3000") + + if err := svc.CancelBooking(context.Background(), booking.Reference, ""); err != ErrUnauthorized { + t.Fatalf("expected ErrUnauthorized, got %v", err) + } + if err := svc.CancelBooking(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized { + t.Fatalf("expected ErrUnauthorized, got %v", err) + } + if err := svc.CancelBooking(context.Background(), booking.Reference, "secret-manage-token"); err != nil { + t.Fatalf("expected successful cancel, got %v", err) + } +} + +func TestRescheduleRejectsConflictingSlot(t *testing.T) { + repo := db.NewMemoryRepository() + svc := NewCustomerService(repo, nil, "http://localhost:3000") + base := time.Now().Add(48 * time.Hour) + + // Existing booking in the same tenant occupying the target slot. + _, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + StartsAt: base, + EndsAt: base.Add(time.Hour), + Status: "confirmed", + Reference: "BOK-OCCUPIED", + ManageToken: "token-a", + }) + if err != nil { + t.Fatalf("create occupying booking: %v", err) + } + + booking := newBookingFixture(t, repo) + err = svc.RescheduleBooking(context.Background(), booking.Reference, domain.RescheduleBookingRequest{ + NewStartsAt: base.Add(30 * time.Minute).Format(time.RFC3339), // overlaps BOK-OCCUPIED + NewEndsAt: base.Add(90 * time.Minute).Format(time.RFC3339), + }, "secret-manage-token") + if err != ErrBookingConflict { + t.Fatalf("expected ErrBookingConflict, got %v", err) + } +} diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 3843f23..78b9555 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -2,6 +2,8 @@ package bookings import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "net/mail" @@ -26,8 +28,9 @@ var ( ) type Service struct { - repo db.Repository - notifier Notifier + repo db.Repository + notifier Notifier + frontendURL string } type Notifier interface { @@ -35,11 +38,14 @@ type Notifier interface { SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error } -func NewService(repo db.Repository, notifier Notifier) *Service { +func NewService(repo db.Repository, notifier Notifier, frontendURL string) *Service { if notifier == nil { notifier = &noopNotifier{} } - return &Service{repo: repo, notifier: notifier} + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + return &Service{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")} } type noopNotifier struct{} @@ -163,7 +169,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques if booking.Status == "cancelled" { continue } - if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) { + if resourcesCollide(booking, request.StaffID, request.LocationID) { return domain.CreateBookingResponse{}, ErrBookingConflict } } @@ -203,6 +209,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques EndsAt: endsAt.UTC(), Status: status, Reference: db.Reference("BK", time.Now()), + ManageToken: generateManageToken(), Notes: notes, }) if err != nil { @@ -234,16 +241,40 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques } // Send confirmation emails (async - don't fail booking if email fails) - go s.sendBookingConfirmationEmails(tenant, created, customerName, customerEmail, startsAt, endsAt) + go s.sendBookingConfirmationEmails(tenant, created, request.ServiceID, request.LocationID, customerName, customerEmail, startsAt, endsAt) return domain.CreateBookingResponse{ BookingID: created.ID, Reference: created.Reference, Status: created.Status, + ManageURL: s.manageURL(created), }, nil } -func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, customerName, customerEmail string, startsAt, endsAt time.Time) { +func (s *Service) manageURL(booking db.CreatedBooking) string { + return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken) +} + +func (s *Service) bookingDescriptors(ctx context.Context, tenantID string, serviceID, locationID *string) (serviceName string, locationName string) { + if serviceID != nil { + if services, err := s.repo.ListServicesByTenant(ctx, tenantID); err == nil { + for _, service := range services { + if service.ID == *serviceID { + serviceName = service.Name + break + } + } + } + } + if locationID != nil { + if location, err := s.repo.GetLocationByID(ctx, *locationID); err == nil { + locationName = location.Name + } + } + return serviceName, locationName +} + +func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, serviceID, locationID *string, customerName, customerEmail string, startsAt, endsAt time.Time) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -254,22 +285,23 @@ func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking brandColor = "#a65c3e" // Default terra color } - managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference) + serviceName, locationName := s.bookingDescriptors(ctx, tenant.ID, serviceID, locationID) emailData := notifications.BookingEmailData{ + TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: customerName, CustomerEmail: customerEmail, - Service: "Service", // Would lookup from booking.ServiceID in full implementation - Location: "Location", // Would lookup from booking.LocationID in full implementation + Service: serviceName, + Location: locationName, Reference: booking.Reference, StartsAt: startsAt, EndsAt: endsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, - ManagementURL: managementURL, + ManagementURL: s.manageURL(booking), } // Send to customer @@ -303,7 +335,7 @@ func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, c } func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.DashboardSummary{}, ErrTenantMembership @@ -311,6 +343,38 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip return domain.DashboardSummary{}, err } + serviceNames := map[string]string{} + if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil { + for _, service := range services { + serviceNames[service.ID] = service.Name + } + } + locationNames := map[string]string{} + if locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID); err == nil { + for _, location := range locations { + locationNames[location.ID] = location.Name + } + } + toDomain := func(booking db.BookingRecord) domain.UpcomingBooking { + out := domain.UpcomingBooking{ + ID: booking.ID, + Reference: booking.Reference, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + Notes: booking.Notes, + StartsAt: booking.StartsAt, + EndsAt: booking.EndsAt, + Status: booking.Status, + } + if booking.ServiceID != nil { + out.Service = serviceNames[*booking.ServiceID] + } + if booking.LocationID != nil { + out.Location = locationNames[*booking.LocationID] + } + return out + } + now := time.Now().UTC() weekEnd := now.AddDate(0, 0, 7) metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd) @@ -323,14 +387,10 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip } upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords)) for _, booking := range upcomingRecords { - upcoming = append(upcoming, domain.UpcomingBooking{ - Reference: booking.Reference, - CustomerName: booking.CustomerName, - CustomerEmail: booking.CustomerEmail, - StartsAt: booking.StartsAt, - EndsAt: booking.EndsAt, - Status: booking.Status, - }) + if booking.Status == "cancelled" { + continue + } + upcoming = append(upcoming, toDomain(booking)) } if len(upcoming) > 5 { upcoming = upcoming[:5] @@ -345,14 +405,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip } allBookings := make([]domain.UpcomingBooking, 0, len(allRecords)) for _, booking := range allRecords { - allBookings = append(allBookings, domain.UpcomingBooking{ - Reference: booking.Reference, - CustomerName: booking.CustomerName, - CustomerEmail: booking.CustomerEmail, - StartsAt: booking.StartsAt, - EndsAt: booking.EndsAt, - Status: booking.Status, - }) + allBookings = append(allBookings, toDomain(booking)) } return domain.DashboardSummary{ @@ -407,52 +460,55 @@ func generateAppointmentSlots( 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)) { + for _, service := range services { + serviceSlots := 0 + for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; dayOffset++ { + day := now.AddDate(0, 0, dayOffset) + for _, rule := range rules { + if int(day.Weekday()) != rule.DayOfWeek { continue } - slotEnd := slotStart.Add(duration) - if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) { + 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 } - 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 + 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, + }) + serviceSlots++ + if serviceSlots >= 4 { + break + } } } } @@ -491,18 +547,47 @@ func collides(bookings []db.BookingRecord, staffID *string, locationID *string, if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) { continue } - if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) { + if resourcesCollide(booking, staffID, locationID) { return true } } return false } -func sameResource(left *string, right *string) bool { - if left == nil || right == nil { - return false +// resourcesCollide reports whether an existing booking shares the resource a +// new booking would consume. A booking with neither staff nor location +// assignment occupies the tenant's single implicit resource, so it conflicts +// with any other unassigned booking. Explicit assignments only collide when +// they match exactly. +func resourcesCollide(booking db.BookingRecord, staffID *string, locationID *string) bool { + if booking.StaffID != nil && staffID != nil && *booking.StaffID == *staffID { + return true } - return *left == *right + if booking.LocationID != nil && locationID != nil && *booking.LocationID == *locationID { + return true + } + return booking.StaffID == nil && staffID == nil && booking.LocationID == nil && locationID == nil +} + +func generateManageToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("generate manage token: %v", err)) + } + return hex.EncodeToString(b) +} + +// GenerateManageToken returns a new cryptographically random booking +// management token. Exported for catalog dashboard bookings. +func GenerateManageToken() string { + return generateManageToken() +} + +// AppointmentResourceConflict reports whether an existing booking shares the +// resource a new appointment would consume. Exported for catalog dashboard +// bookings. +func AppointmentResourceConflict(booking db.BookingRecord, staffID *string, locationID *string) bool { + return resourcesCollide(booking, staffID, locationID) } func sameSecond(left time.Time, right time.Time) bool { diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index e956317..5e29390 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -12,7 +12,7 @@ import ( func TestCreateAppointmentRejectsConflict(t *testing.T) { repo := db.NewMemoryRepository() - service := NewService(repo, nil) + service := NewService(repo, nil, "http://localhost:3000") availability, err := service.Availability(context.Background(), "studio-atelier") if err != nil { @@ -69,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) { func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) { repo := db.NewMemoryRepository() - service := NewService(repo, nil) + service := NewService(repo, nil, "http://localhost:3000") availability, err := service.Availability(context.Background(), "studio-atelier") if err != nil { @@ -126,7 +126,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) { func TestCreateAppointmentRequiresTenantService(t *testing.T) { repo := db.NewMemoryRepository() - service := NewService(repo, nil) + service := NewService(repo, nil, "http://localhost:3000") _, err := service.Create(context.Background(), domain.CreateBookingRequest{ TenantSlug: "studio-atelier", @@ -143,7 +143,7 @@ func TestCreateAppointmentRequiresTenantService(t *testing.T) { func TestCreateClassRequiresExistingSession(t *testing.T) { repo := db.NewMemoryRepository() - service := NewService(repo, nil) + service := NewService(repo, nil, "http://localhost:3000") missingSessionID := "11111111-1111-1111-1111-111111111111" _, err := service.Create(context.Background(), domain.CreateBookingRequest{ @@ -162,7 +162,7 @@ func TestCreateClassRequiresExistingSession(t *testing.T) { func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) { repo := db.NewMemoryRepository() - service := NewService(repo, nil) + service := NewService(repo, nil, "http://localhost:3000") availability, err := service.Availability(context.Background(), "studio-atelier") if err != nil { @@ -185,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) { func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) { repo := db.NewMemoryRepository() - service := NewService(repo, nil) + service := NewService(repo, nil, "http://localhost:3000") availability, err := service.Availability(context.Background(), "studio-atelier") if err != nil { diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 4d28696..61f363f 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -4,8 +4,11 @@ import ( "context" "errors" "fmt" + "net/mail" + "strings" "time" + "bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) @@ -16,6 +19,7 @@ var ( ErrCustomerNotFound = errors.New("customer not found") ErrBookingNotFound = errors.New("booking not found") ErrInvalidBooking = errors.New("invalid booking request") + ErrBookingConflict = errors.New("booking conflicts with an existing reservation") ErrTenantNotFound = errors.New("tenant not found") ErrTenantMembership = errors.New("tenant membership not found") ErrPlanLimitReached = errors.New("plan limit reached") @@ -44,7 +48,7 @@ func NewService(repo db.Repository, billingService interface { // ============================================ func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return nil, ErrTenantMembership } @@ -56,21 +60,25 @@ func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) locations := make([]domain.Location, len(records)) for i, rec := range records { - locations[i] = domain.Location{ - ID: rec.ID, - TenantID: rec.TenantID, - Name: rec.Name, - Type: "room", // Default type - Capacity: 10, // Default capacity - Timezone: rec.Timezone, - CreatedAt: rec.CreatedAt, - } + locations[i] = locationToDomain(rec) } return locations, nil } +func locationToDomain(rec db.LocationRecord) domain.Location { + return domain.Location{ + ID: rec.ID, + TenantID: rec.TenantID, + Name: rec.Name, + Type: rec.Type, + Capacity: rec.Capacity, + Timezone: rec.Timezone, + CreatedAt: rec.CreatedAt, + } +} + func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.Location{}, ErrTenantMembership } @@ -87,9 +95,20 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal } } + locationType := strings.TrimSpace(req.Type) + if locationType == "" { + locationType = "room" + } + capacity := req.Capacity + if capacity <= 0 { + capacity = 1 + } + params := db.CreateLocationParams{ TenantID: membership.Tenant.ID, Name: req.Name, + Type: locationType, + Capacity: capacity, Timezone: membership.Tenant.Timezone, } @@ -110,19 +129,11 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal } } - return domain.Location{ - ID: rec.ID, - TenantID: rec.TenantID, - Name: rec.Name, - Type: req.Type, - Capacity: 10, - Timezone: rec.Timezone, - CreatedAt: rec.CreatedAt, - }, nil + return locationToDomain(rec), nil } func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.Location{}, ErrTenantMembership } @@ -140,25 +151,23 @@ func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal if req.Name != "" { params.Name = &req.Name } + if req.Type != "" { + params.Type = &req.Type + } + if req.Capacity > 0 { + params.Capacity = &req.Capacity + } rec, err := s.repo.UpdateLocation(ctx, locationID, params) if err != nil { return domain.Location{}, err } - return domain.Location{ - ID: rec.ID, - TenantID: rec.TenantID, - Name: rec.Name, - Type: req.Type, - Capacity: 10, - Timezone: rec.Timezone, - CreatedAt: rec.CreatedAt, - }, nil + return locationToDomain(rec), nil } func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return ErrTenantMembership } @@ -179,7 +188,7 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal // ============================================ func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return nil, ErrTenantMembership } @@ -205,7 +214,7 @@ func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principa } func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.BlockedDay{}, ErrTenantMembership } @@ -241,7 +250,7 @@ func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Princip } func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.BlockedDay{}, ErrTenantMembership } @@ -288,7 +297,7 @@ func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Princip } func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return ErrTenantMembership } @@ -318,7 +327,7 @@ func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Princip // ============================================ func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return nil, ErrTenantMembership } @@ -353,7 +362,7 @@ func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, } func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.Customer{}, ErrTenantMembership } @@ -390,7 +399,7 @@ func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal } func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.Customer{}, ErrTenantMembership } @@ -444,7 +453,7 @@ func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal } func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return ErrTenantMembership } @@ -465,7 +474,7 @@ func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal // ============================================ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return nil, ErrTenantMembership } @@ -488,18 +497,129 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip } func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return ErrTenantMembership } - params := db.UpdateWorkingHoursParams{} + params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen} if req.Open != "" { + if _, err := time.Parse("15:04", req.Open); err != nil { + return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking) + } params.StartsLocal = &req.Open } if req.Close != "" { + if _, err := time.Parse("15:04", req.Close); err != nil { + return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking) + } params.EndsLocal = &req.Close } return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params) } + +// CreateBooking creates a manual booking from the dashboard. The service name +// is resolved against the tenant's services; when it matches nothing, the +// tenant's first service is used so the booking still carries a duration. +func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, req domain.DashboardBookingRequest) (domain.UpcomingBooking, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.UpcomingBooking{}, ErrTenantMembership + } + tenant := membership.Tenant + + startsAt, err := time.Parse(time.RFC3339, req.StartsAt) + if err != nil { + return domain.UpcomingBooking{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking) + } + + customerName := strings.TrimSpace(req.CustomerName) + customerEmail := strings.TrimSpace(req.CustomerEmail) + if len(customerName) < 2 || len(customerName) > 120 { + return domain.UpcomingBooking{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking) + } + if _, err := mail.ParseAddress(customerEmail); err != nil { + return domain.UpcomingBooking{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking) + } + + services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) + if err != nil { + return domain.UpcomingBooking{}, err + } + if len(services) == 0 { + return domain.UpcomingBooking{}, fmt.Errorf("%w: tenant has no services configured", ErrInvalidBooking) + } + + selected := services[0] + requested := strings.TrimSpace(req.Service) + for _, service := range services { + if strings.EqualFold(service.Name, requested) || service.ID == requested { + selected = service + break + } + } + + endsAt := startsAt.Add(time.Duration(selected.DurationMinutes) * time.Minute) + + // Upsert the customer record so the booking shows up under the customers page. + customer, err := s.repo.GetCustomerByEmail(ctx, tenant.ID, customerEmail) + if err != nil { + customer, err = s.repo.CreateCustomer(ctx, db.CreateCustomerParams{ + TenantID: tenant.ID, + Name: customerName, + Email: customerEmail, + Status: "active", + }) + if err != nil { + return domain.UpcomingBooking{}, err + } + } + customerPhone := strings.TrimSpace(req.CustomerPhone) + if customerPhone != "" && (customer.Phone == nil || *customer.Phone != customerPhone) { + _, _ = s.repo.UpdateCustomer(ctx, customer.ID, db.UpdateCustomerParams{Phone: &customerPhone}) + } + + existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt) + if err != nil { + return domain.UpcomingBooking{}, err + } + for _, booking := range existing { + if booking.Status == "cancelled" || booking.Status == "waitlisted" { + continue + } + if bookings.AppointmentResourceConflict(booking, nil, nil) { + return domain.UpcomingBooking{}, ErrBookingConflict + } + } + + created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{ + TenantID: tenant.ID, + ServiceID: &selected.ID, + BookingMode: "appointment", + CustomerName: customerName, + CustomerEmail: customerEmail, + CustomerPhone: customerPhone, + StartsAt: startsAt.UTC(), + EndsAt: endsAt.UTC(), + Status: "confirmed", + Reference: db.Reference("BK", time.Now()), + ManageToken: bookings.GenerateManageToken(), + Notes: strings.TrimSpace(req.Notes), + }) + if err != nil { + return domain.UpcomingBooking{}, err + } + + return domain.UpcomingBooking{ + ID: created.ID, + Reference: created.Reference, + CustomerName: customerName, + CustomerEmail: customerEmail, + Service: selected.Name, + StartsAt: startsAt, + EndsAt: endsAt, + Status: created.Status, + Notes: strings.TrimSpace(req.Notes), + }, nil +} diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 08a4ee2..0baaf7e 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -24,10 +24,6 @@ type Config struct { SMTPPort string SMTPUsername string SMTPPassword string - PaddleEnvironment string - PaddleAPIKey string - PaddleWebhookKey string - PaddlePriceMatrix map[string]map[string]string StripeAPIKey string StripeWebhookKey string StripePriceMatrix map[string]map[string]string @@ -58,10 +54,6 @@ func Load() (Config, error) { SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"), SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")), SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")), - PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")), - PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")), - PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")), - PaddlePriceMatrix: paddlePriceMatrixFromEnv(), StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")), StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")), StripePriceMatrix: stripePriceMatrixFromEnv(), @@ -110,19 +102,6 @@ func (cfg Config) validateRuntimeRequirements() error { return nil } -func (cfg Config) PaddleConfigured() bool { - return strings.TrimSpace(cfg.PaddleAPIKey) != "" -} - -func (cfg Config) PaddleWebhookConfigured() bool { - return strings.TrimSpace(cfg.PaddleWebhookKey) != "" -} - -func (cfg Config) PaddleCheckoutConfigured(planCode string) bool { - planCode = shared.NormalizePlanCode(planCode) - return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != "" -} - func (cfg Config) StripeConfigured() bool { return strings.TrimSpace(cfg.StripeAPIKey) != "" } @@ -137,18 +116,15 @@ func (cfg Config) StripeCheckoutConfigured(planCode string) bool { } func (cfg Config) BillingProvider() string { - if cfg.StripeConfigured() { - return "stripe" - } - return "paddle" + return "stripe" } func (cfg Config) BillingConfigured() bool { - return cfg.StripeConfigured() || cfg.PaddleConfigured() + return cfg.StripeConfigured() } func (cfg Config) BillingWebhookConfigured() bool { - return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured() + return cfg.StripeWebhookConfigured() } func (cfg Config) SMSConfigured() bool { @@ -170,20 +146,6 @@ func (cfg Config) StripeSMSPriceID(currency string) string { return cfg.StripeSMSPriceMatrix["czk"] } -func paddlePriceMatrixFromEnv() map[string]map[string]string { - matrix := map[string]map[string]string{ - "starter": {}, - "pro": {}, - "business": {}, - } - for _, planCode := range []string{"starter", "pro", "business"} { - envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_")) - matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_CZK_PRICE_ID")) - matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_USD_PRICE_ID")) - } - return matrix -} - func stripePriceMatrixFromEnv() map[string]map[string]string { matrix := map[string]map[string]string{ "starter": {}, @@ -210,15 +172,6 @@ func stripePriceMatrixFromEnv() map[string]map[string]string { return matrix } -func normalizePaddleEnvironment(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "live", "production": - return "live" - default: - return "sandbox" - } -} - func valueOrDefault(key string, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value diff --git a/apps/backend/internal/config/config_test.go b/apps/backend/internal/config/config_test.go index 498c70e..251d956 100644 --- a/apps/backend/internal/config/config_test.go +++ b/apps/backend/internal/config/config_test.go @@ -2,31 +2,37 @@ package config import "testing" -func TestPaddleCheckoutConfigured(t *testing.T) { +func TestStripeCheckoutConfigured(t *testing.T) { cfg := Config{ - PaddleAPIKey: "pdl_sdbx_apikey_123", - PaddleWebhookKey: "pdl_ntf_123", - PaddlePriceMatrix: map[string]map[string]string{ - "starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"}, - "pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"}, - "business": {"czk": "pri_business_czk", "usd": "pri_business_usd"}, + StripeAPIKey: "sk_test_123", + StripeWebhookKey: "whsec_test_123", + StripePriceMatrix: map[string]map[string]string{ + "starter": {"czk": "price_starter_czk", "usd": "price_starter_usd"}, + "pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"}, + "business": {"czk": "price_business_czk", "usd": "price_business_usd"}, }, } - if !cfg.PaddleCheckoutConfigured("pro") { + if !cfg.StripeCheckoutConfigured("pro") { t.Fatal("expected pro checkout configured") } } -func TestPaddleCheckoutConfiguredRequiresWebhook(t *testing.T) { +func TestStripeCheckoutConfiguredRequiresWebhook(t *testing.T) { cfg := Config{ - PaddleAPIKey: "pdl_sdbx_apikey_123", - PaddlePriceMatrix: map[string]map[string]string{ - "pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"}, + StripeAPIKey: "sk_test_123", + StripePriceMatrix: map[string]map[string]string{ + "pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"}, }, } - if cfg.PaddleCheckoutConfigured("pro") { + if cfg.StripeCheckoutConfigured("pro") { t.Fatal("expected checkout disabled without webhook key") } } + +func TestBillingProviderIsStripe(t *testing.T) { + if (Config{}).BillingProvider() != "stripe" { + t.Fatal("expected stripe billing provider") + } +} diff --git a/apps/backend/internal/db/billing.go b/apps/backend/internal/db/billing.go index b867350..68f3f96 100644 --- a/apps/backend/internal/db/billing.go +++ b/apps/backend/internal/db/billing.go @@ -1,7 +1,7 @@ package db import ( -"context" + "context" ) func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) { @@ -53,7 +53,7 @@ func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params Bi payment_method_last4 = EXCLUDED.payment_method_last4, last_synced_at = EXCLUDED.last_synced_at, updated_at = now() - `, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode, + `, params.TenantID, firstNonEmpty(params.BillingProvider, "stripe"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode, firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd, params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt) return err @@ -64,7 +64,7 @@ func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string, INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at) VALUES ($1, $2, $3, $4, $5::jsonb, now()) ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING - `, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload) + `, tenantID, firstNonEmpty(provider, "stripe"), eventID, eventType, payload) if err != nil { return false, err } diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 573d5e6..378ead2 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -8,7 +8,8 @@ import ( func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, - customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference + customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, + COALESCE(manage_token, ''), COALESCE(notes, '') FROM bookings WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 ORDER BY starts_at ASC @@ -35,6 +36,8 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID &record.EndsAt, &record.Status, &record.Reference, + &record.ManageToken, + &record.Notes, ); err != nil { return nil, err } @@ -49,10 +52,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa INSERT INTO bookings ( tenant_id, service_id, class_session_id, staff_id, location_id, booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at, - status, reference, notes + status, reference, manage_token, notes ) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) - RETURNING id, reference, status + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING id, reference, status, manage_token `, params.TenantID, params.ServiceID, @@ -67,8 +70,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa params.EndsAt, params.Status, params.Reference, + params.ManageToken, params.Notes, - ).Scan(&created.ID, &created.Reference, &created.Status) + ).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken) return created, err } diff --git a/apps/backend/internal/db/email_templates.go b/apps/backend/internal/db/email_templates.go new file mode 100644 index 0000000..d538c5b --- /dev/null +++ b/apps/backend/internal/db/email_templates.go @@ -0,0 +1,55 @@ +package db + +import ( + "context" +) + +func (r *PGRepository) ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at + FROM email_templates + WHERE tenant_id = $1 + ORDER BY type + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []EmailTemplateRecord + for rows.Next() { + var rec EmailTemplateRecord + if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt); err != nil { + return nil, err + } + records = append(records, rec) + } + return records, rows.Err() +} + +func (r *PGRepository) GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) { + var rec EmailTemplateRecord + err := r.pool.QueryRow(ctx, ` + SELECT id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at + FROM email_templates + WHERE tenant_id = $1 AND type = $2 + `, tenantID, templateType).Scan(&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt) + return rec, err +} + +func (r *PGRepository) UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) { + var rec EmailTemplateRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO email_templates (tenant_id, type, subject, body_html, body_text, is_enabled) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (tenant_id, type) DO UPDATE SET + subject = EXCLUDED.subject, + body_html = EXCLUDED.body_html, + body_text = EXCLUDED.body_text, + is_enabled = EXCLUDED.is_enabled, + updated_at = now() + RETURNING id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at + `, params.TenantID, params.Type, params.Subject, params.BodyHTML, params.BodyText, params.IsEnabled).Scan( + &rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt) + return rec, err +} diff --git a/apps/backend/internal/db/locations.go b/apps/backend/internal/db/locations.go index be4e2f1..47e523a 100644 --- a/apps/backend/internal/db/locations.go +++ b/apps/backend/internal/db/locations.go @@ -1,13 +1,15 @@ package db import ( -"context" -"time" + "context" + "time" ) +const locationColumns = "id, tenant_id, name, type, capacity, timezone, created_at" + func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) { rows, err := r.pool.Query(ctx, ` - SELECT id, tenant_id, name, timezone, created_at + SELECT `+locationColumns+` FROM locations WHERE tenant_id = $1 ORDER BY name @@ -20,7 +22,7 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin var records []LocationRecord for rows.Next() { var rec LocationRecord - if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil { + if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt); err != nil { return nil, err } records = append(records, rec) @@ -31,20 +33,20 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) { var rec LocationRecord err := r.pool.QueryRow(ctx, ` - SELECT id, tenant_id, name, timezone, created_at + SELECT `+locationColumns+` FROM locations WHERE id = $1 - `, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt) + `, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt) return rec, err } func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) { var rec LocationRecord err := r.pool.QueryRow(ctx, ` - INSERT INTO locations (tenant_id, name, timezone) - VALUES ($1, $2, $3) - RETURNING id, tenant_id, name, timezone, created_at - `, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt) + INSERT INTO locations (tenant_id, name, type, capacity, timezone) + VALUES ($1, $2, $3, $4, $5) + RETURNING `+locationColumns+` + `, params.TenantID, params.Name, params.Type, params.Capacity, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt) return rec, err } @@ -53,11 +55,13 @@ func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, pa err := r.pool.QueryRow(ctx, ` UPDATE locations SET name = COALESCE($2, name), - timezone = COALESCE($3, timezone), + type = COALESCE($3, type), + capacity = COALESCE($4, capacity), + timezone = COALESCE($5, timezone), updated_at = now() WHERE id = $1 - RETURNING id, tenant_id, name, timezone, created_at - `, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt) + RETURNING `+locationColumns+` + `, locationID, params.Name, params.Type, params.Capacity, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt) return rec, err } @@ -120,7 +124,7 @@ func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) { rows, err := r.pool.Query(ctx, ` - SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local + SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text FROM availability_rules WHERE tenant_id = $1 AND staff_id IS NULL ORDER BY day_of_week @@ -142,11 +146,30 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st } func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error { - _, err := r.pool.Exec(ctx, ` - UPDATE availability_rules - SET starts_local = COALESCE($3, starts_local), - ends_local = COALESCE($4, ends_local) + if params.IsOpen != nil && !*params.IsOpen { + _, err := r.pool.Exec(ctx, ` + DELETE FROM availability_rules + WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL + `, tenantID, dayOfWeek) + return err + } + + if params.StartsLocal == nil || params.EndsLocal == nil { + return nil + } + + // availability_rules has no unique constraint on (tenant, day): replace the + // tenant-level row so repeated updates stay idempotent. + if _, err := r.pool.Exec(ctx, ` + DELETE FROM availability_rules WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL - `, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal) + `, tenantID, dayOfWeek); err != nil { + return err + } + + _, err := r.pool.Exec(ctx, ` + INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local) + VALUES (gen_random_uuid(), $1, NULL, $2, $3::time, $4::time) + `, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal) return err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index df6dc36..14c6d0a 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -19,7 +19,9 @@ type Repository interface { GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) - GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) + GetTenantMembershipBySubject(ctx context.Context, subject string) (TenantMembershipRecord, error) + IsAdminSubject(ctx context.Context, subject string) (bool, error) + GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) @@ -34,8 +36,9 @@ type Repository interface { GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error - UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error - UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error + UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error + UpdateTenantBillingState(ctx context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error + UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error) // Auth methods @@ -87,6 +90,11 @@ type Repository interface { ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error + // Email templates + ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error) + GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) + UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) + // SMS GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error @@ -190,6 +198,13 @@ type BrandProfileRecord struct { UmamiSiteID string } +type UpdateBrandProfileParams struct { + Name *string + SiteURL *string + LogoURL *string + PrimaryColor *string +} + type AvailabilityBlockRecord struct { DayOfWeek int StartsLocal string @@ -246,6 +261,8 @@ type BookingRecord struct { EndsAt time.Time Status string Reference string + ManageToken string + Notes string } type CreateBookingParams struct { @@ -262,13 +279,15 @@ type CreateBookingParams struct { EndsAt time.Time Status string Reference string + ManageToken string Notes string } type CreatedBooking struct { - ID string - Reference string - Status string + ID string + Reference string + Status string + ManageToken string } type WaitlistEntryParams struct { @@ -341,6 +360,8 @@ type LocationRecord struct { ID string TenantID string Name string + Type string + Capacity int Timezone string CreatedAt time.Time } @@ -348,11 +369,15 @@ type LocationRecord struct { type CreateLocationParams struct { TenantID string Name string + Type string + Capacity int Timezone string } type UpdateLocationParams struct { Name *string + Type *string + Capacity *int Timezone *string } @@ -428,6 +453,18 @@ type UpdateWorkingHoursParams struct { IsOpen *bool } +// Email Template Records +type EmailTemplateRecord struct { + ID string + TenantID string + Type string + Subject string + BodyHTML string + BodyText string + IsEnabled bool + UpdatedAt time.Time +} + // SMS Records type TenantSMSSettingsRecord struct { TenantID string @@ -592,11 +629,12 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri var rec BookingRecord err := r.pool.QueryRow(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, - customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference + customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, + COALESCE(manage_token, ''), COALESCE(notes, '') FROM bookings WHERE reference = $1 `, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, - &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference) + &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes) return rec, err } @@ -638,6 +676,7 @@ type MemoryRepository struct { smsSettings TenantSMSSettingsRecord smsLogs []SMSUsageLogRecord smsReports []SMSMonthlyReportRecord + emailTemplates []EmailTemplateRecord } func NewMemoryRepository() *MemoryRepository { @@ -659,7 +698,7 @@ func NewMemoryRepository() *MemoryRepository { Timezone: "Europe/Prague", PlanCode: "pro", SubscriptionStatus: "active", - BillingProvider: "paddle", + BillingProvider: "stripe", BillingCustomerID: stringPtr("ctm_demo_bookra"), }, membership: TenantMembershipRecord{ @@ -672,7 +711,7 @@ func NewMemoryRepository() *MemoryRepository { Timezone: "Europe/Prague", PlanCode: "pro", SubscriptionStatus: "active", - BillingProvider: "paddle", + BillingProvider: "stripe", BillingCustomerID: stringPtr("ctm_demo_bookra"), }, UserID: "demo-owner", @@ -719,7 +758,7 @@ func NewMemoryRepository() *MemoryRepository { deliveryLogs: []NotificationDeliveryLogParams{}, billingSnapshot: BillingSnapshotRecord{ TenantID: tenantID, - BillingProvider: "paddle", + BillingProvider: "stripe", BillingCustomerID: "ctm_demo_bookra", BillingSubscriptionID: "", Status: "active", @@ -841,16 +880,46 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT return r.membership, nil } -func (r *MemoryRepository) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) { - if userID == "" || userID == "demo-owner" { +func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) { + if subject == "" || subject == "demo-owner" { return r.membership, nil } - if userID == r.membership.UserID { + if subject == r.membership.UserID { return r.membership, nil } return TenantMembershipRecord{}, pgx.ErrNoRows } +func (r *MemoryRepository) IsAdminSubject(_ context.Context, subject string) (bool, error) { + return subject == "demo-owner" || subject == r.membership.UserID, nil +} + +func (r *MemoryRepository) GetTenantOwnerEmail(_ context.Context, tenantID string) (string, error) { + if tenantID != r.tenant.ID { + return "", pgx.ErrNoRows + } + return "owner@bookra.dev", nil +} + +func (r *MemoryRepository) UpdateBrandProfile(_ context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) { + if tenantID != r.tenant.ID { + return BrandProfileRecord{}, pgx.ErrNoRows + } + if params.Name != nil { + r.brand.Name = *params.Name + } + if params.SiteURL != nil { + r.brand.SiteURL = *params.SiteURL + } + if params.LogoURL != nil { + r.brand.LogoURL = *params.LogoURL + } + if params.PrimaryColor != nil { + r.brand.PrimaryColor = *params.PrimaryColor + } + return r.brand, nil +} + func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) { if tenantID != r.tenant.ID { return BrandProfileRecord{}, pgx.ErrNoRows @@ -909,9 +978,10 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) { created := CreatedBooking{ - ID: uuid.NewString(), - Reference: params.Reference, - Status: params.Status, + ID: uuid.NewString(), + Reference: params.Reference, + Status: params.Status, + ManageToken: params.ManageToken, } r.bookings = append(r.bookings, BookingRecord{ ID: created.ID, @@ -922,10 +992,13 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking LocationID: params.LocationID, CustomerName: params.CustomerName, CustomerEmail: params.CustomerEmail, + CustomerPhone: params.CustomerPhone, StartsAt: params.StartsAt, EndsAt: params.EndsAt, Status: params.Status, Reference: params.Reference, + ManageToken: params.ManageToken, + Notes: params.Notes, }) return created, nil } @@ -1033,38 +1106,38 @@ func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params return nil } -func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, customerID string) error { +func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, provider string, customerID string) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } - r.tenant.BillingProvider = "paddle" + r.tenant.BillingProvider = provider r.tenant.BillingCustomerID = &customerID - r.membership.Tenant.BillingProvider = "paddle" + r.membership.Tenant.BillingProvider = provider r.membership.Tenant.BillingCustomerID = &customerID - r.billingSnapshot.BillingProvider = "paddle" + r.billingSnapshot.BillingProvider = provider r.billingSnapshot.BillingCustomerID = customerID return nil } -func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error { +func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } r.tenant.PlanCode = planCode r.tenant.SubscriptionStatus = subscriptionStatus - r.tenant.BillingProvider = "paddle" + r.tenant.BillingProvider = provider r.membership.Tenant.PlanCode = planCode r.membership.Tenant.SubscriptionStatus = subscriptionStatus - r.membership.Tenant.BillingProvider = "paddle" + r.membership.Tenant.BillingProvider = provider r.billingSnapshot.PlanCode = planCode r.billingSnapshot.Status = subscriptionStatus - r.billingSnapshot.BillingProvider = "paddle" + r.billingSnapshot.BillingProvider = provider r.billingSnapshot.BillingSubscriptionID = subscriptionID return nil } func (r *MemoryRepository) RecordBillingEvent(_ context.Context, _ string, provider string, eventID string, _ string, payload []byte) (bool, error) { - key := firstNonEmpty(provider, "paddle") + ":" + eventID + key := firstNonEmpty(provider, "stripe") + ":" + eventID if _, exists := r.recordedEvents[key]; exists { return false, nil } @@ -1103,6 +1176,8 @@ func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocati ID: uuid.NewString(), TenantID: params.TenantID, Name: params.Name, + Type: params.Type, + Capacity: params.Capacity, Timezone: params.Timezone, CreatedAt: time.Now(), } @@ -1116,6 +1191,12 @@ func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string, if params.Name != nil { r.locations[i].Name = *params.Name } + if params.Type != nil { + r.locations[i].Type = *params.Type + } + if params.Capacity != nil { + r.locations[i].Capacity = *params.Capacity + } if params.Timezone != nil { r.locations[i].Timezone = *params.Timezone } @@ -1352,6 +1433,51 @@ func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string return pgx.ErrNoRows } +// ============================================ +// EMAIL TEMPLATE METHODS - MEMORY REPOSITORY +// ============================================ + +func (r *MemoryRepository) ListEmailTemplates(_ context.Context, tenantID string) ([]EmailTemplateRecord, error) { + if tenantID != r.tenant.ID { + return nil, pgx.ErrNoRows + } + return r.emailTemplates, nil +} + +func (r *MemoryRepository) GetEmailTemplate(_ context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) { + for _, t := range r.emailTemplates { + if t.TenantID == tenantID && t.Type == templateType { + return t, nil + } + } + return EmailTemplateRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) { + for i, t := range r.emailTemplates { + if t.TenantID == params.TenantID && t.Type == params.Type { + r.emailTemplates[i].Subject = params.Subject + r.emailTemplates[i].BodyHTML = params.BodyHTML + r.emailTemplates[i].BodyText = params.BodyText + r.emailTemplates[i].IsEnabled = params.IsEnabled + r.emailTemplates[i].UpdatedAt = time.Now() + return r.emailTemplates[i], nil + } + } + rec := EmailTemplateRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + Type: params.Type, + Subject: params.Subject, + BodyHTML: params.BodyHTML, + BodyText: params.BodyText, + IsEnabled: params.IsEnabled, + UpdatedAt: time.Now(), + } + r.emailTemplates = append(r.emailTemplates, rec) + return rec, nil +} + // ============================================ // WORKING HOURS METHODS - MEMORY REPOSITORY // ============================================ diff --git a/apps/backend/internal/db/services.go b/apps/backend/internal/db/services.go index bdc76d5..2cc7538 100644 --- a/apps/backend/internal/db/services.go +++ b/apps/backend/internal/db/services.go @@ -1,8 +1,8 @@ package db import ( -"context" -"time" + "context" + "time" ) func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) { diff --git a/apps/backend/internal/db/tenants.go b/apps/backend/internal/db/tenants.go index 7af4686..88fa815 100644 --- a/apps/backend/internal/db/tenants.go +++ b/apps/backend/internal/db/tenants.go @@ -2,6 +2,8 @@ package db import ( "context" + "errors" + "strings" "github.com/jackc/pgx/v5" ) @@ -49,9 +51,20 @@ func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, custome } func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error { + // Link an existing password-auth account to this Neon subject when the + // email already exists but has no subject attached yet. + if trimmed := strings.TrimSpace(email); trimmed != "" { + if _, err := r.pool.Exec(ctx, ` + UPDATE users SET neon_subject = $1 + WHERE email = $2 AND neon_subject IS NULL + `, subject, trimmed); err != nil { + return err + } + } + _, err := r.pool.Exec(ctx, ` INSERT INTO users (id, neon_subject, email, display_name) - VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, '')) + VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), COALESCE(NULLIF($3, ''), '')) ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name) `, subject, email, displayName) return err @@ -123,21 +136,22 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen _, err = tx.Exec(ctx, ` INSERT INTO users (id, neon_subject, email, display_name) - VALUES (gen_random_uuid(), $1, $2, $3) - ON CONFLICT (neon_subject) DO NOTHING - `, params.Subject, params.Subject+"@users.bookra.invalid", "") + SELECT gen_random_uuid(), $1, $2, '' + WHERE NOT EXISTS ( + SELECT 1 FROM users WHERE neon_subject = $1 OR id::text = $1 + ) + `, params.Subject, params.Subject+"@users.bookra.invalid") if err != nil { return TenantMembershipRecord{}, err } - var userID string + var membershipID string err = tx.QueryRow(ctx, ` INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at) - SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now() - FROM users u WHERE u.neon_subject = $2 + VALUES (gen_random_uuid(), $1, $2, 'owner', now()) ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner' RETURNING id - `, tenantID, params.Subject).Scan(&userID) + `, tenantID, params.Subject).Scan(&membershipID) if err != nil { return TenantMembershipRecord{}, err } @@ -156,7 +170,7 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen Locale: params.Locale, Timezone: params.Timezone, }, - UserID: userID, + UserID: membershipID, Role: "owner", }, nil } @@ -170,7 +184,7 @@ func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (Br return record, err } -func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) { +func (r *PGRepository) GetTenantMembershipBySubject(ctx context.Context, subject string) (TenantMembershipRecord, error) { var record TenantMembershipRecord err := r.pool.QueryRow(ctx, ` SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status, @@ -178,11 +192,10 @@ func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID s tm.user_neon_subject, tm.role FROM tenant_memberships tm JOIN tenants t ON t.id = tm.tenant_id - JOIN users u ON u.neon_subject = tm.user_neon_subject - WHERE u.id = $1 + WHERE tm.user_neon_subject = $1 ORDER BY tm.joined_at DESC LIMIT 1 - `, userID).Scan( + `, subject).Scan( &record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset, &record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus, &record.Tenant.BillingProvider, &record.Tenant.BillingCustomerID, &record.Tenant.BillingSubscription, @@ -191,20 +204,68 @@ func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID s return record, err } -func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error { +func (r *PGRepository) IsAdminSubject(ctx context.Context, subject string) (bool, error) { + var isAdmin bool + err := r.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM users + WHERE (neon_subject = $1 OR id::text = $1) + AND role IN ('admin', 'superadmin') + ) + `, subject).Scan(&isAdmin) + return isAdmin, err +} + +func (r *PGRepository) GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error) { + var email string + err := r.pool.QueryRow(ctx, ` + SELECT u.email + FROM tenant_memberships tm + JOIN users u ON u.neon_subject = tm.user_neon_subject OR u.id::text = tm.user_neon_subject + WHERE tm.tenant_id = $1 AND tm.role = 'owner' + ORDER BY tm.joined_at ASC + LIMIT 1 + `, tenantID).Scan(&email) + return email, err +} + +func (r *PGRepository) UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) { + var record BrandProfileRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color) + VALUES ($1, '', NULL, NULL, NULL) + ON CONFLICT (tenant_id) DO NOTHING + `, tenantID).Scan() + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return BrandProfileRecord{}, err + } + err = r.pool.QueryRow(ctx, ` + UPDATE brand_profiles + SET name = COALESCE($2, name), + site_url = COALESCE($3, site_url), + logo_url = COALESCE($4, logo_url), + primary_color = COALESCE($5, primary_color) + WHERE tenant_id = $1 + RETURNING tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '') + `, tenantID, params.Name, params.SiteURL, params.LogoURL, params.PrimaryColor).Scan( + &record.TenantID, &record.Name, &record.SiteURL, &record.LogoURL, &record.PrimaryColor, &record.UmamiSiteID) + return record, err +} + +func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error { _, err := r.pool.Exec(ctx, ` UPDATE tenants - SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now() + SET billing_provider = $2, billing_customer_id = $3, updated_at = now() WHERE id = $1 - `, tenantID, customerID) + `, tenantID, provider, customerID) return err } -func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error { +func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error { _, err := r.pool.Exec(ctx, ` UPDATE tenants - SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now() + SET billing_provider = $2, plan_code = $3, subscription_status = $4, billing_subscription_id = $5, updated_at = now() WHERE id = $1 - `, tenantID, planCode, subscriptionStatus, subscriptionID) + `, tenantID, provider, planCode, subscriptionStatus, subscriptionID) return err } diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 9478391..873d293 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -16,9 +16,14 @@ type DashboardKPI struct { } type UpcomingBooking struct { + ID string `json:"id"` Reference string `json:"reference"` CustomerName string `json:"customerName"` CustomerEmail string `json:"customerEmail"` + Service string `json:"service,omitempty"` + Location string `json:"location,omitempty"` + StaffName string `json:"staffName,omitempty"` + Notes string `json:"notes,omitempty"` StartsAt time.Time `json:"startsAt"` EndsAt time.Time `json:"endsAt"` Status string `json:"status"` @@ -143,6 +148,7 @@ type CreateBookingResponse struct { BookingID string `json:"bookingId"` Reference string `json:"reference"` Status string `json:"status"` + ManageURL string `json:"manageUrl"` } type PlanEntitlements struct { @@ -207,16 +213,9 @@ type CheckoutSessionRequest struct { } type CheckoutLaunchResponse struct { - // Stripe checkout - CheckoutURL string `json:"checkoutUrl,omitempty"` - // Paddle checkout - PriceID string `json:"priceId,omitempty"` - CustomerID string `json:"customerId,omitempty"` - CustomerEmail string `json:"customerEmail,omitempty"` - // Common - SuccessRedirectURL string `json:"successRedirectUrl,omitempty"` - CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"` - CustomData map[string]string `json:"customData,omitempty"` + CheckoutURL string `json:"checkoutUrl,omitempty"` + SuccessRedirectURL string `json:"successRedirectUrl,omitempty"` + CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"` } type PortalSessionResponse struct { @@ -247,6 +246,15 @@ type Location struct { CreatedAt time.Time `json:"createdAt"` } +type DashboardBookingRequest struct { + CustomerName string `json:"customerName" binding:"required"` + CustomerEmail string `json:"customerEmail" binding:"required,email"` + CustomerPhone string `json:"customerPhone,omitempty"` + Service string `json:"service,omitempty"` + StartsAt string `json:"startsAt" binding:"required"` + Notes string `json:"notes,omitempty"` +} + type CreateLocationRequest struct { Name string `json:"name" binding:"required"` Type string `json:"type" binding:"required"` @@ -431,6 +439,20 @@ type EmailTemplate struct { IsEnabled bool `json:"isEnabled"` } +type UpdateBrandRequest struct { + Name string `json:"name,omitempty"` + SiteURL string `json:"siteUrl,omitempty"` + LogoURL string `json:"logoUrl,omitempty"` + PrimaryColor string `json:"primaryColor" binding:"required"` +} + +type UpdateEmailTemplateRequest struct { + Type string `json:"type" binding:"required"` + Subject string `json:"subject"` + Body string `json:"body"` + IsEnabled *bool `json:"isEnabled,omitempty"` +} + type SendEmailRequest struct { To string `json:"to" binding:"required,email"` Subject string `json:"subject" binding:"required"` diff --git a/apps/backend/internal/httpx/middleware.go b/apps/backend/internal/httpx/middleware.go index 5eb6270..da61a7f 100644 --- a/apps/backend/internal/httpx/middleware.go +++ b/apps/backend/internal/httpx/middleware.go @@ -25,10 +25,11 @@ type visitor struct { } type RateLimiter struct { - mu sync.Mutex - visitors map[string]*visitor - limit rate.Limit - burst int + mu sync.Mutex + visitors map[string]*visitor + limit rate.Limit + burst int + cleanupOnce sync.Once } func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter { @@ -40,7 +41,9 @@ func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter { } func (r *RateLimiter) Middleware() gin.HandlerFunc { - go r.cleanupLoop() + r.cleanupOnce.Do(func() { + go r.cleanupLoop() + }) return func(c *gin.Context) { ip := c.ClientIP() diff --git a/apps/backend/internal/notifications/email_templates.go b/apps/backend/internal/notifications/email_templates.go index 7cc8070..9b068c4 100644 --- a/apps/backend/internal/notifications/email_templates.go +++ b/apps/backend/internal/notifications/email_templates.go @@ -21,6 +21,7 @@ const ( type BookingEmailData struct { Type EmailType + TenantID string TenantName string TenantSlug string BusinessEmail string diff --git a/apps/backend/internal/notifications/service.go b/apps/backend/internal/notifications/service.go index f8d4bc7..f033ce2 100644 --- a/apps/backend/internal/notifications/service.go +++ b/apps/backend/internal/notifications/service.go @@ -126,7 +126,7 @@ func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmail data.BusinessEmail = s.cfg.EmailFrom } data.Type = EmailTypeConfirmation - msg := RenderEmailMessage(data) + msg := s.renderWithOverride(ctx, data) _, err := s.emailProvider.Send(ctx, msg) return err } @@ -137,7 +137,7 @@ func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailDa data.BusinessEmail = s.cfg.EmailFrom } data.Type = EmailTypeReschedule - msg := RenderEmailMessage(data) + msg := s.renderWithOverride(ctx, data) _, err := s.emailProvider.Send(ctx, msg) return err } @@ -148,11 +148,73 @@ func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmail data.BusinessEmail = s.cfg.EmailFrom } data.Type = EmailTypeCancellation - msg := RenderEmailMessage(data) + msg := s.renderWithOverride(ctx, data) _, err := s.emailProvider.Send(ctx, msg) return err } +// renderWithOverride renders the default message and, when the tenant has an +// enabled custom template for this email type, swaps in the stored subject and +// body with {{variable}} substitution. +func (s *Service) renderWithOverride(ctx context.Context, data BookingEmailData) EmailMessage { + msg := RenderEmailMessage(data) + if s.repo == nil || data.TenantID == "" { + return msg + } + tpl, err := s.repo.GetEmailTemplate(ctx, data.TenantID, string(data.Type)) + if err != nil || !tpl.IsEnabled { + return msg + } + vars := map[string]string{ + "customerName": data.CustomerName, + "customerEmail": data.CustomerEmail, + "service": data.Service, + "location": data.Location, + "reference": data.Reference, + "startsAt": formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale), + "tenantName": data.TenantName, + "manageUrl": data.ManagementURL, + } + if tpl.Subject != "" { + msg.Subject = substituteTemplateVars(tpl.Subject, vars) + } + if tpl.BodyText != "" { + msg.Text = substituteTemplateVars(tpl.BodyText, vars) + if tpl.BodyHTML == "" { + msg.HTML = wrapTextAsHTML(msg.Text, data.BrandColor, data.TenantName, data.ManagementURL) + } + } + if tpl.BodyHTML != "" { + msg.HTML = substituteTemplateVars(tpl.BodyHTML, vars) + } + return msg +} + +func substituteTemplateVars(body string, vars map[string]string) string { + for key, value := range vars { + body = strings.ReplaceAll(body, "{{"+key+"}}", value) + } + return body +} + +func wrapTextAsHTML(text, brandColor, tenantName, manageURL string) string { + if brandColor == "" { + brandColor = "#c25e3a" + } + html := "" + html += "
" + html += fmt.Sprintf("

%s

", brandColor, tenantName) + for _, p := range splitParagraphs(text) { + if len(p) > 0 { + html += fmt.Sprintf("

%s

", p) + } + } + if manageURL != "" { + html += fmt.Sprintf("
Manage Booking
", manageURL, brandColor) + } + return html + "
" +} + // SendBusinessNotification sends a notification to the business about a new booking func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error { if businessEmail == "" { @@ -311,22 +373,24 @@ func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locatio return fmt.Errorf("failed to get tenant: %w", err) } - // Use a placeholder admin email - in production, would get from tenant owner - adminEmail := "admin@" + tenant.Slug + ".bookra.eu" + ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID) + if err != nil || ownerEmail == "" { + return nil + } emailData := UsageNotificationData{ Type: EmailTypeUsageWarning, TenantName: tenant.Name, TenantSlug: tenant.Slug, BusinessEmail: s.cfg.EmailFrom, - AdminEmail: adminEmail, + AdminEmail: ownerEmail, Locale: tenant.Locale, PlanCode: tenant.PlanCode, LocationCount: locationCount, LocationLimit: locationLimit, UsagePercent: usagePercent, - UpgradeURL: "https://bookra.eu/pricing", - DashboardURL: "https://bookra.eu/dashboard", + UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing", + DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard", } msg := RenderUsageNotificationEmail(emailData) @@ -348,19 +412,21 @@ func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, day return fmt.Errorf("failed to get tenant: %w", err) } - // Use a placeholder admin email - in production, would get from tenant owner - adminEmail := "admin@" + tenant.Slug + ".bookra.eu" + ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID) + if err != nil || ownerEmail == "" { + return nil + } emailData := UsageNotificationData{ Type: EmailTypeTrialEnding, TenantName: tenant.Name, TenantSlug: tenant.Slug, BusinessEmail: s.cfg.EmailFrom, - AdminEmail: adminEmail, + AdminEmail: ownerEmail, Locale: tenant.Locale, PlanCode: tenant.PlanCode, - UpgradeURL: "https://bookra.eu/pricing", - DashboardURL: "https://bookra.eu/dashboard", + UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing", + DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard", } msg := RenderUsageNotificationEmail(emailData) diff --git a/apps/backend/internal/shared/plans.go b/apps/backend/internal/shared/plans.go index ed189f0..9a77930 100644 --- a/apps/backend/internal/shared/plans.go +++ b/apps/backend/internal/shared/plans.go @@ -3,7 +3,7 @@ package shared import "strings" // NormalizePlanCode canonicalizes plan codes from various sources -// (Paddle checkout, webhook payloads, database records) into stable +// (Stripe checkout, webhook payloads, database records) into stable // internal identifiers used across the billing and tenancy domains. func NormalizePlanCode(planCode string) string { switch strings.TrimSpace(planCode) { diff --git a/apps/backend/internal/sms/service.go b/apps/backend/internal/sms/service.go index 0bdd7d6..6bab7b6 100644 --- a/apps/backend/internal/sms/service.go +++ b/apps/backend/internal/sms/service.go @@ -353,7 +353,7 @@ func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRec } // Find owner email - membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, tenant.ID) if err == nil { user, err := s.repo.GetUserByID(ctx, membership.UserID) if err == nil { diff --git a/apps/backend/internal/tenancy/service.go b/apps/backend/internal/tenancy/service.go index a8d8219..099d950 100644 --- a/apps/backend/internal/tenancy/service.go +++ b/apps/backend/internal/tenancy/service.go @@ -32,11 +32,11 @@ func NewService(repo db.Repository) *Service { } func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) { - return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + return s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) } func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) { - membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.TenantBootstrap{ @@ -76,7 +76,7 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do } 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 { + if _, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject); err == nil { return domain.TenantBootstrap{}, ErrTenantAlreadyProvisioned } else if !errors.Is(err, pgx.ErrNoRows) { return domain.TenantBootstrap{}, err @@ -184,6 +184,103 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque }, nil } +// UpdateBrand persists brand profile fields for the caller's tenant. +func (s *Service) UpdateBrand(ctx context.Context, principal domain.Principal, req domain.UpdateBrandRequest) (domain.BrandProfile, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.BrandProfile{}, err + } + + params := db.UpdateBrandProfileParams{ + Name: optionalTrimmed(req.Name), + SiteURL: optionalTrimmed(req.SiteURL), + LogoURL: optionalTrimmed(req.LogoURL), + PrimaryColor: optionalTrimmed(req.PrimaryColor), + } + if _, err := s.repo.UpdateBrandProfile(ctx, membership.Tenant.ID, params); err != nil { + return domain.BrandProfile{}, err + } + return s.brandProfile(ctx, membership.Tenant), nil +} + +func optionalTrimmed(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +var emailTemplateTypes = map[string]struct{}{ + "confirmation": {}, + "reminder": {}, + "reschedule": {}, + "cancellation": {}, +} + +// ListEmailTemplates returns all custom templates stored for the caller's tenant. +func (s *Service) ListEmailTemplates(ctx context.Context, principal domain.Principal) ([]domain.EmailTemplate, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return nil, err + } + records, err := s.repo.ListEmailTemplates(ctx, membership.Tenant.ID) + if err != nil { + return nil, err + } + out := make([]domain.EmailTemplate, len(records)) + for i, rec := range records { + out[i] = emailTemplateToDomain(rec) + } + return out, nil +} + +// UpdateEmailTemplate stores a subject/body override for a booking email type. +// Supported placeholders: {{customerName}}, {{service}}, {{location}}, +// {{reference}}, {{startsAt}}, {{tenantName}}, {{manageUrl}}. +func (s *Service) UpdateEmailTemplate(ctx context.Context, principal domain.Principal, req domain.UpdateEmailTemplateRequest) (domain.EmailTemplate, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.EmailTemplate{}, err + } + templateType := strings.ToLower(strings.TrimSpace(req.Type)) + if _, ok := emailTemplateTypes[templateType]; !ok { + return domain.EmailTemplate{}, ErrInvalidOnboarding + } + if len(req.Subject) > 200 || len(req.Body) > 20000 { + return domain.EmailTemplate{}, ErrInvalidOnboarding + } + + isEnabled := true + if req.IsEnabled != nil { + isEnabled = *req.IsEnabled + } + + rec, err := s.repo.UpsertEmailTemplate(ctx, db.EmailTemplateRecord{ + TenantID: membership.Tenant.ID, + Type: templateType, + Subject: strings.TrimSpace(req.Subject), + BodyText: strings.TrimSpace(req.Body), + IsEnabled: isEnabled, + }) + if err != nil { + return domain.EmailTemplate{}, err + } + return emailTemplateToDomain(rec), nil +} + +func emailTemplateToDomain(rec db.EmailTemplateRecord) domain.EmailTemplate { + return domain.EmailTemplate{ + ID: rec.ID, + TenantID: rec.TenantID, + Type: rec.Type, + Subject: rec.Subject, + BodyHTML: rec.BodyHTML, + BodyText: rec.BodyText, + IsEnabled: rec.IsEnabled, + } +} + func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile { brand, err := s.repo.GetBrandProfile(ctx, tenant.ID) if err != nil { diff --git a/apps/backend/migrations/00004_paddle_billing.sql b/apps/backend/migrations/00005_provider_billing_columns.sql similarity index 88% rename from apps/backend/migrations/00004_paddle_billing.sql rename to apps/backend/migrations/00005_provider_billing_columns.sql index 3c236f8..2234a8e 100644 --- a/apps/backend/migrations/00004_paddle_billing.sql +++ b/apps/backend/migrations/00005_provider_billing_columns.sql @@ -8,6 +8,7 @@ ALTER TABLE billing_snapshots ALTER TABLE subscription_events ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle'; +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -18,7 +19,9 @@ BEGIN ALTER TABLE tenants RENAME COLUMN stripe_customer_id TO billing_customer_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -29,7 +32,9 @@ BEGIN ALTER TABLE tenants RENAME COLUMN stripe_subscription_id TO billing_subscription_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -40,7 +45,9 @@ BEGIN ALTER TABLE billing_snapshots RENAME COLUMN stripe_customer_id TO billing_customer_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -51,7 +58,9 @@ BEGIN ALTER TABLE billing_snapshots RENAME COLUMN stripe_subscription_id TO billing_subscription_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -62,6 +71,7 @@ BEGIN ALTER TABLE subscription_events RENAME COLUMN stripe_event_id TO billing_provider_event_id; END IF; END $$; +-- +goose StatementEnd ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_stripe_event_id_key; ALTER TABLE subscription_events @@ -70,6 +80,7 @@ ALTER TABLE subscription_events -- +goose Down ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_provider_event_key; +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -80,7 +91,9 @@ BEGIN ALTER TABLE subscription_events RENAME COLUMN billing_provider_event_id TO stripe_event_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -91,7 +104,9 @@ BEGIN ALTER TABLE billing_snapshots RENAME COLUMN billing_subscription_id TO stripe_subscription_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -102,7 +117,9 @@ BEGIN ALTER TABLE billing_snapshots RENAME COLUMN billing_customer_id TO stripe_customer_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -113,7 +130,9 @@ BEGIN ALTER TABLE tenants RENAME COLUMN billing_subscription_id TO stripe_subscription_id; END IF; END $$; +-- +goose StatementEnd +-- +goose StatementBegin DO $$ BEGIN IF EXISTS ( @@ -124,6 +143,7 @@ BEGIN ALTER TABLE tenants RENAME COLUMN billing_customer_id TO stripe_customer_id; END IF; END $$; +-- +goose StatementEnd ALTER TABLE subscription_events DROP COLUMN IF EXISTS billing_provider; ALTER TABLE billing_snapshots DROP COLUMN IF EXISTS billing_provider; diff --git a/apps/backend/migrations/00005_neon_auth_subjects.sql b/apps/backend/migrations/00006_neon_auth_subjects.sql similarity index 100% rename from apps/backend/migrations/00005_neon_auth_subjects.sql rename to apps/backend/migrations/00006_neon_auth_subjects.sql diff --git a/apps/backend/migrations/00006_auth_and_admin.sql b/apps/backend/migrations/00007_auth_and_admin.sql similarity index 69% rename from apps/backend/migrations/00006_auth_and_admin.sql rename to apps/backend/migrations/00007_auth_and_admin.sql index 5a38f36..38865eb 100644 --- a/apps/backend/migrations/00006_auth_and_admin.sql +++ b/apps/backend/migrations/00007_auth_and_admin.sql @@ -1,22 +1,21 @@ -- +goose Up -- +goose StatementBegin --- Users table for authentication (migrated from auth-service) -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email VARCHAR(255) NOT NULL UNIQUE, - name VARCHAR(255), - password_hash VARCHAR(255), - email_verified BOOLEAN DEFAULT FALSE, - provider VARCHAR(50) NOT NULL DEFAULT 'email', - provider_id VARCHAR(255), - role VARCHAR(50) NOT NULL DEFAULT 'user', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - last_login_at TIMESTAMP WITH TIME ZONE -); +-- Authentication columns for the users table (migrated from auth-service). +-- The users table itself is created in 00001_init; this migration adds the +-- columns the auth and admin code paths require. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS name VARCHAR(255), + ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255), + ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS provider VARCHAR(50) NOT NULL DEFAULT 'email', + ADD COLUMN IF NOT EXISTS provider_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user', + ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP WITH TIME ZONE; -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +ALTER TABLE users ALTER COLUMN id SET DEFAULT gen_random_uuid(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email); CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id); CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); @@ -92,6 +91,18 @@ DROP TABLE IF EXISTS admin_audit_log; DROP TABLE IF EXISTS oauth_states; DROP TABLE IF EXISTS password_resets; DROP TABLE IF EXISTS magic_links; -DROP TABLE IF EXISTS users; + +DROP INDEX IF EXISTS idx_users_role; +DROP INDEX IF EXISTS idx_users_provider; +DROP INDEX IF EXISTS idx_users_email_unique; + +ALTER TABLE users + DROP COLUMN IF EXISTS last_login_at, + DROP COLUMN IF EXISTS role, + DROP COLUMN IF EXISTS provider_id, + DROP COLUMN IF EXISTS provider, + DROP COLUMN IF EXISTS email_verified, + DROP COLUMN IF EXISTS password_hash, + DROP COLUMN IF EXISTS name; -- +goose StatementEnd diff --git a/apps/backend/migrations/00006_onboarding_brand_billing.sql b/apps/backend/migrations/00008_onboarding_brand_billing.sql similarity index 95% rename from apps/backend/migrations/00006_onboarding_brand_billing.sql rename to apps/backend/migrations/00008_onboarding_brand_billing.sql index 484840e..9b0dd4f 100644 --- a/apps/backend/migrations/00006_onboarding_brand_billing.sql +++ b/apps/backend/migrations/00008_onboarding_brand_billing.sql @@ -27,11 +27,15 @@ CREATE TABLE IF NOT EXISTS team_invites ( email text NOT NULL, role text NOT NULL DEFAULT 'staff', status text NOT NULL DEFAULT 'pending', + expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (tenant_id, email) ); +ALTER TABLE team_invites + ADD COLUMN IF NOT EXISTS expires_at timestamptz; + UPDATE tenants SET plan_code = 'pro' WHERE plan_code = 'growth'; UPDATE tenants SET plan_code = 'business' WHERE plan_code = 'multi-location'; UPDATE billing_snapshots SET plan_code = 'pro' WHERE plan_code = 'growth'; diff --git a/apps/backend/migrations/00007_customers.sql b/apps/backend/migrations/00009_customers.sql similarity index 100% rename from apps/backend/migrations/00007_customers.sql rename to apps/backend/migrations/00009_customers.sql diff --git a/apps/backend/migrations/00008_sms.sql b/apps/backend/migrations/00010_sms.sql similarity index 90% rename from apps/backend/migrations/00008_sms.sql rename to apps/backend/migrations/00010_sms.sql index 964bd7c..42c644d 100644 --- a/apps/backend/migrations/00008_sms.sql +++ b/apps/backend/migrations/00010_sms.sql @@ -34,14 +34,12 @@ CREATE TABLE IF NOT EXISTS sms_monthly_reports ( UNIQUE (tenant_id, year_month) ); -CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_month ON sms_usage_logs (tenant_id, date_trunc('month', created_at)); CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_created ON sms_usage_logs (tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_sms_reports_tenant ON sms_monthly_reports (tenant_id, year_month DESC); -- +goose Down DROP INDEX IF EXISTS idx_sms_reports_tenant; DROP INDEX IF EXISTS idx_sms_usage_tenant_created; -DROP INDEX IF EXISTS idx_sms_usage_tenant_month; DROP TABLE IF EXISTS sms_monthly_reports; DROP TABLE IF EXISTS sms_usage_logs; DROP TABLE IF EXISTS tenant_sms_settings; diff --git a/apps/backend/migrations/00009_booking_phone.sql b/apps/backend/migrations/00011_booking_phone.sql similarity index 100% rename from apps/backend/migrations/00009_booking_phone.sql rename to apps/backend/migrations/00011_booking_phone.sql diff --git a/apps/backend/migrations/00012_memberships_and_manage_token.sql b/apps/backend/migrations/00012_memberships_and_manage_token.sql new file mode 100644 index 0000000..c73f2f7 --- /dev/null +++ b/apps/backend/migrations/00012_memberships_and_manage_token.sql @@ -0,0 +1,51 @@ +-- +goose Up + +-- Tenant memberships keyed by the auth subject (Neon Auth `sub`). +-- The auth middleware resolves JWT subjects to memberships through users.neon_subject. +CREATE TABLE IF NOT EXISTS tenant_memberships ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_neon_subject text NOT NULL, + role text NOT NULL DEFAULT 'staff', + joined_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, user_neon_subject) +); + +CREATE INDEX IF NOT EXISTS idx_tenant_memberships_subject ON tenant_memberships (user_neon_subject); + +-- Unguessable per-booking management token used by /v1/public/bookings/:reference endpoints. +ALTER TABLE bookings + ADD COLUMN IF NOT EXISTS manage_token text; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_manage_token + ON bookings (manage_token) + WHERE manage_token IS NOT NULL; + +-- Backfill tokens for pre-existing bookings. +UPDATE bookings +SET manage_token = replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') +WHERE manage_token IS NULL; + +-- locations.updated_at is written by the repository update path. +ALTER TABLE locations + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +-- Backfill memberships for every existing user linked via tenant_users. +-- Covers the seeded demo owner (users.neon_subject = 'demo-owner'). +INSERT INTO tenant_memberships (tenant_id, user_neon_subject, role) +SELECT tu.tenant_id, u.neon_subject, tu.role +FROM tenant_users tu +JOIN users u ON u.id = tu.user_id +WHERE u.neon_subject IS NOT NULL +ON CONFLICT (tenant_id, user_neon_subject) DO NOTHING; + +-- +goose Down + +ALTER TABLE locations + DROP COLUMN IF EXISTS updated_at; + +DROP INDEX IF EXISTS idx_bookings_manage_token; +ALTER TABLE bookings + DROP COLUMN IF EXISTS manage_token; + +DROP TABLE IF EXISTS tenant_memberships; diff --git a/apps/backend/migrations/00013_location_type_capacity.sql b/apps/backend/migrations/00013_location_type_capacity.sql new file mode 100644 index 0000000..75a4a55 --- /dev/null +++ b/apps/backend/migrations/00013_location_type_capacity.sql @@ -0,0 +1,25 @@ +-- +goose Up +ALTER TABLE locations + ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'room', + ADD COLUMN IF NOT EXISTS capacity integer NOT NULL DEFAULT 1; + +-- Per-tenant custom email templates (overrides for built-in subjects/bodies). +CREATE TABLE IF NOT EXISTS email_templates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + type text NOT NULL, + subject text NOT NULL DEFAULT '', + body_html text NOT NULL DEFAULT '', + body_text text NOT NULL DEFAULT '', + is_enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, type) +); + +-- +goose Down +DROP TABLE IF EXISTS email_templates; + +ALTER TABLE locations + DROP COLUMN IF EXISTS type, + DROP COLUMN IF EXISTS capacity; diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 6cb3b17..b68df44 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1,18 +1,22 @@ openapi: 3.0.3 info: title: Bookra API - version: 0.1.0 + version: 0.2.0 description: > Remote-first booking API for Bookra. The Go backend owns business rules, - scheduling logic, tenant isolation, and Paddle-backed plan enforcement. + scheduling logic, tenant isolation, and Stripe-backed plan enforcement. servers: - url: http://localhost:8080 tags: - name: Health + - name: Auth + - name: Admin - name: Public Booking - name: Dashboard - name: Tenant + - name: Catalog - name: Billing + - name: SMS - name: Jobs paths: /healthz: @@ -37,6 +41,218 @@ paths: application/json: schema: $ref: "#/components/schemas/PublicConfig" + /v1/public/widget.js: + get: + tags: [Public Booking] + operationId: getWidgetScript + responses: + "200": + description: Embeddable widget script + content: + application/javascript: + schema: + type: string + # ---------------- Auth ---------------- + /v1/auth/register: + post: + tags: [Auth] + operationId: register + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + minLength: 8 + name: + type: string + responses: + "201": + description: User created + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "400": + description: Invalid request + "409": + description: Email already registered + /v1/auth/login: + post: + tags: [Auth] + operationId: login + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + responses: + "200": + description: Login succeeded + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "401": + description: Invalid credentials + /v1/auth/magic-link: + post: + tags: [Auth] + operationId: requestMagicLink + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + "200": + description: Magic link issued (always 200 to prevent enumeration) + /v1/auth/verify: + post: + tags: [Auth] + operationId: verifyMagicLink + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + responses: + "200": + description: Token verified + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "401": + description: Invalid or expired token + /v1/auth/refresh: + post: + tags: [Auth] + operationId: refreshTokens + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + responses: + "200": + description: Tokens rotated + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "401": + description: Invalid refresh token + # ---------------- Admin ---------------- + /v1/admin/stats: + get: + tags: [Admin] + operationId: getAdminStats + security: + - adminAuth: [] + - bearerAuth: [] + responses: + "200": + description: Platform statistics + "403": + description: Forbidden + /v1/admin/tenants: + get: + tags: [Admin] + operationId: listAdminTenants + security: + - adminAuth: [] + - bearerAuth: [] + responses: + "200": + description: All tenants + "403": + description: Forbidden + /v1/admin/users: + get: + tags: [Admin] + operationId: listAdminUsers + security: + - adminAuth: [] + - bearerAuth: [] + responses: + "200": + description: All users + "403": + description: Forbidden + /v1/admin/users/{userID}/role: + put: + tags: [Admin] + operationId: updateUserRole + security: + - adminAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: userID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + enum: [owner, staff, admin] + responses: + "200": + description: Role updated + "403": + description: Forbidden + /v1/admin/trigger-trial-emails: + post: + tags: [Admin] + operationId: triggerTrialEmails + security: + - adminAuth: [] + - bearerAuth: [] + responses: + "200": + description: Trial emails triggered + "403": + description: Forbidden + # ---------------- Public Booking ---------------- /v1/public/tenants/{tenantSlug}/availability: get: tags: [Public Booking] @@ -71,6 +287,112 @@ paths: application/json: schema: $ref: "#/components/schemas/CreateBookingResponse" + "409": + description: Slot no longer available + /v1/public/bookings/{reference}: + get: + tags: [Public Booking] + operationId: getBookingByReference + parameters: + - in: path + name: reference + required: true + schema: + type: string + - in: query + name: token + required: true + schema: + type: string + description: Manage token issued at booking time + responses: + "200": + description: Booking details + "403": + description: Invalid manage token + "404": + description: Booking not found + /v1/public/bookings/{reference}/reschedule: + post: + tags: [Public Booking] + operationId: rescheduleBooking + parameters: + - in: path + name: reference + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, startsAt] + properties: + token: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + responses: + "200": + description: Booking rescheduled + "403": + description: Invalid manage token + "409": + description: New slot conflicts + /v1/public/bookings/{reference}/cancel: + post: + tags: [Public Booking] + operationId: cancelBooking + parameters: + - in: path + name: reference + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + responses: + "200": + description: Booking cancelled + "403": + description: Invalid manage token + /v1/public/contact: + post: + tags: [Public Booking] + operationId: submitContactForm + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, email, message] + properties: + name: + type: string + email: + type: string + format: email + message: + type: string + responses: + "200": + description: Message accepted + # ---------------- Dashboard ---------------- /v1/dashboard/summary: get: tags: [Dashboard] @@ -86,6 +408,7 @@ paths: $ref: "#/components/schemas/DashboardSummary" "401": description: Unauthorized + # ---------------- Tenant ---------------- /v1/tenants/bootstrap: get: tags: [Tenant] @@ -126,6 +449,356 @@ paths: description: Unauthorized "409": description: Conflict + /v1/tenants/brand: + put: + tags: [Tenant] + operationId: updateTenantBrand + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateBrandRequest" + responses: + "200": + description: Brand profile updated + content: + application/json: + schema: + $ref: "#/components/schemas/BrandProfile" + "401": + description: Unauthorized + /v1/tenants/email-templates: + get: + tags: [Tenant] + operationId: listEmailTemplates + security: + - bearerAuth: [] + responses: + "200": + description: Tenant email templates + content: + application/json: + schema: + type: object + required: [templates] + properties: + templates: + type: array + items: + $ref: "#/components/schemas/EmailTemplate" + "401": + description: Unauthorized + /v1/tenants/email-template: + put: + tags: [Tenant] + operationId: updateEmailTemplate + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEmailTemplateRequest" + responses: + "200": + description: Template saved + content: + application/json: + schema: + $ref: "#/components/schemas/EmailTemplate" + "400": + description: Invalid request + "401": + description: Unauthorized + # ---------------- Catalog ---------------- + /v1/catalog/locations: + get: + tags: [Catalog] + operationId: listLocations + security: + - bearerAuth: [] + responses: + "200": + description: Tenant locations + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Location" + "401": + description: Unauthorized + post: + tags: [Catalog] + operationId: createLocation + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertLocationRequest" + responses: + "201": + description: Location created + content: + application/json: + schema: + $ref: "#/components/schemas/Location" + "400": + description: Invalid request + "403": + description: Plan limit reached + /v1/catalog/locations/{locationID}: + put: + tags: [Catalog] + operationId: updateLocation + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/locationID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertLocationRequest" + responses: + "200": + description: Location updated + content: + application/json: + schema: + $ref: "#/components/schemas/Location" + "404": + description: Location not found + delete: + tags: [Catalog] + operationId: deleteLocation + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/locationID" + responses: + "200": + description: Location deleted + "404": + description: Location not found + /v1/catalog/bookings: + post: + tags: [Catalog] + operationId: createDashboardBooking + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DashboardBookingRequest" + responses: + "201": + description: Booking created + content: + application/json: + schema: + $ref: "#/components/schemas/UpcomingBooking" + "400": + description: Invalid request + "409": + description: Conflict with existing booking + /v1/catalog/blocked-days: + get: + tags: [Catalog] + operationId: listBlockedDays + security: + - bearerAuth: [] + responses: + "200": + description: Blocked days + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BlockedDay" + post: + tags: [Catalog] + operationId: createBlockedDay + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertBlockedDayRequest" + responses: + "201": + description: Blocked day created + content: + application/json: + schema: + $ref: "#/components/schemas/BlockedDay" + /v1/catalog/blocked-days/{blockedDayID}: + put: + tags: [Catalog] + operationId: updateBlockedDay + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/blockedDayID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertBlockedDayRequest" + responses: + "200": + description: Blocked day updated + content: + application/json: + schema: + $ref: "#/components/schemas/BlockedDay" + "404": + description: Not found + delete: + tags: [Catalog] + operationId: deleteBlockedDay + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/blockedDayID" + responses: + "200": + description: Blocked day deleted + "404": + description: Not found + /v1/catalog/customers: + get: + tags: [Catalog] + operationId: listCustomers + security: + - bearerAuth: [] + parameters: + - in: query + name: query + schema: + type: string + - in: query + name: limit + schema: + type: integer + default: 50 + responses: + "200": + description: Tenant customers + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Customer" + post: + tags: [Catalog] + operationId: createCustomer + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertCustomerRequest" + responses: + "201": + description: Customer created + content: + application/json: + schema: + $ref: "#/components/schemas/Customer" + /v1/catalog/customers/{customerID}: + put: + tags: [Catalog] + operationId: updateCustomer + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/customerID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertCustomerRequest" + responses: + "200": + description: Customer updated + content: + application/json: + schema: + $ref: "#/components/schemas/Customer" + "404": + description: Not found + delete: + tags: [Catalog] + operationId: deleteCustomer + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/customerID" + responses: + "200": + description: Customer deleted + "404": + description: Not found + /v1/catalog/working-hours: + get: + tags: [Catalog] + operationId: listWorkingHours + security: + - bearerAuth: [] + responses: + "200": + description: Weekly working hours + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkingHours" + /v1/catalog/working-hours/{dayOfWeek}: + put: + tags: [Catalog] + operationId: updateWorkingHours + security: + - bearerAuth: [] + parameters: + - in: path + name: dayOfWeek + required: true + schema: + type: integer + minimum: 0 + maximum: 6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateWorkingHoursRequest" + responses: + "200": + description: Working hours updated + "400": + description: Invalid request + # ---------------- Billing ---------------- /v1/billing/subscription: get: tags: [Billing] @@ -155,7 +828,7 @@ paths: $ref: "#/components/schemas/CheckoutSessionRequest" responses: "200": - description: Paddle checkout launch payload + description: Stripe checkout session content: application/json: schema: @@ -191,7 +864,7 @@ paths: - bearerAuth: [] responses: "200": - description: Paddle customer portal session + description: Stripe customer portal session content: application/json: schema: @@ -202,10 +875,10 @@ paths: description: Unauthorized "503": description: Billing provider unavailable - /v1/webhooks/paddle: + /v1/webhooks/stripe: post: tags: [Billing] - operationId: handlePaddleWebhook + operationId: handleStripeWebhook requestBody: required: true content: @@ -215,6 +888,94 @@ paths: responses: "200": description: Webhook accepted + "400": + description: Invalid signature or payload + # ---------------- SMS ---------------- + /v1/sms/settings: + get: + tags: [SMS] + operationId: getSmsSettings + security: + - bearerAuth: [] + responses: + "200": + description: SMS settings and current-month usage + post: + tags: [SMS] + operationId: updateSmsSettings + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + sender: + type: string + monthlyLimit: + type: integer + responses: + "200": + description: SMS settings saved + /v1/sms/send: + post: + tags: [SMS] + operationId: sendSms + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [to, message] + properties: + to: + type: string + message: + type: string + responses: + "200": + description: SMS queued + /v1/sms/usage: + get: + tags: [SMS] + operationId: getSmsUsage + security: + - bearerAuth: [] + parameters: + - in: query + name: month + schema: + type: string + example: "2026-09" + responses: + "200": + description: Usage for the month + /v1/sms/history: + get: + tags: [SMS] + operationId: getSmsHistory + security: + - bearerAuth: [] + responses: + "200": + description: Recent SMS logs + /v1/sms/invoices: + get: + tags: [SMS] + operationId: listSmsInvoices + security: + - bearerAuth: [] + responses: + "200": + description: Monthly invoice reports + # ---------------- Jobs ---------------- /v1/internal/jobs/reminders/dispatch: post: tags: [Jobs] @@ -236,6 +997,17 @@ paths: $ref: "#/components/schemas/DispatchReminderJobsResponse" "401": description: Unauthorized + /v1/internal/jobs/sms/invoices: + post: + tags: [Jobs] + operationId: generateSmsInvoices + security: + - jobRunnerKey: [] + responses: + "200": + description: Monthly SMS invoices generated + "401": + description: Unauthorized components: securitySchemes: bearerAuth: @@ -246,6 +1018,33 @@ components: type: apiKey in: header name: X-Bookra-Job-Key + adminAuth: + type: apiKey + in: header + name: X-Admin-Key + description: Requires matching X-Admin-Email header as well. + parameters: + locationID: + in: path + name: locationID + required: true + schema: + type: string + format: uuid + blockedDayID: + in: path + name: blockedDayID + required: true + schema: + type: string + format: uuid + customerID: + in: path + name: customerID + required: true + schema: + type: string + format: uuid schemas: HealthResponse: type: object @@ -272,6 +1071,25 @@ components: format: uri demoMode: type: boolean + AuthResponse: + type: object + required: [accessToken, refreshToken] + properties: + accessToken: + type: string + refreshToken: + type: string + expiresIn: + type: integer + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string TimeSlot: type: object required: [startsAt, endsAt, mode] @@ -367,6 +1185,9 @@ components: status: type: string enum: [confirmed, waitlisted] + manageToken: + type: string + description: Token required to view/reschedule/cancel the booking DashboardKPI: type: object required: [code, label, value] @@ -412,12 +1233,19 @@ components: UpcomingBooking: type: object properties: + id: + type: string + format: uuid reference: type: string customerName: type: string customerEmail: type: string + service: + type: string + location: + type: string startsAt: type: string format: date-time @@ -428,6 +1256,30 @@ components: type: string label: type: string + notes: + type: string + DashboardBookingRequest: + type: object + required: [customerName, customerEmail, startsAt] + properties: + customerName: + type: string + customerEmail: + type: string + format: email + customerPhone: + type: string + serviceId: + type: string + format: uuid + locationId: + type: string + format: uuid + startsAt: + type: string + format: date-time + notes: + type: string WidgetSnippet: type: object properties: @@ -457,6 +1309,42 @@ components: type: string primaryColor: type: string + UpdateBrandRequest: + type: object + properties: + name: + type: string + siteUrl: + type: string + logoUrl: + type: string + primaryColor: + type: string + EmailTemplate: + type: object + properties: + type: + type: string + enum: [confirmation, reminder, reschedule, cancellation] + subject: + type: string + bodyText: + type: string + isEnabled: + type: boolean + UpdateEmailTemplateRequest: + type: object + required: [type] + properties: + type: + type: string + enum: [confirmation, reminder, reschedule, cancellation] + subject: + type: string + body: + type: string + isEnabled: + type: boolean TenantBootstrap: type: object required: [tenantId, tenantName, preset, locale, timezone, currentUser] @@ -561,6 +1449,121 @@ components: format: email role: type: string + Location: + type: object + required: [id, name] + properties: + id: + type: string + format: uuid + name: + type: string + type: + type: string + enum: [room, private, hall] + capacity: + type: integer + address: + type: string + UpsertLocationRequest: + type: object + required: [name] + properties: + name: + type: string + type: + type: string + enum: [room, private, hall] + capacity: + type: integer + address: + type: string + BlockedDay: + type: object + required: [id, date, type] + properties: + id: + type: string + format: uuid + date: + type: string + format: date-time + type: + type: string + enum: [closure, holiday, maintenance] + reason: + type: string + UpsertBlockedDayRequest: + type: object + required: [date, type] + properties: + date: + type: string + format: date-time + type: + type: string + enum: [closure, holiday, maintenance] + reason: + type: string + Customer: + type: object + required: [id, name, email] + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + format: email + phone: + type: string + notes: + type: string + bookingsCount: + type: integer + lastBookingAt: + type: string + format: date-time + nullable: true + UpsertCustomerRequest: + type: object + required: [name, email] + properties: + name: + type: string + email: + type: string + format: email + phone: + type: string + notes: + type: string + WorkingHours: + type: object + properties: + dayOfWeek: + type: integer + open: + type: string + example: "09:00" + close: + type: string + example: "18:00" + isOpen: + type: boolean + UpdateWorkingHoursRequest: + type: object + properties: + open: + type: string + example: "09:00" + close: + type: string + example: "18:00" + isOpen: + type: boolean PlanEntitlements: type: object required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] @@ -569,6 +1572,8 @@ components: type: integer maxStaff: type: integer + maxBookingsMonth: + type: integer emailReminders: type: boolean widgetEmbedding: @@ -586,7 +1591,7 @@ components: format: uuid provider: type: string - example: paddle + example: stripe customerId: type: string subscriptionId: @@ -676,25 +1681,12 @@ components: description: Percentage saved with yearly billing CheckoutLaunchResponse: type: object - description: | - Checkout launch response supporting both Stripe and Paddle providers. - For Stripe: checkoutUrl is returned (redirect-based checkout). - For Paddle: priceId, customerId, customerEmail, customData are returned (client-side checkout). + description: Stripe checkout launch payload — redirect the user to checkoutUrl. properties: checkoutUrl: type: string format: uri description: Stripe checkout URL (redirect the user to this URL) - priceId: - type: string - description: Paddle price ID for client-side checkout - customerId: - type: string - description: Paddle customer ID - customerEmail: - type: string - format: email - description: Customer email for Paddle checkout successRedirectUrl: type: string format: uri @@ -703,11 +1695,6 @@ components: type: string format: uri description: URL to redirect after cancelled checkout - customData: - type: object - additionalProperties: - type: string - description: Custom metadata for Paddle checkout PortalSessionResponse: type: object required: [url] diff --git a/apps/backend/sql/bookings.sql b/apps/backend/sql/bookings.sql deleted file mode 100644 index 1709041..0000000 --- a/apps/backend/sql/bookings.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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; - diff --git a/apps/backend/sql/tenants.sql b/apps/backend/sql/tenants.sql deleted file mode 100644 index 3fca78e..0000000 --- a/apps/backend/sql/tenants.sql +++ /dev/null @@ -1,10 +0,0 @@ --- name: GetTenantBySlug :one -SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_customer_id, billing_subscription_id, created_at, updated_at -FROM tenants -WHERE slug = $1; - --- name: ListTenantUsers :many -SELECT tenant_id, user_id, role, created_at -FROM tenant_users -WHERE tenant_id = $1 -ORDER BY created_at ASC; diff --git a/apps/backend/sqlc.yaml b/apps/backend/sqlc.yaml deleted file mode 100644 index b8054cc..0000000 --- a/apps/backend/sqlc.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 - diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example index 6bf8aea..50ea4ae 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -1,10 +1,13 @@ VITE_BOOKRA_APP_ENV=staging VITE_BOOKRA_API_URL=http://localhost:8080 + # Primary production auth path: Neon Auth. -VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth -VITE_PADDLE_ENV=sandbox -VITE_PADDLE_CLIENT_TOKEN= +VITE_NEON_AUTH_URL=https://ep-example.neonauth.region.aws.neon.tech/neondb/auth + VITE_DEFAULT_LOCALE=cs # Demo Mode: Set to true to show demo UI indicators VITE_BOOKRA_DEMO_MODE=false + +# Optional error monitoring +VITE_SENTRY_DSN= diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 5c92300..21b1952 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -12,11 +12,9 @@ "dependencies": { "@bookra/api-client": "0.1.0", "@bookra/shared-types": "0.1.0", - "@neondatabase/neon-js": "^0.2.0-beta.1", - "@paddle/paddle-js": "^1.3.2", + "@neondatabase/neon-js": "^0.7.0-beta", "@sentry/react": "^10.52.0", "@solidjs/router": "^0.15.3", - "@stripe/stripe-js": "^4.0.0", "solid-js": "^1.9.5" }, "devDependencies": { diff --git a/apps/frontend/src/components/dashboard/calendar-view.tsx b/apps/frontend/src/components/dashboard/calendar-view.tsx index 14ffcf0..55f1942 100644 --- a/apps/frontend/src/components/dashboard/calendar-view.tsx +++ b/apps/frontend/src/components/dashboard/calendar-view.tsx @@ -83,17 +83,29 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin return calendarDays().find((d) => d.day === day)?.bookings ?? []; }); - // Booking visual density for bars + const eventDot = (status: string) => + status === "confirmed" ? "bg-accent" : status === "pending" ? "bg-ink-subtle" : "bg-border-strong"; + + // Up to two readable event chips, then a "+N" overflow marker. const getBookingBars = (bookings: any[]) => { if (bookings.length === 0) return null; - const count = bookings.length; - const hasMultiple = count >= 2; - const hasMany = count >= 3; + const visible = bookings.slice(0, 2); + const overflow = bookings.length - visible.length; return ( -
-
- {hasMultiple &&
} - {hasMany &&
} +
+ + {(b) => ( +
+ + + {new Date(b.startsAt).toLocaleTimeString(isCs() ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })} + +
+ )} +
+ 0}> +

+{overflow} {isCs() ? "další" : "more"}

+
); }; @@ -145,29 +157,25 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
{calendarDays().map(({ day, bookings, isToday }) => { if (!day) { - return
; + return
; } return ( ); })} diff --git a/apps/frontend/src/components/dashboard/notification-dropdown.tsx b/apps/frontend/src/components/dashboard/notification-dropdown.tsx index 221e0a4..03fca7c 100644 --- a/apps/frontend/src/components/dashboard/notification-dropdown.tsx +++ b/apps/frontend/src/components/dashboard/notification-dropdown.tsx @@ -26,8 +26,8 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) id: `booking-${i}`, type: "booking", title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`, - message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString()}`, - time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }), + message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US")}`, + time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }), read: false, }); }); diff --git a/apps/frontend/src/components/dashboard/revenue-chart.tsx b/apps/frontend/src/components/dashboard/revenue-chart.tsx index 974133a..5fdf6e7 100644 --- a/apps/frontend/src/components/dashboard/revenue-chart.tsx +++ b/apps/frontend/src/components/dashboard/revenue-chart.tsx @@ -1,32 +1,43 @@ -import { createMemo } from "solid-js"; +import { createMemo, Show } from "solid-js"; import { useI18n } from "../../providers/i18n-provider"; interface DataPoint { label: string; + dateLabel: string; value: number; + isToday: boolean; + isFuture: boolean; } export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) { const i18n = useI18n(); + const isCs = () => i18n.locale() === "cs"; - const chartData = createMemo(() => { + const chartData = createMemo(() => { if (props.data && props.data.length > 0) return props.data; - // Build last 7 days from bookings - const days: DataPoint[] = []; + // Bucket bookings into the current calendar week (Mon-Sun) so both + // completed and upcoming bookings are visible. const bookings = props.bookings ?? []; const today = new Date(); - for (let i = 6; i >= 0; i--) { - const d = new Date(today); - d.setDate(d.getDate() - i); - const dateStr = d.toISOString().split("T")[0]; + const monday = new Date(today); + monday.setDate(today.getDate() - ((today.getDay() + 6) % 7)); + + const days: DataPoint[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + const isToday = d.toDateString() === today.toDateString(); const count = bookings.filter((b: any) => { - const bd = new Date(b.startsAt).toISOString().split("T")[0]; - return bd === dateStr; + const bd = new Date(b.startsAt); + return bd.getFullYear() === d.getFullYear() && bd.getMonth() === d.getMonth() && bd.getDate() === d.getDate(); }).length; days.push({ - label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }), + label: d.toLocaleDateString(isCs() ? "cs-CZ" : "en-US", { weekday: "short" }), + dateLabel: d.toLocaleDateString(isCs() ? "cs-CZ" : "en-US", { day: "numeric", month: "numeric" }), value: count, + isToday, + isFuture: d > today, }); } return days; @@ -36,34 +47,46 @@ export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) { const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0)); return ( -
-
+
+
-

{i18n.t("dashboard.revenueTitle")}

+

{i18n.t("dashboard.revenueTitle")}

{i18n.t("dashboard.revenueSubtitle")}

-
-

{total()}

+
+

{total()}

{i18n.t("dashboard.totalBookings")}

-
- {chartData().map((point, i) => { +
+ {chartData().map((point) => { const heightPct = Math.round((point.value / maxValue()) * 100); return ( -
-
+
+
-
+ class={`w-full rounded-t-lg transition-all duration-300 ${ + point.isToday + ? "bg-accent" + : point.isFuture + ? "bg-accent/40 group-hover:bg-accent/60" + : "bg-accent/70 group-hover:bg-accent" + }`} + style={{ height: `${Math.max(heightPct, point.value > 0 ? 8 : 3)}%`, "min-height": point.value > 0 ? "6px" : "3px", opacity: point.value === 0 ? 0.15 : 1 }} + /> + 0}> + {point.value} -
-
+ + +
+
+ + {point.label} + +

{point.dateLabel}

- {point.label}
); })} diff --git a/apps/frontend/src/components/pinned-list.tsx b/apps/frontend/src/components/pinned-list.tsx index 66a1d7d..8ae25c3 100644 --- a/apps/frontend/src/components/pinned-list.tsx +++ b/apps/frontend/src/components/pinned-list.tsx @@ -82,21 +82,21 @@ export function PinnedList(props: PinnedListProps) { const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => { const content = (
-
+
{p.item.icon}
-

{p.item.name}

-

{p.item.subtitle}

+

{p.item.name}

+

{p.item.subtitle}

diff --git a/apps/frontend/src/lib/paddle.ts b/apps/frontend/src/lib/paddle.ts deleted file mode 100644 index 0aa3fd2..0000000 --- a/apps/frontend/src/lib/paddle.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { initializePaddle, type Paddle } from "@paddle/paddle-js"; - -const paddleToken = import.meta.env.VITE_PADDLE_CLIENT_TOKEN ?? ""; -const paddleEnvironment = (import.meta.env.VITE_PADDLE_ENV ?? "sandbox").toLowerCase() === "live" ? "production" : "sandbox"; - -let paddlePromise: Promise | null = null; - -export function paddleConfigured() { - return paddleToken.trim() !== ""; -} - -export async function getPaddle() { - if (!paddleConfigured()) { - return undefined; - } - if (!paddlePromise) { - paddlePromise = initializePaddle({ - environment: paddleEnvironment, - token: paddleToken, - }); - } - return paddlePromise; -} diff --git a/apps/frontend/src/lib/stripe.ts b/apps/frontend/src/lib/stripe.ts deleted file mode 100644 index 976c558..0000000 --- a/apps/frontend/src/lib/stripe.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { loadStripe, type Stripe } from "@stripe/stripe-js"; - -const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; - -let stripePromise: Promise | null = null; - -export function stripeConfigured() { - return stripePublishableKey.trim() !== ""; -} - -export async function getStripe() { - if (!stripeConfigured()) { - return null; - } - if (!stripePromise) { - stripePromise = loadStripe(stripePublishableKey); - } - return stripePromise; -} diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 8401875..80da44f 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -348,7 +348,11 @@ const dictionaries = { "dashboard.onboarding.submit": "Vytvořit prostor", "dashboard.onboarding.pending": "Vytvářím prostor...", "dashboard.revenueTitle": "Trend rezervací", - "dashboard.revenueSubtitle": "Počet rezervací za posledních 7 dní", + "dashboard.revenueSubtitle": "Počet rezervací v tomto týdnu", + "dashboard.customerStatus.active": "Aktivní", + "dashboard.customerStatus.inactive": "Neaktivní", + "dashboard.bookingModal.date": "Datum", + "dashboard.bookingModal.time": "Čas", "dashboard.noNotifications": "Žádná nová oznámení", "dashboard.markAllRead": "Označit vše jako přečtené", "dashboard.notifications.title": "Oznámení", @@ -512,7 +516,7 @@ const dictionaries = { "legal.terms.service.title": "Používání služby", "legal.terms.service.body": "Službu je nutné používat v souladu se zákonem, bez zneužití rezervačních formulářů, obcházení zabezpečení nebo nahrávání zakázaného obsahu.", "legal.terms.billing.title": "Předplatné", - "legal.terms.billing.body": "Placené plány se účtují přes Paddle. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce.", + "legal.terms.billing.body": "Placené plány se účtují přes Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce.", }, en: { // Navigation & Auth @@ -853,7 +857,11 @@ const dictionaries = { "dashboard.onboarding.submit": "Create workspace", "dashboard.onboarding.pending": "Creating workspace...", "dashboard.revenueTitle": "Booking trend", - "dashboard.revenueSubtitle": "Bookings over the last 7 days", + "dashboard.revenueSubtitle": "Bookings this week", + "dashboard.customerStatus.active": "Active", + "dashboard.customerStatus.inactive": "Inactive", + "dashboard.bookingModal.date": "Date", + "dashboard.bookingModal.time": "Time", "dashboard.noNotifications": "No new notifications", "dashboard.markAllRead": "Mark all as read", "dashboard.notifications.title": "Notifications", @@ -1017,7 +1025,7 @@ const dictionaries = { "legal.terms.service.title": "Using the service", "legal.terms.service.body": "The service must be used lawfully, without abusing booking forms, bypassing security, or uploading prohibited content.", "legal.terms.billing.title": "Subscription", - "legal.terms.billing.body": "Paid plans are billed through Paddle. The active plan controls limits, add-ons, and support features.", + "legal.terms.billing.body": "Paid plans are billed through Stripe. The active plan controls limits, add-ons, and support features.", }, } satisfies Record>; diff --git a/apps/frontend/src/routes/dashboard-route.tsx b/apps/frontend/src/routes/dashboard-route.tsx deleted file mode 100644 index df5daee..0000000 --- a/apps/frontend/src/routes/dashboard-route.tsx +++ /dev/null @@ -1,1654 +0,0 @@ -import { Show, For, createResource, createSignal, createMemo, createEffect, type JSX } from "solid-js"; -import { A, useSearchParams } from "@solidjs/router"; -import { apiClient } from "../lib/api-client"; -import { getPaddle, paddleConfigured } from "../lib/paddle"; -import { getStripe, stripeConfigured } from "../lib/stripe"; -import { useAuth } from "../providers/auth-provider"; -import { useI18n } from "../providers/i18n-provider"; -import { useTheme } from "../providers/theme-provider"; -import { BookraCharacter } from "../components/bookra-character"; -import { WidgetBuilder } from "../components/widget-builder"; -import { IntegrationModal } from "../components/integration-modal"; -import { SMSSettings } from "../components/sms-settings"; -import { Select } from "../components/ui/select"; -import { Input } from "../components/ui/input"; -import { Textarea } from "../components/ui/textarea"; -import { RevenueChart } from "../components/dashboard/revenue-chart"; -import { CalendarView } from "../components/dashboard/calendar-view"; -import { NotificationDropdown } from "../components/dashboard/notification-dropdown"; -import type { Section } from "../components/dashboard/types"; -import { getInitials, getBookingDuration } from "../components/dashboard/types"; -import { - LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon, - LogOutIcon, MenuIcon, XIcon, TrendingUpIcon, TrendingDownIcon, - ClockIcon, CheckCircleIcon, AlertCircleIcon, MoreHorizontalIcon, - ChevronLeftIcon, ChevronRightIcon, SparklesIcon, BellIcon, PlusIcon, - UsersIcon, UserCircleIcon, MapPinIcon, BarChart3Icon, CheckIcon, - SunIcon, MoonIcon, GlobeIcon, PaletteIcon, MailIcon, XCircleIcon, - BanIcon, ClockOffIcon, Trash2Icon, EditIcon, EyeIcon -} from "../components/dashboard/icons"; - -// ============================================ -// DEMO DATA -// ============================================ -function createDemoData(i18n: ReturnType) { - const cs = i18n.locale() === "cs"; - const now = new Date(); - const fmt = (d: Date) => d.toISOString(); - const addDays = (n: number) => { const d = new Date(now); d.setDate(d.getDate() + n); return d; }; - const addHours = (d: Date, h: number) => { const r = new Date(d); r.setHours(r.getHours() + h); return r; }; - - const services = cs - ? ["Masaz", "Kosmetika", "Fyzioterapie", "Manikura"] - : ["Massage", "Cosmetics", "Physiotherapy", "Manicure"]; - const customers = [ - { name: cs ? "Martina Novakova" : "Martina Novakova", email: "martina@example.com" }, - { name: cs ? "David Svoboda" : "David Svoboda", email: "david@example.com" }, - { name: cs ? "Jana KovarovA" : "Jana KovarovA", email: "jana@example.com" }, - { name: cs ? "Alice Johnson" : "Alice Johnson", email: "alice@example.com" }, - { name: cs ? "Bob Smith" : "Bob Smith", email: "bob@example.com" }, - ]; - - const allBookings = [ - { id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(0), 10)), endsAt: fmt(addHours(addDays(0), 11)), reference: "BOK-001", location: cs ? "Hlavni salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, - { id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(addHours(addDays(1), 14)), endsAt: fmt(addHours(addDays(1), 15)), reference: "BOK-002", location: cs ? "Hlavni salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" }, - { id: "b3", customerName: customers[2].name, customerEmail: customers[2].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(2), 9)), endsAt: fmt(addHours(addDays(2), 10)), reference: "BOK-003", location: cs ? "Studio B" : "Studio B", staffName: "", notes: cs ? "Bolest zad" : "Back pain" }, - { id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(addHours(addDays(-1), 11)), endsAt: fmt(addHours(addDays(-1), 12)), reference: "BOK-004", location: cs ? "Hlavni salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, - { id: "b5", customerName: customers[4].name, customerEmail: customers[4].email, service: services[0], status: "completed", startsAt: fmt(addHours(addDays(-2), 13)), endsAt: fmt(addHours(addDays(-2), 14)), reference: "BOK-005", location: cs ? "Studio B" : "Studio B", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" }, - { id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(addHours(addDays(3), 10)), endsAt: fmt(addHours(addDays(3), 11)), reference: "BOK-006", location: cs ? "Hlavni salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, - { id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(0), 16)), endsAt: fmt(addHours(addDays(0), 17)), reference: "BOK-007", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" }, - { id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(addHours(addDays(4), 9)), endsAt: fmt(addHours(addDays(4), 10)), reference: "BOK-008", location: cs ? "Hlavni salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" }, - { id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(5), 11)), endsAt: fmt(addHours(addDays(5), 12)), reference: "BOK-009", location: cs ? "Hlavni salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, - { id: "b10", customerName: customers[4].name, customerEmail: customers[4].email, service: services[1], status: "completed", startsAt: fmt(addHours(addDays(-3), 10)), endsAt: fmt(addHours(addDays(-3), 11)), reference: "BOK-010", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" }, - ]; - - return { - summary: { - tenantName: cs ? "Demo Studio" : "Demo Studio", - tenantSlug: "demo-studio", - publicBookingUrl: "http://localhost:3000/book/demo-studio", - kpis: [ - { label: i18n.t("dashboard.kpi.bookings"), value: 42, change: "+12%", trend: "up" }, - { label: i18n.t("dashboard.kpi.cancelled"), value: 3, change: "-5%", trend: "down" }, - { label: i18n.t("dashboard.kpi.completed"), value: 38, change: "+8%", trend: "up" }, - { label: i18n.t("dashboard.kpi.newClients"), value: 12, change: "+24%", trend: "up" }, - ], - staff: [{ id: "s1", name: cs ? "Petra M." : "Petra M.", role: cs ? "Specialista" : "Specialist", email: "petra@demo.cz" }], - upcomingBookings: allBookings.filter(b => b.status !== "cancelled" && b.status !== "completed"), - allBookings, - recentActivity: [ - { action: cs ? "Nova rezervace" : "New booking", detail: `${customers[0].name} - ${services[0]}`, time: cs ? "Dnes, 10:00" : "Today, 10:00", type: "booking" }, - { action: cs ? "Zruseni rezervace" : "Booking cancelled", detail: `${customers[3].name} - ${services[3]}`, time: cs ? "Vcera" : "Yesterday", type: "cancel" }, - { action: cs ? "Dokoncena rezervace" : "Booking completed", detail: `${customers[4].name} - ${services[1]}`, time: cs ? "Pred 2 dny" : "2 days ago", type: "reminder" }, - ], - }, - bootstrap: { - tenantName: cs ? "Demo Studio" : "Demo Studio", - slug: "demo-studio", - brand: { primaryColor: "#c25e3a" }, - locale: cs ? "cs" : "en", - timezone: "Europe/Prague", - }, - billing: { - planCode: "pro", - planName: "Pro", - subscriptionStatus: "trialing", - trialDaysRemaining: 12, - entitlements: { maxLocations: 3, maxBookings: -1, maxStaff: 10 }, - billingProvider: "stripe", - }, - }; -} - -// ============================================ -// KPI CARD -// ============================================ -function KpiCard(props: { kpi: any; index: number }) { - const trend = () => props.kpi.trend ?? "neutral"; - const trendClass = () => { - switch (trend()) { - case "up": return "bg-canvas-muted text-accent"; - case "down": return "bg-canvas-muted text-ink-muted"; - default: return "bg-canvas-muted text-ink-muted"; - } - }; - return ( -
-
-
-

{props.kpi.label}

-

{props.kpi.value}

-
-
- {trend() === "up" ? : trend() === "down" ? : } - {props.kpi.change ?? "—"} -
-
-
- {[30, 55, 40, 70, 50, 65, 85, 60, 75, 90].map((h, i) => ( -
- ))} -
-
- ); -} - -// ============================================ -// ACTIVITY TIMELINE -// ============================================ -function ActivityTimeline(props: { activities: any[] }) { - const i18n = useI18n(); - const getIcon = (type: string) => { - const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0"; - switch (type) { - case "booking": return
; - case "cancel": return
; - case "client": return
; - case "reschedule": return
; - case "reminder": return
; - default: return
; - } - }; - return ( -
-

{i18n.t("dashboard.recentActivity")}

- 0} fallback={ -
-

{i18n.t("dashboard.recentActivity.empty")}

-
- }> -
- {props.activities.map((activity, index) => ( -
- {index < props.activities.length - 1 && ( -
- )} -
- {getIcon(activity.type)} -
-
-
-
-

{activity.action}

-

{activity.detail}

-
- {activity.time} -
-
-
- ))} -
- -
- ); -} - -export function DashboardRoute() { - const i18n = useI18n(); - const auth = useAuth(); - const theme = useTheme(); - const isDark = () => theme.resolvedTheme() === "dark"; - const [searchParams, setSearchParams] = useSearchParams(); - const initialTab = () => { - const t = searchParams["tab"]; - const tab = Array.isArray(t) ? t[0] : t; - const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings", "analytics"]; - return validTabs.includes(tab as Section) ? (tab as Section) : "overview"; - }; - const [activeSection, setActiveSection] = createSignal
(initialTab()); - const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false); - const [isPageTransitioning, setIsPageTransitioning] = createSignal(false); - const [billingNotice, setBillingNotice] = createSignal(null); - const [billingError, setBillingError] = createSignal(null); - const [billingAction, setBillingAction] = createSignal<"checkout" | "refresh" | "portal" | null>(null); - const [handledBillingState, setHandledBillingState] = createSignal(null); - const [showIntegrationModal, setShowIntegrationModal] = createSignal(false); - const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly"); - const [demoNotice, setDemoNotice] = createSignal(null); - const [token] = createResource(() => auth.session()?.session?.id, () => auth.getToken()); - - const isDemoMode = () => token()?.startsWith("demo.") ?? false; - const demoData = createMemo(() => createDemoData(i18n)); - - const [summary] = createResource(token, async (bearer) => { - if (!bearer) return null; - if (bearer.startsWith("demo.")) return demoData().summary; - const response = await apiClient.GET("/v1/dashboard/summary", { headers: { Authorization: `Bearer ${bearer}` } }); - return response.data ?? null; - }); - - const [bootstrap] = createResource(token, async (bearer) => { - if (!bearer) return null; - if (bearer.startsWith("demo.")) return demoData().bootstrap; - 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; - if (bearer.startsWith("demo.")) return demoData().billing; - const response = await apiClient.GET("/v1/billing/subscription", { headers: { Authorization: `Bearer ${bearer}` } }); - return response.data ?? null; - }); - - const [locationData] = createResource(token, async (bearer) => { - if (!bearer) return 0; - if (bearer.startsWith("demo.")) return 1; - try { - const response = await (apiClient as any).GET("/v1/catalog/locations", { headers: { Authorization: `Bearer ${bearer}` } }); - return (response.data as any[])?.length ?? 0; - } catch { return 0; } - }); - - const [smsUsage] = createResource(token, async (bearer) => { - if (!bearer || bearer.startsWith("demo.")) return null; - try { - const response = await (apiClient as any).GET("/v1/sms/usage", { headers: { Authorization: `Bearer ${bearer}` } }); - return response.data as any ?? null; - } catch { return null; } - }); - - const resolvedSummary = () => (summary.latest as any) ?? (isDemoMode() ? demoData().summary : undefined); - const resolvedBootstrap = () => (bootstrap.latest as any) ?? (isDemoMode() ? demoData().bootstrap : undefined); - const resolvedBilling = () => (billing.latest as any) ?? (isDemoMode() ? demoData().billing : undefined); - - const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({ - ...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—", - }))); - - const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({ - ...booking, - avatar: booking.avatar ?? getInitials(booking.customerName), - service: booking.service ?? booking.label ?? booking.reference ?? (i18n.locale() === "cs" ? "Rezervace" : "Booking"), - duration: booking.duration ?? getBookingDuration(booking.startsAt, booking.endsAt), - }))); - - const normalizedRecentActivity = createMemo(() => { - if (resolvedSummary()?.recentActivity?.length) return resolvedSummary().recentActivity; - return normalizedUpcomingBookings().slice(0, 5).map((booking: any) => ({ - action: i18n.locale() === "cs" ? "Nadchazejici rezervace" : "Upcoming booking", - detail: `${booking.customerName} • ${booking.service}`, - time: new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }), - type: "booking", - })); - }); - - const normalizedAllBookings = createMemo(() => resolvedSummary()?.allBookings ?? normalizedUpcomingBookings()); - - const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading; - - const changeSection = (section: Section) => { - if (section === activeSection()) return; - setIsPageTransitioning(true); - setTimeout(() => { setActiveSection(section); setIsPageTransitioning(false); }, 150); - setSearchParams({ tab: section }); - }; - - const openCheckout = async () => { - const bearer = token(); - if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo rezimu nelze upravovat predplatne." : "Cannot modify billing in demo mode."); return; } - setBillingAction("checkout"); - setBillingError(null); - try { - const response = await (apiClient as any).POST("/v1/billing/checkout", { - headers: { Authorization: `Bearer ${bearer}` }, - body: { interval: billingInterval() }, - }); - if (response.data?.url) window.location.href = response.data.url; - else throw new Error("No checkout URL"); - } catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); } - finally { setBillingAction(null); } - }; - - const openPortal = async () => { - const bearer = token(); - if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo rezimu nelze upravovat predplatne." : "Cannot modify billing in demo mode."); return; } - setBillingAction("portal"); - setBillingError(null); - try { - const response = await (apiClient as any).POST("/v1/billing/portal", { headers: { Authorization: `Bearer ${bearer}` } }); - if (response.data?.url) window.location.href = response.data.url; - else throw new Error("No portal URL"); - } catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); } - finally { setBillingAction(null); } - }; - - const refreshBilling = async () => { - const bearer = token(); - if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo rezimu nelze obnovit predplatne." : "Cannot refresh billing in demo mode."); return; } - setBillingAction("refresh"); - setBillingError(null); - try { - const response = await (apiClient as any).POST("/v1/billing/refresh", { headers: { Authorization: `Bearer ${bearer}` } }); - if (response.data) { setBillingNotice(i18n.locale() === "cs" ? "Predplatne obnoveno." : "Billing refreshed."); void refetchBilling(); } - } catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); } - finally { setBillingAction(null); } - }; - - createEffect(() => { - const t = searchParams["tab"]; - const tab = Array.isArray(t) ? t[0] : t; - const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings", "analytics"]; - if (tab && validTabs.includes(tab as Section) && tab !== activeSection()) { - setIsPageTransitioning(true); - setTimeout(() => { setActiveSection(tab as Section); setIsPageTransitioning(false); }, 150); - } - }); - - createEffect(() => { - const state = Array.isArray(searchParams["billing"]) ? searchParams["billing"][0] : searchParams["billing"]; - if (state && state !== handledBillingState()) { - setHandledBillingState(state); - if (state === "success") setBillingNotice(i18n.locale() === "cs" ? "Platba uspesna! Vase predplatne je aktivni." : "Payment successful! Your subscription is active."); - else if (state === "cancelled") setBillingNotice(i18n.locale() === "cs" ? "Platba byla zrusena." : "Payment was cancelled."); - } - }); - - const navItems = [ - { id: "overview" as Section, label: i18n.t("dashboard.overview"), icon: LayoutDashboardIcon }, - { id: "bookings" as Section, label: i18n.t("dashboard.bookings"), icon: CalendarDaysIcon }, - { id: "customers" as Section, label: i18n.t("dashboard.customers"), icon: UserCircleIcon }, - { id: "zones" as Section, label: i18n.t("dashboard.zones"), icon: MapPinIcon }, - { id: "billing" as Section, label: i18n.t("dashboard.billing"), icon: CreditCardIcon }, - { id: "analytics" as Section, label: i18n.locale() === "cs" ? "Analytika" : "Analytics", icon: BarChart3Icon }, - { id: "settings" as Section, label: i18n.t("dashboard.settings"), icon: Settings2Icon }, - ]; - - const LanguageToggle = () => ( - - ); - - const ThemeToggle = () => ( - - ); - - const DemoBanner = () => ( - -
-
- -
-

{i18n.t("dashboard.demoBanner")}

-

{i18n.t("dashboard.demoBannerDesc")}

-
-
- -
-
- ); - - const Sidebar = () => ( - - ); - - const MobileMenu = () => ( - -
-
setIsMobileMenuOpen(false)} /> -
-
-
- Bookra - Bookra -
- -
- -
-
- - -
- -
-
-
- - ); - - const [showBookingDetail, setShowBookingDetail] = createSignal(false); - const [selectedBooking, setSelectedBooking] = createSignal(null); - - const openBookingDetail = (booking: any) => { - setSelectedBooking(booking); - setShowBookingDetail(true); - }; - - const BookingDetailModal = () => ( - -
-
setShowBookingDetail(false)} /> -
-
-

{i18n.t("dashboard.bookingDetails")}

- -
-
-
-
- {getInitials(selectedBooking()?.customerName)} -
-
-

{selectedBooking()?.customerName}

-

{selectedBooking()?.customerEmail}

-
-
-
-
-

{i18n.t("dashboard.bookingModal.service")}

-

{selectedBooking()?.service}

-
-
-

{i18n.t("dashboard.bookingModal.location")}

-

{selectedBooking()?.location || "—"}

-
-
-

{i18n.t("dashboard.bookingModal.assignedTo")}

- {i18n.t("dashboard.bookingModal.noAssign")}

}> -
-
- {selectedBooking()?.staffName?.split(" ").map((n: string) => n[0]).join("")} -
-

{selectedBooking()?.staffName}

-
-
-
-
-
-

{i18n.t("dashboard.bookingModal.dateTime")}

-

- {new Date(selectedBooking()?.startsAt).toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })} -

-

{i18n.t("dashboard.bookingModal.duration")}: {getBookingDuration(selectedBooking()?.startsAt, selectedBooking()?.endsAt)}

-
-
-
-

{i18n.t("dashboard.bookingModal.reference")}

-

{selectedBooking()?.reference}

-
- - {i18n.t(`dashboard.${selectedBooking()?.status}`)} - -
- -
-

{i18n.t("dashboard.bookingModal.notes")}

-

{selectedBooking()?.notes}

-
-
-
-
-
- - ); - - - // ========================================== - const OverviewPage = () => ( -
- {/* Header */} -
-
-

- {i18n.t("dashboard.welcome")} {auth.session()?.user?.name} -

-

{i18n.t("dashboard.overviewFor")} {resolvedBootstrap()?.tenantName}

-
-
- - - -
-
- - {/* KPI Cards */} -
- {normalizedKpis().map((kpi: any, index: number) => ( - - ))} -
- - {/* SMS Usage */} - {(() => { - const usage = smsUsage.latest; - if (!usage || usage.messageCount === 0) return null; - return ( -
-
-
-
- -
-
-

SMS {i18n.t("dashboard.billing.planUsage")}

-

{usage.messageCount} messages — {(usage.totalCostCents / 100).toFixed(2)} Kc

-
-
- -
-
- ); - })()} - - {/* Plan Limit Warning */} - {(() => { - const entitlements = resolvedBilling()?.entitlements; - const limit = entitlements?.maxLocations ?? -1; - const count = locationData() ?? 0; - const isNear = limit > 0 && count >= limit * 0.8; - const isAt = limit > 0 && count >= limit; - if (!isNear && !isAt) return null; - return ( -
-
-
- -
-
-

{isAt ? i18n.t("dashboard.locationLimitReached") : i18n.t("dashboard.nearingLocationLimit")}

-

{i18n.t("dashboard.locationsUsed")} {count}/{limit === -1 ? "∞" : limit}

-
- -
-
- ); - })()} - - {/* Revenue Chart + Calendar + Activity */} -
-
- - -
-
- -
-
- - {/* Upcoming Bookings */} -
-
-
-

{i18n.t("dashboard.upcomingBookings")}

-

{normalizedUpcomingBookings().length} total

-
- -
-
- {normalizedUpcomingBookings().slice(0, 5).map((booking: any) => ( - - ))} -
-
-
- ); - - // ========================================== - // BOOKINGS PAGE - // ========================================== - const BookingsPage = () => { - const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all"); - const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all"); - const [searchQuery, setSearchQuery] = createSignal(""); - const [showNewBooking, setShowNewBooking] = createSignal(false); - const [creatingBooking, setCreatingBooking] = createSignal(false); - - const [newBookingCustomer, setNewBookingCustomer] = createSignal(""); - const [newBookingEmail, setNewBookingEmail] = createSignal(""); - const [newBookingService, setNewBookingService] = createSignal(""); - const [newBookingDate, setNewBookingDate] = createSignal(""); - const [newBookingTime, setNewBookingTime] = createSignal(""); - const [newBookingNotes, setNewBookingNotes] = createSignal(""); - - const resolvedAllBookings = () => (summary.latest as any)?.allBookings ?? (isDemoMode() ? demoData().summary.allBookings : []) ?? []; - - const filteredBookings = createMemo(() => { - let bookings = resolvedAllBookings(); - if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus()); - const now = new Date(); - if (filterDateRange() === "today") bookings = bookings.filter((b: any) => new Date(b.startsAt).toDateString() === now.toDateString()); - else if (filterDateRange() === "week") { - const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= weekEnd; }); - } else if (filterDateRange() === "month") { - const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); - bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= monthEnd; }); - } - if (searchQuery().trim()) { - const q = searchQuery().toLowerCase(); - bookings = bookings.filter((b: any) => - b.customerName?.toLowerCase().includes(q) || - b.service?.toLowerCase().includes(q) || - b.reference?.toLowerCase().includes(q) - ); - } - return bookings.sort((a: any, b: any) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime()); - }); - - const bookingStats = createMemo(() => { - const bookings = resolvedAllBookings(); - return { - total: bookings.length, - confirmed: bookings.filter((b: any) => b.status === "confirmed").length, - pending: bookings.filter((b: any) => b.status === "pending").length, - cancelled: bookings.filter((b: any) => b.status === "cancelled").length, - completed: bookings.filter((b: any) => b.status === "completed").length, - }; - }); - - const handleCreateBooking = async () => { - const bearer = token(); - if (!bearer || bearer.startsWith("demo.")) { - setDemoNotice(i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode."); - setShowNewBooking(false); return; - } - setCreatingBooking(true); - try { - await (apiClient as any).POST("/v1/catalog/bookings", { - headers: { Authorization: `Bearer ${bearer}` }, - body: { - customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(), - startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes() - } - }); - setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes(""); - setShowNewBooking(false); - } catch { setDemoNotice(i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); } - finally { setCreatingBooking(false); } - }; - - const statusLabels: Record = { - confirmed: i18n.t("dashboard.confirmed"), pending: i18n.t("dashboard.pending"), - cancelled: i18n.t("dashboard.cancelled"), completed: i18n.t("dashboard.completed") - }; - - return ( -
-
-
-

{i18n.t("dashboard.bookingManagement")}

-

{bookingStats().total} {i18n.t("dashboard.totalBookings")}

-
- -
- - {/* Stats */} -
- {[ - { key: "confirmed", label: i18n.t("dashboard.confirmed"), value: bookingStats().confirmed }, - { key: "pending", label: i18n.t("dashboard.pending"), value: bookingStats().pending }, - { key: "cancelled", label: i18n.t("dashboard.cancelled"), value: bookingStats().cancelled }, - { key: "completed", label: i18n.t("dashboard.completed"), value: bookingStats().completed }, - ].map((s) => ( - - ))} -
- - {/* Filters */} -
-
- setSearchQuery(e.currentTarget.value)} /> -
-
- setFilterDateRange(v as any)} options={[ - { value: "all", label: i18n.t("dashboard.filter.all") }, - { value: "today", label: i18n.t("dashboard.filter.today") }, - { value: "week", label: i18n.t("dashboard.filter.week") }, - { value: "month", label: i18n.t("dashboard.filter.month") }, - ]} /> -
-
- - {/* Bookings List */} -
- 0} fallback={ -
-
- -
-

{i18n.t("dashboard.empty.title")}

-

{i18n.t("dashboard.empty.bookingsDesc")}

-
- }> -
- {filteredBookings().map((booking: any) => ( -
-
- {getInitials(booking.customerName)} -
-
-

{booking.customerName}

-

{booking.service} • {new Date(booking.startsAt).toLocaleDateString()} {new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}

-
-
- - {statusLabels[booking.status]} - - -
-
- ))} -
-
-
- - {/* New Booking Modal */} - -
-
setShowNewBooking(false)} /> -
-
-

{i18n.t("dashboard.newBooking")}

- -
-
- setNewBookingCustomer(e.currentTarget.value)} /> - setNewBookingEmail(e.currentTarget.value)} /> - setNewBookingService(e.currentTarget.value)} /> -
- setNewBookingDate(e.currentTarget.value)} /> - setNewBookingTime(e.currentTarget.value)} /> -
-