mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
refactor(db): replace sqlc with handwritten pgx repos
- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs - Renumber migrations; add 00012 memberships+manage_token, 00013 location_type_capacity - Add tests: admin, auth, bookings customer service - Remove graphify-out artifacts and stale env files Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2039669e2c
commit
f2f7439050
93 files changed
+6987
-69060
No files matched your search
+19
-50
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
+8
-1
@@ -77,4 +77,11 @@ playwright/.cache/
|
|||||||
# Desloppify
|
# Desloppify
|
||||||
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/
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+20
@@ -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;
|
||||||
File renamed without changes.
+27
-16
@@ -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
|
||||||
+4
@@ -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';
|
||||||
File renamed without changes.
@@ -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;
|
||||||
File renamed without changes.
@@ -0,0 +1,50 @@
|
|||||||
|
-- +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();
|
||||||
|
|
||||||
|
-- Demo tenant membership so demo-owner resolves to Studio Atelier.
|
||||||
|
INSERT INTO tenant_memberships (tenant_id, user_neon_subject, role)
|
||||||
|
SELECT t.id, u.neon_subject, 'owner'
|
||||||
|
FROM tenants t
|
||||||
|
JOIN users u ON u.neon_subject = 'demo-owner'
|
||||||
|
WHERE t.slug = 'studio-atelier'
|
||||||
|
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
@@ -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;
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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=
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,7 +67,7 @@ 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);
|
||||||
@@ -81,7 +81,7 @@ function BookingsPage() {
|
|||||||
});
|
});
|
||||||
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."); }
|
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoření rezervace selhalo." : "Failed to create booking."); }
|
||||||
finally { setCreatingBooking(false); }
|
finally { setCreatingBooking(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,7 +174,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} • {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} • {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 +224,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",
|
||||||
@@ -100,7 +113,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;
|
||||||
@@ -175,9 +188,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 +234,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 +242,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); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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} • {booking.duration}</p>
|
<p class="text-sm text-ink-muted">{booking.service} • {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); }
|
||||||
|
|||||||
@@ -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)} • {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p>
|
<p class="text-sm text-ink-muted">{typeLabel(zone.type)} • {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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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
@@ -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 +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 +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 +0,0 @@
|
|||||||
{"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
|
|
||||||
@@ -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._
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 · 1114 edges · 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 & 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 & 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 & 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 & Magic Links", "count": 18}, {"cid": 16, "color": "#B07AA1", "label": "Business Service", "count": 17}, {"cid": 17, "color": "#FF9DA7", "label": "Shell & 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
-20524
File diff suppressed because it is too large.
Load diff
@@ -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
|
|
||||||
}
|
|
||||||
Generated
+1739
-3413
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
|
|
||||||
Reference in new issue
Block a user