Merge pull request #1 from Dvorinka/feat/stripe-billing-pgx-repositories
CI / Frontend (push) Successful in 9m53s
CI / Go - apps/backend (push) Successful in 10m5s
CI / Docker publish - backend (push) Failing after 20s

refactor: replace sqlc with pgx repos, finish Stripe migration
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-17 20:37:04 +02:00
commit 7a6223c07b
93 files changed
+6993 -69061

No files matched your search

+19 -50
View File
@@ -2,33 +2,31 @@
# Put concrete values into app-specific env files. # Put concrete values into app-specific env files.
BOOKRA_APP_ENV=staging BOOKRA_APP_ENV=staging
BOOKRA_APP_URL=https://app.bookra.example
BOOKRA_API_URL=https://api.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) # Stripe billing (the only billing provider)
BOOKRA_STRIPE_SECRET_KEY=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 BOOKRA_STRIPE_API_KEY=sk_test_xxx
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx
# Stripe Monthly Prices # Stripe Monthly Prices
BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_xxx
# Stripe Yearly Prices (17% discount) # Stripe Yearly Prices
BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_1TVAlqGrjyNQaOSGNiZQ5tEx BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_1TVAnSGrjyNQaOSGTHQHrgv3 BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_1TVAmVGrjyNQaOSGWDgWqYvb BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_xxx
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_xxx
# Stripe SMS prices — one per market/currency (create as standard prices, not metered) # Stripe SMS prices — one per currency (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ł
BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx
BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_xxx BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_xxx
BOOKRA_STRIPE_SMS_EUR_PRICE_ID= 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_API_KEY=your_smsmanager_api_key
BOOKRA_SMSMANAGER_BASE_URL=https://api.smsmngr.com/v2 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) # Admin credentials (for platform management)
BOOKRA_ADMIN_EMAIL=[email protected] BOOKRA_ADMIN_EMAIL=[email protected]
BOOKRA_ADMIN_KEY=your-secure-admin-key-here BOOKRA_ADMIN_KEY=replace-with-secure-random-key
# 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
-3
View File
@@ -48,7 +48,6 @@ jobs:
matrix: matrix:
app: app:
- apps/backend - apps/backend
- apps/auth-service
defaults: defaults:
run: run:
working-directory: ${{ matrix.app }} working-directory: ${{ matrix.app }}
@@ -83,8 +82,6 @@ jobs:
service: service:
- name: backend - name: backend
context: apps/backend context: apps/backend
- name: auth-service
context: apps/auth-service
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
+7
View File
@@ -78,3 +78,10 @@ playwright/.cache/
desloppify-out/ desloppify-out/
.desloppify/ .desloppify/
.opencode .opencode
# Local deploy env files (contain live secrets — never commit)
*.env
!*.env.example
# Analysis output
graphify-out/
output/
+1 -1
View File
@@ -9,7 +9,7 @@
## Database ## Database
- Uses PostgreSQL via Neon (pooled URL for app, direct URL for migrations) - Uses PostgreSQL via Neon (pooled URL for app, direct URL for migrations)
- Migrations with Goose: `npm run db:migrate:up` - 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 ## SMS Feature
+12
View File
@@ -27,3 +27,15 @@
- Many dashboard inline i18n ternaries remain (~280). Systematic extraction to `i18n.t()` keys is an ongoing task. - 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. - 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.
+26 -10
View File
@@ -1,18 +1,17 @@
# Bookra # 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 - frontend on Vercel
- backend on Railway - backend on Railway
- Neon Postgres + Neon Auth - Neon Postgres + Neon Auth
- Paddle billing - Stripe billing (checkout, customer portal, webhooks, SMS usage invoices)
- no Docker-based local runtime - no Docker-based local runtime
## Workspace ## Workspace
- `apps/frontend` SolidJS frontend - `apps/frontend` SolidJS frontend
- `apps/backend` Go API - `apps/backend` Go API
- `apps/auth-service` auth + internal admin service
- `packages/api-client` generated TypeScript client/types from OpenAPI - `packages/api-client` generated TypeScript client/types from OpenAPI
- `packages/shared-types` shared frontend constants and helpers - `packages/shared-types` shared frontend constants and helpers
@@ -36,14 +35,17 @@ Both apps expect remote services:
- Neon Postgres - Neon Postgres
- Neon Auth - Neon Auth
- Paddle - Stripe
Optional extra service:
- `apps/auth-service` for standalone auth/admin workflows
See `.env.example`, `apps/frontend/.env.example`, and `apps/backend/.env.example`. 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 ## CI/CD
- GitHub Actions CI lives in [`.github/workflows/ci.yml`](/home/tdvorak/Desktop/PROG+HTML/Bookra/.github/workflows/ci.yml:1) - 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 ## Backend database commands
```bash ```bash
npm run db:generate
npm run db:migrate:status npm run db:migrate:status
npm run db:migrate:up npm run db:migrate:up
``` ```
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell. `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 ## Brand Colors
Bookra uses a sophisticated color system designed for modern booking interfaces with support for both light and dark themes. 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 System
- **Shadow XS** (`--shadow-xs`): 0 1px 2px 0 rgba(0, 0, 0, 0.05) - **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 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 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) - **Shadow XL** (`--shadow-xl`): 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)
+50 -19
View File
@@ -5,7 +5,7 @@
# - frontend signs users in with Neon Auth # - frontend signs users in with Neon Auth
# - backend verifies Neon Auth JWTs # - backend verifies Neon Auth JWTs
# - backend serves booking + billing APIs # - backend serves booking + billing APIs
# - Paddle handles SaaS billing + customer portal # - Stripe handles SaaS billing + customer portal
# ============================================ # ============================================
BOOKRA_APP_ENV=staging BOOKRA_APP_ENV=staging
@@ -37,33 +37,47 @@ BOOKRA_DATABASE_DIRECT_URL=postgres://user:[email protected]
# AUTHENTICATION (Required when DEMO_MODE=false) # AUTHENTICATION (Required when DEMO_MODE=false)
# -------------------------------------------- # --------------------------------------------
# Neon Auth base URL for JWKS verification. # 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 BOOKRA_NEON_AUTH_URL=https://ep-example.neonauth.region.aws.neon.tech/neondb/auth
# Optional emergency/local fallback for non-Neon JWT verification. # Local JWT fallback secret for the password-auth endpoints (/v1/auth/*).
# Leave blank in normal Neon Auth setup. # Required when DEMO_MODE=false if you use local auth.
BOOKRA_AUTH_JWT_SECRET= BOOKRA_AUTH_JWT_SECRET=
# -------------------------------------------- # --------------------------------------------
# INTERNAL SERVICES # INTERNAL SERVICES
# -------------------------------------------- # --------------------------------------------
# Job runner key for internal API endpoints (reminders, notifications) # 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". # Secret API key + webhook signing secret from the Stripe dashboard.
# API key: Paddle dashboard -> Developer tools -> Authentication. # Price IDs: Stripe dashboard -> Product catalog -> Price IDs.
# Webhook secret: Paddle dashboard -> Notification settings -> destination secret key. BOOKRA_STRIPE_API_KEY=sk_test_xxx
# Price IDs: Paddle dashboard -> Catalog -> Product price IDs. BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx
BOOKRA_PADDLE_ENV=sandbox
BOOKRA_PADDLE_API_KEY= # Monthly subscription price IDs
BOOKRA_PADDLE_WEBHOOK_SECRET= BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_xxx
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID= BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_xxx
BOOKRA_PADDLE_STARTER_USD_PRICE_ID= BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_xxx
BOOKRA_PADDLE_PRO_CZK_PRICE_ID= BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_xxx
BOOKRA_PADDLE_PRO_USD_PRICE_ID= BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_xxx
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID= BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_xxx
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=
# 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) # EMAIL (Optional)
@@ -75,8 +89,25 @@ BOOKRA_SMTP_PORT=587
BOOKRA_SMTP_USERNAME= BOOKRA_SMTP_USERNAME=
BOOKRA_SMTP_PASSWORD= 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) # ANALYTICS (Optional)
# -------------------------------------------- # --------------------------------------------
BOOKRA_UMAMI_API_URL= BOOKRA_UMAMI_API_URL=
BOOKRA_UMAMI_API_KEY= BOOKRA_UMAMI_API_KEY=
# --------------------------------------------
# MONITORING (Optional)
# --------------------------------------------
BOOKRA_SENTRY_DSN=
# --------------------------------------------
# ADMIN (Optional — platform admin API)
# --------------------------------------------
BOOKRA_ADMIN_EMAIL=
BOOKRA_ADMIN_KEY=
+37 -30
View File
@@ -1,13 +1,13 @@
# Bookra Backend # 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 ## Commands
```bash ```bash
go run ./cmd/api go run ./cmd/api
go build ./... go build ./...
npm run db:generate go test ./...
npm run db:migrate:status npm run db:migrate:status
npm run db:migrate:up npm run db:migrate:up
``` ```
@@ -18,57 +18,64 @@ npm run db:migrate:up
- `BOOKRA_DATABASE_URL` Neon pooled connection - `BOOKRA_DATABASE_URL` Neon pooled connection
- `BOOKRA_DATABASE_DIRECT_URL` Neon direct connection for migrations/admin tasks - `BOOKRA_DATABASE_DIRECT_URL` Neon direct connection for migrations/admin tasks
- `BOOKRA_NEON_AUTH_URL` Neon Auth base URL used for JWKS verification - `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_AUTH_JWT_SECRET` local JWT secret for the password-auth endpoints
- `BOOKRA_JOB_RUNNER_KEY` shared secret for remote reminder dispatch calls - `BOOKRA_JOB_RUNNER_KEY` shared secret for remote job-runner calls
- `BOOKRA_EMAIL_FROM` sender identity for email reminders - `BOOKRA_EMAIL_FROM` sender identity for email reminders
- `BOOKRA_PADDLE_ENV` billing environment: `sandbox` or `live` - `BOOKRA_SMTP_*` optional SMTP transport for email delivery
- `BOOKRA_PADDLE_API_KEY` Paddle API key - `BOOKRA_STRIPE_API_KEY` Stripe secret API key
- `BOOKRA_PADDLE_WEBHOOK_SECRET` Paddle notification destination secret - `BOOKRA_STRIPE_WEBHOOK_SECRET` Stripe webhook signing secret
- `BOOKRA_PADDLE_{STARTER,PRO,BUSINESS}_{CZK,USD}_PRICE_ID` Paddle price IDs - `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_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 ## Notes
- Auth verification is isolated in `internal/auth`. - Auth verification is isolated in `internal/auth`.
- OpenAPI lives in `openapi/bookra.openapi.yaml`. - OpenAPI lives in `openapi/bookra.openapi.yaml`.
- SQL migrations live in `migrations/`. - SQL migrations live in `migrations/` (goose).
- `sqlc.yaml` is wired through `npm run db:generate`. - 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. - 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 ## Production Auth
Bookra production auth should use Neon Auth directly: Bookra production auth uses Neon Auth directly:
- frontend uses `VITE_NEON_AUTH_URL` - frontend uses `VITE_NEON_AUTH_URL`
- backend verifies Neon JWTs with `BOOKRA_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. 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_STRIPE_API_KEY`: Developers -> API keys -> secret key
- `BOOKRA_PADDLE_API_KEY`: Developer tools -> Authentication - `BOOKRA_STRIPE_WEBHOOK_SECRET`: Developers -> Webhooks -> destination signing secret
- `BOOKRA_PADDLE_WEBHOOK_SECRET`: Notification settings -> destination secret key - `BOOKRA_STRIPE_*_PRICE_ID`: Product catalog -> recurring price IDs
- `BOOKRA_PADDLE_*_PRICE_ID`: Catalog -> each SaaS plan recurring price ID
Create one recurring price per plan/currency you support: Create one recurring price per plan/currency/interval you support:
- `starter` `czk` - `starter` / `pro` / `business` × `czk` / `usd` × `monthly` / `yearly`
- `starter` `usd`
- `pro` `czk`
- `pro` `usd`
- `business` `czk`
- `business` `usd`
Set your webhook destination to: Set the webhook destination to:
```text ```text
POST /v1/webhooks/paddle POST /v1/webhooks/stripe
POST /api/paddle_webhook
``` ```
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
```
+2 -2
View File
@@ -25,8 +25,8 @@ func initSentry(cfg config.Config) {
Dsn: cfg.SentryDSN, Dsn: cfg.SentryDSN,
Environment: cfg.Environment, Environment: cfg.Environment,
Release: "[email protected]", Release: "[email protected]",
// Set TracesSampleRate to 1.0 to capture 100% of transactions for testing // 10% trace sampling keeps production volumes sane.
TracesSampleRate: 1.0, TracesSampleRate: 0.1,
}) })
if err != nil { if err != nil {
log.Fatalf("Sentry initialization failed: %v", err) log.Fatalf("Sentry initialization failed: %v", err)
+2 -6
View File
@@ -4,13 +4,14 @@ go 1.26.2
require ( require (
github.com/MicahParks/keyfunc/v3 v3.8.0 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-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1 github.com/jackc/pgx/v5 v5.9.1
github.com/stripe/stripe-go/v81 v81.0.0 github.com/stripe/stripe-go/v81 v81.0.0
golang.org/x/crypto v0.48.0
golang.org/x/time v0.9.0 golang.org/x/time v0.9.0
) )
@@ -21,16 +22,12 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // 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/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -47,7 +44,6 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.23.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/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
+8 -10
View File
@@ -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/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 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= 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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 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/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 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= 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 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= 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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 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= 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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= 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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
+74 -58
View File
@@ -6,8 +6,8 @@ import (
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"time"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/db" "bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/domain"
@@ -21,9 +21,9 @@ var (
) )
type Service struct { type Service struct {
repo db.Repository repo db.Repository
adminEmail string adminEmail string
adminKey string adminKey string
} }
func NewService(repo db.Repository, adminEmail, adminKey string) *Service { func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
@@ -52,20 +52,39 @@ func (s *Service) ValidateAdminLogin(email, key string) bool {
return emailMatch && keyMatch return emailMatch && keyMatch
} }
// RequireAdmin is middleware that checks for admin authentication const adminActorKey = "adminActor"
// It supports two modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access) // ActorFromContext returns the admin identity recorded by RequireAdmin.
// 2. Session-based auth where the user has role "admin" or "superadmin" func ActorFromContext(c *gin.Context) string {
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc { 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) { return func(c *gin.Context) {
if demoMode {
c.Set(adminActorKey, auth.DemoPrincipal.Subject)
c.Next()
return
}
// Check for admin header credentials (direct admin login) // Check for admin header credentials (direct admin login)
adminEmail := c.GetHeader("X-Admin-Email") adminEmail := c.GetHeader("X-Admin-Email")
adminKey := c.GetHeader("X-Admin-Key") adminKey := c.GetHeader("X-Admin-Key")
if adminEmail != "" && adminKey != "" { if adminEmail != "" || adminKey != "" {
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) { if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
c.Set("isAdmin", true) c.Set(adminActorKey, adminEmail)
c.Set("adminMode", "credentials")
c.Next() c.Next()
return return
} }
@@ -74,31 +93,39 @@ func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Cont
} }
// Check for Bearer token with admin role // Check for Bearer token with admin role
auth := c.GetHeader("Authorization") tokenString, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if auth != "" && strings.HasPrefix(auth, "Bearer ") { if !ok || strings.TrimSpace(tokenString) == "" {
// The auth middleware should have already validated the token c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
// and set the user info in context return
userID, exists := c.Get("userID") }
if !exists { if verifier == nil || !verifier.Enabled() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
return 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
}
} }
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{ return domain.AdminDashboardStats{
TotalTenants: stats.TotalTenants, TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers, TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions, ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions, TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth, BookingsThisMonth: stats.BookingsThisMonth,
RevenueThisMonthCents: stats.RevenueThisMonth, RevenueThisMonthCents: stats.RevenueThisMonth,
}, nil }, nil
} }
@@ -144,12 +171,12 @@ func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.A
for i, t := range tenants { for i, t := range tenants {
result.Tenants[i] = domain.AdminTenant{ result.Tenants[i] = domain.AdminTenant{
ID: t.ID, ID: t.ID,
Slug: t.Slug, Slug: t.Slug,
Name: t.Name, Name: t.Name,
PlanCode: t.PlanCode, PlanCode: t.PlanCode,
SubscriptionStatus: t.SubscriptionStatus, 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 { func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
// Validate role // Validate role
validRoles := map[string]bool{ validRoles := map[string]bool{
"user": true, "user": true,
"admin": true, "admin": true,
"superadmin": true, "superadmin": true,
} }
if !validRoles[newRole] { 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 { func stringPtrToStr(s *string) string {
if s == nil { if s == nil {
return "" return ""
} }
return *s return *s
} }
func init() {
// Ensure time package is imported
_ = time.Now()
}
+117
View File
@@ -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, "[email protected]", "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, "[email protected]", "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": "[email protected]",
"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, "[email protected]", "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": "[email protected]",
"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, "[email protected]", "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(), "[email protected]", "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)
}
}
+101 -43
View File
@@ -49,8 +49,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
repository := db.NewRepository(pools, cfg.DemoMode) repository := db.NewRepository(pools, cfg.DemoMode)
notificationService := notifications.NewService(cfg, repository) notificationService := notifications.NewService(cfg, repository)
bookingService := bookings.NewService(repository, notificationService) bookingService := bookings.NewService(repository, notificationService, cfg.FrontendURL)
customerBookingService := bookings.NewCustomerService(repository, notificationService) customerBookingService := bookings.NewCustomerService(repository, notificationService, cfg.FrontendURL)
tenantService := tenancy.NewService(repository) tenantService := tenancy.NewService(repository)
billingService := billing.NewService(cfg, repository) billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService) 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{ server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
AllowOrigins: allowedOrigins(cfg), AllowOrigins: allowedOrigins(cfg),
AllowHeaders: []string{"Authorization", "Content-Type"}, AllowHeaders: []string{"Authorization", "Content-Type", "X-Admin-Email", "X-Admin-Key"},
AllowMethods: []string{"GET", "POST", "OPTIONS"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowCredentials: true, AllowCredentials: true,
MaxAge: 12 * time.Hour,
}), httpx.SecurityHeaders()) }), httpx.SecurityHeaders())
server.router.GET("/healthz", func(c *gin.Context) { 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 if cfg.Environment == "development" || cfg.Environment == "test" {
server.router.GET("/debug/sentry", func(c *gin.Context) { // Test endpoints, only exposed outside production
sentry.CaptureMessage("Test message from Bookra API") server.router.GET("/debug/sentry", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"}) 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) {
server.router.GET("/debug/billing-test", func(c *gin.Context) { if billingService != nil {
if billingService != nil { c.JSON(http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{ "status": "ok",
"status": "ok", "billingService": "initialized",
"billingService": "initialized", "message": "Billing service is working",
"message": "Billing service is working", })
}) } else {
} else { c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"}) }
} })
}) }
server.router.GET("/v1/meta/config", func(c *gin.Context) { server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -119,6 +120,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
// AUTH API // AUTH API
// ============================================ // ============================================
authGroup := server.router.Group("/v1/auth") authGroup := server.router.Group("/v1/auth")
authGroup.Use(publicRateLimiter.Middleware())
{ {
authGroup.POST("/register", func(c *gin.Context) { authGroup.POST("/register", func(c *gin.Context) {
var request struct { var request struct {
@@ -255,7 +257,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
// ADMIN API // ADMIN API
// ============================================ // ============================================
adminGroup := server.router.Group("/v1/admin") 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) { adminGroup.GET("/stats", func(c *gin.Context) {
stats, err := adminService.GetDashboardStats(c.Request.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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return return
} }
adminUserID, _ := c.Get("userID")
err := adminService.UpdateUserRole( err := adminService.UpdateUserRole(
c.Request.Context(), c.Request.Context(),
adminUserID.(string), admin.ActorFromContext(c),
c.Param("userID"), c.Param("userID"),
request.Role, request.Role,
c.ClientIP(), c.ClientIP(),
@@ -433,6 +434,45 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusCreated, response) 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 // 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"}) 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 // 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) response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrBookingNotFound) { switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
} }
c.JSON(status, gin.H{"error": err.Error()}) c.JSON(status, gin.H{"error": err.Error()})
return return
@@ -712,10 +778,14 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
switch { switch {
case errors.Is(err, bookings.ErrBookingNotFound): case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled): case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule): case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest status = http.StatusBadRequest
case errors.Is(err, bookings.ErrBookingConflict):
status = http.StatusConflict
} }
c.JSON(status, gin.H{"error": err.Error()}) c.JSON(status, gin.H{"error": err.Error()})
return return
@@ -730,6 +800,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
switch { switch {
case errors.Is(err, bookings.ErrBookingNotFound): case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled): case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict status = http.StatusConflict
} }
@@ -766,7 +838,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
if errors.Is(err, billing.ErrBillingPlanUnsupported) { if errors.Is(err, billing.ErrBillingPlanUnsupported) {
status = http.StatusBadRequest status = http.StatusBadRequest
} }
if errors.Is(err, billing.ErrPaddleNotConfigured) { if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
c.JSON(status, gin.H{"error": err.Error()}) 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) { if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound status = http.StatusNotFound
} }
if errors.Is(err, billing.ErrPaddleNotConfigured) { if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
c.JSON(status, gin.H{"error": err.Error()}) 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 status = http.StatusNotFound
case errors.Is(err, billing.ErrBillingCustomerMissing): case errors.Is(err, billing.ErrBillingCustomerMissing):
status = http.StatusBadRequest status = http.StatusBadRequest
case errors.Is(err, billing.ErrPaddleNotConfigured): case errors.Is(err, billing.ErrStripeNotConfigured):
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
c.JSON(status, gin.H{"error": err.Error()}) 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) 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) { server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil { if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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"}) 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) { server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) { if !authorizeJobRunner(c, cfg) {
+29 -16
View File
@@ -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) { func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
if !v.enabled { if !v.enabled {
return nil, errors.New("neon auth verifier is disabled") return nil, errors.New("neon auth verifier is disabled")
} }
if len(v.localSecret) > 0 && v.jwks == nil { if len(v.localSecret) > 0 {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { claims, err := v.verifyLocal(tokenString)
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if err == nil {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) return claims, nil
} }
return v.localSecret, nil if v.jwks == nil {
}, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second))
if err != nil {
return nil, err return nil, err
} }
claims, ok := token.Claims.(jwt.MapClaims) }
if !ok || !token.Valid { if v.jwks == nil {
return nil, errors.New("invalid token claims") return nil, errors.New("no token verification mechanism configured")
}
if tokenType, _ := claims["type"].(string); tokenType != "access" {
return nil, errors.New("invalid token type")
}
return claims, nil
} }
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc, token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
+7 -1
View File
@@ -20,6 +20,8 @@ const (
refreshTokenTTL = 30 * 24 * time.Hour refreshTokenTTL = 30 * 24 * time.Hour
magicLinkTTL = 15 * time.Minute magicLinkTTL = 15 * time.Minute
passwordResetTTL = 30 * time.Minute passwordResetTTL = 30 * time.Minute
tokenIssuer = "bookra-auth"
tokenAudience = "bookra"
) )
var ( 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 nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
} }
return s.jwtSecret, nil return s.jwtSecret, nil
}) }, jwt.WithIssuer(tokenIssuer), jwt.WithAudience(tokenAudience))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -266,6 +268,8 @@ func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPai
Role: role, Role: role,
Type: "access", Type: "access",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: tokenIssuer,
Audience: jwt.ClaimStrings{tokenAudience},
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)), ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
@@ -285,6 +289,8 @@ func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPai
Role: role, Role: role,
Type: "refresh", Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: tokenIssuer,
Audience: jwt.ClaimStrings{tokenAudience},
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)), ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
@@ -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(), "[email protected]", "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(), "[email protected]", "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(), "[email protected]", "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")
}
}
+32 -427
View File
@@ -16,7 +16,6 @@ import (
"bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/shared" "bookra/apps/backend/internal/shared"
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81"
portalsession "github.com/stripe/stripe-go/v81/billingportal/session" portalsession "github.com/stripe/stripe-go/v81/billingportal/session"
@@ -30,28 +29,11 @@ var (
ErrBillingMembership = errors.New("billing membership not found") ErrBillingMembership = errors.New("billing membership not found")
ErrBillingPlanUnsupported = errors.New("billing plan is not configured") ErrBillingPlanUnsupported = errors.New("billing plan is not configured")
ErrBillingCustomerMissing = errors.New("billing customer is not available") 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") ErrStripeNotConfigured = errors.New("stripe is not configured")
ErrStripeSignatureMissing = errors.New("stripe signature is missing") ErrStripeSignatureMissing = errors.New("stripe signature is missing")
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured") 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{ var allowedStripeWebhookEvents = []stripe.EventType{
stripe.EventTypeCheckoutSessionCompleted, stripe.EventTypeCheckoutSessionCompleted,
stripe.EventTypeCustomerSubscriptionCreated, stripe.EventTypeCustomerSubscriptionCreated,
@@ -64,48 +46,15 @@ var allowedStripeWebhookEvents = []stripe.EventType{
} }
type Service struct { type Service struct {
cfg config.Config cfg config.Config
repo db.Repository 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"`
} }
func NewService(cfg config.Config, repo db.Repository) *Service { 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) != "" { if strings.TrimSpace(cfg.StripeAPIKey) != "" {
stripe.Key = cfg.StripeAPIKey stripe.Key = cfg.StripeAPIKey
service.stripeEnabled = true
} }
return &Service{cfg: cfg, repo: repo}
return service
} }
// GetEntitlements returns the plan entitlements for a tenant (used by other services for limit enforcement) // 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) { 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.SubscriptionSnapshot{}, ErrBillingMembership return domain.SubscriptionSnapshot{}, ErrBillingMembership
@@ -135,7 +84,7 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID, TenantID: membership.Tenant.ID,
BillingProvider: s.cfg.BillingProvider(), BillingProvider: "stripe",
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"), Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk", 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) { 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.CheckoutLaunchResponse{}, ErrBillingMembership return domain.CheckoutLaunchResponse{}, ErrBillingMembership
} }
return domain.CheckoutLaunchResponse{}, err return domain.CheckoutLaunchResponse{}, err
} }
if !s.cfg.StripeConfigured() {
return domain.CheckoutLaunchResponse{}, ErrStripeNotConfigured
}
// Default to monthly if not specified // Default to monthly if not specified
if billingInterval == "" { if billingInterval == "" {
billingInterval = "monthly" billingInterval = "monthly"
} }
// Prefer Stripe if configured return s.createStripeCheckoutSession(ctx, principal, membership, planCode, currency, billingInterval)
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)
} }
func (s *Service) createStripeCheckoutSession(ctx context.Context, principal domain.Principal, membership db.TenantMembershipRecord, planCode string, currency string, billingInterval string) (domain.CheckoutLaunchResponse, error) { 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) return domain.CheckoutLaunchResponse{}, fmt.Errorf("failed to create stripe customer: %w", err)
} }
customerID = cust.ID 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 return domain.CheckoutLaunchResponse{}, err
} }
} }
@@ -241,35 +187,8 @@ func (s *Service) createStripeCheckoutSession(ctx context.Context, principal dom
}, nil }, 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) { 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.SubscriptionSnapshot{}, ErrBillingMembership return domain.SubscriptionSnapshot{}, ErrBillingMembership
@@ -281,36 +200,26 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
if customerID == "" { if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{ return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID, TenantID: membership.Tenant.ID,
BillingProvider: s.cfg.BillingProvider(), BillingProvider: "stripe",
Status: "inactive", Status: "inactive",
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk", Currency: "czk",
}, s.cfg), nil }, s.cfg), nil
} }
// Prefer Stripe if configured if !s.cfg.StripeConfigured() {
if s.cfg.StripeConfigured() { return domain.SubscriptionSnapshot{}, ErrStripeNotConfigured
record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
if err != nil {
return domain.SubscriptionSnapshot{}, err
}
return toSnapshot(membership.Tenant, record, s.cfg), nil
} }
// Fall back to Paddle record, err := s.syncStripeDataToKV(ctx, membership.Tenant, customerID)
if s.client == nil {
return domain.SubscriptionSnapshot{}, ErrPaddleNotConfigured
}
record, err := s.syncPaddleData(ctx, membership.Tenant, customerID)
if err != nil { if err != nil {
return domain.SubscriptionSnapshot{}, err return domain.SubscriptionSnapshot{}, err
} }
return toSnapshot(membership.Tenant, record, s.cfg), nil return toSnapshot(membership.Tenant, record, s.cfg), nil
} }
func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Principal) (domain.PortalSessionResponse, error) { 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.PortalSessionResponse{}, ErrBillingMembership return domain.PortalSessionResponse{}, ErrBillingMembership
@@ -322,14 +231,11 @@ func (s *Service) CreatePortalSession(ctx context.Context, principal domain.Prin
if customerID == "" { if customerID == "" {
return domain.PortalSessionResponse{}, ErrBillingCustomerMissing return domain.PortalSessionResponse{}, ErrBillingCustomerMissing
} }
if !s.cfg.StripeConfigured() {
// Prefer Stripe if configured return domain.PortalSessionResponse{}, ErrStripeNotConfigured
if s.cfg.StripeConfigured() {
return s.createStripePortalSession(customerID)
} }
// Fall back to Paddle return s.createStripePortalSession(customerID)
return s.createPaddlePortalSession(ctx, membership, customerID)
} }
func (s *Service) createStripePortalSession(customerID string) (domain.PortalSessionResponse, error) { 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 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 { func (s *Service) HandleStripeWebhook(ctx context.Context, req *http.Request) error {
return s.handleStripeWebhook(ctx, req) return s.handleStripeWebhook(ctx, req)
} }
@@ -397,6 +258,9 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
if s.cfg.StripeWebhookKey == "" { if s.cfg.StripeWebhookKey == "" {
return ErrStripeWebhookMissing return ErrStripeWebhookMissing
} }
if strings.TrimSpace(req.Header.Get("Stripe-Signature")) == "" {
return ErrStripeSignatureMissing
}
body, err := io.ReadAll(req.Body) body, err := io.ReadAll(req.Body)
if err != nil { if err != nil {
@@ -433,7 +297,7 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
return err return err
} }
if customerID != "" && derefString(tenant.BillingCustomerID) == "" { 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 return err
} }
tenant.BillingCustomerID = &customerID tenant.BillingCustomerID = &customerID
@@ -476,141 +340,6 @@ func (s *Service) handleStripeWebhook(ctx context.Context, req *http.Request) er
return err 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. // syncStripeDataToKV is the core sync function following the KV sync model.
// It fetches full subscription state from Stripe and stores it in the database. // 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. // 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 { if err := s.repo.UpsertSubscriptionSnapshot(ctx, record); err != nil {
return db.BillingSnapshotRecord{}, err 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 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)) customerID := firstNonEmpty(record.BillingCustomerID, derefString(tenant.BillingCustomerID))
provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, cfg.BillingProvider()) provider := firstNonEmpty(record.BillingProvider, tenant.BillingProvider, "stripe")
syncAvailable := cfg.BillingConfigured()
portalAvailable := cfg.BillingConfigured() && customerID != ""
checkoutAvailable := billingCheckoutAvailable(cfg, record.PlanCode)
return domain.SubscriptionSnapshot{ return domain.SubscriptionSnapshot{
TenantID: tenant.ID, TenantID: tenant.ID,
@@ -761,9 +486,9 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
return 0 return 0
}(), }(),
LastSyncedAt: record.LastSyncedAt, LastSyncedAt: record.LastSyncedAt,
CheckoutURLAvailable: checkoutAvailable, CheckoutURLAvailable: billingCheckoutAvailable(cfg, record.PlanCode),
SyncAvailable: syncAvailable, SyncAvailable: cfg.StripeConfigured(),
PortalAvailable: portalAvailable, 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 { func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.StripePriceMatrix { for planCode, currencies := range s.cfg.StripePriceMatrix {
for _, configuredPriceID := range currencies { for _, configuredPriceID := range currencies {
@@ -834,23 +548,6 @@ func (s *Service) stripePlanCodeForPrice(priceID string, fallback string) string
return shared.NormalizePlanCode(fallback) 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) { func (s *Service) stripePriceForPlan(planCode string, currency string, billingInterval string) (string, string, string) {
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode)) resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" { if resolvedPlan == "" {
@@ -893,23 +590,6 @@ func (s *Service) stripePriceForPlan(planCode string, currency string, billingIn
return s.cfg.StripePriceMatrix[resolvedPlan][resolvedPlan+":czk"], resolvedPlan, "czk" 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 { func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
switch shared.NormalizePlanCode(planCode) { switch shared.NormalizePlanCode(planCode) {
case "starter": case "starter":
@@ -944,21 +624,12 @@ func normalizeCurrency(currency string) string {
} }
} }
func normalizeSubscriptionStatus(status string) string { func billingCheckoutAvailable(cfg config.Config, planCode string) bool {
switch strings.TrimSpace(strings.ToLower(status)) { if !cfg.StripeConfigured() || !cfg.StripeWebhookConfigured() {
case "active", "trialing", "past_due", "paused", "canceled":
return strings.TrimSpace(strings.ToLower(status))
default:
return "inactive"
}
}
func checkoutAvailable(cfg config.Config, planCode string) bool {
if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() {
return false return false
} }
planCode = shared.NormalizePlanCode(planCode) planCode = shared.NormalizePlanCode(planCode)
for _, priceID := range cfg.PaddlePriceMatrix[planCode] { for _, priceID := range cfg.StripePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" { if strings.TrimSpace(priceID) != "" {
return true return true
} }
@@ -966,72 +637,6 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
return false 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 { func derefString(value *string) string {
if value == nil { if value == nil {
return "" return ""
+28 -35
View File
@@ -12,12 +12,12 @@ import (
func testConfig() config.Config { func testConfig() config.Config {
return config.Config{ return config.Config{
FrontendURL: "http://localhost:3000", FrontendURL: "http://localhost:3000",
PaddleAPIKey: "pdl_sdbx_apikey_123", StripeAPIKey: "sk_test_123",
PaddleWebhookKey: "pdl_ntf_123", StripeWebhookKey: "whsec_test_123",
PaddlePriceMatrix: map[string]map[string]string{ StripePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"}, "starter": {"czk": "price_starter_czk", "usd": "price_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"}, "pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"}, "business": {"czk": "price_business_czk", "usd": "price_business_usd"},
}, },
} }
} }
@@ -36,66 +36,59 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
if snapshot.PlanCode != "pro" { if snapshot.PlanCode != "pro" {
t.Fatalf("expected pro, got %s", snapshot.PlanCode) t.Fatalf("expected pro, got %s", snapshot.PlanCode)
} }
if snapshot.Provider != "paddle" { if snapshot.Provider != "stripe" {
t.Fatalf("expected paddle provider, got %s", snapshot.Provider) t.Fatalf("expected stripe provider, got %s", snapshot.Provider)
} }
if snapshot.Entitlements.MaxLocations != 3 { if snapshot.Entitlements.MaxLocations != 3 {
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations) t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
} }
} }
func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) { func TestCreateCheckoutRequiresStripeConfig(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.PaddleAPIKey = "" cfg.StripeAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository()) service := NewService(cfg, db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{ response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner", Subject: "demo-owner",
Email: "[email protected]", Email: "[email protected]",
}, "pro", "czk", "monthly") }, "pro", "czk", "monthly")
if err != ErrPaddleNotConfigured { if err != ErrStripeNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err) t.Fatalf("expected ErrStripeNotConfigured, got response=%v err=%v", response, err)
} }
} }
func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) { func TestCreateCheckoutRejectsUnconfiguredPlan(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository()) 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", Subject: "demo-owner",
Email: "[email protected]", Email: "[email protected]",
}, "pro", "czk", "monthly") }, "pro", "czk", "monthly")
if err != nil { if err != ErrBillingPlanUnsupported {
t.Fatalf("create checkout: %v", err) t.Fatalf("expected ErrBillingPlanUnsupported, got %v", err)
}
if response.PriceID != "pri_pro_czk" {
t.Fatalf("expected pri_pro_czk, got %s", response.PriceID)
}
if response.CustomData["tenantId"] == "" {
t.Fatal("expected tenantId in customData")
}
if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" {
t.Fatal("expected redirect URLs")
} }
} }
func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) { func TestRefreshRequiresStripeKeyWhenCustomerExists(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.PaddleAPIKey = "" cfg.StripeAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository()) service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.Refresh(context.Background(), domain.Principal{ snapshot, err := service.Refresh(context.Background(), domain.Principal{
Subject: "demo-owner", Subject: "demo-owner",
Email: "[email protected]", Email: "[email protected]",
}) })
if err != ErrPaddleNotConfigured { if err != ErrStripeNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err) t.Fatalf("expected ErrStripeNotConfigured, got snapshot=%v err=%v", snapshot, err)
} }
} }
func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) { func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.PaddleWebhookKey = "" cfg.StripeWebhookKey = ""
service := NewService(cfg, db.NewMemoryRepository()) service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{ 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()) service := NewService(testConfig(), db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{ snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
@@ -121,7 +114,7 @@ func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
t.Fatalf("get subscription: %v", err) t.Fatalf("get subscription: %v", err)
} }
if !snapshot.CheckoutURLAvailable { if !snapshot.CheckoutURLAvailable {
t.Fatal("expected checkout available when paddle is configured") t.Fatal("expected checkout available when stripe is configured")
} }
if !snapshot.PortalAvailable { if !snapshot.PortalAvailable {
t.Fatal("expected portal available when customer exists") t.Fatal("expected portal available when customer exists")
@@ -130,11 +123,11 @@ func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
func TestCreatePortalSessionRequiresCustomer(t *testing.T) { func TestCreatePortalSessionRequiresCustomer(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner") membership, err := repo.GetTenantMembershipBySubject(context.Background(), "demo-owner")
if err != nil { if err != nil {
t.Fatalf("get membership: %v", err) 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) t.Fatalf("clear billing customer: %v", err)
} }
service := NewService(testConfig(), repo) service := NewService(testConfig(), repo)
@@ -2,7 +2,10 @@ package bookings
import ( import (
"context" "context"
"crypto/subtle"
"errors" "errors"
"fmt"
"strings"
"time" "time"
"bookra/apps/backend/internal/db" "bookra/apps/backend/internal/db"
@@ -17,8 +20,9 @@ var (
) )
type CustomerService struct { type CustomerService struct {
repo db.Repository repo db.Repository
notifier CustomerNotifier notifier CustomerNotifier
frontendURL string
} }
type CustomerNotifier interface { type CustomerNotifier interface {
@@ -26,11 +30,14 @@ type CustomerNotifier interface {
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error 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 { if notifier == nil {
notifier = &customerNoopNotifier{} 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{} type customerNoopNotifier struct{}
@@ -43,29 +50,70 @@ func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data
return nil 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 // GetBookingByReference returns booking details for customer management link
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) { func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
booking, err := s.repo.GetBookingByReference(ctx, reference) booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil { if err != nil {
return domain.CustomerBookingView{}, ErrBookingNotFound 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) tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil { if err != nil {
return domain.CustomerBookingView{}, err return domain.CustomerBookingView{}, err
} }
serviceName, locationName := s.descriptors(ctx, booking)
return domain.CustomerBookingView{ return domain.CustomerBookingView{
Reference: booking.Reference, Reference: booking.Reference,
CustomerName: booking.CustomerName, CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail, CustomerEmail: booking.CustomerEmail,
Service: "Service", // Would get from service ID in full implementation Service: serviceName,
BusinessName: tenant.Name, BusinessName: tenant.Name,
StartsAt: booking.StartsAt, StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt, EndsAt: booking.EndsAt,
Location: "Location", // Would get from location ID in full implementation Location: locationName,
Status: booking.Status, Status: booking.Status,
Notes: booking.Notes,
}, nil }, nil
} }
@@ -75,6 +123,9 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
if err != nil { if err != nil {
return ErrBookingNotFound return ErrBookingNotFound
} }
if err := authorize(booking, token); err != nil {
return err
}
if booking.Status == "cancelled" { if booking.Status == "cancelled" {
return ErrBookingCancelled return ErrBookingCancelled
@@ -98,6 +149,20 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
return ErrInvalidReschedule 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 { if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
return err return err
} }
@@ -114,6 +179,9 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
if err != nil { if err != nil {
return ErrBookingNotFound return ErrBookingNotFound
} }
if err := authorize(booking, token); err != nil {
return err
}
if booking.Status == "cancelled" { if booking.Status == "cancelled" {
return ErrBookingCancelled return ErrBookingCancelled
@@ -144,22 +212,23 @@ func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStart
brandColor = "#a65c3e" brandColor = "#a65c3e"
} }
managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference serviceName, locationName := s.descriptors(ctx, booking)
emailData := notifications.BookingEmailData{ emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name, TenantName: tenant.Name,
TenantSlug: tenant.Slug, TenantSlug: tenant.Slug,
BrandColor: brandColor, BrandColor: brandColor,
CustomerName: booking.CustomerName, CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail, CustomerEmail: booking.CustomerEmail,
Service: "Service", Service: serviceName,
Location: "Location", Location: locationName,
Reference: booking.Reference, Reference: booking.Reference,
StartsAt: newStartsAt, StartsAt: newStartsAt,
EndsAt: newEndsAt, EndsAt: newEndsAt,
Timezone: tenant.Timezone, Timezone: tenant.Timezone,
Locale: tenant.Locale, Locale: tenant.Locale,
ManagementURL: managementURL, ManagementURL: s.managementURL(booking),
} }
s.notifier.SendBookingReschedule(ctx, emailData) s.notifier.SendBookingReschedule(ctx, emailData)
@@ -180,14 +249,17 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
brandColor = "#a65c3e" brandColor = "#a65c3e"
} }
serviceName, locationName := s.descriptors(ctx, booking)
emailData := notifications.BookingEmailData{ emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name, TenantName: tenant.Name,
TenantSlug: tenant.Slug, TenantSlug: tenant.Slug,
BrandColor: brandColor, BrandColor: brandColor,
CustomerName: booking.CustomerName, CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail, CustomerEmail: booking.CustomerEmail,
Service: "Service", Service: serviceName,
Location: "Location", Location: locationName,
Reference: booking.Reference, Reference: booking.Reference,
StartsAt: booking.StartsAt, StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt, EndsAt: booking.EndsAt,
@@ -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: "[email protected]",
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)
}
}
+155 -70
View File
@@ -2,6 +2,8 @@ package bookings
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
@@ -26,8 +28,9 @@ var (
) )
type Service struct { type Service struct {
repo db.Repository repo db.Repository
notifier Notifier notifier Notifier
frontendURL string
} }
type Notifier interface { type Notifier interface {
@@ -35,11 +38,14 @@ type Notifier interface {
SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error 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 { if notifier == nil {
notifier = &noopNotifier{} 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{} type noopNotifier struct{}
@@ -163,7 +169,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if booking.Status == "cancelled" { if booking.Status == "cancelled" {
continue continue
} }
if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) { if resourcesCollide(booking, request.StaffID, request.LocationID) {
return domain.CreateBookingResponse{}, ErrBookingConflict return domain.CreateBookingResponse{}, ErrBookingConflict
} }
} }
@@ -203,6 +209,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
EndsAt: endsAt.UTC(), EndsAt: endsAt.UTC(),
Status: status, Status: status,
Reference: db.Reference("BK", time.Now()), Reference: db.Reference("BK", time.Now()),
ManageToken: generateManageToken(),
Notes: notes, Notes: notes,
}) })
if err != nil { 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) // 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{ return domain.CreateBookingResponse{
BookingID: created.ID, BookingID: created.ID,
Reference: created.Reference, Reference: created.Reference,
Status: created.Status, Status: created.Status,
ManageURL: s.manageURL(created),
}, nil }, 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -254,22 +285,23 @@ func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking
brandColor = "#a65c3e" // Default terra color 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{ emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name, TenantName: tenant.Name,
TenantSlug: tenant.Slug, TenantSlug: tenant.Slug,
BrandColor: brandColor, BrandColor: brandColor,
CustomerName: customerName, CustomerName: customerName,
CustomerEmail: customerEmail, CustomerEmail: customerEmail,
Service: "Service", // Would lookup from booking.ServiceID in full implementation Service: serviceName,
Location: "Location", // Would lookup from booking.LocationID in full implementation Location: locationName,
Reference: booking.Reference, Reference: booking.Reference,
StartsAt: startsAt, StartsAt: startsAt,
EndsAt: endsAt, EndsAt: endsAt,
Timezone: tenant.Timezone, Timezone: tenant.Timezone,
Locale: tenant.Locale, Locale: tenant.Locale,
ManagementURL: managementURL, ManagementURL: s.manageURL(booking),
} }
// Send to customer // 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) { 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.DashboardSummary{}, ErrTenantMembership return domain.DashboardSummary{}, ErrTenantMembership
@@ -311,6 +343,38 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
return domain.DashboardSummary{}, err 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() now := time.Now().UTC()
weekEnd := now.AddDate(0, 0, 7) weekEnd := now.AddDate(0, 0, 7)
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd) 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)) upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
for _, booking := range upcomingRecords { for _, booking := range upcomingRecords {
upcoming = append(upcoming, domain.UpcomingBooking{ if booking.Status == "cancelled" {
Reference: booking.Reference, continue
CustomerName: booking.CustomerName, }
CustomerEmail: booking.CustomerEmail, upcoming = append(upcoming, toDomain(booking))
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
} }
if len(upcoming) > 5 { if len(upcoming) > 5 {
upcoming = 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)) allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range allRecords { for _, booking := range allRecords {
allBookings = append(allBookings, domain.UpcomingBooking{ allBookings = append(allBookings, toDomain(booking))
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
} }
return domain.DashboardSummary{ return domain.DashboardSummary{
@@ -407,52 +460,55 @@ func generateAppointmentSlots(
location = time.UTC location = time.UTC
} }
service := services[0]
now := time.Now().In(location) now := time.Now().In(location)
var slots []domain.TimeSlot var slots []domain.TimeSlot
for dayOffset := 0; dayOffset < 7 && len(slots) < 6; dayOffset++ { for _, service := range services {
day := now.AddDate(0, 0, dayOffset) serviceSlots := 0
for _, rule := range rules { for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; dayOffset++ {
if int(day.Weekday()) != rule.DayOfWeek { day := now.AddDate(0, 0, dayOffset)
continue for _, rule := range rules {
} if int(day.Weekday()) != rule.DayOfWeek {
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
if err != nil {
continue
}
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
if err != nil {
continue
}
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
duration := time.Duration(service.DurationMinutes) * time.Minute
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
if slotStart.Before(now.Add(2 * time.Hour)) {
continue continue
} }
slotEnd := slotStart.Add(duration) startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) { if err != nil {
continue
}
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
if err != nil {
continue continue
} }
serviceID := service.ID windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
slots = append(slots, domain.TimeSlot{ windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
ServiceID: &serviceID,
StaffID: rule.StaffID, step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
StartsAt: slotStart.UTC().Format(time.RFC3339), duration := time.Duration(service.DurationMinutes) * time.Minute
EndsAt: slotEnd.UTC().Format(time.RFC3339), for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
Mode: "appointment", if slotStart.Before(now.Add(2 * time.Hour)) {
Label: service.Name, continue
}) }
if len(slots) >= 6 {
break 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)) { if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) {
continue continue
} }
if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) { if resourcesCollide(booking, staffID, locationID) {
return true return true
} }
} }
return false return false
} }
func sameResource(left *string, right *string) bool { // resourcesCollide reports whether an existing booking shares the resource a
if left == nil || right == nil { // new booking would consume. A booking with neither staff nor location
return false // 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 { func sameSecond(left time.Time, right time.Time) bool {
@@ -12,7 +12,7 @@ import (
func TestCreateAppointmentRejectsConflict(t *testing.T) { func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
service := NewService(repo, nil) service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier") availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil { if err != nil {
@@ -69,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) { func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
service := NewService(repo, nil) service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier") availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil { if err != nil {
@@ -126,7 +126,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
func TestCreateAppointmentRequiresTenantService(t *testing.T) { func TestCreateAppointmentRequiresTenantService(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
service := NewService(repo, nil) service := NewService(repo, nil, "http://localhost:3000")
_, err := service.Create(context.Background(), domain.CreateBookingRequest{ _, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier", TenantSlug: "studio-atelier",
@@ -143,7 +143,7 @@ func TestCreateAppointmentRequiresTenantService(t *testing.T) {
func TestCreateClassRequiresExistingSession(t *testing.T) { func TestCreateClassRequiresExistingSession(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
service := NewService(repo, nil) service := NewService(repo, nil, "http://localhost:3000")
missingSessionID := "11111111-1111-1111-1111-111111111111" missingSessionID := "11111111-1111-1111-1111-111111111111"
_, err := service.Create(context.Background(), domain.CreateBookingRequest{ _, err := service.Create(context.Background(), domain.CreateBookingRequest{
@@ -162,7 +162,7 @@ func TestCreateClassRequiresExistingSession(t *testing.T) {
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) { func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
service := NewService(repo, nil) service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier") availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil { if err != nil {
@@ -185,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) { func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
service := NewService(repo, nil) service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier") availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil { if err != nil {
+162 -42
View File
@@ -4,8 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/mail"
"strings"
"time" "time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db" "bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/domain"
) )
@@ -16,6 +19,7 @@ var (
ErrCustomerNotFound = errors.New("customer not found") ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found") ErrBookingNotFound = errors.New("booking not found")
ErrInvalidBooking = errors.New("invalid booking request") ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found") ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found") ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached") 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) { 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 { if err != nil {
return nil, ErrTenantMembership return nil, ErrTenantMembership
} }
@@ -56,21 +60,25 @@ func (s *Service) ListLocations(ctx context.Context, principal domain.Principal)
locations := make([]domain.Location, len(records)) locations := make([]domain.Location, len(records))
for i, rec := range records { for i, rec := range records {
locations[i] = domain.Location{ locations[i] = locationToDomain(rec)
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: "room", // Default type
Capacity: 10, // Default capacity
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
} }
return locations, nil 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) { 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 { if err != nil {
return domain.Location{}, ErrTenantMembership 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{ params := db.CreateLocationParams{
TenantID: membership.Tenant.ID, TenantID: membership.Tenant.ID,
Name: req.Name, Name: req.Name,
Type: locationType,
Capacity: capacity,
Timezone: membership.Tenant.Timezone, Timezone: membership.Tenant.Timezone,
} }
@@ -110,19 +129,11 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
} }
} }
return domain.Location{ return locationToDomain(rec), nil
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
} }
func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) { 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 { if err != nil {
return domain.Location{}, ErrTenantMembership return domain.Location{}, ErrTenantMembership
} }
@@ -140,25 +151,23 @@ func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal
if req.Name != "" { if req.Name != "" {
params.Name = &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) rec, err := s.repo.UpdateLocation(ctx, locationID, params)
if err != nil { if err != nil {
return domain.Location{}, err return domain.Location{}, err
} }
return domain.Location{ return locationToDomain(rec), nil
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
} }
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error { 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 { if err != nil {
return ErrTenantMembership 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) { 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 { if err != nil {
return nil, ErrTenantMembership 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) { 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 { if err != nil {
return domain.BlockedDay{}, ErrTenantMembership 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) { 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 { if err != nil {
return domain.BlockedDay{}, ErrTenantMembership 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 { 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 { if err != nil {
return ErrTenantMembership 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) { 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 { if err != nil {
return nil, ErrTenantMembership 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) { 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 { if err != nil {
return domain.Customer{}, ErrTenantMembership 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) { 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 { if err != nil {
return domain.Customer{}, ErrTenantMembership 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 { 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 { if err != nil {
return ErrTenantMembership 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) { 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 { if err != nil {
return nil, ErrTenantMembership 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 { 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 { if err != nil {
return ErrTenantMembership return ErrTenantMembership
} }
params := db.UpdateWorkingHoursParams{} params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
if req.Open != "" { 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 params.StartsLocal = &req.Open
} }
if req.Close != "" { 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 params.EndsLocal = &req.Close
} }
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params) 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
}
+3 -50
View File
@@ -24,10 +24,6 @@ type Config struct {
SMTPPort string SMTPPort string
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
PaddleEnvironment string
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string StripeAPIKey string
StripeWebhookKey string StripeWebhookKey string
StripePriceMatrix map[string]map[string]string StripePriceMatrix map[string]map[string]string
@@ -58,10 +54,6 @@ func Load() (Config, error) {
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"), SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")), SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")), 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")), StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")), StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceMatrix: stripePriceMatrixFromEnv(), StripePriceMatrix: stripePriceMatrixFromEnv(),
@@ -110,19 +102,6 @@ func (cfg Config) validateRuntimeRequirements() error {
return nil 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 { func (cfg Config) StripeConfigured() bool {
return strings.TrimSpace(cfg.StripeAPIKey) != "" return strings.TrimSpace(cfg.StripeAPIKey) != ""
} }
@@ -137,18 +116,15 @@ func (cfg Config) StripeCheckoutConfigured(planCode string) bool {
} }
func (cfg Config) BillingProvider() string { func (cfg Config) BillingProvider() string {
if cfg.StripeConfigured() { return "stripe"
return "stripe"
}
return "paddle"
} }
func (cfg Config) BillingConfigured() bool { func (cfg Config) BillingConfigured() bool {
return cfg.StripeConfigured() || cfg.PaddleConfigured() return cfg.StripeConfigured()
} }
func (cfg Config) BillingWebhookConfigured() bool { func (cfg Config) BillingWebhookConfigured() bool {
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured() return cfg.StripeWebhookConfigured()
} }
func (cfg Config) SMSConfigured() bool { func (cfg Config) SMSConfigured() bool {
@@ -170,20 +146,6 @@ func (cfg Config) StripeSMSPriceID(currency string) string {
return cfg.StripeSMSPriceMatrix["czk"] 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 { func stripePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{ matrix := map[string]map[string]string{
"starter": {}, "starter": {},
@@ -210,15 +172,6 @@ func stripePriceMatrixFromEnv() map[string]map[string]string {
return matrix 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 { func valueOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" { if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value return value
+19 -13
View File
@@ -2,31 +2,37 @@ package config
import "testing" import "testing"
func TestPaddleCheckoutConfigured(t *testing.T) { func TestStripeCheckoutConfigured(t *testing.T) {
cfg := Config{ cfg := Config{
PaddleAPIKey: "pdl_sdbx_apikey_123", StripeAPIKey: "sk_test_123",
PaddleWebhookKey: "pdl_ntf_123", StripeWebhookKey: "whsec_test_123",
PaddlePriceMatrix: map[string]map[string]string{ StripePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"}, "starter": {"czk": "price_starter_czk", "usd": "price_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"}, "pro": {"czk": "price_pro_czk", "usd": "price_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"}, "business": {"czk": "price_business_czk", "usd": "price_business_usd"},
}, },
} }
if !cfg.PaddleCheckoutConfigured("pro") { if !cfg.StripeCheckoutConfigured("pro") {
t.Fatal("expected pro checkout configured") t.Fatal("expected pro checkout configured")
} }
} }
func TestPaddleCheckoutConfiguredRequiresWebhook(t *testing.T) { func TestStripeCheckoutConfiguredRequiresWebhook(t *testing.T) {
cfg := Config{ cfg := Config{
PaddleAPIKey: "pdl_sdbx_apikey_123", StripeAPIKey: "sk_test_123",
PaddlePriceMatrix: map[string]map[string]string{ StripePriceMatrix: map[string]map[string]string{
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"}, "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") t.Fatal("expected checkout disabled without webhook key")
} }
} }
func TestBillingProviderIsStripe(t *testing.T) {
if (Config{}).BillingProvider() != "stripe" {
t.Fatal("expected stripe billing provider")
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
package db package db
import ( import (
"context" "context"
) )
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) { 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, payment_method_last4 = EXCLUDED.payment_method_last4,
last_synced_at = EXCLUDED.last_synced_at, last_synced_at = EXCLUDED.last_synced_at,
updated_at = now() 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, firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt) params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err 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) 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()) VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING 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 { if err != nil {
return false, err return false, err
} }
+9 -5
View File
@@ -8,7 +8,8 @@ import (
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, 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 FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC ORDER BY starts_at ASC
@@ -35,6 +36,8 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.EndsAt, &record.EndsAt,
&record.Status, &record.Status,
&record.Reference, &record.Reference,
&record.ManageToken,
&record.Notes,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -49,10 +52,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
INSERT INTO bookings ( INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id, tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at, 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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING id, reference, status RETURNING id, reference, status, manage_token
`, `,
params.TenantID, params.TenantID,
params.ServiceID, params.ServiceID,
@@ -67,8 +70,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
params.EndsAt, params.EndsAt,
params.Status, params.Status,
params.Reference, params.Reference,
params.ManageToken,
params.Notes, params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status) ).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
return created, err return created, err
} }
@@ -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
}
+42 -19
View File
@@ -1,13 +1,15 @@
package db package db
import ( import (
"context" "context"
"time" "time"
) )
const locationColumns = "id, tenant_id, name, type, capacity, timezone, created_at"
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) { func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, timezone, created_at SELECT `+locationColumns+`
FROM locations FROM locations
WHERE tenant_id = $1 WHERE tenant_id = $1
ORDER BY name ORDER BY name
@@ -20,7 +22,7 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin
var records []LocationRecord var records []LocationRecord
for rows.Next() { for rows.Next() {
var rec LocationRecord 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 return nil, err
} }
records = append(records, rec) 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) { func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
var rec LocationRecord var rec LocationRecord
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, timezone, created_at SELECT `+locationColumns+`
FROM locations FROM locations
WHERE id = $1 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 return rec, err
} }
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) { func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
var rec LocationRecord var rec LocationRecord
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
INSERT INTO locations (tenant_id, name, timezone) INSERT INTO locations (tenant_id, name, type, capacity, timezone)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, $5)
RETURNING id, tenant_id, name, timezone, created_at RETURNING `+locationColumns+`
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt) `, 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 return rec, err
} }
@@ -53,11 +55,13 @@ func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, pa
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
UPDATE locations UPDATE locations
SET name = COALESCE($2, name), SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone), type = COALESCE($3, type),
capacity = COALESCE($4, capacity),
timezone = COALESCE($5, timezone),
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, tenant_id, name, timezone, created_at RETURNING `+locationColumns+`
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt) `, 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 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) { func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, ` 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 FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week 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 { func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
_, err := r.pool.Exec(ctx, ` if params.IsOpen != nil && !*params.IsOpen {
UPDATE availability_rules _, err := r.pool.Exec(ctx, `
SET starts_local = COALESCE($3, starts_local), DELETE FROM availability_rules
ends_local = COALESCE($4, ends_local) 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 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 return err
} }
+152 -26
View File
@@ -19,7 +19,9 @@ type Repository interface {
GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error)
EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error
CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, 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) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error)
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, 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) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error)
GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error)
UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error
UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID 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) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Auth methods // Auth methods
@@ -87,6 +90,11 @@ type Repository interface {
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) 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 // SMS
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
@@ -190,6 +198,13 @@ type BrandProfileRecord struct {
UmamiSiteID string UmamiSiteID string
} }
type UpdateBrandProfileParams struct {
Name *string
SiteURL *string
LogoURL *string
PrimaryColor *string
}
type AvailabilityBlockRecord struct { type AvailabilityBlockRecord struct {
DayOfWeek int DayOfWeek int
StartsLocal string StartsLocal string
@@ -246,6 +261,8 @@ type BookingRecord struct {
EndsAt time.Time EndsAt time.Time
Status string Status string
Reference string Reference string
ManageToken string
Notes string
} }
type CreateBookingParams struct { type CreateBookingParams struct {
@@ -262,13 +279,15 @@ type CreateBookingParams struct {
EndsAt time.Time EndsAt time.Time
Status string Status string
Reference string Reference string
ManageToken string
Notes string Notes string
} }
type CreatedBooking struct { type CreatedBooking struct {
ID string ID string
Reference string Reference string
Status string Status string
ManageToken string
} }
type WaitlistEntryParams struct { type WaitlistEntryParams struct {
@@ -341,6 +360,8 @@ type LocationRecord struct {
ID string ID string
TenantID string TenantID string
Name string Name string
Type string
Capacity int
Timezone string Timezone string
CreatedAt time.Time CreatedAt time.Time
} }
@@ -348,11 +369,15 @@ type LocationRecord struct {
type CreateLocationParams struct { type CreateLocationParams struct {
TenantID string TenantID string
Name string Name string
Type string
Capacity int
Timezone string Timezone string
} }
type UpdateLocationParams struct { type UpdateLocationParams struct {
Name *string Name *string
Type *string
Capacity *int
Timezone *string Timezone *string
} }
@@ -428,6 +453,18 @@ type UpdateWorkingHoursParams struct {
IsOpen *bool 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 // SMS Records
type TenantSMSSettingsRecord struct { type TenantSMSSettingsRecord struct {
TenantID string TenantID string
@@ -592,11 +629,12 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
var rec BookingRecord var rec BookingRecord
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, 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 FROM bookings
WHERE reference = $1 WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, `, 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 return rec, err
} }
@@ -638,6 +676,7 @@ type MemoryRepository struct {
smsSettings TenantSMSSettingsRecord smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
} }
func NewMemoryRepository() *MemoryRepository { func NewMemoryRepository() *MemoryRepository {
@@ -659,7 +698,7 @@ func NewMemoryRepository() *MemoryRepository {
Timezone: "Europe/Prague", Timezone: "Europe/Prague",
PlanCode: "pro", PlanCode: "pro",
SubscriptionStatus: "active", SubscriptionStatus: "active",
BillingProvider: "paddle", BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"), BillingCustomerID: stringPtr("ctm_demo_bookra"),
}, },
membership: TenantMembershipRecord{ membership: TenantMembershipRecord{
@@ -672,7 +711,7 @@ func NewMemoryRepository() *MemoryRepository {
Timezone: "Europe/Prague", Timezone: "Europe/Prague",
PlanCode: "pro", PlanCode: "pro",
SubscriptionStatus: "active", SubscriptionStatus: "active",
BillingProvider: "paddle", BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"), BillingCustomerID: stringPtr("ctm_demo_bookra"),
}, },
UserID: "demo-owner", UserID: "demo-owner",
@@ -719,7 +758,7 @@ func NewMemoryRepository() *MemoryRepository {
deliveryLogs: []NotificationDeliveryLogParams{}, deliveryLogs: []NotificationDeliveryLogParams{},
billingSnapshot: BillingSnapshotRecord{ billingSnapshot: BillingSnapshotRecord{
TenantID: tenantID, TenantID: tenantID,
BillingProvider: "paddle", BillingProvider: "stripe",
BillingCustomerID: "ctm_demo_bookra", BillingCustomerID: "ctm_demo_bookra",
BillingSubscriptionID: "", BillingSubscriptionID: "",
Status: "active", Status: "active",
@@ -841,16 +880,46 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
return r.membership, nil return r.membership, nil
} }
func (r *MemoryRepository) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) { func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) {
if userID == "" || userID == "demo-owner" { if subject == "" || subject == "demo-owner" {
return r.membership, nil return r.membership, nil
} }
if userID == r.membership.UserID { if subject == r.membership.UserID {
return r.membership, nil return r.membership, nil
} }
return TenantMembershipRecord{}, pgx.ErrNoRows 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 "[email protected]", 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) { func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID { if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows 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) { func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
created := CreatedBooking{ created := CreatedBooking{
ID: uuid.NewString(), ID: uuid.NewString(),
Reference: params.Reference, Reference: params.Reference,
Status: params.Status, Status: params.Status,
ManageToken: params.ManageToken,
} }
r.bookings = append(r.bookings, BookingRecord{ r.bookings = append(r.bookings, BookingRecord{
ID: created.ID, ID: created.ID,
@@ -922,10 +992,13 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
LocationID: params.LocationID, LocationID: params.LocationID,
CustomerName: params.CustomerName, CustomerName: params.CustomerName,
CustomerEmail: params.CustomerEmail, CustomerEmail: params.CustomerEmail,
CustomerPhone: params.CustomerPhone,
StartsAt: params.StartsAt, StartsAt: params.StartsAt,
EndsAt: params.EndsAt, EndsAt: params.EndsAt,
Status: params.Status, Status: params.Status,
Reference: params.Reference, Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
}) })
return created, nil return created, nil
} }
@@ -1033,38 +1106,38 @@ func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params
return nil 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 { if tenantID != r.tenant.ID {
return pgx.ErrNoRows return pgx.ErrNoRows
} }
r.tenant.BillingProvider = "paddle" r.tenant.BillingProvider = provider
r.tenant.BillingCustomerID = &customerID r.tenant.BillingCustomerID = &customerID
r.membership.Tenant.BillingProvider = "paddle" r.membership.Tenant.BillingProvider = provider
r.membership.Tenant.BillingCustomerID = &customerID r.membership.Tenant.BillingCustomerID = &customerID
r.billingSnapshot.BillingProvider = "paddle" r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingCustomerID = customerID r.billingSnapshot.BillingCustomerID = customerID
return nil 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 { if tenantID != r.tenant.ID {
return pgx.ErrNoRows return pgx.ErrNoRows
} }
r.tenant.PlanCode = planCode r.tenant.PlanCode = planCode
r.tenant.SubscriptionStatus = subscriptionStatus r.tenant.SubscriptionStatus = subscriptionStatus
r.tenant.BillingProvider = "paddle" r.tenant.BillingProvider = provider
r.membership.Tenant.PlanCode = planCode r.membership.Tenant.PlanCode = planCode
r.membership.Tenant.SubscriptionStatus = subscriptionStatus r.membership.Tenant.SubscriptionStatus = subscriptionStatus
r.membership.Tenant.BillingProvider = "paddle" r.membership.Tenant.BillingProvider = provider
r.billingSnapshot.PlanCode = planCode r.billingSnapshot.PlanCode = planCode
r.billingSnapshot.Status = subscriptionStatus r.billingSnapshot.Status = subscriptionStatus
r.billingSnapshot.BillingProvider = "paddle" r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingSubscriptionID = subscriptionID r.billingSnapshot.BillingSubscriptionID = subscriptionID
return nil return nil
} }
func (r *MemoryRepository) RecordBillingEvent(_ context.Context, _ string, provider string, eventID string, _ string, payload []byte) (bool, error) { 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 { if _, exists := r.recordedEvents[key]; exists {
return false, nil return false, nil
} }
@@ -1103,6 +1176,8 @@ func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocati
ID: uuid.NewString(), ID: uuid.NewString(),
TenantID: params.TenantID, TenantID: params.TenantID,
Name: params.Name, Name: params.Name,
Type: params.Type,
Capacity: params.Capacity,
Timezone: params.Timezone, Timezone: params.Timezone,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -1116,6 +1191,12 @@ func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string,
if params.Name != nil { if params.Name != nil {
r.locations[i].Name = *params.Name 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 { if params.Timezone != nil {
r.locations[i].Timezone = *params.Timezone r.locations[i].Timezone = *params.Timezone
} }
@@ -1352,6 +1433,51 @@ func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string
return pgx.ErrNoRows 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 // WORKING HOURS METHODS - MEMORY REPOSITORY
// ============================================ // ============================================
+2 -2
View File
@@ -1,8 +1,8 @@
package db package db
import ( import (
"context" "context"
"time" "time"
) )
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) { func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
+80 -19
View File
@@ -2,6 +2,8 @@ package db
import ( import (
"context" "context"
"errors"
"strings"
"github.com/jackc/pgx/v5" "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 { 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, ` _, err := r.pool.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name) 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) ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name)
`, subject, email, displayName) `, subject, email, displayName)
return err return err
@@ -123,21 +136,22 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name) INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, $2, $3) SELECT gen_random_uuid(), $1, $2, ''
ON CONFLICT (neon_subject) DO NOTHING WHERE NOT EXISTS (
`, params.Subject, params.Subject+"@users.bookra.invalid", "") SELECT 1 FROM users WHERE neon_subject = $1 OR id::text = $1
)
`, params.Subject, params.Subject+"@users.bookra.invalid")
if err != nil { if err != nil {
return TenantMembershipRecord{}, err return TenantMembershipRecord{}, err
} }
var userID string var membershipID string
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at) INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now() VALUES (gen_random_uuid(), $1, $2, 'owner', now())
FROM users u WHERE u.neon_subject = $2
ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner' ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner'
RETURNING id RETURNING id
`, tenantID, params.Subject).Scan(&userID) `, tenantID, params.Subject).Scan(&membershipID)
if err != nil { if err != nil {
return TenantMembershipRecord{}, err return TenantMembershipRecord{}, err
} }
@@ -156,7 +170,7 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen
Locale: params.Locale, Locale: params.Locale,
Timezone: params.Timezone, Timezone: params.Timezone,
}, },
UserID: userID, UserID: membershipID,
Role: "owner", Role: "owner",
}, nil }, nil
} }
@@ -170,7 +184,7 @@ func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (Br
return record, err 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 var record TenantMembershipRecord
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status, 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 tm.user_neon_subject, tm.role
FROM tenant_memberships tm FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id JOIN tenants t ON t.id = tm.tenant_id
JOIN users u ON u.neon_subject = tm.user_neon_subject WHERE tm.user_neon_subject = $1
WHERE u.id = $1
ORDER BY tm.joined_at DESC ORDER BY tm.joined_at DESC
LIMIT 1 LIMIT 1
`, userID).Scan( `, subject).Scan(
&record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset, &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.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider, &record.Tenant.BillingCustomerID, &record.Tenant.BillingSubscription, &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 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, ` _, err := r.pool.Exec(ctx, `
UPDATE tenants 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 WHERE id = $1
`, tenantID, customerID) `, tenantID, provider, customerID)
return err 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, ` _, err := r.pool.Exec(ctx, `
UPDATE tenants 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 WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID) `, tenantID, provider, planCode, subscriptionStatus, subscriptionID)
return err return err
} }
+32 -10
View File
@@ -16,9 +16,14 @@ type DashboardKPI struct {
} }
type UpcomingBooking struct { type UpcomingBooking struct {
ID string `json:"id"`
Reference string `json:"reference"` Reference string `json:"reference"`
CustomerName string `json:"customerName"` CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"` 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"` StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"` EndsAt time.Time `json:"endsAt"`
Status string `json:"status"` Status string `json:"status"`
@@ -143,6 +148,7 @@ type CreateBookingResponse struct {
BookingID string `json:"bookingId"` BookingID string `json:"bookingId"`
Reference string `json:"reference"` Reference string `json:"reference"`
Status string `json:"status"` Status string `json:"status"`
ManageURL string `json:"manageUrl"`
} }
type PlanEntitlements struct { type PlanEntitlements struct {
@@ -207,16 +213,9 @@ type CheckoutSessionRequest struct {
} }
type CheckoutLaunchResponse struct { type CheckoutLaunchResponse struct {
// Stripe checkout CheckoutURL string `json:"checkoutUrl,omitempty"`
CheckoutURL string `json:"checkoutUrl,omitempty"` SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
// Paddle checkout CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
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"`
} }
type PortalSessionResponse struct { type PortalSessionResponse struct {
@@ -247,6 +246,15 @@ type Location struct {
CreatedAt time.Time `json:"createdAt"` 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 { type CreateLocationRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` Type string `json:"type" binding:"required"`
@@ -431,6 +439,20 @@ type EmailTemplate struct {
IsEnabled bool `json:"isEnabled"` 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 { type SendEmailRequest struct {
To string `json:"to" binding:"required,email"` To string `json:"to" binding:"required,email"`
Subject string `json:"subject" binding:"required"` Subject string `json:"subject" binding:"required"`
+8 -5
View File
@@ -25,10 +25,11 @@ type visitor struct {
} }
type RateLimiter struct { type RateLimiter struct {
mu sync.Mutex mu sync.Mutex
visitors map[string]*visitor visitors map[string]*visitor
limit rate.Limit limit rate.Limit
burst int burst int
cleanupOnce sync.Once
} }
func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter { 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 { func (r *RateLimiter) Middleware() gin.HandlerFunc {
go r.cleanupLoop() r.cleanupOnce.Do(func() {
go r.cleanupLoop()
})
return func(c *gin.Context) { return func(c *gin.Context) {
ip := c.ClientIP() ip := c.ClientIP()
@@ -21,6 +21,7 @@ const (
type BookingEmailData struct { type BookingEmailData struct {
Type EmailType Type EmailType
TenantID string
TenantName string TenantName string
TenantSlug string TenantSlug string
BusinessEmail string BusinessEmail string
+79 -13
View File
@@ -126,7 +126,7 @@ func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmail
data.BusinessEmail = s.cfg.EmailFrom data.BusinessEmail = s.cfg.EmailFrom
} }
data.Type = EmailTypeConfirmation data.Type = EmailTypeConfirmation
msg := RenderEmailMessage(data) msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg) _, err := s.emailProvider.Send(ctx, msg)
return err return err
} }
@@ -137,7 +137,7 @@ func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailDa
data.BusinessEmail = s.cfg.EmailFrom data.BusinessEmail = s.cfg.EmailFrom
} }
data.Type = EmailTypeReschedule data.Type = EmailTypeReschedule
msg := RenderEmailMessage(data) msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg) _, err := s.emailProvider.Send(ctx, msg)
return err return err
} }
@@ -148,11 +148,73 @@ func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmail
data.BusinessEmail = s.cfg.EmailFrom data.BusinessEmail = s.cfg.EmailFrom
} }
data.Type = EmailTypeCancellation data.Type = EmailTypeCancellation
msg := RenderEmailMessage(data) msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg) _, err := s.emailProvider.Send(ctx, msg)
return err 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><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", brandColor, tenantName)
for _, p := range splitParagraphs(text) {
if len(p) > 0 {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
if manageURL != "" {
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", manageURL, brandColor)
}
return html + "</div></body></html>"
}
// SendBusinessNotification sends a notification to the business about a new booking // SendBusinessNotification sends a notification to the business about a new booking
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error { func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
if businessEmail == "" { 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) return fmt.Errorf("failed to get tenant: %w", err)
} }
// Use a placeholder admin email - in production, would get from tenant owner ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
adminEmail := "admin@" + tenant.Slug + ".bookra.eu" if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{ emailData := UsageNotificationData{
Type: EmailTypeUsageWarning, Type: EmailTypeUsageWarning,
TenantName: tenant.Name, TenantName: tenant.Name,
TenantSlug: tenant.Slug, TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom, BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail, AdminEmail: ownerEmail,
Locale: tenant.Locale, Locale: tenant.Locale,
PlanCode: tenant.PlanCode, PlanCode: tenant.PlanCode,
LocationCount: locationCount, LocationCount: locationCount,
LocationLimit: locationLimit, LocationLimit: locationLimit,
UsagePercent: usagePercent, UsagePercent: usagePercent,
UpgradeURL: "https://bookra.eu/pricing", UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: "https://bookra.eu/dashboard", DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
} }
msg := RenderUsageNotificationEmail(emailData) 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) return fmt.Errorf("failed to get tenant: %w", err)
} }
// Use a placeholder admin email - in production, would get from tenant owner ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
adminEmail := "admin@" + tenant.Slug + ".bookra.eu" if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{ emailData := UsageNotificationData{
Type: EmailTypeTrialEnding, Type: EmailTypeTrialEnding,
TenantName: tenant.Name, TenantName: tenant.Name,
TenantSlug: tenant.Slug, TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom, BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail, AdminEmail: ownerEmail,
Locale: tenant.Locale, Locale: tenant.Locale,
PlanCode: tenant.PlanCode, PlanCode: tenant.PlanCode,
UpgradeURL: "https://bookra.eu/pricing", UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: "https://bookra.eu/dashboard", DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
} }
msg := RenderUsageNotificationEmail(emailData) msg := RenderUsageNotificationEmail(emailData)
+1 -1
View File
@@ -3,7 +3,7 @@ package shared
import "strings" import "strings"
// NormalizePlanCode canonicalizes plan codes from various sources // 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. // internal identifiers used across the billing and tenancy domains.
func NormalizePlanCode(planCode string) string { func NormalizePlanCode(planCode string) string {
switch strings.TrimSpace(planCode) { switch strings.TrimSpace(planCode) {
+1 -1
View File
@@ -353,7 +353,7 @@ func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRec
} }
// Find owner email // Find owner email
membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID) membership, err := s.repo.GetTenantMembershipBySubject(ctx, tenant.ID)
if err == nil { if err == nil {
user, err := s.repo.GetUserByID(ctx, membership.UserID) user, err := s.repo.GetUserByID(ctx, membership.UserID)
if err == nil { if err == nil {
+100 -3
View File
@@ -32,11 +32,11 @@ func NewService(repo db.Repository) *Service {
} }
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) { 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) { 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.TenantBootstrap{ 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) { 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 return domain.TenantBootstrap{}, ErrTenantAlreadyProvisioned
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return domain.TenantBootstrap{}, err return domain.TenantBootstrap{}, err
@@ -184,6 +184,103 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
}, nil }, 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 { func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile {
brand, err := s.repo.GetBrandProfile(ctx, tenant.ID) brand, err := s.repo.GetBrandProfile(ctx, tenant.ID)
if err != nil { if err != nil {
@@ -8,6 +8,7 @@ ALTER TABLE billing_snapshots
ALTER TABLE subscription_events ALTER TABLE subscription_events
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle'; ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -18,7 +19,9 @@ BEGIN
ALTER TABLE tenants RENAME COLUMN stripe_customer_id TO billing_customer_id; ALTER TABLE tenants RENAME COLUMN stripe_customer_id TO billing_customer_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -29,7 +32,9 @@ BEGIN
ALTER TABLE tenants RENAME COLUMN stripe_subscription_id TO billing_subscription_id; ALTER TABLE tenants RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -40,7 +45,9 @@ BEGIN
ALTER TABLE billing_snapshots RENAME COLUMN stripe_customer_id TO billing_customer_id; ALTER TABLE billing_snapshots RENAME COLUMN stripe_customer_id TO billing_customer_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -51,7 +58,9 @@ BEGIN
ALTER TABLE billing_snapshots RENAME COLUMN stripe_subscription_id TO billing_subscription_id; ALTER TABLE billing_snapshots RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -62,6 +71,7 @@ BEGIN
ALTER TABLE subscription_events RENAME COLUMN stripe_event_id TO billing_provider_event_id; ALTER TABLE subscription_events RENAME COLUMN stripe_event_id TO billing_provider_event_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_stripe_event_id_key; ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_stripe_event_id_key;
ALTER TABLE subscription_events ALTER TABLE subscription_events
@@ -70,6 +80,7 @@ ALTER TABLE subscription_events
-- +goose Down -- +goose Down
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_provider_event_key; ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_provider_event_key;
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -80,7 +91,9 @@ BEGIN
ALTER TABLE subscription_events RENAME COLUMN billing_provider_event_id TO stripe_event_id; ALTER TABLE subscription_events RENAME COLUMN billing_provider_event_id TO stripe_event_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -91,7 +104,9 @@ BEGIN
ALTER TABLE billing_snapshots RENAME COLUMN billing_subscription_id TO stripe_subscription_id; ALTER TABLE billing_snapshots RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -102,7 +117,9 @@ BEGIN
ALTER TABLE billing_snapshots RENAME COLUMN billing_customer_id TO stripe_customer_id; ALTER TABLE billing_snapshots RENAME COLUMN billing_customer_id TO stripe_customer_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -113,7 +130,9 @@ BEGIN
ALTER TABLE tenants RENAME COLUMN billing_subscription_id TO stripe_subscription_id; ALTER TABLE tenants RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
-- +goose StatementBegin
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -124,6 +143,7 @@ BEGIN
ALTER TABLE tenants RENAME COLUMN billing_customer_id TO stripe_customer_id; ALTER TABLE tenants RENAME COLUMN billing_customer_id TO stripe_customer_id;
END IF; END IF;
END $$; END $$;
-- +goose StatementEnd
ALTER TABLE subscription_events DROP COLUMN IF EXISTS billing_provider; ALTER TABLE subscription_events DROP COLUMN IF EXISTS billing_provider;
ALTER TABLE billing_snapshots DROP COLUMN IF EXISTS billing_provider; ALTER TABLE billing_snapshots DROP COLUMN IF EXISTS billing_provider;
@@ -1,22 +1,21 @@
-- +goose Up -- +goose Up
-- +goose StatementBegin -- +goose StatementBegin
-- Users table for authentication (migrated from auth-service) -- Authentication columns for the users table (migrated from auth-service).
CREATE TABLE IF NOT EXISTS users ( -- The users table itself is created in 00001_init; this migration adds the
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- columns the auth and admin code paths require.
email VARCHAR(255) NOT NULL UNIQUE, ALTER TABLE users
name VARCHAR(255), ADD COLUMN IF NOT EXISTS name VARCHAR(255),
password_hash VARCHAR(255), ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
provider VARCHAR(50) NOT NULL DEFAULT 'email', ADD COLUMN IF NOT EXISTS provider VARCHAR(50) NOT NULL DEFAULT 'email',
provider_id VARCHAR(255), ADD COLUMN IF NOT EXISTS provider_id VARCHAR(255),
role VARCHAR(50) NOT NULL DEFAULT 'user', ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP WITH TIME ZONE;
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); 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_provider ON users(provider, provider_id);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 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 oauth_states;
DROP TABLE IF EXISTS password_resets; DROP TABLE IF EXISTS password_resets;
DROP TABLE IF EXISTS magic_links; 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 -- +goose StatementEnd
@@ -27,11 +27,15 @@ CREATE TABLE IF NOT EXISTS team_invites (
email text NOT NULL, email text NOT NULL,
role text NOT NULL DEFAULT 'staff', role text NOT NULL DEFAULT 'staff',
status text NOT NULL DEFAULT 'pending', status text NOT NULL DEFAULT 'pending',
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email) 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 = 'pro' WHERE plan_code = 'growth';
UPDATE tenants SET plan_code = 'business' WHERE plan_code = 'multi-location'; UPDATE tenants SET plan_code = 'business' WHERE plan_code = 'multi-location';
UPDATE billing_snapshots SET plan_code = 'pro' WHERE plan_code = 'growth'; UPDATE billing_snapshots SET plan_code = 'pro' WHERE plan_code = 'growth';
@@ -34,14 +34,12 @@ CREATE TABLE IF NOT EXISTS sms_monthly_reports (
UNIQUE (tenant_id, year_month) 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_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); CREATE INDEX IF NOT EXISTS idx_sms_reports_tenant ON sms_monthly_reports (tenant_id, year_month DESC);
-- +goose Down -- +goose Down
DROP INDEX IF EXISTS idx_sms_reports_tenant; 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_created;
DROP INDEX IF EXISTS idx_sms_usage_tenant_month;
DROP TABLE IF EXISTS sms_monthly_reports; DROP TABLE IF EXISTS sms_monthly_reports;
DROP TABLE IF EXISTS sms_usage_logs; DROP TABLE IF EXISTS sms_usage_logs;
DROP TABLE IF EXISTS tenant_sms_settings; DROP TABLE IF EXISTS tenant_sms_settings;
@@ -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;
@@ -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;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -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;
-10
View File
@@ -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;
-16
View File
@@ -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
+6 -3
View File
@@ -1,10 +1,13 @@
VITE_BOOKRA_APP_ENV=staging VITE_BOOKRA_APP_ENV=staging
VITE_BOOKRA_API_URL=http://localhost:8080 VITE_BOOKRA_API_URL=http://localhost:8080
# Primary production auth path: Neon Auth. # Primary production auth path: Neon Auth.
VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth VITE_NEON_AUTH_URL=https://ep-example.neonauth.region.aws.neon.tech/neondb/auth
VITE_PADDLE_ENV=sandbox
VITE_PADDLE_CLIENT_TOKEN=
VITE_DEFAULT_LOCALE=cs VITE_DEFAULT_LOCALE=cs
# Demo Mode: Set to true to show demo UI indicators # Demo Mode: Set to true to show demo UI indicators
VITE_BOOKRA_DEMO_MODE=false VITE_BOOKRA_DEMO_MODE=false
# Optional error monitoring
VITE_SENTRY_DSN=
+1 -3
View File
@@ -12,11 +12,9 @@
"dependencies": { "dependencies": {
"@bookra/api-client": "0.1.0", "@bookra/api-client": "0.1.0",
"@bookra/shared-types": "0.1.0", "@bookra/shared-types": "0.1.0",
"@neondatabase/neon-js": "^0.2.0-beta.1", "@neondatabase/neon-js": "^0.7.0-beta",
"@paddle/paddle-js": "^1.3.2",
"@sentry/react": "^10.52.0", "@sentry/react": "^10.52.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@stripe/stripe-js": "^4.0.0",
"solid-js": "^1.9.5" "solid-js": "^1.9.5"
}, },
"devDependencies": { "devDependencies": {
@@ -83,17 +83,29 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
return calendarDays().find((d) => d.day === day)?.bookings ?? []; 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[]) => { const getBookingBars = (bookings: any[]) => {
if (bookings.length === 0) return null; if (bookings.length === 0) return null;
const count = bookings.length; const visible = bookings.slice(0, 2);
const hasMultiple = count >= 2; const overflow = bookings.length - visible.length;
const hasMany = count >= 3;
return ( return (
<div class="mt-2 space-y-1.5"> <div class="mt-1.5 space-y-1">
<div class="h-2 bg-accent/30 rounded-full w-full group-hover:bg-accent/40 transition-colors" /> <For each={visible}>
{hasMultiple && <div class="h-2 bg-accent/20 rounded-full w-3/4 group-hover:bg-accent/30 transition-colors" />} {(b) => (
{hasMany && <div class="h-2 bg-accent/10 rounded-full w-1/2 group-hover:bg-accent/20 transition-colors" />} <div class="flex items-center gap-1 rounded-md bg-accent-subtle/60 px-1.5 py-0.5 group-hover:bg-accent-subtle transition-colors">
<span class={`w-1.5 h-1.5 rounded-full shrink-0 ${eventDot(b.status)}`} />
<span class="text-[10px] font-medium text-ink leading-tight whitespace-nowrap">
{new Date(b.startsAt).toLocaleTimeString(isCs() ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
)}
</For>
<Show when={overflow > 0}>
<p class="text-[10px] font-medium text-ink-subtle pl-1">+{overflow} {isCs() ? "další" : "more"}</p>
</Show>
</div> </div>
); );
}; };
@@ -145,29 +157,25 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
<div class={`grid grid-cols-7 gap-px bg-border/50 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}> <div class={`grid grid-cols-7 gap-px bg-border/50 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }) => { {calendarDays().map(({ day, bookings, isToday }) => {
if (!day) { if (!day) {
return <div class="p-3 min-h-[80px] lg:min-h-[100px] bg-canvas/50" />; return <div class="p-2 min-h-[80px] lg:min-h-[96px] bg-canvas/50" />;
} }
return ( return (
<button <button
onClick={() => handleDayClick(day)} onClick={() => handleDayClick(day)}
class={` class={`
p-3 min-h-[80px] lg:min-h-[100px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left p-2 min-h-[80px] lg:min-h-[96px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left flex flex-col
${isToday ? "ring-2 ring-inset ring-accent bg-accent-soft" : ""} ${isToday ? "bg-accent-soft" : ""}
${selectedDay() === day ? "ring-2 ring-inset ring-accent bg-accent-subtle/30" : ""} ${selectedDay() === day ? "bg-accent-subtle/40" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/10" : ""}
`} `}
> >
<span class={`text-sm transition-colors ${isToday ? "font-semibold text-accent" : "text-ink-muted group-hover:text-ink"}`}> <span class={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm transition-colors ${
isToday
? "bg-accent text-canvas font-semibold"
: "text-ink-muted group-hover:text-ink"
}`}>
{day} {day}
</span> </span>
{getBookingBars(bookings)} {getBookingBars(bookings)}
{bookings.length > 0 && (
<div class="absolute top-2 right-2">
<span class="text-[10px] font-semibold text-accent bg-accent/10 px-1.5 py-0.5 rounded-full">
{bookings.length}
</span>
</div>
)}
</button> </button>
); );
})} })}
@@ -26,8 +26,8 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any })
id: `booking-${i}`, id: `booking-${i}`,
type: "booking", type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`, title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString()}`, message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US")}`,
time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }), time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }),
read: false, read: false,
}); });
}); });
@@ -1,32 +1,43 @@
import { createMemo } from "solid-js"; import { createMemo, Show } from "solid-js";
import { useI18n } from "../../providers/i18n-provider"; import { useI18n } from "../../providers/i18n-provider";
interface DataPoint { interface DataPoint {
label: string; label: string;
dateLabel: string;
value: number; value: number;
isToday: boolean;
isFuture: boolean;
} }
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) { export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
const i18n = useI18n(); const i18n = useI18n();
const isCs = () => i18n.locale() === "cs";
const chartData = createMemo(() => { const chartData = createMemo<DataPoint[]>(() => {
if (props.data && props.data.length > 0) return props.data; if (props.data && props.data.length > 0) return props.data;
// Build last 7 days from bookings // Bucket bookings into the current calendar week (Mon-Sun) so both
const days: DataPoint[] = []; // completed and upcoming bookings are visible.
const bookings = props.bookings ?? []; const bookings = props.bookings ?? [];
const today = new Date(); const today = new Date();
for (let i = 6; i >= 0; i--) { const monday = new Date(today);
const d = new Date(today); monday.setDate(today.getDate() - ((today.getDay() + 6) % 7));
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0]; 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 count = bookings.filter((b: any) => {
const bd = new Date(b.startsAt).toISOString().split("T")[0]; const bd = new Date(b.startsAt);
return bd === dateStr; return bd.getFullYear() === d.getFullYear() && bd.getMonth() === d.getMonth() && bd.getDate() === d.getDate();
}).length; }).length;
days.push({ 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, value: count,
isToday,
isFuture: d > today,
}); });
} }
return days; 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)); const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0));
return ( return (
<div class="surface-card p-6"> <div class="surface-card p-6 h-full flex flex-col">
<div class="flex items-start justify-between mb-6"> <div class="flex items-start justify-between mb-8">
<div> <div>
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3> <h3 class="text-lg font-semibold text-ink font-display">{i18n.t("dashboard.revenueTitle")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p> <p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p>
</div> </div>
<div class="text-right"> <div class="text-right shrink-0">
<p class="text-2xl font-bold text-ink tracking-tight">{total()}</p> <p class="text-3xl font-bold text-ink tracking-tight font-display">{total()}</p>
<p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p> <p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p>
</div> </div>
</div> </div>
<div class="flex items-end gap-2 sm:gap-3 h-40"> <div class="flex items-end gap-2 sm:gap-3 flex-1 min-h-40">
{chartData().map((point, i) => { {chartData().map((point) => {
const heightPct = Math.round((point.value / maxValue()) * 100); const heightPct = Math.round((point.value / maxValue()) * 100);
return ( return (
<div class="flex-1 flex flex-col items-center gap-2"> <div class="flex-1 flex flex-col items-center gap-2 h-full" title={`${point.dateLabel}: ${point.value}`}>
<div class="w-full flex-1 flex items-end"> <div class="w-full flex-1 flex items-end relative group">
<div <div
class="w-full rounded-t-md bg-accent/80 hover:bg-accent transition-all duration-300 relative group" class={`w-full rounded-t-lg transition-all duration-300 ${
style={{ height: `${Math.max(heightPct, 4)}%` }} point.isToday
> ? "bg-accent"
<div class="absolute -top-7 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-md bg-ink text-canvas text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none"> : 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 }}
/>
<Show when={point.value > 0}>
<span class={`absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-semibold ${point.isToday ? "text-accent" : "text-ink-muted"} opacity-0 group-hover:opacity-100 transition-opacity`}>
{point.value} {point.value}
</div> </span>
</div> </Show>
</div>
<div class="text-center">
<span class={`text-[11px] font-medium uppercase tracking-wide ${point.isToday ? "text-accent font-semibold" : "text-ink-subtle"}`}>
{point.label}
</span>
<p class={`text-[10px] leading-tight ${point.isToday ? "text-accent" : "text-ink-subtle/70"}`}>{point.dateLabel}</p>
</div> </div>
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
</div> </div>
); );
})} })}
+4 -4
View File
@@ -82,21 +82,21 @@ export function PinnedList(props: PinnedListProps) {
const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => { const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => {
const content = ( const content = (
<div <div
class={`flex items-center gap-3 rounded-xl px-3 py-3 border transition-all duration-200 ${ class={`flex items-center gap-2.5 rounded-lg px-2.5 py-2.5 border transition-all duration-200 ${
p.pinned p.pinned
? "bg-accent-soft border-accent/20" ? "bg-accent-soft border-accent/20"
: "bg-canvas-subtle/40 border-border/50 hover:border-border" : "bg-canvas-subtle/40 border-border/50 hover:border-border"
}`} }`}
> >
<Show when={p.item.icon}> <Show when={p.item.icon}>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted"> <div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
{p.item.icon} {p.item.icon}
</div> </div>
</Show> </Show>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{p.item.name}</p> <p class="truncate text-[13px] font-medium text-ink">{p.item.name}</p>
<p class="truncate text-xs text-ink-muted">{p.item.subtitle}</p> <p class="truncate text-[11px] text-ink-muted">{p.item.subtitle}</p>
</div> </div>
<DotToggle id={p.item.id} /> <DotToggle id={p.item.id} />
-23
View File
@@ -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<Paddle | undefined> | 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;
}
-19
View File
@@ -1,19 +0,0 @@
import { loadStripe, type Stripe } from "@stripe/stripe-js";
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
let stripePromise: Promise<Stripe | null> | null = null;
export function stripeConfigured() {
return stripePublishableKey.trim() !== "";
}
export async function getStripe() {
if (!stripeConfigured()) {
return null;
}
if (!stripePromise) {
stripePromise = loadStripe(stripePublishableKey);
}
return stripePromise;
}
+12 -4
View File
@@ -348,7 +348,11 @@ const dictionaries = {
"dashboard.onboarding.submit": "Vytvořit prostor", "dashboard.onboarding.submit": "Vytvořit prostor",
"dashboard.onboarding.pending": "Vytvářím prostor...", "dashboard.onboarding.pending": "Vytvářím prostor...",
"dashboard.revenueTitle": "Trend rezervací", "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.noNotifications": "Žádná nová oznámení",
"dashboard.markAllRead": "Označit vše jako přečtené", "dashboard.markAllRead": "Označit vše jako přečtené",
"dashboard.notifications.title": "Oznámení", "dashboard.notifications.title": "Oznámení",
@@ -512,7 +516,7 @@ const dictionaries = {
"legal.terms.service.title": "Používání služby", "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.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.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: { en: {
// Navigation & Auth // Navigation & Auth
@@ -853,7 +857,11 @@ const dictionaries = {
"dashboard.onboarding.submit": "Create workspace", "dashboard.onboarding.submit": "Create workspace",
"dashboard.onboarding.pending": "Creating workspace...", "dashboard.onboarding.pending": "Creating workspace...",
"dashboard.revenueTitle": "Booking trend", "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.noNotifications": "No new notifications",
"dashboard.markAllRead": "Mark all as read", "dashboard.markAllRead": "Mark all as read",
"dashboard.notifications.title": "Notifications", "dashboard.notifications.title": "Notifications",
@@ -1017,7 +1025,7 @@ const dictionaries = {
"legal.terms.service.title": "Using the service", "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.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.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<Locale, Record<string, string>>; } satisfies Record<Locale, Record<string, string>>;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -20,11 +20,11 @@ function BillingPage() {
{ label: data.i18n.t("dashboard.billing.staff"), value: entitlements?.maxStaff ?? 1, limit: entitlements?.maxStaff ?? 1 }, { label: data.i18n.t("dashboard.billing.staff"), value: entitlements?.maxStaff ?? 1, limit: entitlements?.maxStaff ?? 1 },
]; ];
if (planCode === "pro" || planCode === "business") { if (planCode === "pro" || planCode === "business") {
base.push({ label: cs ? "Emailove pripominky" : "Email reminders", value: "\u2713", limit: "\u2713" }); base.push({ label: cs ? "E-mailové připomínky" : "Email reminders", value: "\u2713", limit: "\u2713" });
base.push({ label: cs ? "Analytika" : "Analytics", value: "\u2713", limit: "\u2713" }); base.push({ label: cs ? "Analytika" : "Analytics", value: "\u2713", limit: "\u2713" });
} }
if (planCode === "business") { if (planCode === "business") {
base.push({ label: cs ? "API pristup" : "API access", value: "\u2713", limit: "\u2713" }); base.push({ label: cs ? "API přístup" : "API access", value: "\u2713", limit: "\u2713" });
} }
return base; return base;
}); });
@@ -56,14 +56,14 @@ function BillingPage() {
<div> <div>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold text-ink font-display">{planName}</h3> <h3 class="text-xl font-bold text-ink font-display">{planName}</h3>
{isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkusebni doba (${trialDays} dnu)` : `Trial (${trialDays} days)`}</span>} {isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkušební doba (${trialDays} dní)` : `Trial (${trialDays} days)`}</span>}
{isActive && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.confirmed")}</span>} {isActive && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.confirmed")}</span>}
</div> </div>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Mesicne" : "Monthly") : (data.i18n.locale() === "cs" ? "Rocne" : "Yearly")}</p> <p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Měsíčně" : "Monthly") : (data.i18n.locale() === "cs" ? "Ročně" : "Yearly")}</p>
</div> </div>
<div class="flex gap-2 shrink-0"> <div class="flex gap-2 shrink-0">
<button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Mesicne" : "Monthly"}</button> <button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Měsíčně" : "Monthly"}</button>
<button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Rocne" : "Yearly"}</button> <button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Ročně" : "Yearly"}</button>
</div> </div>
</div> </div>
@@ -76,15 +76,44 @@ function BillingPage() {
))} ))}
</div> </div>
{/* Plan selection for checkout */}
<div class="mt-6">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-3">{data.i18n.locale() === "cs" ? "Vybrat plán" : "Select plan"}</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
{(["starter", "pro", "business"] as const).map((code) => {
const prices = (current?.displayPrices ?? []) as any[];
const czk = prices.find((p) => p.currency === "czk");
const isCurrent = planCode === code;
return (
<div class={`p-4 rounded-xl border text-left ${isCurrent ? "border-accent bg-accent-subtle" : "border-border bg-canvas-subtle"}`}>
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-ink capitalize">{code}</p>
<Show when={isCurrent}><span class="text-xs text-accent font-medium">{data.i18n.locale() === "cs" ? "Aktuální" : "Current"}</span></Show>
</div>
<Show when={czk} fallback={<p class="text-sm text-ink-muted">—</p>}>
<p class="text-sm text-ink-muted">{data.billingInterval() === "yearly" ? czk.yearlyFormatted : czk.formatted}</p>
</Show>
<button
onClick={() => void data.openCheckout(code)}
disabled={data.billingAction() === "checkout"}
class={`mt-3 w-full py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${isCurrent ? "bg-accent/15 text-accent" : "bg-accent text-canvas hover:bg-accent-hover"}`}
>
{isCurrent
? (data.i18n.locale() === "cs" ? "Změnit období" : "Change interval")
: (data.i18n.locale() === "cs" ? "Vybrat" : "Select")}
</button>
</div>
);
})}
</div>
</div>
<div class="flex flex-wrap gap-3 mt-6"> <div class="flex flex-wrap gap-3 mt-6">
<button onClick={() => void data.openCheckout()} disabled={data.billingAction() === "checkout"} class="btn-primary disabled:opacity-50">
{data.billingAction() === "checkout" ? (data.i18n.locale() === "cs" ? "Otevirani..." : "Opening...") : (isTrialing ? data.i18n.t("dashboard.upgrade") : data.i18n.t("dashboard.checkout"))}
</button>
<Show when={isActive || isTrialing}> <Show when={isActive || isTrialing}>
<button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat predplatne" : "Manage subscription"}</button> <button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat předplatné" : "Manage subscription"}</button>
</Show> </Show>
<button onClick={() => void data.refreshBilling()} disabled={data.billingAction() === "refresh"} class="btn-secondary disabled:opacity-50"> <button onClick={() => void data.refreshBilling()} disabled={data.billingAction() === "refresh"} class="btn-secondary disabled:opacity-50">
{data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovovani..." : "Refreshing...") : data.i18n.t("dashboard.refreshBilling")} {data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovování…" : "Refreshing…") : data.i18n.t("dashboard.refreshBilling")}
</button> </button>
</div> </div>
</div> </div>
@@ -67,21 +67,23 @@ function BookingsPage() {
const handleCreateBooking = async () => { const handleCreateBooking = async () => {
const bearer = data.token(); const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode."); data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze vytvářet rezervace." : "Cannot create bookings in demo mode.");
setShowNewBooking(false); return; setShowNewBooking(false); return;
} }
setCreatingBooking(true); setCreatingBooking(true);
try { try {
await (apiClient as any).POST("/v1/catalog/bookings", { const response = await (apiClient as any).POST("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` }, headers: { Authorization: `Bearer ${bearer}` },
body: { body: {
customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(), customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(),
startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes() startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes()
} }
}); });
if (response.error) throw new Error(typeof response.error === "string" ? response.error : "booking create failed");
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes(""); setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
setShowNewBooking(false); setShowNewBooking(false);
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); } void data.refetchSummary();
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoření rezervace selhalo." : "Failed to create booking."); }
finally { setCreatingBooking(false); } finally { setCreatingBooking(false); }
}; };
@@ -174,7 +176,7 @@ function BookingsPage() {
<p class="font-medium text-ink">{booking.customerName}</p> <p class="font-medium text-ink">{booking.customerName}</p>
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />} {isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
</div> </div>
<p class="text-sm text-ink-muted">{booking.service} &bull; {new Date(booking.startsAt).toLocaleDateString()} {new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</p> <p class="text-sm text-ink-muted">{booking.service} &bull; {new Date(booking.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "numeric" })} {new Date(booking.startsAt).toLocaleTimeString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}</p>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<button <button
@@ -224,8 +226,8 @@ function BookingsPage() {
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} /> <Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} /> <Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<Input type="date" label={data.i18n.t("dashboard.bookingModal.dateTime")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} /> <Input type="date" label={data.i18n.t("dashboard.bookingModal.date")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
<Input type="time" label={data.i18n.t("dashboard.bookingModal.duration")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} /> <Input type="time" label={data.i18n.t("dashboard.bookingModal.time")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
</div> </div>
<Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" /> <Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" />
</div> </div>
@@ -1,4 +1,5 @@
import { Show, createSignal, createMemo } from "solid-js"; import { Show, createSignal, createMemo, createResource } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { getInitials } from "../../components/dashboard/types"; import { getInitials } from "../../components/dashboard/types";
import { UsersIcon, XIcon } from "../../components/dashboard/icons"; import { UsersIcon, XIcon } from "../../components/dashboard/icons";
@@ -12,14 +13,31 @@ function CustomersPage() {
const [pinnedCustomerIds, setPinnedCustomerIds] = createSignal<Set<string>>(new Set()); const [pinnedCustomerIds, setPinnedCustomerIds] = createSignal<Set<string>>(new Set());
const demoCustomers = [ const demoCustomers = [
{ id: "1", name: "Martina Novakova", email: "[email protected]", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Alergie na silne vune" }, { id: "1", name: "Martina Nováková", email: "[email protected]", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Alergie na silné vůně" },
{ id: "2", name: "David Svoboda", email: "[email protected]", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" }, { id: "2", name: "David Svoboda", email: "[email protected]", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" },
{ id: "3", name: "Jana KovarovA", email: "[email protected]", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Uprednostnuje ranni terminy" }, { id: "3", name: "Jana Kovářová", email: "[email protected]", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Upřednostňuje ranní termíny" },
{ id: "4", name: "Alice Johnson", email: "[email protected]", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" }, { id: "4", name: "Alice Johnson", email: "[email protected]", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" },
{ id: "5", name: "Bob Smith", email: "[email protected]", phone: "+420 444 555 666", bookingsCount: 12, lastBookingAt: new Date(Date.now() - 432000000).toISOString(), status: "vip", notes: "Loyal customer" }, { id: "5", name: "Bob Smith", email: "[email protected]", phone: "+420 444 555 666", bookingsCount: 12, lastBookingAt: new Date(Date.now() - 432000000).toISOString(), status: "vip", notes: "Loyal customer" },
]; ];
const resolvedCustomers = () => demoCustomers; const [customers] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) return demoCustomers;
try {
const response = await (apiClient as any).GET("/v1/catalog/customers", {
headers: { Authorization: `Bearer ${bearer}` },
params: { query: { limit: 100 } },
});
return (response.data as any[]) ?? [];
} catch {
return [];
}
},
);
const resolvedCustomers = () => customers() ?? [];
const resolvedBookings = () => data.normalizedAllBookings(); const resolvedBookings = () => data.normalizedAllBookings();
const filteredCustomers = createMemo(() => { const filteredCustomers = createMemo(() => {
@@ -42,9 +60,9 @@ function CustomersPage() {
const statusBadge = (status: string) => { const statusBadge = (status: string) => {
switch (status) { switch (status) {
case "active": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.filter.all")}</span>; case "active": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.customerStatus.active")}</span>;
case "vip": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-ink/10 text-ink">VIP</span>; case "vip": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-ink/10 text-ink">VIP</span>;
default: return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-canvas-subtle text-ink-subtle">{data.i18n.t("dashboard.customer.status")}</span>; default: return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-canvas-subtle text-ink-subtle">{data.i18n.t("dashboard.customerStatus.inactive")}</span>;
} }
}; };
@@ -53,7 +71,7 @@ function CustomersPage() {
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1> <h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zakazniku" : "customers"}</p> <p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zákazníků" : "customers"}</p>
</div> </div>
</div> </div>
<div class="surface-card p-4"> <div class="surface-card p-4">
@@ -80,7 +98,7 @@ function CustomersPage() {
}); });
}; };
return ( return (
<button onClick={() => { setSelectedCustomer(customer); setShowCustomerDetail(true); }} class={`w-full text-left flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors ${isPinned() ? "bg-accent-soft/40" : ""}`}> <div onClick={() => { setSelectedCustomer(customer); setShowCustomerDetail(true); }} class={`w-full text-left flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors cursor-pointer ${isPinned() ? "bg-accent-soft/40" : ""}`}>
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">{getInitials(customer.name)}</div> <div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">{getInitials(customer.name)}</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -113,7 +131,7 @@ function CustomersPage() {
<p class="text-xs text-ink-muted">{data.i18n.t("dashboard.customer.totalBookings")}</p> <p class="text-xs text-ink-muted">{data.i18n.t("dashboard.customer.totalBookings")}</p>
</div> </div>
</div> </div>
</button> </div>
); );
})} })}
</div> </div>
@@ -151,7 +169,7 @@ function CustomersPage() {
<div class="flex items-center gap-3 p-3 rounded-xl border border-border/60"> <div class="flex items-center gap-3 p-3 rounded-xl border border-border/60">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink">{b.service}</p> <p class="text-sm font-medium text-ink">{b.service}</p>
<p class="text-xs text-ink-muted">{new Date(b.startsAt).toLocaleDateString()}</p> <p class="text-xs text-ink-muted">{new Date(b.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US")}</p>
</div> </div>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${b.status === "confirmed" ? "bg-accent/10 text-accent" : "bg-canvas-muted text-ink-muted"}`}>{data.i18n.t(`dashboard.${b.status}`)}</span> <span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${b.status === "confirmed" ? "bg-accent/10 text-accent" : "bg-canvas-muted text-ink-muted"}`}>{data.i18n.t(`dashboard.${b.status}`)}</span>
</div> </div>
@@ -8,40 +8,53 @@ export type Section = "overview" | "bookings" | "customers" | "zones" | "billing
function createDemoData(i18n: ReturnType<typeof useI18n>) { function createDemoData(i18n: ReturnType<typeof useI18n>) {
const cs = i18n.locale() === "cs"; const cs = i18n.locale() === "cs";
const now = new Date();
const fmt = (d: Date) => d.toISOString(); const fmt = (d: Date) => d.toISOString();
const addDays = (n: number) => { const d = new Date(now); d.setDate(d.getDate() + n); return d; }; // Exact local clock time on a day relative to today.
const addHours = (d: Date, h: number) => { const r = new Date(d); r.setHours(r.getHours() + h); return r; }; const at = (dayOffset: number, hour: number, minute = 0) => {
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(hour, minute, 0, 0);
return d;
};
const services = cs const services = cs
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"] ? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"]; : ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
const customers = [ const customers = [
{ name: cs ? "Martina Novakova" : "Martina Novakova", email: "[email protected]" }, { name: cs ? "Martina Nováková" : "Martina Novakova", email: "[email protected]" },
{ name: cs ? "David Svoboda" : "David Svoboda", email: "[email protected]" }, { name: "David Svoboda", email: "[email protected]" },
{ name: cs ? "Jana KovarovA" : "Jana KovarovA", email: "[email protected]" }, { name: cs ? "Jana Kovářová" : "Jana Kovarova", email: "[email protected]" },
{ name: cs ? "Alice Johnson" : "Alice Johnson", email: "[email protected]" }, { name: "Alice Johnson", email: "[email protected]" },
{ name: cs ? "Bob Smith" : "Bob Smith", email: "[email protected]" }, { name: "Bob Smith", email: "[email protected]" },
]; ];
const salonA = cs ? "Hlavní sál" : "Main hall";
const salonB = "Studio B";
const staff1 = "Petra M.";
const staff2 = "Lucie K.";
const allBookings = [ 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 ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, // past days - completed/cancelled history for the trend chart
{ 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 ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" }, { id: "b10", customerName: customers[4].name, customerEmail: customers[4].email, service: services[1], status: "completed", startsAt: fmt(at(-4, 10)), endsAt: fmt(at(-4, 11)), reference: "BOK-010", location: salonB, staffName: "", 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: "b5", customerName: customers[4].name, customerEmail: customers[4].email, service: services[0], status: "completed", startsAt: fmt(at(-2, 13, 30)), endsAt: fmt(at(-2, 14, 30)), reference: "BOK-005", location: salonB, staffName: staff2, notes: "" },
{ 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 ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, { id: "b12", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "completed", startsAt: fmt(at(-2, 9)), endsAt: fmt(at(-2, 10)), reference: "BOK-012", location: salonA, staffName: staff1, 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: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, 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 ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" }, { id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, 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: "" }, // today
{ 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 ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" }, { id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, 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 ? "Hlavní 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(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", 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: "" }, // upcoming
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" },
{ id: "b3", customerName: customers[2].name, customerEmail: customers[2].email, service: services[2], status: "confirmed", startsAt: fmt(at(2, 9)), endsAt: fmt(at(2, 10)), reference: "BOK-003", location: salonB, staffName: "", notes: cs ? "Bolest zad" : "Back pain" },
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
]; ];
return { return {
summary: { summary: {
tenantName: cs ? "Demo Studio" : "Demo Studio", tenantName: cs ? "Demo Studio" : "Demo Studio",
tenantSlug: "demo-studio", tenantSlug: "studio-atelier",
publicBookingUrl: "http://localhost:3000/book/demo-studio", publicBookingUrl: "http://localhost:3000/book/studio-atelier",
kpis: [ kpis: [
{ label: i18n.t("dashboard.kpi.bookings"), value: 42, change: "+12%", trend: "up" }, { 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.cancelled"), value: 3, change: "-5%", trend: "down" },
@@ -59,7 +72,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
}, },
bootstrap: { bootstrap: {
tenantName: cs ? "Demo Studio" : "Demo Studio", tenantName: cs ? "Demo Studio" : "Demo Studio",
slug: "demo-studio", slug: "studio-atelier",
brand: { primaryColor: "#c25e3a" }, brand: { primaryColor: "#c25e3a" },
locale: cs ? "cs" : "en", locale: cs ? "cs" : "en",
timezone: "Europe/Prague", timezone: "Europe/Prague",
@@ -81,6 +94,7 @@ export interface DashboardData {
token: () => string | null | undefined; token: () => string | null | undefined;
isDemoMode: () => boolean; isDemoMode: () => boolean;
resolvedSummary: () => any; resolvedSummary: () => any;
refetchSummary: () => void;
resolvedBootstrap: () => any; resolvedBootstrap: () => any;
resolvedBilling: () => any; resolvedBilling: () => any;
normalizedKpis: () => any[]; normalizedKpis: () => any[];
@@ -100,7 +114,7 @@ export interface DashboardData {
setBillingAction: (v: "checkout" | "refresh" | "portal" | null) => void; setBillingAction: (v: "checkout" | "refresh" | "portal" | null) => void;
billingInterval: () => "monthly" | "yearly"; billingInterval: () => "monthly" | "yearly";
setBillingInterval: (v: "monthly" | "yearly") => void; setBillingInterval: (v: "monthly" | "yearly") => void;
openCheckout: () => Promise<void>; openCheckout: (planCode?: string) => Promise<void>;
openPortal: () => Promise<void>; openPortal: () => Promise<void>;
refreshBilling: () => Promise<void>; refreshBilling: () => Promise<void>;
showIntegrationModal: () => boolean; showIntegrationModal: () => boolean;
@@ -137,7 +151,7 @@ export function DashboardDataProvider(props: { children: any }) {
const isDemoMode = () => token()?.startsWith("demo.") ?? false; const isDemoMode = () => token()?.startsWith("demo.") ?? false;
const demoData = createMemo(() => createDemoData(i18n)); const demoData = createMemo(() => createDemoData(i18n));
const [summary] = createResource(token, async (bearer) => { const [summary, { refetch: refetchSummary }] = createResource(token, async (bearer) => {
if (!bearer) return null; if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().summary; if (bearer.startsWith("demo.")) return demoData().summary;
const response = await apiClient.GET("/v1/dashboard/summary", { headers: { Authorization: `Bearer ${bearer}` } }); const response = await apiClient.GET("/v1/dashboard/summary", { headers: { Authorization: `Bearer ${bearer}` } });
@@ -175,9 +189,26 @@ export function DashboardDataProvider(props: { children: any }) {
} catch { return null; } } catch { return null; }
}); });
const resolvedSummary = () => (summary.latest as any) ?? (isDemoMode() ? demoData().summary : undefined); // Demo data is reactive to locale changes — prefer it over the stale resource value.
const resolvedBootstrap = () => (bootstrap.latest as any) ?? (isDemoMode() ? demoData().bootstrap : undefined); const resolvedSummary = () => (isDemoMode() ? demoData().summary : (summary.latest as any)) ?? undefined;
const resolvedBilling = () => (billing.latest as any) ?? (isDemoMode() ? demoData().billing : undefined); const resolvedBootstrap = () => (isDemoMode() ? demoData().bootstrap : (bootstrap.latest as any)) ?? undefined;
const planNames: Record<string, string> = { starter: "Starter", pro: "Pro", business: "Business" };
const resolvedBilling = () => {
const raw = (isDemoMode() ? demoData().billing : (billing.latest as any)) ?? undefined;
if (!raw) return undefined;
const ent = raw.entitlements ?? {};
return {
...raw,
subscriptionStatus: raw.status ?? raw.subscriptionStatus ?? "inactive",
planName: planNames[raw.planCode] ?? raw.planCode ?? "Starter",
trialDaysRemaining: raw.trialDays ?? raw.trialDaysRemaining ?? 0,
entitlements: {
...ent,
maxBookings: ent.maxBookingsMonth ?? ent.maxBookings,
},
};
};
const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({ const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({
...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—", ...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—",
@@ -204,7 +235,7 @@ export function DashboardDataProvider(props: { children: any }) {
const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading; const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading;
const openCheckout = async () => { const openCheckout = async (planCode?: string) => {
const bearer = token(); const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; } if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
setBillingAction("checkout"); setBillingAction("checkout");
@@ -212,10 +243,10 @@ export function DashboardDataProvider(props: { children: any }) {
try { try {
const response = await (apiClient as any).POST("/v1/billing/checkout", { const response = await (apiClient as any).POST("/v1/billing/checkout", {
headers: { Authorization: `Bearer ${bearer}` }, headers: { Authorization: `Bearer ${bearer}` },
body: { interval: billingInterval() }, body: { planCode: planCode ?? resolvedBilling()?.planCode ?? "pro", currency: "czk", billingInterval: billingInterval() },
}); });
if (response.data?.url) window.location.href = response.data.url; if (response.data?.checkoutUrl) window.location.href = response.data.checkoutUrl;
else throw new Error("No checkout URL"); else throw new Error(response.error ? String(response.error) : "No checkout URL");
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); } } catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); } finally { setBillingAction(null); }
}; };
@@ -256,6 +287,7 @@ export function DashboardDataProvider(props: { children: any }) {
token: () => token(), token: () => token(),
isDemoMode, isDemoMode,
resolvedSummary, resolvedSummary,
refetchSummary,
resolvedBootstrap, resolvedBootstrap,
resolvedBilling, resolvedBilling,
normalizedKpis, normalizedKpis,
+25 -16
View File
@@ -31,8 +31,9 @@ function DashboardShell(props: { children: JSX.Element }) {
const activeSection = () => { const activeSection = () => {
const p = location.pathname; const p = location.pathname;
if (p === "/dashboard" || p === "/dashboard/") return "overview"; const exact = navItems.find(item => p === item.path);
const match = navItems.find(item => p === item.path || p.startsWith(item.path + "/")); if (exact) return exact.id;
const match = navItems.find(item => item.id !== "overview" && p.startsWith(item.path + "/"));
return match?.id ?? "overview"; return match?.id ?? "overview";
}; };
@@ -88,41 +89,49 @@ function DashboardShell(props: { children: JSX.Element }) {
const Sidebar = () => ( const Sidebar = () => (
<aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0"> <aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0">
<div class="p-6"> <div class="px-5 pt-5 pb-4">
<A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors"> <A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors">
<DashboardLogo /> <DashboardLogo />
</A> </A>
</div> </div>
<nav class="flex-1 px-4 space-y-1 overflow-y-auto"> <nav class="flex-1 px-3 space-y-0.5 overflow-y-auto">
{navItems.map((item) => ( {navItems.map((item) => (
<A <A
href={item.path} href={item.path}
class={` class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 relative w-full flex items-center gap-3 px-3.5 py-2.5 rounded-lg text-sm font-medium transition-all duration-200
${activeSection() === item.id ${activeSection() === item.id
? "bg-accent text-canvas shadow-sm" ? "bg-accent-subtle text-accent font-semibold"
: "text-ink-muted hover:bg-canvas-subtle hover:text-ink" : "text-ink-muted hover:bg-canvas-subtle hover:text-ink"
} }
`} `}
> >
<Show when={activeSection() === item.id}>
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-full bg-accent" />
</Show>
<item.icon /> {data.i18n.t(item.label)} <item.icon /> {data.i18n.t(item.label)}
</A> </A>
))} ))}
</nav> </nav>
<div class="p-4 border-t border-border space-y-1"> <div class="p-3 border-t border-border space-y-1">
<div class="flex items-center gap-2 px-4 py-2"> <div class="flex items-center gap-1 px-1.5 py-1">
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
<Show when={data.auth.session()?.user}> <Show when={data.auth.session()?.user}>
<div class="flex items-center gap-3 px-4 py-3"> <div class="flex items-center gap-3 px-3 py-2.5 rounded-lg">
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0"> <div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
{getInitials(data.auth.session()?.user?.name)} {getInitials(data.auth.session()?.user?.name)}
</div> </div>
<div class="min-w-0"> <div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-ink truncate">{data.auth.session()?.user?.name}</p> <p class="text-sm font-semibold text-ink truncate">{data.auth.session()?.user?.name}</p>
<p class="text-xs text-ink-muted truncate">{data.auth.session()?.user?.email}</p> <p class="text-xs text-ink-muted truncate">{data.auth.session()?.user?.email}</p>
</div> </div>
<Show when={data.resolvedBilling()?.planName}>
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent-subtle px-2 py-0.5 rounded-full">
{data.resolvedBilling()?.planName}
</span>
</Show>
</div> </div>
</Show> </Show>
<button <button
@@ -154,7 +163,7 @@ function DashboardShell(props: { children: JSX.Element }) {
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
class={` class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all
${activeSection() === item.id ? "bg-accent text-canvas" : "text-ink-muted hover:bg-canvas-subtle"} ${activeSection() === item.id ? "bg-accent-subtle text-accent font-semibold" : "text-ink-muted hover:bg-canvas-subtle hover:text-ink"}
`} `}
> >
<item.icon /> {data.i18n.t(item.label)} <item.icon /> {data.i18n.t(item.label)}
@@ -220,7 +229,7 @@ function DashboardShell(props: { children: JSX.Element }) {
<div class="p-4 bg-canvas-subtle rounded-xl"> <div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.dateTime")}</p> <p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.dateTime")}</p>
<p class="font-medium text-ink"> <p class="font-medium text-ink">
{new Date(data.selectedBooking()?.startsAt).toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })} {new Date(data.selectedBooking()?.startsAt).toLocaleString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</p> </p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.bookingModal.duration")}: {getBookingDuration(data.selectedBooking()?.startsAt, data.selectedBooking()?.endsAt)}</p> <p class="text-sm text-ink-muted">{data.i18n.t("dashboard.bookingModal.duration")}: {getBookingDuration(data.selectedBooking()?.startsAt, data.selectedBooking()?.endsAt)}</p>
</div> </div>
@@ -258,7 +267,7 @@ function DashboardShell(props: { children: JSX.Element }) {
<DashboardLogo /> <DashboardLogo />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} /> <NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevrit menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all"> <button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevřít menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
<MenuIcon /> <MenuIcon />
</button> </button>
</div> </div>
@@ -286,7 +295,7 @@ function DashboardShell(props: { children: JSX.Element }) {
<div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" /> <div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" />
<BookraCharacter pose="hello" size="xl" animate={true} /> <BookraCharacter pose="hello" size="xl" animate={true} />
</div> </div>
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{data.i18n.locale() === "cs" ? "Vas rezervacni system ceka" : "Your booking system awaits"}</h2> <h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{data.i18n.locale() === "cs" ? "Váš rezervační systém čeká" : "Your booking system awaits"}</h2>
<p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{data.i18n.t("dashboard.welcome.body")}</p> <p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{data.i18n.t("dashboard.welcome.body")}</p>
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto"> <button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto">
@@ -297,7 +306,7 @@ function DashboardShell(props: { children: JSX.Element }) {
<SparklesIcon /> {data.i18n.t("dashboard.tryDemo")} <SparklesIcon /> {data.i18n.t("dashboard.tryDemo")}
</button> </button>
</div> </div>
<p class="mt-6 text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Jeste nemate ucet? " : "No account yet? "} <p class="mt-6 text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Ještě nemáte účet? " : "No account yet? "}
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{data.i18n.t("auth.createAccount")}</button> <button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{data.i18n.t("auth.createAccount")}</button>
</p> </p>
</div> </div>
@@ -307,7 +316,7 @@ function DashboardShell(props: { children: JSX.Element }) {
<Show when={data.token() && !data.isDashboardReady()}> <Show when={data.token() && !data.isDashboardReady()}>
<div class="flex flex-col items-center justify-center py-20"> <div class="flex flex-col items-center justify-center py-20">
<BookraCharacter pose="walk" size="lg" animate={true} /> <BookraCharacter pose="walk" size="lg" animate={true} />
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{data.i18n.locale() === "cs" ? "Nacitani dashboardu..." : "Loading your dashboard..."}</p> <p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{data.i18n.locale() === "cs" ? "Načítání dashboardu…" : "Loading your dashboard…"}</p>
</div> </div>
</Show> </Show>
@@ -43,9 +43,9 @@ function OverviewPage() {
))} ))}
</div> </div>
{/* Setup Guide */} {/* Setup Guide + Trend */}
<div class="grid lg:grid-cols-3 gap-6"> <div class="grid lg:grid-cols-5 gap-6">
<div class="lg:col-span-1 surface-card p-5"> <div class="lg:col-span-2 surface-card p-5">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-accent-subtle flex items-center justify-center text-accent"> <div class="w-9 h-9 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/></svg>
@@ -106,8 +106,8 @@ function OverviewPage() {
/> />
</div> </div>
<div class="lg:col-span-2"> <div class="lg:col-span-3">
<RevenueChart /> <RevenueChart bookings={data.normalizedAllBookings()} />
</div> </div>
</div> </div>
@@ -161,11 +161,10 @@ function OverviewPage() {
); );
})()} })()}
{/* Revenue Chart + Calendar + Activity */} {/* Calendar + Activity */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6"> <div class="lg:col-span-2">
<RevenueChart bookings={data.normalizedAllBookings()} /> <CalendarView bookings={data.normalizedAllBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
<CalendarView bookings={data.normalizedUpcomingBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<ActivityTimeline activities={data.normalizedRecentActivity()} /> <ActivityTimeline activities={data.normalizedRecentActivity()} />
@@ -197,7 +196,7 @@ function OverviewPage() {
<p class="text-sm text-ink-muted">{booking.service} &bull; {booking.duration}</p> <p class="text-sm text-ink-muted">{booking.service} &bull; {booking.duration}</p>
</div> </div>
<div class="text-right shrink-0"> <div class="text-right shrink-0">
<p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</p> <p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short", month: "short", day: "numeric" })}</p>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ <span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent" booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted" : booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
@@ -1,4 +1,4 @@
import { Show, createSignal } from "solid-js"; import { Show, createEffect, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client"; import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
@@ -16,6 +16,26 @@ function SettingsPage() {
const [emailBody, setEmailBody] = createSignal(""); const [emailBody, setEmailBody] = createSignal("");
const [emailSaving, setEmailSaving] = createSignal(false); const [emailSaving, setEmailSaving] = createSignal(false);
const [emailTemplates, { refetch: refetchTemplates }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return [];
try {
const response = await (apiClient as any).GET("/v1/tenants/email-templates", { headers: { Authorization: `Bearer ${bearer}` } });
return (response.data?.templates as any[]) ?? [];
} catch {
return [];
}
},
);
// Prefill the editor from the stored template whenever the type changes.
createEffect(() => {
const tpl = (emailTemplates() ?? []).find((t: any) => t.type === activeEmailType());
setEmailSubject(tpl?.subject ?? "");
setEmailBody(tpl?.bodyText ?? tpl?.bodyHtml ?? "");
});
const presetColors = [ const presetColors = [
{ name: data.i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" }, { name: data.i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
{ name: data.i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" }, { name: data.i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
@@ -68,7 +88,7 @@ function SettingsPage() {
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div> <div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
<div> <div>
<p class="font-semibold text-ink">Bookra</p> <p class="font-semibold text-ink">Bookra</p>
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termin" : "Book an appointment"}</p> <p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termín" : "Book an appointment"}</p>
</div> </div>
</div> </div>
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{data.i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button> <button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{data.i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
@@ -109,7 +129,8 @@ function SettingsPage() {
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; } if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setEmailSaving(true); setEmailSaving(true);
try { try {
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody() } }); await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody(), isEnabled: true } });
void refetchTemplates();
data.setDemoNotice(data.i18n.locale() === "cs" ? "Šablona uložena." : "Template saved."); data.setDemoNotice(data.i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); } } catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
finally { setEmailSaving(false); } finally { setEmailSaving(false); }
+248 -59
View File
@@ -1,31 +1,146 @@
import { Show, createSignal } from "solid-js"; import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { LocationMap } from "../../components/location-map"; import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon } from "../../components/dashboard/icons";
import { MapPinIcon, PlusIcon, XIcon, BanIcon, ClockIcon, SparklesIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout"; import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() { function ZonesPage() {
const data = useDashboardData(); const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones"); const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones");
const [showAddZone, setShowAddZone] = createSignal(false); const [showAddZone, setShowAddZone] = createSignal(false);
const [newZoneName, setNewZoneName] = createSignal(""); const [newZoneName, setNewZoneName] = createSignal("");
const [newZoneType, setNewZoneType] = createSignal("room"); const [newZoneType, setNewZoneType] = createSignal("room");
const [newZoneCapacity, setNewZoneCapacity] = createSignal(10); const [newZoneCapacity, setNewZoneCapacity] = createSignal(10);
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false); const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const [newBlockedDate, setNewBlockedDate] = createSignal("");
const [newBlockedReason, setNewBlockedReason] = createSignal("");
const [blockedSaving, setBlockedSaving] = createSignal(false);
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
const resolvedLocations = () => [ const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
{ id: "z1", name: "Main Hall", type: "hall", capacity: 20, bookingsToday: 5 },
{ id: "z2", name: "Private Room A", type: "private", capacity: 4, bookingsToday: 2 },
{ id: "z3", name: "Treatment Room", type: "room", capacity: 2, bookingsToday: 8 },
];
const [locations, { refetch: refetchLocations }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "z1", name: cs() ? "Hlavní sál" : "Main Hall", type: "hall", capacity: 20 },
{ id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A", type: "private", capacity: 4 },
{ id: "z3", name: cs() ? "Ošetřovna" : "Treatment Room", type: "room", capacity: 2 },
];
}
const response = await (apiClient as any).GET("/v1/catalog/locations", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [blockedDays, { refetch: refetchBlocked }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "blocked") return [];
if (bearer.startsWith("demo.")) return [];
const response = await (apiClient as any).GET("/v1/catalog/blocked-days", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "hours") return [];
if (bearer.startsWith("demo.")) {
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d === 0 ? "" : "09:00", close: d === 0 ? "" : "18:00", isOpen: d !== 0 }));
}
const response = await (apiClient as any).GET("/v1/catalog/working-hours", authHeaders());
const rows = (response.data as any[]) ?? [];
return [0, 1, 2, 3, 4, 5, 6].map((d) => {
const row = rows.find((r) => r.dayOfWeek === d);
return { dayOfWeek: d, open: (row?.open ?? "").slice(0, 5), close: (row?.close ?? "").slice(0, 5), isOpen: row?.isOpen ?? false };
});
},
);
const resolvedLocations = () => locations() ?? [];
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3; const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
const canAddLocation = () => resolvedLocations().length < locationLimit(); const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
const handleAddZone = () => { const handleAddZone = async () => {
const bearer = data.token();
if (!newZoneName().trim()) return;
if (!canAddLocation()) { setShowUpgradePrompt(true); setShowAddZone(false); return; } if (!canAddLocation()) { setShowUpgradePrompt(true); setShowAddZone(false); return; }
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false); if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
try {
await (apiClient as any).POST("/v1/catalog/locations", {
...authHeaders(),
body: { name: newZoneName().trim(), type: newZoneType(), capacity: newZoneCapacity() },
});
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false);
void refetchLocations();
} catch {
data.setDemoNotice(cs() ? "Uložení zóny selhalo." : "Failed to save location.");
}
};
const handleDeleteZone = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/locations/${id}`, authHeaders());
void refetchLocations();
} catch {
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
}
};
const handleAddBlockedDay = async () => {
const bearer = data.token();
if (!newBlockedDate()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setBlockedSaving(true);
try {
await (apiClient as any).POST("/v1/catalog/blocked-days", {
...authHeaders(),
body: { date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(), type: "closure", reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed") },
});
setNewBlockedDate(""); setNewBlockedReason("");
void refetchBlocked();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setBlockedSaving(false);
}
};
const handleDeleteBlockedDay = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) return;
try {
await (apiClient as any).DELETE(`/v1/catalog/blocked-days/${id}`, authHeaders());
void refetchBlocked();
} catch { /* noop */ }
};
const handleSaveHours = async (day: { dayOfWeek: number; open: string; close: string; isOpen: boolean }) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setHoursSaving(day.dayOfWeek);
try {
await (apiClient as any).PUT(`/v1/catalog/working-hours/${day.dayOfWeek}`, {
...authHeaders(),
body: { open: day.open, close: day.close, isOpen: day.isOpen },
});
void refetchHours();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setHoursSaving(null);
}
}; };
const typeLabel = (type: string) => { const typeLabel = (type: string) => {
@@ -33,12 +148,19 @@ function ZonesPage() {
return map[type] || type; return map[type] || type;
}; };
const dayLabel = (d: number) => {
const labels = cs()
? ["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"]
: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
return labels[d];
};
return ( return (
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.zones")}</h1> <h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.zones")}</h1>
<p class="text-ink-muted mt-1">{resolvedLocations().length} {data.i18n.locale() === "cs" ? "zon" : "zones"}</p> <p class="text-ink-muted mt-1">{resolvedLocations().length} {cs() ? "zón" : "locations"}</p>
</div> </div>
</div> </div>
@@ -55,39 +177,38 @@ function ZonesPage() {
</div> </div>
{activeTab() === "zones" && ( {activeTab() === "zones" && (
<div class="space-y-6"> <div class="surface-card overflow-hidden">
<LocationMap <div class="p-6 border-b border-border flex items-center justify-between">
coordinates={{ latitude: 50.0755, longitude: 14.4378, zoom: 14, address: data.i18n.locale() === "cs" ? "Praha, Česko" : "Prague, Czechia", source: "coordinates" }} <h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3>
markerColor={data.resolvedBootstrap()?.brand?.primaryColor} <Show when={canAddLocation()} fallback={
height={320} <button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-60 cursor-not-allowed">
class="shadow-sm" <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
/> {data.i18n.t("dashboard.zone.limitReached")}
<div class="surface-card overflow-hidden"> </button>
<div class="p-6 border-b border-border flex items-center justify-between"> }>
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3> <button onClick={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
<Show when={canAddLocation()} fallback={
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-60 cursor-not-allowed">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{data.i18n.t("dashboard.zone.limitReached")}
</button>
}>
<button onClick={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
</Show>
</div>
<Show when={showAddZone()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={data.i18n.locale() === "cs" ? "např. Hlavní sál" : "e.g. Main Hall"} />
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={handleAddZone} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show> </Show>
</div>
<Show when={showAddZone()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={cs() ? "např. Hlavní sál" : "e.g. Main Hall"} />
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={() => void handleAddZone()} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<Show when={resolvedLocations().length > 0} fallback={
<div class="p-12 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-subtle mt-1">{cs() ? "Přidejte první zónu nebo místnost." : "Add your first location or room."}</p>
</div>
}>
<div class="divide-y divide-border/60"> <div class="divide-y divide-border/60">
{resolvedLocations().map((zone: any) => ( {resolvedLocations().map((zone: any) => (
<div class="flex items-center justify-between p-6 hover:bg-canvas-subtle/30 transition-colors"> <div class="flex items-center justify-between p-6 hover:bg-canvas-subtle/30 transition-colors">
@@ -98,30 +219,98 @@ function ZonesPage() {
<p class="text-sm text-ink-muted">{typeLabel(zone.type)} &bull; {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p> <p class="text-sm text-ink-muted">{typeLabel(zone.type)} &bull; {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p>
</div> </div>
</div> </div>
<div class="text-right"> <button onClick={() => void handleDeleteZone(zone.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}>
<p class="text-sm font-medium text-ink">{zone.bookingsToday}</p> <Trash2Icon />
<p class="text-sm text-ink-muted">{zone.bookingsToday} {data.i18n.locale() === "cs" ? "rezervací dnes" : "bookings today"}</p> </button>
</div>
</div> </div>
))} ))}
</div> </div>
</div> </Show>
</div> </div>
)} )}
{activeTab() === "blocked" && ( {activeTab() === "blocked" && (
<div class="surface-card p-12 text-center"> <div class="surface-card overflow-hidden">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><BanIcon /></div> <div class="p-6 border-b border-border">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p> <h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.blockedDays")}</h3>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Spravujte zóny, blokované dny a pracovní dobu." : "Manage locations, blocked days and working hours."}</p> <p class="text-sm text-ink-muted mt-1">{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input type="date" label={cs() ? "Datum" : "Date"} value={newBlockedDate()} onInput={(e) => setNewBlockedDate(e.currentTarget.value)} />
<Input type="text" label={cs() ? "Důvod" : "Reason"} value={newBlockedReason()} onInput={(e) => setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} />
<div class="flex items-end">
<button onClick={() => void handleAddBlockedDay()} disabled={blockedSaving() || !newBlockedDate()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{blockedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
</div>
</div>
</div>
<Show when={(blockedDays() ?? []).length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><BanIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-muted">{cs() ? "Zatím žádné blokované dny." : "No blocked days yet."}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={blockedDays() ?? []}>
{(day: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div>
<p class="font-medium text-ink">{new Date(day.date).toLocaleDateString(cs() ? "cs-CZ" : "en-US", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</p>
<p class="text-sm text-ink-muted">{day.reason || "—"}</p>
</div>
<button onClick={() => void handleDeleteBlockedDay(day.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div>
)}
</For>
</div>
</Show>
</div> </div>
)} )}
{activeTab() === "hours" && ( {activeTab() === "hours" && (
<div class="surface-card p-12 text-center"> <div class="surface-card overflow-hidden">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><ClockIcon /></div> <div class="p-6 border-b border-border">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p> <h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.workingHours")}</h3>
<p class="text-ink-muted text-sm">{data.i18n.locale() === "cs" ? "Nastavení pracovní doby bude dostupné brzy." : "Working hours settings coming soon."}</p> <p class="text-sm text-ink-muted mt-1">{cs() ? "Nastavte dny a časy, kdy přijímáte rezervace." : "Set the days and times you accept bookings."}</p>
</div>
<div class="divide-y divide-border/60">
<For each={workingHours() ?? []}>
{(day) => {
const isOpen = () => day.isOpen;
const setField = (patch: Partial<typeof day>) => {
const updated = { ...day, ...patch };
mutateHours((workingHours() ?? []).map((d) => (d.dayOfWeek === day.dayOfWeek ? updated : d)));
};
return (
<div class="flex flex-col sm:flex-row sm:items-center gap-3 p-4 px-6">
<div class="w-32 shrink-0 font-medium text-ink">{dayLabel(day.dayOfWeek)}</div>
<label class="flex items-center gap-2 text-sm text-ink-muted shrink-0">
<input
type="checkbox"
checked={isOpen()}
onChange={(e) => setField({ isOpen: e.currentTarget.checked, open: e.currentTarget.checked ? (day.open || "09:00") : day.open, close: e.currentTarget.checked ? (day.close || "18:00") : day.close })}
class="rounded border-border accent-[#c25e3a]"
/>
{cs() ? "Otevřeno" : "Open"}
</label>
<Show when={isOpen()}>
<div class="flex items-center gap-2">
<input type="time" value={day.open} onInput={(e) => setField({ open: e.currentTarget.value })} class="px-3 py-2 bg-canvas-subtle border border-border rounded-lg text-sm text-ink" />
<span class="text-ink-muted">–</span>
<input type="time" value={day.close} onInput={(e) => setField({ close: e.currentTarget.value })} class="px-3 py-2 bg-canvas-subtle border border-border rounded-lg text-sm text-ink" />
</div>
</Show>
<button onClick={() => void handleSaveHours(workingHours()?.find((d) => d.dayOfWeek === day.dayOfWeek) ?? day)} disabled={hoursSaving() === day.dayOfWeek} class="sm:ml-auto px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-60">
{hoursSaving() === day.dayOfWeek ? "…" : data.i18n.t("dashboard.settings.save")}
</button>
</div>
);
}}
</For>
</div>
</div> </div>
)} )}
@@ -133,8 +322,8 @@ function ZonesPage() {
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0"><SparklesIcon /></div> <div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0"><SparklesIcon /></div>
<div class="flex-1"> <div class="flex-1">
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.locationLimitReached")}</h4> <h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.locationLimitReached")}</h4>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Váš plán umožňuje pouze omezený počet lokací. Pro více lokací upgradujte." : "Your plan only allows a limited number of locations. Upgrade for more."}</p> <p class="text-sm text-ink-muted">{cs() ? "Váš plán umožňuje pouze omezený počet lokací. Pro více lokací upgradujte." : "Your plan only allows a limited number of locations. Upgrade for more."}</p>
<div class="flex gap-3"> <div class="flex gap-3 mt-3">
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button> <button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button> <button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
</div> </div>
+2 -2
View File
@@ -44,8 +44,8 @@ export function LegalRoute() {
{ {
title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments", title: isCs() ? "3. Předplatné a platby" : "3. Subscription and payments",
body: isCs() body: isCs()
? "Placené plány se účtují předem prostřednictvím platební brány Paddle nebo Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období." ? "Placené plány se účtují předem prostřednictvím platební brány Stripe. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce. Při roční platbě je poskytována sleva oproti měsíčnímu zúčtování. Uživatel může předplatné kdykoliv zrušit; přístup zůstává do konce zaplaceného období. Neposkytujeme refundace za již zaplacená období."
: "Paid plans are billed in advance through Paddle or Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.", : "Paid plans are billed in advance through Stripe. The active plan determines available limits, add-ons, and support features. Annual billing includes a discount compared to monthly billing. Users may cancel anytime; access continues until the end of the paid period. No refunds are provided for already-paid periods.",
}, },
{ {
title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations", title: isCs() ? "4. Odpovědnost a omezení" : "4. Liability and limitations",
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
+10 -27
View File
@@ -3,10 +3,8 @@
## Current shape ## Current shape
- CI runs in GitHub Actions from `.github/workflows/ci.yml`. - CI runs in GitHub Actions from `.github/workflows/ci.yml`.
- Frontend CD should stay on Vercel Git deployments. - Frontend CD stays on Vercel Git deployments.
- Backend and auth-service can stay on Railway Git autodeploys, or consume GHCR images built by CI. - Backend stays on Railway Git autodeploys, or consumes the GHCR image built by CI.
This keeps Vercel/Railway Git deployment simple while still producing immutable backend images on every production push.
## CI coverage ## CI coverage
@@ -14,11 +12,10 @@ Every pull request, push to `main`, and manual workflow run executes:
1. frontend install, API client generation, typecheck, and production build 1. frontend install, API client generation, typecheck, and production build
2. backend `go test` + `go build` 2. backend `go test` + `go build`
3. auth-service `go test` + `go build` 3. Docker image build for `apps/backend`
4. Docker image builds for `apps/backend` and `apps/auth-service` 4. GHCR publishing on push to `main` and manual workflow runs
5. GHCR publishing on push to `main` and manual workflow runs
Go is pinned to `1.26.2`, the latest stable release at time of setup. Go is pinned via `apps/backend/go.mod`.
## Platform CD wiring ## Platform CD wiring
@@ -32,39 +29,31 @@ Go is pinned to `1.26.2`, the latest stable release at time of setup.
### Railway ### Railway
Create two Railway services from this repo: Create one Railway service from this repo:
- backend root directory: `apps/backend` - backend root directory: `apps/backend`
- auth-service root directory: `apps/auth-service` - code-based config: [apps/backend/railway.json](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/railway.json:1)
Both services now have code-based Railway config: Enable:
- [apps/backend/railway.json](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/railway.json:1)
- [apps/auth-service/railway.json](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/railway.json:1)
Enable for each service:
- GitHub Autodeploys - GitHub Autodeploys
- Wait for CI - Wait for CI
Health checks: Health check:
- backend: `/healthz` - backend: `/healthz`
- auth-service: `/health`
Published images: Published images:
- `ghcr.io/<owner>/bookra-backend:<sha|branch|latest>` - `ghcr.io/<owner>/bookra-backend:<sha|branch|latest>`
- `ghcr.io/<owner>/bookra-auth-service:<sha|branch|latest>`
Use SHA tags for production rollouts. `latest` is emitted only from the default branch. Use SHA tags for production rollouts. `latest` is emitted only from the default branch.
## Local mirror of CI ## Local mirror of CI
```bash ```bash
PATH=/usr/local/go/bin:$PATH npm run verify npm run verify
docker build -t bookra-backend-ci ./apps/backend docker build -t bookra-backend-ci ./apps/backend
docker build -t bookra-auth-ci ./apps/auth-service
``` ```
## Low-RAM act checks ## Low-RAM act checks
@@ -81,9 +70,3 @@ act push -W .github/workflows/ci.yml -j go \
--concurrent-jobs 1 -b \ --concurrent-jobs 1 -b \
--container-options='--memory=3g --cpus=2' --container-options='--memory=3g --cpus=2'
``` ```
Notes:
- `frontend` job was executed successfully with `act`.
- `go` job was executed successfully with `act` for `apps/backend`.
- Dockerfiles were validated with direct `docker build`, which is cheaper than running full Docker-action matrix through `act`.
@@ -1,515 +0,0 @@
# Paddle Billing Migration Design
Date: 2026-04-21
Project: Bookra
Status: Proposed
## Goal
Replace all Stripe billing with Paddle, remove duplicate billing logic from `apps/auth-service`, and simplify runtime architecture to two deployables:
- `apps/frontend`
- `apps/backend`
Target outcome:
- one billing system only: Paddle
- one backend only: `apps/backend`
- frontend launches Paddle checkout directly through Paddle.js
- backend owns all billing truth, webhook processing, and tenant entitlement sync
- auth uses Neon Auth as primary production auth path
- local and production env files match actual runtime needs
## Current State
Current repo has duplicated auth and billing responsibilities:
- `apps/backend` already serves product APIs, tenant logic, scheduling, booking, and active dashboard billing APIs
- `apps/auth-service` still carries standalone auth plus duplicate Stripe billing APIs and admin screens
- frontend dashboard already talks to backend billing routes, not auth-service billing routes
- backend billing implementation is fully Stripe-specific in config, DB columns, repository names, webhook route, OpenAPI, generated API client, and UI copy
This creates three problems:
1. duplicated billing implementations
2. Stripe-specific naming embedded into core data model
3. deploy/runtime confusion from keeping `auth-service` alive when production auth should use Neon Auth directly
## Non-Goals
This migration does not add:
- end-customer booking checkout
- multi-provider billing abstraction
- migration support for existing live Stripe subscribers
There are no published users yet, so clean cutover is correct.
## Recommended Approach
Do full hard cutover in single migration:
- delete Stripe integration
- add Paddle integration
- move any still-needed auth-service auth capabilities into backend only if frontend/backend still require them
- otherwise decommission auth-service entirely from runtime and docs
Rationale:
- project is not live
- no customer migration burden
- avoids carrying dead provider compatibility code
- reduces long-term maintenance and deployment risk
## Target Architecture
### Runtime
- frontend:
- Neon Auth client
- Paddle.js client-side token
- generated API client for backend
- backend:
- Neon Auth JWT verification
- Paddle API integration
- Paddle webhook receiver
- billing state persistence
- customer portal session generation
`apps/auth-service` becomes retired code. Preferred end state: remove it from active project workflows and docs. If any still-useful auth handlers remain, port only those needed pieces into backend first, then remove auth-service.
### Billing Source of Truth
Backend is sole source of truth for:
- current plan
- subscription status
- billing customer ID
- billing subscription ID
- latest normalized billing snapshot
- entitlement updates after Paddle webhooks or explicit refresh
Frontend never treats checkout success redirect as proof of paid state. Redirect only triggers refetch.
## Paddle Integration Model
### Frontend
Frontend loads Paddle.js using official hosted script or package wrapper and initializes once with `VITE_PADDLE_CLIENT_TOKEN`.
When user starts checkout:
- dashboard gets plan/price readiness from backend
- frontend calls backend `POST /v1/billing/checkout`
- backend returns resolved `priceId`, customer hints, and checkout metadata
- frontend opens `Paddle.Checkout.open()` with:
- `items`
- customer email or Paddle customer ID if already known
- `customData`
- success/cancel URLs pointing back to dashboard
Reason for backend-generated checkout input:
- keeps plan/price resolution on server
- prevents frontend drift from env/config
- makes auditing easier
`customData` must include at least:
- `tenantId`
- `tenantSlug`
- `userId`
- `userEmail`
- `planCode`
- `source=bookra-dashboard`
Paddle docs state checkout `customData` is copied to resulting subscription for recurring purchases, making webhook reconciliation deterministic.
### Backend
Backend uses Paddle API key over Bearer auth.
Backend responsibilities:
- resolve plan/currency to Paddle price ID
- return checkout launch payload
- verify `Paddle-Signature`
- dedupe webhook events
- fetch and normalize Paddle customer/subscription data
- update tenant billing state
- create customer portal sessions for authenticated users
### Customer Portal
Do not build custom card-update or cancel-subscription UI.
Backend adds `POST /v1/billing/portal`:
- find tenant billing customer
- create Paddle customer portal session
- optionally include known subscription ID for deep links
- return temporary portal URL
Frontend opens returned URL.
## Data Model Changes
Current schema uses Stripe-specific names. Replace with provider-neutral billing names.
### Tenants table
Rename:
- `stripe_customer_id` -> `billing_customer_id`
- `stripe_subscription_id` -> `billing_subscription_id`
Add:
- `billing_provider text not null default 'paddle'`
### billing_snapshots table
Rename:
- `stripe_customer_id` -> `billing_customer_id`
- `stripe_subscription_id` -> `billing_subscription_id`
Keep:
- `tenant_id`
- `status`
- `plan_code`
- `currency`
- `price_id`
- `current_period_start`
- `current_period_end`
- `cancel_at_period_end`
- `updated_at`
Add if absent:
- `billing_provider text not null default 'paddle'`
### subscription_events table
Rename:
- `stripe_event_id` -> `billing_provider_event_id`
Add if absent:
- `billing_provider text not null default 'paddle'`
Webhook dedupe unique key becomes provider-aware:
- unique (`billing_provider`, `billing_provider_event_id`)
### Repository/API naming
Rename repository methods away from Stripe:
- `GetTenantByStripeCustomerID` -> `GetTenantByBillingCustomerID`
- `UpdateTenantStripeCustomerID` -> `UpdateTenantBillingCustomerID`
- `RecordStripeEvent` -> `RecordBillingEvent`
Struct field names must match.
## API Contract Changes
### Keep
Keep frontend-facing routes where practical to reduce frontend churn:
- `GET /v1/billing/subscription`
- `POST /v1/billing/checkout`
- `POST /v1/billing/refresh`
### Add
- `POST /v1/billing/portal`
### Replace
- remove `POST /v1/webhooks/stripe`
- add `POST /v1/webhooks/paddle`
### Response shape updates
Billing snapshot payload should remain mostly stable, but provider-specific wording changes:
- `checkoutUrlAvailable` now means Paddle checkout ready
- `syncAvailable` now means Paddle API configured
Checkout creation response should carry data frontend needs for Paddle.js, not a Stripe hosted session URL:
- `priceId`
- `customerId` optional
- `customerAuthToken` optional if needed later for saved payment methods
- `successRedirectUrl`
- `cancelRedirectUrl`
- `customData`
OpenAPI operation and backend type should use explicit name `CheckoutLaunchResponse`.
### OpenAPI and generated client
Must update:
- `apps/backend/openapi/bookra.openapi.yaml`
- generated package in `packages/api-client`
No handwritten duplicated TS billing types after migration.
## Auth-Service Collapse
### Decision
Move project to frontend + backend only.
### Practical meaning
- production auth stays Neon Auth
- backend verifies Neon Auth JWTs
- auth-service billing removed fully
- auth-service docs/env/examples removed or replaced with deprecation note
### Implementation path
1. inspect whether frontend still depends on any auth-service-only endpoints
2. move any still-needed endpoint behavior into backend if required
3. remove auth-service billing code
4. remove auth-service from root docs, env examples, verification flow, and deployment assumptions
If no remaining required auth-service endpoint exists after Neon Auth-first changes already landed, app can retire auth-service immediately.
## Config Contract
### Frontend env
Required:
- `VITE_API_BASE_URL`
- `VITE_NEON_AUTH_URL`
- `VITE_PADDLE_CLIENT_TOKEN`
Optional:
- existing non-billing frontend vars
### Backend env
Required for billing:
- `BOOKRA_PADDLE_API_KEY`
- `BOOKRA_PADDLE_WEBHOOK_SECRET`
- `BOOKRA_PADDLE_ENV`
- `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`
Required for auth/runtime:
- `BOOKRA_DATABASE_URL`
- `BOOKRA_NEON_AUTH_URL`
- existing backend app config vars already used for frontend URL, jobs, email, etc.
### Remove from active env examples
- all `STRIPE_*`
- all `BOOKRA_STRIPE_*`
- auth-service billing env
## Billing State Mapping
Normalize Paddle subscription states into internal states used by Bookra.
Initial mapping:
- Paddle active/trialing -> `active`
- Paddle paused -> `past_due` or `paused` depending on internal enum support
- Paddle canceled -> `canceled`
- missing subscription with no billing customer -> `inactive`
- incomplete/failed transaction states -> `past_due`
If internal model lacks `paused`, keep current internal enum and map conservatively, documented in code comments.
Plan mapping:
- Paddle price ID -> internal `plan_code`
- backend owns exact mapping through env-configured price matrix
- no plan inference from frontend text labels
## Webhook Handling
Subscribe at minimum to:
- `subscription.created`
- `subscription.updated`
- `subscription.activated`
- `subscription.canceled`
- `subscription.paused`
- `subscription.resumed`
- `transaction.completed`
- `transaction.updated`
- `transaction.payment_failed` if used by Paddle event catalog for current account
Handler flow:
1. read raw body
2. verify `Paddle-Signature`
3. extract event ID, type, customer/subscription IDs, and `custom_data`
4. idempotency insert into `subscription_events`
5. resolve tenant by:
- `custom_data.tenantId` first if present and trustworthy
- else `billing_customer_id`
6. fetch latest subscription/customer state from Paddle API when needed
7. write normalized billing snapshot
8. update tenant plan/status
9. return `200`
Webhook payload is trigger, not final source of truth. Backend may fetch fresh Paddle entity state before persisting.
## Error Handling
Backend returns honest states only.
Rules:
- missing Paddle API key => checkout disabled, refresh unavailable, `503` for checkout/refresh attempts
- missing price ID for selected plan => `400`
- invalid webhook signature => `400`
- duplicate webhook => `200` no-op
- unknown tenant mapping => `202` or `200` with logged warning, depending on handler style
- Paddle API failure during sync => `503` for user-triggered refresh, retryable log path for webhook
Frontend rules:
- no fake “paid” notice after redirect
- success query param triggers backend refresh + refetch only
- if checkout not ready, button disabled and message says Paddle not configured
- “Manage billing” hidden or disabled until portal available
## Testing Plan
### Unit
Backend unit tests for:
- plan/currency -> price ID resolution
- config readiness helpers
- checkout launch payload creation
- webhook signature validation
- webhook idempotency
- subscription normalization
- customer portal session creation
- repository rename/regression coverage
### Integration
Backend integration tests for:
- DB migrations on fresh database
- DB migrations from old Stripe-shaped schema
- webhook event persistence
- entitlement updates after simulated Paddle event payloads
### Frontend verification
- typecheck/build passes
- billing UI opens Paddle checkout through injected abstraction or browser global
- billing refresh/portal actions behave correctly for ready/not-ready states
- copy no longer says Stripe anywhere
### Live sandbox smoke
Using Paddle sandbox:
1. create sandbox API key
2. create client-side token
3. create sandbox prices for starter/pro/business in CZK/USD
4. configure webhook destination to backend
5. complete checkout from dashboard
6. verify webhook updates tenant state
7. verify refresh endpoint returns normalized active subscription
8. verify customer portal session opens
9. cancel/pause through portal if supported, then verify webhook sync
## Documentation Changes
Update:
- root `README.md`
- `project.md`
- `apps/backend/README.md`
- root `.env.example`
- `apps/backend/.env.example`
- `apps/frontend/.env.example`
Remove or deprecate:
- `apps/auth-service/README.md`
- `apps/auth-service/.env.example`
Frontend/legal copy must replace “Stripe” with “Paddle” or provider-neutral wording.
## Implementation Order
1. schema migration from Stripe names to billing names
2. repository/model rename
3. backend config rename from Stripe env to Paddle env
4. backend Paddle service integration
5. webhook route swap
6. portal session endpoint
7. OpenAPI update + client regeneration
8. frontend Paddle.js integration + dashboard action changes
9. auth-service billing removal
10. docs/env cleanup
11. full test + sandbox smoke
## Risks
### Risk: auth-service still secretly needed
Mitigation:
- inspect frontend/runtime references before deleting
- port only remaining necessary auth endpoint behavior into backend
### Risk: Paddle event/state model differs from current internal assumptions
Mitigation:
- normalize through dedicated mapping layer
- use webhook-triggered sync against live Paddle API instead of trusting event payload alone
### Risk: schema rename breaks old queries
Mitigation:
- update sqlc SQL and repository code in same change set
- run all backend tests plus migration smoke
### Risk: frontend Paddle global initialization issues
Mitigation:
- wrap Paddle.js loading/init in dedicated provider/module
- initialize once
- fail closed when token missing
## Definition of Done
Migration done when all conditions true:
- no Stripe dependency in active codepaths
- no frontend user-facing Stripe copy remains
- backend billing routes use Paddle only
- webhook route is Paddle only
- auth-service not needed for production runtime
- env examples document only actual required vars
- OpenAPI and generated TS client match backend
- local tests pass
- Paddle sandbox checkout + webhook + portal smoke pass
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"files": {"code": ["/home/tdvorak/Desktop/PROG+HTML/Bookra/generate_map.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/map.ts", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/cmd/api/main.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/email/templates.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/email/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/db/db.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/db/repository.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/config/config_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/config/config.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/oauth/google.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/handlers/handlers.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/handlers/admin.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/billing/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/billing/service_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/auth/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/auth/neon.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/auth/service_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/cmd/api/main.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/catalog/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/httpx/middleware.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/db/repository.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/db/pool.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/domain/models.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/config/config_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/config/config.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/notifications/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/notifications/email_templates.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/notifications/service_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/bookings/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/bookings/customer_service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/bookings/service_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/billing/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/billing/service_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/auth/middleware.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/auth/neon.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/api/server.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/api/server_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/tenancy/service.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/tenancy/service_test.go", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/vite.config.ts", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/App.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/main.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/about-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/auth-callback-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/public-booking-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/dashboard-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/contact-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/home-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/not-found-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/legal-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/booking-manage-route.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/bookra-character.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/index.ts", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/integration-modal.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/location-map.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/shell.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/widget-builder.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/dashboard/icons.tsx", "/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/dashboard/types.ts", "/home/tdvorak/Desktop/PROG+HTML/BLine truncated
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"0": "Neon Database Core", "1": "Paddle Billing & Dashboard", "2": "Memory Repository", "3": "PostgreSQL Repository", "4": "Domain Models", "5": "Server & Main Entry", "6": "Admin Dashboard", "7": "Test Suite", "8": "Database Records", "9": "Widget Builder & Icons", "10": "Email Templates Service", "11": "Notification Service", "12": "Database Connection", "13": "UI Icons Library", "14": "Email Provider", "15": "Auth & Magic Links", "16": "Business Service", "17": "Shell & Auth UI", "18": "Landing Page UI", "19": "Email Localization", "20": "Customer Notifications", "21": "Maps & Geocoding", "22": "Brand Profile Service", "23": "Dialog Component", "24": "Google OAuth", "25": "Card Component", "26": "Map URL Parsing", "27": "Location Map", "28": "Tabs Component", "29": "Legal Pages", "30": "Auth Provider", "31": "Skeleton Component", "32": "Theme Provider", "33": "User Repository", "34": "Paddle Config Tests", "35": "App Root", "36": "Public Booking", "37": "Bookra Character", "38": "Booking Types", "39": "Contact Map", "40": "About Page", "41": "Auth Callback", "42": "Contact Page", "43": "Not Found Page", "44": "Integration Modal", "45": "Tooltip Component", "46": "Input Component", "47": "Badge Component", "48": "Select Component", "49": "Textarea Component", "50": "Avatar Component", "51": "Vite Config", "52": "Main Entry", "53": "API Index", "54": "Button Component", "55": "Repository Index", "56": "I18n Provider", "57": "API Client", "58": "Shared Types", "59": "Index Export", "60": "Repository Export", "61": "Types Export", "62": "Generate Script"}
-1
View File
@@ -1 +0,0 @@
{"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
-221
View File
@@ -1,221 +0,0 @@
# Graph Report - /home/tdvorak/Desktop/PROG+HTML/Bookra (2026-04-30)
## Corpus Check
- 166 files · ~380,279 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 794 nodes · 1114 edges · 29 communities detected
- Extraction: 87% EXTRACTED · 13% INFERRED · 0% AMBIGUOUS · INFERRED: 150 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Neon Database Core|Neon Database Core]]
- [[_COMMUNITY_Paddle Billing & Dashboard|Paddle Billing & Dashboard]]
- [[_COMMUNITY_Memory Repository|Memory Repository]]
- [[_COMMUNITY_PostgreSQL Repository|PostgreSQL Repository]]
- [[_COMMUNITY_Domain Models|Domain Models]]
- [[_COMMUNITY_Server & Main Entry|Server & Main Entry]]
- [[_COMMUNITY_Admin Dashboard|Admin Dashboard]]
- [[_COMMUNITY_Test Suite|Test Suite]]
- [[_COMMUNITY_Database Records|Database Records]]
- [[_COMMUNITY_Widget Builder & Icons|Widget Builder & Icons]]
- [[_COMMUNITY_Email Templates Service|Email Templates Service]]
- [[_COMMUNITY_Notification Service|Notification Service]]
- [[_COMMUNITY_Database Connection|Database Connection]]
- [[_COMMUNITY_Email Provider|Email Provider]]
- [[_COMMUNITY_Auth & Magic Links|Auth & Magic Links]]
- [[_COMMUNITY_Business Service|Business Service]]
- [[_COMMUNITY_Shell & Auth UI|Shell & Auth UI]]
- [[_COMMUNITY_Email Localization|Email Localization]]
- [[_COMMUNITY_Customer Notifications|Customer Notifications]]
- [[_COMMUNITY_Maps & Geocoding|Maps & Geocoding]]
- [[_COMMUNITY_Brand Profile Service|Brand Profile Service]]
- [[_COMMUNITY_Google OAuth|Google OAuth]]
- [[_COMMUNITY_Map URL Parsing|Map URL Parsing]]
- [[_COMMUNITY_Location Map|Location Map]]
- [[_COMMUNITY_Tabs Component|Tabs Component]]
- [[_COMMUNITY_Legal Pages|Legal Pages]]
- [[_COMMUNITY_Auth Provider|Auth Provider]]
- [[_COMMUNITY_User Repository|User Repository]]
- [[_COMMUNITY_Public Booking|Public Booking]]
## God Nodes (most connected - your core abstractions)
1. `trimSpace()` - 47 edges
2. `PGRepository` - 46 edges
3. `MemoryRepository` - 46 edges
4. `NewService()` - 28 edges
5. `NewMemoryRepository()` - 22 edges
6. `Handler` - 20 edges
7. `Service` - 20 edges
8. `DB` - 18 edges
9. `New()` - 17 edges
10. `NewServer()` - 17 edges
## Surprising Connections (you probably didn't know these)
- `New()` --calls--> `NewGoogleProvider()` [INFERRED]
apps/auth-service/internal/handlers/handlers.go → apps/auth-service/internal/oauth/google.go
- `NewServer()` --calls--> `NewCustomerService()` [INFERRED]
apps/backend/internal/api/server.go → apps/backend/internal/bookings/customer_service.go
- `main()` --calls--> `Load()` [INFERRED]
apps/backend/cmd/api/main.go → apps/backend/internal/config/config.go
- `main()` --calls--> `getEnv()` [INFERRED]
apps/backend/cmd/api/main.go → apps/auth-service/internal/config/config.go
- `main()` --calls--> `New()` [INFERRED]
apps/backend/cmd/api/main.go → apps/auth-service/internal/handlers/handlers.go
## Communities
### Community 0 - "Neon Database Core"
Cohesion: 0.06
Nodes (42): NewNeonVerifier(), NeonVerifier, CheckoutSession, PaymentMethod, Service, checkoutAvailable(), customDataString(), customerSnapshotKey() (+34 more)
### Community 1 - "Paddle Billing & Dashboard"
Cohesion: 0.04
Nodes (38): getPaddle(), paddleConfigured(), handleCancel(), handleReschedule(), reference(), token(), changeMonth(), demoData() (+30 more)
### Community 2 - "Memory Repository"
Cohesion: 0.04
Nodes (3): MemoryRepository, defaultAvailabilityBlocks(), maxInt()
### Community 3 - "PostgreSQL Repository"
Cohesion: 0.04
Nodes (2): PGRepository, firstNonEmpty()
### Community 4 - "Domain Models"
Cohesion: 0.05
Nodes (40): AvailabilityBlockRequest, BlockedDay, BookingDefaultsRequest, BrandProfile, CancelBookingRequest, CheckoutLaunchResponse, CheckoutSessionRequest, CreateBlockedDayRequest (+32 more)
### Community 5 - "Server & Main Entry"
Cohesion: 0.06
Nodes (22): main(), runMigrations(), Server, allowedOrigins(), authorizeJobRunner(), NewServer(), TestDispatchReminderJobsAcceptsConfiguredJobRunnerKey(), TestDispatchReminderJobsRequiresJobRunnerKey() (+14 more)
### Community 6 - "Admin Dashboard"
Cohesion: 0.06
Nodes (12): NewAdminDashboard(), AdminDashboard, CheckoutRequest, Handler, detectLocale(), generateState(), oauthCookieSecure(), LoginRequest (+4 more)
### Community 7 - "Test Suite"
Cohesion: 0.13
Nodes (28): TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(), TestRefreshTokensRejectsInvalidToken(), TestRefreshTokensReturnsRotatedPair(), TestCheckoutAvailableForPlanRequiresConfiguredPlan(), TestCheckoutAvailableForPlanRequiresSecret(), testConfig(), TestCreateCheckoutRequiresPaddleConfig(), TestCreateCheckoutReturnsLaunchPayload() (+20 more)
### Community 8 - "Database Records"
Cohesion: 0.06
Nodes (31): AvailabilityBlockRecord, AvailabilityRuleRecord, BillingSnapshotRecord, BlockedDayRecord, BookingRecord, BrandProfileRecord, ClassSessionRecord, CreateBlockedDayParams (+23 more)
### Community 9 - "Widget Builder & Icons"
Cohesion: 0.08
Nodes (5): generateCode(), generatedMapElementId(), generateMapCode(), json(), safeHexColor()
### Community 10 - "Email Templates Service"
Cohesion: 0.14
Nodes (15): Config, EmailTemplate, Service, bookingConfirmationCS(), BookingConfirmationEmail(), bookingConfirmationEN(), MagicLinkEmail(), magicLinkEmailCS() (+7 more)
### Community 11 - "Notification Service"
Cohesion: 0.15
Nodes (13): noopNotifier, Notifier, Service, collides(), countClassBookings(), generateAppointmentSlots(), generateClassSlots(), normalizePlanCode() (+5 more)
### Community 12 - "Database Connection"
Cohesion: 0.1
Nodes (2): DB, Stats
### Community 14 - "Email Provider"
Cohesion: 0.18
Nodes (11): DeliveryReceipt, EmailMessage, EmailProvider, noopEmailProvider, Service, localizedStartsAt(), NewService(), reminderRecipient() (+3 more)
### Community 15 - "Auth & Magic Links"
Cohesion: 0.19
Nodes (4): Claims, Service, generateRandomToken(), TokenPair
### Community 16 - "Business Service"
Cohesion: 0.12
Nodes (1): Service
### Community 17 - "Shell & Auth UI"
Cohesion: 0.14
Nodes (2): submitSignIn(), translateAuthError()
### Community 19 - "Email Localization"
Cohesion: 0.3
Nodes (11): BookingEmailData, formatLocalizedDateTime(), formatLocalizedTime(), RenderEmailMessage(), renderHTMLBody(), RenderReminderEmail(), renderSubject(), renderTextBody() (+3 more)
### Community 20 - "Customer Notifications"
Cohesion: 0.26
Nodes (4): NewCustomerService(), customerNoopNotifier, CustomerNotifier, CustomerService
### Community 21 - "Maps & Geocoding"
Cohesion: 0.36
Nodes (11): resolveMapLocation(), geocodeLocation(), mapStyleById(), parseCoordinateText(), parseGoogleMapsUrl(), parseMapUrl(), parseMapyCzUrl(), resolveLocationInput() (+3 more)
### Community 22 - "Brand Profile Service"
Cohesion: 0.33
Nodes (7): Service, firstNonEmpty(), normalizeClock(), normalizePlanCode(), toAvailabilityBlocks(), toTeamInvites(), validateAvailabilityBlocks()
### Community 24 - "Google OAuth"
Cohesion: 0.25
Nodes (3): NewGoogleProvider(), GoogleProvider, GoogleUser
### Community 26 - "Map URL Parsing"
Cohesion: 0.47
Nodes (3): parseGoogleMapsUrl(), parseMapUrl(), parseMapyCzUrl()
### Community 27 - "Location Map"
Cohesion: 0.4
Nodes (2): createMarkerIcon(), safeMarkerColor()
### Community 28 - "Tabs Component"
Cohesion: 0.47
Nodes (3): TabsContent(), TabsTrigger(), useTabs()
### Community 29 - "Legal Pages"
Cohesion: 0.7
Nodes (4): helperPose(), heroPose(), kind(), sections()
### Community 30 - "Auth Provider"
Cohesion: 0.5
Nodes (2): parseJwtPayload(), sessionFromLocalToken()
### Community 33 - "User Repository"
Cohesion: 0.67
Nodes (2): MagicLink, User
### Community 36 - "Public Booking"
Cohesion: 1.0
Nodes (2): bookSlot(), tenantSlug()
## Knowledge Gaps
- **97 isolated node(s):** `EmailTemplate`, `Config`, `Stats`, `User`, `MagicLink` (+92 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `PostgreSQL Repository`** (47 nodes): `.RecordBillingEvent()`, `PGRepository`, `.AppendWaitlistEntry()`, `.CreateBlockedDay()`, `.CreateBooking()`, `.CreateCustomer()`, `.CreateLocation()`, `.CreateNotificationDeliveryLog()`, `.CreateReminderJob()`, `.DeleteBlockedDay()`, `.DeleteCustomer()`, `.DeleteLocation()`, `.EnsureUserIdentity()`, `.GetBookingByReference()`, `.GetBrandProfile()`, `.GetCustomerBookingsCount()`, `.GetCustomerByEmail()`, `.GetCustomerByID()`, `.GetCustomerLastBooking()`, `.GetDashboardMetrics()`, `.GetLocationByID()`, `.GetSubscriptionSnapshot()`, `.GetTenantByBillingCustomerID()`, `.GetTenantByID()`, `.GetTenantBySlug()`, `.GetTenantMembershipByUserID()`, `.ListAvailabilityRulesByTenant()`, `.ListBlockedDaysByTenant()`, `.ListBookingsByTenantBetween()`, `.ListClassSessionsByTenant()`, `.ListCustomersByTenant()`, `.ListDueReminderJobs()`, `.ListLocationsByTenant()`, `.ListServicesByTenant()`, `.ListWorkingHoursByTenant()`, `.MarkReminderJobDispatched()`, `.RecordBillingEvent()`, `.RescheduleBooking()`, `.UpdateBlockedDay()`, `.UpdateBookingStatus()`, `.UpdateCustomer()`, `.UpdateLocation()`, `.UpdateTenantBillingCustomerID()`, `.UpdateTenantBillingState()`, `.UpdateWorkingHours()`, `.UpsertSubscriptionSnapshot()`, `firstNonEmpty()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Database Connection`** (21 nodes): `db.go`, `DB`, `.Close()`, `.CreateMagicLink()`, `.CreateUser()`, `.Exec()`, `.GetKV()`, `.GetMagicLink()`, `.GetStats()`, `.GetUserByEmail()`, `.GetUserByID()`, `.GetUserByProviderID()`, `.MarkMagicLinkUsed()`, `New()`, `.Pool()`, `.PutKV()`, `.Query()`, `.QueryRow()`, `.UpdateLastLogin()`, `.UpdateUser()`, `Stats`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Business Service`** (17 nodes): `service.go`, `Service`, `.CreateBlockedDay()`, `.CreateCustomer()`, `.CreateLocation()`, `.DeleteBlockedDay()`, `.DeleteCustomer()`, `.DeleteLocation()`, `.ListBlockedDays()`, `.ListCustomers()`, `.ListLocations()`, `.ListWorkingHours()`, `NewService()`, `.UpdateBlockedDay()`, `.UpdateCustomer()`, `.UpdateLocation()`, `.UpdateWorkingHours()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Shell & Auth UI`** (15 nodes): `shell.tsx`, `GlobeIcon()`, `GoogleIcon()`, `handleOpenAuth()`, `hideHeader()`, `isDemoMode()`, `MenuIcon()`, `MoonIcon()`, `sendMagicLink()`, `showGoogleSignIn()`, `signInWithGoogle()`, `submitSignIn()`, `SunIcon()`, `translateAuthError()`, `XIcon()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Location Map`** (6 nodes): `location-map.tsx`, `createMarkerIcon()`, `escapeHtml()`, `loadLeaflet()`, `LocationMap()`, `safeMarkerColor()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Auth Provider`** (5 nodes): `auth-provider.tsx`, `AuthProvider()`, `parseJwtPayload()`, `sessionFromLocalToken()`, `useAuth()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `User Repository`** (3 nodes): `repository.go`, `MagicLink`, `User`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Public Booking`** (3 nodes): `public-booking-route.tsx`, `bookSlot()`, `tenantSlug()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `trimSpace()` connect `Neon Database Core` to `Memory Repository`, `PostgreSQL Repository`, `Server & Main Entry`, `Admin Dashboard`, `Notification Service`, `Email Provider`, `Email Localization`, `Brand Profile Service`?**
_High betweenness centrality (0.246) - this node is a cross-community bridge._
- **Why does `NewServer()` connect `Server & Main Entry` to `Neon Database Core`, `Customer Notifications`, `Test Suite`?**
_High betweenness centrality (0.116) - this node is a cross-community bridge._
- **Why does `reference()` connect `Paddle Billing & Dashboard` to `Notification Service`?**
_High betweenness centrality (0.101) - this node is a cross-community bridge._
- **Are the 45 inferred relationships involving `trimSpace()` (e.g. with `.StripeSecretConfigured()` and `.StripeWebhookConfigured()`) actually correct?**
_`trimSpace()` has 45 INFERRED edges - model-reasoned connections that need verification._
- **Are the 27 inferred relationships involving `NewService()` (e.g. with `New()` and `TestPriceForPlanUsesConfiguredPlanCodesOnly()`) actually correct?**
_`NewService()` has 27 INFERRED edges - model-reasoned connections that need verification._
- **Are the 19 inferred relationships involving `NewMemoryRepository()` (e.g. with `TestDispatchDueProcessesPendingEmailReminders()` and `TestDispatchDueFailsUnknownChannel()`) actually correct?**
_`NewMemoryRepository()` has 19 INFERRED edges - model-reasoned connections that need verification._
- **What connects `EmailTemplate`, `Config`, `Stats` to the rest of the system?**
_97 weakly-connected nodes found - possible documentation gaps or missing edges._
-12
View File
@@ -1,12 +0,0 @@
{
"runs": [
{
"date": "2026-04-30T05:47:16.076690+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 166
}
],
"total_input_tokens": 0,
"total_output_tokens": 0
}
-276
View File
@@ -1,276 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>graphify - graphify-out/graph.html</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
#graph { flex: 1; }
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
#search:focus { border-color: #4E79A7; }
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-item:hover { background: #2a2a4e; }
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
#info-content .field { margin-bottom: 5px; }
#info-content .field b { color: #e0e0e0; }
#info-content .empty { color: #555; font-style: italic; }
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
.neighbor-link:hover { background: #2a2a4e; }
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
.legend-item.dimmed { opacity: 0.35; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend-count { color: #666; font-size: 11px; }
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
#legend-controls { display: flex; gap: 6px; margin-bottom: 8px; }
#legend-controls button { flex: 1; background: #0f0f1a; border: 1px solid #3a3a5e; color: #aaa; padding: 4px 0; border-radius: 4px; font-size: 11px; cursor: pointer; }
#legend-controls button:hover { border-color: #4E79A7; color: #e0e0e0; }
</style>
</head>
<body>
<div id="graph"></div>
<div id="sidebar">
<div id="search-wrap">
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
<div id="search-results"></div>
</div>
<div id="info-panel">
<h3>Node Info</h3>
<div id="info-content"><span class="empty">Click a node to inspect it</span></div>
</div>
<div id="legend-wrap">
<h3>Communities</h3>
<div id="legend-controls">
<button onclick="toggleAllCommunities(false)">Show All</button>
<button onclick="toggleAllCommunities(true)">Hide All</button>
</div>
<div id="legend"></div>
</div>
<div id="stats">794 nodes &middot; 1114 edges &middot; 63 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "generate_map_tsx", "label": "generate_map.tsx", "color": {"background": "#BAB0AC", "border": "#BAB0AC", "highlight": {"background": "#ffffff", "border": "#BAB0AC"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "generate_map.tsx", "community": 39, "community_name": "Contact Map", "source_file": "generate_map.tsx", "file_type": "code", "degree": 1}, {"id": "bookra_generate_map_contactmap", "label": "ContactMap()", "color": {"background": "#BAB0AC", "border": "#BAB0AC", "highlight": {"background": "#ffffff", "border": "#BAB0AC"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "ContactMap()", "community": 39, "community_name": "Contact Map", "source_file": "generate_map.tsx", "file_type": "code", "degree": 1}, {"id": "map_ts", "label": "map.ts", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 12.5, "font": {"size": 0, "color": "#ffffff"}, "title": "map.ts", "community": 26, "community_name": "Map URL Parsing", "source_file": "map.ts", "file_type": "code", "degree": 5}, {"id": "bookra_map_parsemapyczurl", "label": "parseMapyCzUrl()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 11.0, "font": {"size": 0, "color": "#ffffff"}, "title": "parseMapyCzUrl()", "community": 26, "community_name": "Map URL Parsing", "source_file": "map.ts", "file_type": "code", "degree": 2}, {"id": "bookra_map_parsegooglemapsurl", "label": "parseGoogleMapsUrl()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 11.0, "font": {"size": 0, "color": "#ffffff"}, "title": "parseGoogleMapsUrl()", "community": 26, "community_name": "Map URL Parsing", "source_file": "map.ts", "file_type": "code", "degree": 2}, {"id": "bookra_map_parsemapurl", "label": "parseMapUrl()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 11.5, "font": {"size": 0, "color": "#ffffff"}, "title": "parseMapUrl()", "community": 26, "community_name": "Map URL Parsing", "source_file": "map.ts", "file_type": "code", "degree": 3}, {"id": "bookra_map_validatecoordinates", "label": "validateCoordinates()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "validateCoordinates()", "community": 26, "community_name": "Map URL Parsing", "source_file": "map.ts", "file_type": "code", "degree": 1}, {"id": "bookra_map_reversegeocode", "label": "reverseGeocode()", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "reverseGeocode()", "community": 26, "community_name": "Map URL Parsing", "source_file": "map.ts", "file_type": "code", "degree": 1}, {"id": "apps_auth_service_cmd_api_main_go", "label": "main.go", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 11.0, "font": {"size": 0, "color": "#ffffff"}, "title": "main.go", "community": 5, "community_name": "Server & Main Entry", "source_file": "apps/auth-service/cmd/api/main.go", "file_type": "code", "degree": 2}, {"id": "api_main_main", "label": "main()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 14.1, "font": {"size": 0, "color": "#ffffff"}, "title": "main()", "community": 5, "community_name": "Server & Main Entry", "source_file": "apps/backend/cmd/api/main.go", "file_type": "code", "degree": 8}, {"id": "api_main_runmigrations", "label": "runMigrations()", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 11.5, "font": {"size": 0, "color": "#ffffff"}, "title": "runMigrations()", "community": 5, "community_name": "Server & Main Entry", "source_file": "apps/auth-service/cmd/api/main.go", "file_type": "code", "degree": 3}, {"id": "apps_auth_service_internal_email_templates_go", "label": "templates.go", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 16.6, "font": {"size": 12, "color": "#ffffff"}, "title": "templates.go", "community": 10, "community_name": "Email Templates Service", "source_file": "apps/auth-service/internal/email/templates.go", "file_type": "code", "degree": 13}, {"id": "email_emailtemplate", "label": "EmailTemplate", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.5, "font": {"size": 0, "color": "#ffffff"}, "title": "EmailTemplate", "community": 10, "community_name": "Email Templates Service", "source_file"Line truncated
const RAW_EDGES = [{"from": "generate_map_tsx", "to": "bookra_generate_map_contactmap", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "map_ts", "to": "bookra_map_parsemapyczurl", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "map_ts", "to": "bookra_map_parsegooglemapsurl", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "map_ts", "to": "bookra_map_parsemapurl", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "map_ts", "to": "bookra_map_validatecoordinates", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "map_ts", "to": "bookra_map_reversegeocode", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "bookra_map_parsemapyczurl", "to": "bookra_map_parsemapurl", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "bookra_map_parsegooglemapsurl", "to": "bookra_map_parsemapurl", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_cmd_api_main_go", "to": "api_main_main", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_cmd_api_main_go", "to": "api_main_runmigrations", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "api_main_main", "to": "api_main_runmigrations", "label": "calls", "title": "calls [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "api_main_main", "to": "apps_backend_cmd_api_main_go", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "api_main_main", "to": "config_config_load", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "api_main_main", "to": "config_config_getenv", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "api_main_main", "to": "handlers_handlers_new", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "api_main_main", "to": "db_pool_newpools", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "api_main_main", "to": "api_server_newserver", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "api_main_runmigrations", "to": "api_server_close", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "apps_auth_service_internal_email_templates_go", "to": "email_emailtemplate", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_internal_email_templates_go", "to": "email_templates_magiclinkemail", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_internal_email_templates_go", "to": "email_templates_welcomeemail", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_internal_email_templates_go", "to": "email_templates_bookingconfirmationemail", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_internal_email_templates_go", "to": "email_templates_passwordresetemail", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_internal_email_templates_go", "to": "email_templates_magiclinkemailen", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "apps_auth_service_internal_email_templaLine truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "Neon Database Core", "count": 79}, {"cid": 1, "color": "#F28E2B", "label": "Paddle Billing &amp; Dashboard", "count": 72}, {"cid": 2, "color": "#E15759", "label": "Memory Repository", "count": 48}, {"cid": 3, "color": "#76B7B2", "label": "PostgreSQL Repository", "count": 47}, {"cid": 4, "color": "#59A14F", "label": "Domain Models", "count": 41}, {"cid": 5, "color": "#EDC948", "label": "Server &amp; Main Entry", "count": 40}, {"cid": 6, "color": "#B07AA1", "label": "Admin Dashboard", "count": 39}, {"cid": 7, "color": "#FF9DA7", "label": "Test Suite", "count": 35}, {"cid": 8, "color": "#9C755F", "label": "Database Records", "count": 33}, {"cid": 9, "color": "#BAB0AC", "label": "Widget Builder &amp; Icons", "count": 28}, {"cid": 10, "color": "#4E79A7", "label": "Email Templates Service", "count": 24}, {"cid": 11, "color": "#F28E2B", "label": "Notification Service", "count": 23}, {"cid": 12, "color": "#E15759", "label": "Database Connection", "count": 21}, {"cid": 13, "color": "#76B7B2", "label": "UI Icons Library", "count": 20}, {"cid": 14, "color": "#59A14F", "label": "Email Provider", "count": 19}, {"cid": 15, "color": "#EDC948", "label": "Auth &amp; Magic Links", "count": 18}, {"cid": 16, "color": "#B07AA1", "label": "Business Service", "count": 17}, {"cid": 17, "color": "#FF9DA7", "label": "Shell &amp; Auth UI", "count": 15}, {"cid": 18, "color": "#9C755F", "label": "Landing Page UI", "count": 14}, {"cid": 19, "color": "#BAB0AC", "label": "Email Localization", "count": 12}, {"cid": 20, "color": "#4E79A7", "label": "Customer Notifications", "count": 12}, {"cid": 21, "color": "#F28E2B", "label": "Maps &amp; Geocoding", "count": 12}, {"cid": 22, "color": "#E15759", "label": "Brand Profile Service", "count": 11}, {"cid": 23, "color": "#76B7B2", "label": "Dialog Component", "count": 9}, {"cid": 24, "color": "#59A14F", "label": "Google OAuth", "count": 8}, {"cid": 25, "color": "#EDC948", "label": "Card Component", "count": 7}, {"cid": 26, "color": "#B07AA1", "label": "Map URL Parsing", "count": 6}, {"cid": 27, "color": "#FF9DA7", "label": "Location Map", "count": 6}, {"cid": 28, "color": "#9C755F", "label": "Tabs Component", "count": 6}, {"cid": 29, "color": "#BAB0AC", "label": "Legal Pages", "count": 5}, {"cid": 30, "color": "#4E79A7", "label": "Auth Provider", "count": 5}, {"cid": 31, "color": "#F28E2B", "label": "Skeleton Component", "count": 4}, {"cid": 32, "color": "#E15759", "label": "Theme Provider", "count": 4}, {"cid": 33, "color": "#76B7B2", "label": "User Repository", "count": 3}, {"cid": 34, "color": "#59A14F", "label": "Paddle Config Tests", "count": 3}, {"cid": 35, "color": "#EDC948", "label": "App Root", "count": 3}, {"cid": 36, "color": "#B07AA1", "label": "Public Booking", "count": 3}, {"cid": 37, "color": "#FF9DA7", "label": "Bookra Character", "count": 3}, {"cid": 38, "color": "#9C755F", "label": "Booking Types", "count": 3}, {"cid": 39, "color": "#BAB0AC", "label": "Contact Map", "count": 2}, {"cid": 40, "color": "#4E79A7", "label": "About Page", "count": 2}, {"cid": 41, "color": "#F28E2B", "label": "Auth Callback", "count": 2}, {"cid": 42, "color": "#E15759", "label": "Contact Page", "count": 2}, {"cid": 43, "color": "#76B7B2", "label": "Not Found Page", "count": 2}, {"cid": 44, "color": "#59A14F", "label": "Integration Modal", "count": 2}, {"cid": 45, "color": "#EDC948", "label": "Tooltip Component", "count": 2}, {"cid": 46, "color": "#B07AA1", "label": "Input Component", "count": 2}, {"cid": 47, "color": "#FF9DA7", "label": "Badge Component", "count": 2}, {"cid": 48, "color": "#9C755F", "label": "Select Component", "count": 2}, {"cid": 49, "color": "#BAB0AC", "label": "Textarea Component", "count": 2}, {"cid": 50, "color": "#4E79A7", "label": "Avatar Component", "count": 2}, {"cid": 51, "color": "#F28E2B", "label": "Vite Config", "count": 1}, {"cid": 52, "color": "#E15759", "label": "Main Entry", "count": 1}, {"cid": 53, "color": "#76B7B2", "label": "API Index", "count": 1}, {"cid": 54, "color": "#59A14F", "label": "Button Component", "count": 1}, {"cid": 55, "color": "#EDC948", "label": "Repository Index", "count": 1}, {"cid": 56, "color": "#B07AA1", "label": "I18n Provider", "count": 1}, {"cid": 57, "color": "#FF9DA7", "label": "API Client", "count": 1}, {"cid": 58, "color": "#9C755F", "label": "Shared Types", "count": 1}, {"cid": 59, "color": "#BAB0AC", "label": "Index Export", "count": 1}, {"cid": 60, "color": "#4E79A7", "label": "Repository Export", "count": 1}, {"cid": 61, "color": "#F28E2B", "label": "Types Export", "count": 1}, {"cid": 62, "color": "#E15759", "label": "Generate Script", "count": 1}];
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// Build vis datasets
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({
id: n.id, label: n.label, color: n.color, size: n.size,
font: n.font, title: n.title,
_community: n.community, _community_name: n.community_name,
_source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
})));
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({
id: i, from: e.from, to: e.to,
label: '',
title: e.title,
dashes: e.dashes,
width: e.width,
color: e.color,
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
})));
const container = document.getElementById('graph');
const network = new vis.Network(container, { nodes: nodesDS, edges: edgesDS }, {
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -60,
centralGravity: 0.005,
springLength: 120,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0.8,
},
stabilization: { iterations: 200, fit: true },
},
interaction: {
hover: true,
tooltipDelay: 100,
hideEdgesOnDrag: true,
navigationButtons: false,
keyboard: false,
},
nodes: { shape: 'dot', borderWidth: 1.5 },
edges: { smooth: { type: 'continuous', roundness: 0.2 }, selectionWidth: 3 },
});
network.once('stabilizationIterationsDone', () => {
network.setOptions({ physics: { enabled: false } });
});
function showInfo(nodeId) {
const n = nodesDS.get(nodeId);
if (!n) return;
const neighborIds = network.getConnectedNodes(nodeId);
const neighborItems = neighborIds.map(nid => {
const nb = nodesDS.get(nid);
const color = nb ? nb.color.background : '#555';
return `<span class="neighbor-link" style="border-left-color:${esc(color)}" onclick="focusNode(${JSON.stringify(nid)})">${esc(nb ? nb.label : nid)}</span>`;
}).join('');
document.getElementById('info-content').innerHTML = `
<div class="field"><b>${esc(n.label)}</b></div>
<div class="field">Type: ${esc(n._file_type || 'unknown')}</div>
<div class="field">Community: ${esc(n._community_name)}</div>
<div class="field">Source: ${esc(n._source_file || '-')}</div>
<div class="field">Degree: ${n._degree}</div>
${neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${neighborIds.length})</div><div id="neighbors-list">${neighborItems}</div>` : ''}
`;
}
function focusNode(nodeId) {
network.focus(nodeId, { scale: 1.4, animation: true });
network.selectNodes([nodeId]);
showInfo(nodeId);
}
// Track hovered node — hover detection is more reliable than click params
let hoveredNodeId = null;
network.on('hoverNode', params => {
hoveredNodeId = params.node;
container.style.cursor = 'pointer';
});
network.on('blurNode', () => {
hoveredNodeId = null;
container.style.cursor = 'default';
});
container.addEventListener('click', () => {
if (hoveredNodeId !== null) {
showInfo(hoveredNodeId);
network.selectNodes([hoveredNodeId]);
}
});
network.on('click', params => {
if (params.nodes.length > 0) {
showInfo(params.nodes[0]);
} else if (hoveredNodeId === null) {
document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
}
});
const searchInput = document.getElementById('search');
const searchResults = document.getElementById('search-results');
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
searchResults.innerHTML = '';
if (!q) { searchResults.style.display = 'none'; return; }
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
if (!matches.length) { searchResults.style.display = 'none'; return; }
searchResults.style.display = 'block';
matches.forEach(n => {
const el = document.createElement('div');
el.className = 'search-item';
el.textContent = n.label;
el.style.borderLeft = `3px solid ${n.color.background}`;
el.style.paddingLeft = '8px';
el.onclick = () => {
network.focus(n.id, { scale: 1.5, animation: true });
network.selectNodes([n.id]);
showInfo(n.id);
searchResults.style.display = 'none';
searchInput.value = '';
};
searchResults.appendChild(el);
});
});
document.addEventListener('click', e => {
if (!searchResults.contains(e.target) && e.target !== searchInput)
searchResults.style.display = 'none';
});
const hiddenCommunities = new Set();
function toggleAllCommunities(hide) {
document.querySelectorAll('.legend-item').forEach(item => {
hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
});
LEGEND.forEach(c => {
if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
});
const updates = RAW_NODES.map(n => ({ id: n.id, hidden: hide }));
nodesDS.update(updates);
}
const legendEl = document.getElementById('legend');
LEGEND.forEach(c => {
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `<div class="legend-dot" style="background:${c.color}"></div>
<span class="legend-label">${c.label}</span>
<span class="legend-count">${c.count}</span>`;
item.onclick = () => {
if (hiddenCommunities.has(c.cid)) {
hiddenCommunities.delete(c.cid);
item.classList.remove('dimmed');
} else {
hiddenCommunities.add(c.cid);
item.classList.add('dimmed');
}
const updates = RAW_NODES
.filter(n => n.community === c.cid)
.map(n => ({ id: n.id, hidden: hiddenCommunities.has(c.cid) }));
nodesDS.update(updates);
};
legendEl.appendChild(item);
});
</script>
<script>
// Render hyperedges as shaded regions
const hyperedges = [];
// afterDrawing passes ctx already transformed to network coordinate space.
// Draw node positions raw — no manual pan/zoom/DPR math needed.
network.on('afterDrawing', function(ctx) {
hyperedges.forEach(h => {
const positions = h.nodes
.map(nid => network.getPositions([nid])[nid])
.filter(p => p !== undefined);
if (positions.length < 2) return;
ctx.save();
ctx.globalAlpha = 0.12;
ctx.fillStyle = '#6366f1';
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2;
ctx.beginPath();
// Centroid and expanded hull in network coordinates
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
const expanded = positions.map(p => ({
x: cx + (p.x - cx) * 1.15,
y: cy + (p.y - cy) * 1.15
}));
ctx.moveTo(expanded[0].x, expanded[0].y);
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 0.4;
ctx.stroke();
// Label
ctx.globalAlpha = 0.8;
ctx.fillStyle = '#4f46e5';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(h.label, cx, cy - 5);
ctx.restore();
});
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-168
View File
@@ -1,168 +0,0 @@
{
"/home/tdvorak/Desktop/PROG+HTML/Bookra/generate_map.tsx": 1776666624.8887467,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/map.ts": 1776666624.9033415,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/cmd/api/main.go": 1776752207.8631995,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/email/templates.go": 1776602005.9417026,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/email/service.go": 1776670728.696918,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/db/db.go": 1776673568.9536011,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/db/repository.go": 1776602985.9187567,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/config/config_test.go": 1776753441.2964497,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/config/config.go": 1776753433.2604668,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/oauth/google.go": 1776600255.6443596,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/handlers/handlers.go": 1776753485.007356,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/handlers/admin.go": 1776752223.8340588,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/billing/service.go": 1776753644.080393,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/billing/service_test.go": 1776753472.1483834,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/auth/service.go": 1776752246.6598577,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/auth/neon.go": 1776604492.8585796,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/internal/auth/service_test.go": 1776752259.460893,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/cmd/api/main.go": 1776154845.3740513,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/catalog/service.go": 1776766156.708989,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/httpx/middleware.go": 1775805857.4949794,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/db/repository.go": 1776766029.7570255,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/db/pool.go": 1775805087.4963162,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/domain/models.go": 1776765331.3064964,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/config/config_test.go": 1776760126.2416844,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/config/config.go": 1776759750.1264958,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/notifications/service.go": 1776771186.9076862,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/notifications/email_templates.go": 1776771124.7805548,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/notifications/service_test.go": 1776604768.0589664,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/bookings/service.go": 1776771471.275241,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/bookings/customer_service.go": 1776771428.1311963,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/bookings/service_test.go": 1776784217.9115837,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/billing/service.go": 1776760009.5927346,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/billing/service_test.go": 1776760116.1976886,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/auth/middleware.go": 1776672549.3119998,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/auth/neon.go": 1776670077.8565476,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/api/server.go": 1776784168.2225142,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/api/server_test.go": 1776752223.8311362,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/tenancy/service.go": 1776604326.3473518,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/internal/tenancy/service_test.go": 1775808604.7880082,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/vite.config.ts": 1775804396.9782922,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/App.tsx": 1776673902.149617,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/main.tsx": 1777455784.3577452,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/about-route.tsx": 1776752690.362625,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/auth-callback-route.tsx": 1776760235.7786372,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/public-booking-route.tsx": 1776674167.8148322,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/dashboard-route.tsx": 1776797782.1085014,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/contact-route.tsx": 1776674430.6888938,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/home-route.tsx": 1777456312.473124,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/not-found-route.tsx": 1776674381.8262,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/legal-route.tsx": 1776752690.3636248,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/routes/booking-manage-route.tsx": 1776767972.4928482,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/bookra-character.tsx": 1776752690.3546247,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/index.ts": 1776760875.805695,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/integration-modal.tsx": 1776760859.2087228,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/location-map.tsx": 1776669785.3826764,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/shell.tsx": 1776782961.7637327,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/widget-builder.tsx": 1776669857.0035434,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/dashboard/icons.tsx": 1776789476.8345778,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/dashboard/types.ts": 1776789531.009494,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/tooltip.tsx": 1776152848.7478018,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/input.tsx": 1776148007.514892,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/badge.tsx": 1776148004.4917321,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/select.tsx": 1776152733.964673,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/button.tsx": 1776147997.6907823,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/textarea.tsx": 1776152781.1648598,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/index.ts": 1776152961.1018794,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/card.tsx": 1776148002.4907696,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/skeleton.tsx": 1776148009.7859027,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/avatar.tsx": 1776152787.4049883,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/tabs.tsx": 1776152841.947702,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/components/ui/dialog.tsx": 1776152909.6496222,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/providers/theme-provider.tsx": 1776095550.9844944,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/providers/auth-provider.tsx": 1776760223.0926425,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/providers/i18n-provider.tsx": 1776760652.0982246,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/lib/map.ts": 1776669661.7833047,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/lib/api-client.ts": 1775804396.9789596,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/lib/types.ts": 1775807876.3199303,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/src/lib/paddle.ts": 1776760715.6411605,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/packages/shared-types/src/index.ts": 1776604570.1236856,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/packages/api-client/src/index.ts": 1775804396.9789596,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/packages/api-client/src/generated/types.ts": 1777456335.8890824,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/packages/api-client/scripts/generate.mjs": 1775805010.3883135,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/project.md": 1776760816.0081425,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/FRONTEND_ISSUES.md": 1776605116.9474611,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/prompt.md": 1776768681.7567835,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/README.md": 1777280416.8592253,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/test-emails.html": 1776602140.645646,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/README.md": 1776784168.2225142,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/backend/README.md": 1776784168.2225142,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/index.html": 1776155053.752991,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/docs/ci-cd.md": 1777455829.58997,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/docs/superpowers/specs/2026-04-21-paddle-billing-migration-design.md": 1776759387.7846043,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/favicon.svg": 1776155053.7538831,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-logo.svg": 1776608388.1235633,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/headphones.svg": 1776752581.4101584,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/clock.svg": 1776752581.4081745,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/walk.svg": 1776752581.4124937,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/logo_text_horizontal.svg": 1776752581.4109368,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/lookup.svg": 1776752581.4112468,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/happy-note.svg": 1776752581.4098568,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/like.svg": 1776752581.4107628,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/success.svg": 1776752581.4123247,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/maintenance.svg": 1776752581.4115665,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/coffee.svg": 1776752581.4084466,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/sad.svg": 1776752581.4118588,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/connection-error.svg": 1776752581.4087214,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/flag.svg": 1776752581.4093015,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/404.svg": 1776752581.4074118,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/announcement.svg": 1776752581.4080162,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/sleep.svg": 1776752581.4121594,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/educate.svg": 1776752581.4090226,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/laptop.svg": 1776752581.410602,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/main.svg": 1776752581.4114072,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/forbidden.svg": 1776752581.409577,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/hello.svg": 1776752581.410437,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/angry-bulb.svg": 1776752581.40772,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-illustrations/logo_text_vertical.svg": 1776752581.411093,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/walk.svg": 1776606519.5665152,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/logo_text_horizontal.svg": 1776606519.5637534,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/lookup.svg": 1776606519.5642965,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/like.svg": 1776606519.5623937,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/success.svg": 1776606519.5659773,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/announcement.svg": 1776606519.5606966,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/sleep.svg": 1776606519.5655613,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/laptop.svg": 1776606519.5619218,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/main.svg": 1776606519.564816,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/hello.svg": 1776606519.561268,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/frontend/public/bookra-characters/logo_text_vertical.svg": 1776606519.5642073,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/privacy.png": 1776671490.3701248,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/auth-magic-link-sent.png": 1776671593.7941616,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/bookra-dashboard-check.png": 1777456216.1062973,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/home-mobile.png": 1776671653.0399165,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/home-desktop.png": 1776671305.7341623,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/contact-success.png": 1776671424.1384103,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/dashboard-magic-link.png": 1776671214.315326,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/terms.png": 1776671502.096919,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/bookra-home-check.png": 1777456059.8225186,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/booking-mobile.png": 1776671663.6709323,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/booking-confirmed.png": 1776671475.877128,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/dashboard-map-brno.png": 1776671259.678833,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/about-desktop.png": 1776671345.3982804,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/pricing-section.png": 1776671325.0954196,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/output/playwright/dashboard-after-magic-link.png": 1776671625.547864,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/headphones.svg": 1776693673.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/clock.svg": 1776693727.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/walk.svg": 1776605163.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/logo_text_horizontal.svg": 1776605698.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/lookup.svg": 1776605059.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/happy-note.svg": 1776694043.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/like.svg": 1776605122.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/success.svg": 1776604968.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/maintenance.svg": 1776693910.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/coffee.svg": 1776694073.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/sad.svg": 1776693708.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/connection-error.svg": 1776693861.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/flag.svg": 1776693653.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/404.svg": 1776693878.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/announcement.svg": 1776605100.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/sleep.svg": 1776604846.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/educate.svg": 1776694005.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/laptop.svg": 1776605043.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/main.svg": 1776604578.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/forbidden.svg": 1776693929.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/hello.svg": 1776605143.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/angry-bulb.svg": 1776694024.0,
"/home/tdvorak/Desktop/PROG+HTML/Bookra/bookra-img/logo_text_vertical.svg": 1776605520.0
}
+1739 -3413
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -15,7 +15,6 @@
"build:backend": "cd apps/backend && go build ./...", "build:backend": "cd apps/backend && go build ./...",
"test:backend": "cd apps/backend && go test ./...", "test:backend": "cd apps/backend && go test ./...",
"test": "npm run test:backend", "test": "npm run test:backend",
"db:generate": "cd apps/backend && go run github.com/sqlc-dev/sqlc/cmd/[email protected] generate -f sqlc.yaml",
"db:migrate:up": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/[email protected] -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" up", "db:migrate:up": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/[email protected] -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" up",
"db:migrate:status": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/[email protected] -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" status", "db:migrate:status": "cd apps/backend && go run github.com/pressly/goose/v3/cmd/[email protected] -dir migrations postgres \"$BOOKRA_DATABASE_DIRECT_URL\" status",
"lint:frontend": "npm run lint --workspace @bookra/frontend", "lint:frontend": "npm run lint --workspace @bookra/frontend",
File diff suppressed because it is too large. Load diff
+16 -16
View File
@@ -21,11 +21,11 @@
- frontend dashboard now hydrates from API when a Neon Auth JWT is available - frontend dashboard now hydrates from API when a Neon Auth JWT is available
- frontend dashboard degrades to contract-shaped preview mode when auth is unavailable - frontend dashboard degrades to contract-shaped preview mode when auth is unavailable
- Neon Auth token acquisition remains adapter-isolated and still needs production validation against live Neon Auth - Neon Auth token acquisition remains adapter-isolated and still needs production validation against live Neon Auth
- `[done/partial]` Milestone 4: Paddle subscription sync and entitlement baseline completed - `[done/partial]` Milestone 4: Stripe subscription sync and entitlement baseline completed
- normalized billing snapshot model added to backend and OpenAPI contract - normalized billing snapshot model added to backend and OpenAPI contract
- backend-owned subscription state now syncs through one overwrite path - backend-owned subscription state now syncs through one overwrite path
- Paddle checkout launch endpoint added - Stripe checkout launch endpoint added
- Paddle webhook endpoint added with signature verification and event filtering - Stripe webhook endpoint added with signature verification and event filtering
- billing snapshot refresh endpoint added - billing snapshot refresh endpoint added
- customer portal endpoint added - customer portal endpoint added
- plan entitlements now derive from normalized plan state - plan entitlements now derive from normalized plan state
@@ -134,16 +134,16 @@ v1 reminder strategy:
- Czech-first provider support is preferred, with room for generic fallback providers later - Czech-first provider support is preferred, with room for generic fallback providers later
## Billing ## Billing
Bookra uses Paddle for B2B SaaS subscription billing. Bookra uses Stripe for B2B SaaS subscription billing. (Migrated from Paddle; the Paddle integration was removed.)
Paddle scope in v1: Stripe scope in v1:
- tenant subscription plans - tenant subscription plans
- plan upgrades/downgrades - plan upgrades/downgrades
- add-ons such as SMS or expanded limits - add-ons such as SMS or expanded limits
- webhook-based subscription state sync - webhook-based subscription state sync
- customer self-serve billing portal - customer self-serve billing portal
Paddle is not used for end-customer booking checkout in v1. Stripe is not used for end-customer booking checkout in v1.
## Remote-First Architecture ## Remote-First Architecture
Bookra follows the `tdvorak-fullstack` profile with remote deployment overrides. Bookra follows the `tdvorak-fullstack` profile with remote deployment overrides.
@@ -151,7 +151,7 @@ Bookra follows the `tdvorak-fullstack` profile with remote deployment overrides.
### Stack ### Stack
- Frontend: SolidJS + Vite + TypeScript + Tailwind - Frontend: SolidJS + Vite + TypeScript + Tailwind
- Backend: Go + Gin - Backend: Go + Gin
- Database: Neon Postgres + sqlc + goose - Database: Neon Postgres + goose (handwritten pgx repositories; sqlc removed)
- Auth: Neon Auth - Auth: Neon Auth
- API contract: OpenAPI as source of truth - API contract: OpenAPI as source of truth
- Frontend deployment: Vercel - Frontend deployment: Vercel
@@ -245,21 +245,21 @@ Key entities:
- delivery logging now exists for reminder execution - delivery logging now exists for reminder execution
### Phase 4 ### Phase 4
- Paddle subscriptions - Stripe subscriptions
- SMS add-on support - SMS add-on support
- plan enforcement - plan enforcement
- hardening, logging, and operational tooling - hardening, logging, and operational tooling
- status: in progress - status: in progress
- current implementation: - current implementation:
- billing snapshot persistence added - billing snapshot persistence added
- Paddle checkout route added - Stripe checkout route added (Checkout Sessions)
- Paddle webhook route added - Stripe webhook route added with signature verification
- Paddle portal route added - Stripe portal route added (customer portal sessions)
- subscription refresh route added - subscription refresh route added
- entitlement mapping is now available to the frontend - entitlement mapping is now available to the frontend
- API edge now includes security headers and public route rate limiting - API edge now includes security headers and public route rate limiting
- remote job-runner endpoint added for reminder execution - remote job-runner endpoint added for reminder execution
- sqlc and goose commands are now wired into repeatable npm scripts - goose commands are wired into repeatable npm scripts
## Current Implementation Notes ## Current Implementation Notes
- Backend is no longer static-demo-only for booking flows. - Backend is no longer static-demo-only for booking flows.
@@ -276,7 +276,7 @@ Key entities:
- Billing now follows a backend-owned snapshot model rather than trusting incremental webhook payloads. - Billing now follows a backend-owned snapshot model rather than trusting incremental webhook payloads.
- `apps/auth-service` is now retired from active runtime and docs. - `apps/auth-service` is now retired from active runtime and docs.
- Reminder delivery now has a working runtime path instead of just a queued schema. - Reminder delivery now has a working runtime path instead of just a queued schema.
- sqlc generation and goose migration execution are now runnable through workspace scripts. - goose migration execution is runnable through workspace scripts.
- Protected backend requests now perform automatic app-side identity sync before tenant membership resolution. - Protected backend requests now perform automatic app-side identity sync before tenant membership resolution.
- `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users. - `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users.
@@ -284,14 +284,14 @@ Key entities:
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants. - Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
- First-time onboarding exists, but there are still no CRUD screens for locations, staff, services, or schedules after workspace creation. - First-time onboarding exists, but there are still no CRUD screens for locations, staff, services, or schedules after workspace creation.
- Paddle lifecycle sync is implemented structurally, but still needs live environment validation against real products, price IDs, client token, and webhook signatures. - Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures.
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops. - Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup. - The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
## Next Build Targets ## Next Build Targets
- replace preview-mode dashboard hydration with fully validated Neon Auth JWT flow - replace preview-mode dashboard hydration with fully validated Neon Auth JWT flow
- sync Neon Auth identities into tenant membership records and onboarding bootstrap - sync Neon Auth identities into tenant membership records and onboarding bootstrap
- validate Paddle checkout/webhooks/portal and reminder dispatch against live staging infrastructure - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
- replace noop notification providers with the first production email transport and a gated SMS transport - replace noop notification providers with the first production email transport and a gated SMS transport
- add real app management screens for services, staff, locations, and availability after onboarding - add real app management screens for services, staff, locations, and availability after onboarding
@@ -312,7 +312,7 @@ Bookra is successful when a small business can:
- send reminders reliably - send reminders reliably
- manage cancellations and waitlists - manage cancellations and waitlists
- view simple business health metrics - view simple business health metrics
- subscribe to a paid plan through Paddle - subscribe to a paid plan through Stripe
## Final Positioning ## Final Positioning
Bookra is not an ERP, not a medical system, and not a marketplace. Bookra is not an ERP, not a medical system, and not a marketplace.
-60
View File
@@ -1,60 +0,0 @@
# Railway Backend Environment Variables
# Deploy to: api.bookra.eu
# App Configuration
BOOKRA_APP_ENV=production
BOOKRA_APP_URL=https://bookra.eu
BOOKRA_API_URL=https://api.bookra.eu
BOOKRA_FRONTEND_URL=https://bookra.eu
# Neon Database
# Pooled connection (used by the app)
BOOKRA_DATABASE_URL=postgresql://neondb_owner:npg_64KyAGZtIsSk@ep-mute-water-alem1v8u-pooler.c-3.eu-central-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
# Direct connection (used by migrations)
BOOKRA_DATABASE_DIRECT_URL=postgresql://neondb_owner:npg_64KyAGZtIsSk@ep-mute-water-alem1v8u.c-3.eu-central-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Auth JWT Secret
BOOKRA_AUTH_JWT_SECRET=RECyrjswpfmw/D5UK4gsg38unglVkgc+9YTq0cKke7w=
# Job Runner Key (protects internal cron endpoints)
BOOKRA_JOB_RUNNER_KEY=870d6bd91cfb87dff0b736849001c4521404bb385a18f7e26e84743593b746e7
# Stripe (Secret key only - publishable key not needed on backend)
BOOKRA_STRIPE_API_KEY=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
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_Rq2w77ersHwjiQKEOqA17v81BacP5Wev
# Stripe Price IDs (Monthly)
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
# Stripe Price IDs (Yearly)
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
# 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
# Admin Credentials
BOOKRA_ADMIN_EMAIL=[email protected]
BOOKRA_ADMIN_KEY=51vSg7NKU8F2HHT1!
# Email (SMTP)
BOOKRA_SMTP_HOST=smtp.purelymail.com
BOOKRA_SMTP_PORT=465
BOOKRA_SMTP_USERNAME=[email protected]
BOOKRA_SMTP_PASSWORD=8deCHFCuVQAxUg@YxP
BOOKRA_EMAIL_FROM=[email protected]
-16
View File
@@ -1,16 +0,0 @@
# Vercel Frontend Environment Variables
# Deploy to: bookra.eu
# API Configuration (connects to Railway backend)
VITE_API_URL=https://api.bookra.eu
VITE_APP_URL=https://bookra.eu
# Stripe (Publishable key only - secret key NOT needed)
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51TV6GOGrjyNQaOSGddY1BS14H63e1uYgZUgPe25WhP7yMxmZxMprN3U3scnsmKGBmREzHLrhJlVSEiErimNwVtuR00TRAcHaJi
# Neon Auth
VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Feature Flags
VITE_DEMO_MODE=false
VITE_SHOW_PRICING=true