Compare commits

...

9 Commits

Author SHA1 Message Date
Tomas Dvorak 2039669e2c feat(ui): add analytics dashboard and enhance frontend components
CI / Frontend (push) Successful in 10m8s
CI / Go - apps/auth-service (push) Failing after 3s
CI / Go - apps/backend (push) Successful in 10m9s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
- Implement new analytics section in the dashboard with KPI cards and charts
- Add video player scrubber and playback controls
- Improve FloatingDock layout and responsiveness
- Enhance HomeRoute with demo mode redirection and improved trust section UI
- Update dashboard routing to support the new analytics tab
2026-05-18 18:27:54 +02:00
Tomas Dvorak da5ba13eab feat(ui): implement comprehensive dashboard and enhance frontend experience
This commit introduces a major overhaul of the user interface, transitioning from a basic structure to a feature-rich dashboard system. Key improvements include:

- **Dashboard Implementation**: Added a complete dashboard routing system with dedicated pages for Overview, Bookings, Customers, Zones, Billing, and Settings.
- **New UI Components**: Introduced a variety of high-quality components including `AnimatedList`, `FloatingDock`, `HoverFeatureCards`, `VideoPlayer` (with ambient glow effect), `PinnedList`, and `DashboardMockup`.
- **Enhanced Dashboard Features**:
    - Integrated real-time KPI cards and activity timelines.
    - Implemented a multi-view calendar system.
    - Added customer and booking management interfaces with filtering and search capabilities.
    - Added a zone/location management view with map integration.
- **Branding & Visuals**: Updated the application with new SVG logos (horizontal and vertical variants) and implemented dark/light mode optimized branding.
- **Internationalization**: Expanded i18n support with comprehensive Czech and English translations for the new dashboard and integration modules.
- **Integration Tools**: Added a new `IntegrationModal` allowing users to easily embed Bookra widgets via HTML, React, SolidJS, or PHP.
- **Backend Support**: Updated the booking service to provide comprehensive dashboard summary data, including historical booking records for charts.
2026-05-18 14:31:20 +02:00
Tomas Dvorak 9d63fa7620 chore(config): update environment variables
CI / Frontend (push) Successful in 10m8s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m17s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
- rename BOOKRA_STRIPE_SECRET_KEY to BOOKRA_STRIPE_API_KEY
- split database connection into BOOKRA_DATABASE_URL (pooled) and BOOKRA_DATABASE_DIRECT_URL (direct)
- add BOOKRA_JOB_RUNNER_KEY for cron endpoint protection
- add BOOKRA_EMAIL_FROM for SMTP configuration
2026-05-11 18:36:53 +02:00
Tomas Dvorak 3b6f46828b refactor(config): remove paddle configuration validation 2026-05-11 18:16:46 +02:00
Tomas Dvorak 7d3e3448cf feat(sms): implement SMS messaging and metered billing
CI / Frontend (push) Successful in 9m50s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m18s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
Implement a complete SMS messaging system including:
- Integration with SMS Manager.cz API for sending messages.
- Metered billing via Stripe using monthly aggregate invoice items.
- Backend services for managing SMS settings, usage logging, and monthly reporting.
- Database migrations for tenant settings, usage logs, and monthly reports.
- Frontend dashboard components for SMS configuration, usage tracking, and history.
- Support for customer phone numbers in the booking flow.

Includes new migrations, backend services, and frontend UI components.
2026-05-10 11:40:53 +02:00
Tomas Dvorak 164a37e997 feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.

Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
    - Added a new pricing page with monthly/yearly toggle and comparison table.
    - Integrated Stripe and Sentry for payments and error tracking.
    - Improved dashboard UX/UI and added i18n support for new features.
    - Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
2026-05-09 18:25:25 +02:00
Tomas Dvorak cf3315e8fc cleanup
CI / Frontend (push) Successful in 11m7s
CI / Go - apps/auth-service (push) Failing after 8s
CI / Go - apps/backend (push) Failing after 2s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
2026-05-05 09:48:15 +02:00
Tomas Dvorak 48c3e15a38 cleanup 2026-05-05 09:48:07 +02:00
Tomas Dvorak d854614a87 docs: add paddle migration spec 2026-04-21 10:16:36 +02:00
225 changed files with 239347 additions and 1515 deletions
+66 -1
View File
@@ -4,5 +4,70 @@
BOOKRA_APP_ENV=staging
BOOKRA_APP_URL=https://app.bookra.example
BOOKRA_API_URL=https://api.bookra.example
BOOKRA_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Stripe billing (preferred)
BOOKRA_STRIPE_SECRET_KEY=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
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_xxx
# Stripe Monthly Prices
BOOKRA_STRIPE_STARTER_CZK_MONTHLY_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
BOOKRA_STRIPE_STARTER_USD_MONTHLY_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
BOOKRA_STRIPE_PRO_CZK_MONTHLY_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
BOOKRA_STRIPE_PRO_USD_MONTHLY_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
BOOKRA_STRIPE_BUSINESS_CZK_MONTHLY_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
BOOKRA_STRIPE_BUSINESS_USD_MONTHLY_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
# Stripe Yearly Prices (17% discount)
BOOKRA_STRIPE_STARTER_CZK_YEARLY_PRICE_ID=price_1TVAlqGrjyNQaOSGNiZQ5tEx
BOOKRA_STRIPE_STARTER_USD_YEARLY_PRICE_ID=price_1TVAnSGrjyNQaOSGTHQHrgv3
BOOKRA_STRIPE_PRO_CZK_YEARLY_PRICE_ID=price_1TVAmVGrjyNQaOSGWDgWqYvb
BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua
# Stripe SMS prices — one per market/currency (create as standard prices, not metered)
# These are used for monthly aggregate billing (invoice item at month end)
# Czech: 1.50 CZK per SMS | US: ~$0.065 per SMS | EUR: ~€0.060 | GBP: ~£0.050 | PLN: ~0.27zł
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=
# SMS Manager.cz API (optional - SMS feature)
BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key
BOOKRA_SMSMANAGER_BASE_URL=https://api.smsmngr.com/v2
# Legacy price IDs (fallback)
BOOKRA_STRIPE_STARTER_CZK_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
BOOKRA_STRIPE_STARTER_USD_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
BOOKRA_STRIPE_PRO_CZK_PRICE_ID=price_1TV6ZZGrjyNQaOSGqWENnjDD
BOOKRA_STRIPE_PRO_USD_PRICE_ID=price_1TV6dXGrjyNQaOSGeWzJlg2n
BOOKRA_STRIPE_BUSINESS_CZK_PRICE_ID=price_1TV6bgGrjyNQaOSGqzGTM68E
BOOKRA_STRIPE_BUSINESS_USD_PRICE_ID=price_1TV6dpGrjyNQaOSGCqKO42Oi
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51TV6GOGrjyNQaOSGddY1BS14H63e1uYgZUgPe25WhP7yMxmZxMprN3U3scnsmKGBmREzHLrhJlVSEiErimNwVtuR00TRAcHaJi
# Admin credentials (for platform management)
BOOKRA_ADMIN_EMAIL=admin@example.com
BOOKRA_ADMIN_KEY=your-secure-admin-key-here
# Paddle billing (fallback)
BOOKRA_PADDLE_ENV=sandbox
BOOKRA_PADDLE_API_KEY=pdl_sdbx_api_key
BOOKRA_PADDLE_WEBHOOK_SECRET=pdl_ntfset_secret
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=pri_starter_czk_123
BOOKRA_PADDLE_STARTER_USD_PRICE_ID=pri_starter_usd_123
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=pri_pro_czk_123
BOOKRA_PADDLE_PRO_USD_PRICE_ID=pri_pro_usd_123
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=pri_business_czk_123
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=pri_business_usd_123
VITE_PADDLE_CLIENT_TOKEN=test_paddle_client_token
BOOKRA_SMTP_HOST=smtp.example.com
BOOKRA_UMAMI_API_URL=https://umami.example.com
# Sentry (optional)
VITE_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352
BOOKRA_SENTRY_DSN=https://462fb8597035778961e2e06c48c7c7fd@o4511360379191296.ingest.de.sentry.io/4511360406454352
+129
View File
@@ -0,0 +1,129 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
frontend:
name: Frontend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Generate API client
run: npm run generate:api-client
- name: Typecheck frontend
run: npm run lint:frontend
- name: Build frontend
run: npm run build:frontend
go:
name: Go - ${{ matrix.app }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app:
- apps/backend
- apps/auth-service
defaults:
run:
working-directory: ${{ matrix.app }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: ${{ matrix.app }}/go.mod
cache-dependency-path: ${{ matrix.app }}/go.sum
- name: Run tests
run: go test ./...
- name: Build service
run: go build ./...
docker:
name: Docker publish - ${{ matrix.service.name }}
needs:
- frontend
- go
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
service:
- name: backend
context: apps/backend
- name: auth-service
context: apps/auth-service
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image name
id: image
env:
OWNER: ${{ github.repository_owner }}
SERVICE_NAME: ${{ matrix.service.name }}
run: |
echo "repository=$(echo "${OWNER}/bookra-${SERVICE_NAME}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.image.outputs.repository }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and publish container image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.service.context }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.service.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.service.name }}
+76 -10
View File
@@ -1,14 +1,80 @@
.DS_Store
# Dependencies
node_modules/
# Build outputs
dist/
.output/
.solid/
# Environment files
.env
.env.*
!.env.example
node_modules
dist
.solid
.output
coverage
package-lock.json
bin
tmp
*.log
# Go binaries and artifacts
bin/
*.exe
*.dll
*.so
*.dylib
# Test and coverage
coverage/
*.cover
*.cov
*.out
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Temporary files
tmp/
temp/
*.tmp
*.temp
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local databases
*.db
*.sqlite
*.sqlite3
# Graphify output - keep only html/json/md
graphify-out/*
!graphify-out/*.html
!graphify-out/*.json
!graphify-out/*.md
# Playwright
.playwright-cli/
.playwright-mcp/
test-results/
playwright-report/
playwright/.cache/
# Desloppify
desloppify-out/
.desloppify/
.opencode
+32
View File
@@ -0,0 +1,32 @@
# Bookra Design Context
## Design Context
### Users
- **Primary**: Small local service business owners (salons, massage therapists, clinics, repair shops, studios)
- **Context**: Managing daily bookings, customer relationships, and business operations
- **Job to be done**: Simple setup, reliable scheduling, clear reminders, lightweight reporting, clean SaaS billing
- **Emotional goals**: Confidence, calm, professionalism — they want to feel in control without overwhelm
### Brand Personality
- **Voice**: Calm, professional, trustworthy, quietly premium
- **Tone**: Helpful but not chatty; structured but not rigid
- **3-word personality**: Calm. Premium. Structured.
- **Emotional goals**: Users should feel their business is elevated by using Bookra, not that they're wrestling with software
### Aesthetic Direction
- **Visual tone**: Warm, sophisticated minimalism with intentional depth
- **References**: Linear (clean data density), Notion (calm whitespace), Figma (refined components)
- **Anti-references**: Generic SaaS dashboards with purple gradients, cluttered admin panels, overly playful/bootstrap aesthetics
- **Theme**: Light mode primary, dark mode supported. Warm cream/terracotta palette — never cold blue-gray.
- **Colors**: Deep warm terracotta accent (#a65c3e family), warm cream backgrounds, ink-colored text with warmth
- **Fonts**:
- Marketing/landing: Space Grotesk (headings) + Newsreader (body, editorial feel)
- Dashboard/admin: Space Grotesk (headings) + DM Sans (body, data legibility) — dashboards need sans-serif for dense tables and metrics
### Design Principles
1. **Calm density** — Dashboards are information-dense by necessity, but never cluttered. Whitespace is earned through alignment, not arbitrary padding.
2. **Warm precision** — Every element feels intentional and warm. No cold grays. No pure black/white. Tint everything toward the terracotta warmth.
3. **Progressive disclosure** — Start simple, reveal sophistication through interaction. Don't show every option at once.
4. **Data dignity** — Tables, metrics, and lists are the core of the dashboard. They deserve refined typography, clear hierarchy, and thoughtful hover states.
5. **No generic SaaS clutter** — No random gradient cards, no decorative sparklines that mean nothing, no icon+heading+text card grids repeated endlessly.
+83
View File
@@ -0,0 +1,83 @@
# Bookra Project Notes
## Build & Test Commands
- `npm run build:frontend` — Build SolidJS frontend
- `npm run build:backend` — Build Go backend
- `npm run test` — Run backend tests
- `npm run verify` — Full verification (client gen, lint, test, build)
## Database
- Uses PostgreSQL via Neon (pooled URL for app, direct URL for migrations)
- Migrations with Goose: `npm run db:migrate:up`
- SQLC for typed queries: `npm run db:generate`
## SMS Feature
### Architecture
- Optional add-on, off by default
- Only available on **Pro** and **Business** plans
- Uses SMS Manager.cz JSON API v2 (`https://api.smsmngr.com/v2`)
- Metered billing via Stripe (1.50 CZK per SMS)
- Tracks usage locally in `sms_usage_logs` table
### Database Tables
- `tenant_sms_settings` — per-tenant SMS config (enabled, sender, limit, stripe item ID)
- `sms_usage_logs` — every SMS sent with cost tracking
- `sms_monthly_reports` — aggregated monthly usage for invoices
### API Endpoints
- `GET /v1/sms/settings` — Get SMS settings + current month stats
- `POST /v1/sms/settings` — Enable/disable SMS, configure sender/limit
- `POST /v1/sms/send` — Send an SMS (tracked & billed)
- `GET /v1/sms/usage` — Usage for a specific month
- `GET /v1/sms/history` — Recent SMS logs
- `GET /v1/sms/invoices` — Monthly invoice reports
- `POST /v1/internal/jobs/sms/invoices` — Cron endpoint to generate monthly reports & emails
### What to Configure on Stripe
1. **Create standard Prices** for SMS in each currency:
- Product: "SMS Messages"
- Price: 1.50 CZK per unit (or equivalent in USD/EUR)
- Billing mode: **Standard** one-time (not metered)
- No free trial
2. **Environment variables** to add:
```
BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx
BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_yyy
BOOKRA_STRIPE_SMS_EUR_PRICE_ID=price_zzz
BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key
```
### Stripe CLI Commands (for testing)
```bash
# Login to Stripe
stripe login
# Create test product
stripe products create --name="SMS Messages"
# Create prices in each currency (replace prod_xxx with actual product ID)
stripe prices create --product=prod_xxx --unit-amount=150 --currency=czk
stripe prices create --product=prod_xxx --unit-amount=6 --currency=usd
stripe prices create --product=prod_xxx --unit-amount=6 --currency=eur
# Listen to webhooks locally
stripe listen --forward-to http://localhost:8080/v1/webhooks/stripe
```
### Monthly Invoice Flow
- At month end, a background job (`POST /v1/internal/jobs/sms/invoices`) aggregates all SMS usage per tenant
- It creates a Stripe `invoiceitem` with quantity = messages sent × 1.50 CZK (or configured currency price)
- The item is added to the customer's next subscription invoice automatically
- A usage summary email is sent showing: messages sent, total cost, and invoice details
- Reports are visible in-app under Settings > SMS Messages > Invoice reports
### Taxes
- The 1.50 CZK is the base unit price
- Stripe handles tax calculation based on the customer's location and your tax settings
- Displayed prices in the app show pre-tax amounts; the final invoice includes tax
### No Free Trial
- SMS is charged from the first message
- No trial period — usage is aggregated and invoiced monthly
+29
View File
@@ -0,0 +1,29 @@
# Frontend Issues Found During Production-Readiness Review
## Fixed During This Pass
- Landing pricing showed outdated EUR tiers and a 14-day trial. The product copy now uses CZK-first pricing with USD equivalents and a 30-day trial.
- SMS reminder copy appeared in features, testimonials, and pricing. The main customer-facing copy now says email reminders instead.
- Czech `home.step2.desc` was missing and leaked the translation key on the landing page.
- Footer year was stale.
## Fixed This Session
- The dashboard unauthenticated state replaced with a dedicated conversion screen (headline, benefit cards, Sign In / Demo CTAs, register link).
- The pricing highlight label now uses the `home.pricing.popular` i18n key in both `home-route.tsx` and `pricing-route.tsx`.
- The mobile booking page now smooth-scrolls to the contact form and highlights it when a user taps a slot without filling required details, breaking the validation loop.
- The IntegrationModal widget snippets now derive the base URL from `publicBookingUrl` instead of hardcoding `bookra.eu`, fixing local dev and custom domain scenarios.
- Email settings section now has a working **Save Email Settings** button with loading state, wired to `PUT /v1/tenants/email-settings`.
## Fixed This Session (continued)
- **Accessibility**: Added `aria-label` attributes to all icon-only buttons in the dashboard (calendar nav, notifications, modal close buttons, mobile menu).
- **Widget-builder UX**: Replaced `console.error` on copy failure with a user-visible error banner.
- **Booking-manage route**: Contact email now uses `businessEmail` from booking data instead of hardcoded `support@bookra.eu`.
- **Loading states**: Added spinners and disabled states to booking create/update buttons and brand save button.
- **i18n cleanup**: Added 30+ new `dashboard.*` keys to the i18n dictionary and replaced ~20 inline ternary expressions with `i18n.t()` calls across nav items, page titles, status labels, and action buttons.
## Still Needs Frontend Polish
- 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.
+55 -1
View File
@@ -5,12 +5,14 @@ Remote-first booking SaaS scaffold aligned to the `tdvorak-fullstack` profile wi
- frontend on Vercel
- backend on Railway
- Neon Postgres + Neon Auth
- Paddle billing
- no Docker-based local runtime
## Workspace
- `apps/frontend` SolidJS frontend
- `apps/backend` Go API
- `apps/auth-service` auth + internal admin service
- `packages/api-client` generated TypeScript client/types from OpenAPI
- `packages/shared-types` shared frontend constants and helpers
@@ -34,10 +36,19 @@ Both apps expect remote services:
- Neon Postgres
- Neon Auth
- Stripe
- Paddle
Optional extra service:
- `apps/auth-service` for standalone auth/admin workflows
See `.env.example`, `apps/frontend/.env.example`, and `apps/backend/.env.example`.
## CI/CD
- GitHub Actions CI lives in [`.github/workflows/ci.yml`](/home/tdvorak/Desktop/PROG+HTML/Bookra/.github/workflows/ci.yml:1)
- rollout notes and platform wiring live in [docs/ci-cd.md](/home/tdvorak/Desktop/PROG+HTML/Bookra/docs/ci-cd.md:1)
## Backend database commands
```bash
@@ -47,3 +58,46 @@ npm run db:migrate:up
```
`db:migrate:*` expects `BOOKRA_DATABASE_DIRECT_URL` to be exported in the shell.
## Brand Colors
Bookra uses a sophisticated color system designed for modern booking interfaces with support for both light and dark themes.
### Primary Palette
- **Canvas** (`--color-canvas`): #FFFFFF (light) / #0A0A0A (dark)
- **Canvas Elevated** (`--color-canvas-elevated`): #F8F9FA (light) / #1A1A1A (dark)
- **Canvas Sunken** (`--color-canvas-sunken`): #F1F3F4 (light) / #252525 (dark)
### Accent Colors
- **Primary** (`--color-primary`): #3B82F6
- **Primary Hover** (`--color-primary-hover`): #2563EB
- **Primary Active** (`--color-primary-active`): #1D4ED8
### Semantic Colors
- **Success** (`--color-success`): #10B981
- **Warning** (`--color-warning`): #F59E0B
- **Error** (`--color-error`): #EF4444
- **Info** (`--color-info`): #06B6D4
### Text Colors
- **Text Primary** (`--color-text-primary`): #111827 (light) / #F9FAFB (dark)
- **Text Secondary** (`--color-text-secondary`): #6B7280 (light) / #D1D5DB (dark)
- **Text Muted** (`--color-text-muted`): #9CA3AF (light) / #9CA3AF (dark)
### Border & Surface
- **Border** (`--color-border`): #E5E7EB (light) / #374151 (dark)
- **Surface Glass** (`--color-surface-glass`): rgba(255, 255, 255, 0.8) (light) / rgba(0, 0, 0, 0.8) (dark)
### Shadow System
- **Shadow XS** (`--shadow-xs`): 0 1px 2px 0 rgba(0, 0, 0, 0.05)
- **Shadow SM** (`--shadow-sm`): 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)
- **Shadow MD** (`--shadow-md`): 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)
- **Shadow LG** (`--shadow-lg`): 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)
- **Shadow XL** (`--shadow-xl`): 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)
- **Shadow 2XL** (`--shadow-2xl`): 0 25px 50px -12px rgba(0, 0, 0, 0.25)
### Animation Timing
- **Ease Out Expo** (`--ease-out-expo`): cubic-bezier(0.16, 1, 0.3, 1)
- **Ease Spring** (`--ease-spring`): cubic-bezier(0.68, -0.55, 0.265, 1.55)
These colors are implemented as CSS custom properties and are used throughout the frontend application for consistent theming and accessibility.
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
+77 -10
View File
@@ -1,15 +1,82 @@
# ============================================
# Bookra Backend API Configuration
# ============================================
# Active stack:
# - frontend signs users in with Neon Auth
# - backend verifies Neon Auth JWTs
# - backend serves booking + billing APIs
# - Paddle handles SaaS billing + customer portal
# ============================================
BOOKRA_APP_ENV=staging
BOOKRA_API_PORT=8080
BOOKRA_API_URL=http://localhost:8080
BOOKRA_FRONTEND_URL=http://localhost:3000
BOOKRA_DATABASE_URL=postgres://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require
BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require
BOOKRA_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
# --------------------------------------------
# DEMO MODE (Standalone showcase mode)
# --------------------------------------------
# Set to true to enable permanent demo mode with in-memory data.
# When enabled:
# - API uses MemoryRepository with pre-populated demo data
# - All requests are auto-authenticated as demo-owner
# - No database connection required
# - Perfect for showcasing the app without external dependencies
# Note: When DEMO_MODE=false, the following are required:
# - BOOKRA_DATABASE_URL
# - BOOKRA_NEON_AUTH_URL (for JWT verification)
BOOKRA_DEMO_MODE=false
# --------------------------------------------
# DATABASE (Required when DEMO_MODE=false)
# --------------------------------------------
BOOKRA_DATABASE_URL=postgres://user:password@ep-example-pooler.region.aws.neon.tech/bookra_backend?sslmode=require
BOOKRA_DATABASE_DIRECT_URL=postgres://user:password@ep-example.region.aws.neon.tech/bookra_backend?sslmode=require
# --------------------------------------------
# AUTHENTICATION (Required when DEMO_MODE=false)
# --------------------------------------------
# Neon Auth base URL for JWKS verification.
BOOKRA_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Optional emergency/local fallback for non-Neon JWT verification.
# Leave blank in normal Neon Auth setup.
BOOKRA_AUTH_JWT_SECRET=
# --------------------------------------------
# INTERNAL SERVICES
# --------------------------------------------
# Job runner key for internal API endpoints (reminders, notifications)
BOOKRA_JOB_RUNNER_KEY=job_runner_secret_123
BOOKRA_EMAIL_FROM=noreply@bookra.dev
BOOKRA_SMS_FROM=Bookra
BOOKRA_STRIPE_SECRET_KEY=sk_test_123
BOOKRA_STRIPE_WEBHOOK_SECRET=whsec_123
BOOKRA_STRIPE_STARTER_PRICE_ID=price_starter_123
BOOKRA_STRIPE_GROWTH_PRICE_ID=price_growth_123
BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID=price_multi_location_123
# --------------------------------------------
# PADDLE BILLING (Required for live checkout/webhooks)
# --------------------------------------------
# BOOKRA_PADDLE_ENV must be "sandbox" or "live".
# API key: Paddle dashboard -> Developer tools -> Authentication.
# Webhook secret: Paddle dashboard -> Notification settings -> destination secret key.
# Price IDs: Paddle dashboard -> Catalog -> Product price IDs.
BOOKRA_PADDLE_ENV=sandbox
BOOKRA_PADDLE_API_KEY=
BOOKRA_PADDLE_WEBHOOK_SECRET=
BOOKRA_PADDLE_STARTER_CZK_PRICE_ID=
BOOKRA_PADDLE_STARTER_USD_PRICE_ID=
BOOKRA_PADDLE_PRO_CZK_PRICE_ID=
BOOKRA_PADDLE_PRO_USD_PRICE_ID=
BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID=
BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID=
# --------------------------------------------
# EMAIL (Optional)
# --------------------------------------------
# Only configure if backend needs to send reminder emails directly.
BOOKRA_EMAIL_FROM=
BOOKRA_SMTP_HOST=
BOOKRA_SMTP_PORT=587
BOOKRA_SMTP_USERNAME=
BOOKRA_SMTP_PASSWORD=
# --------------------------------------------
# ANALYTICS (Optional)
# --------------------------------------------
BOOKRA_UMAMI_API_URL=
BOOKRA_UMAMI_API_KEY=
+33
View File
@@ -0,0 +1,33 @@
FROM golang:1.26.2-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/backend ./cmd/api
FROM alpine:3.22
WORKDIR /app
RUN apk add --no-cache ca-certificates \
&& addgroup -S bookra \
&& adduser -S -D -H -u 10001 -G bookra bookra
COPY --from=builder --chown=bookra:bookra /app/backend /app/
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
ENV PORT=8080
EXPOSE 8080
USER bookra
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/healthz" >/dev/null || exit 1
CMD ["/app/backend"]
+44 -4
View File
@@ -1,6 +1,6 @@
# Bookra Backend
Go + Gin API for Bookra, designed for Railway deployment and Neon-backed persistence.
Go + Gin API for Bookra, designed for Railway deployment with Neon Auth, Neon Postgres, and Paddle billing.
## Commands
@@ -18,11 +18,14 @@ npm run db:migrate:up
- `BOOKRA_DATABASE_URL` Neon pooled connection
- `BOOKRA_DATABASE_DIRECT_URL` Neon direct connection for migrations/admin tasks
- `BOOKRA_NEON_AUTH_URL` Neon Auth base URL used for JWKS verification
- `BOOKRA_AUTH_JWT_SECRET` optional local JWT fallback when not using Neon Auth
- `BOOKRA_JOB_RUNNER_KEY` shared secret for remote reminder dispatch calls
- `BOOKRA_EMAIL_FROM` sender identity for email reminders
- `BOOKRA_SMS_FROM` sender label for future SMS reminders
- `BOOKRA_STRIPE_SECRET_KEY` Stripe API secret
- `BOOKRA_STRIPE_WEBHOOK_SECRET` Stripe webhook secret
- `BOOKRA_PADDLE_ENV` billing environment: `sandbox` or `live`
- `BOOKRA_PADDLE_API_KEY` Paddle API key
- `BOOKRA_PADDLE_WEBHOOK_SECRET` Paddle notification destination secret
- `BOOKRA_PADDLE_{STARTER,PRO,BUSINESS}_{CZK,USD}_PRICE_ID` Paddle price IDs
- `BOOKRA_UMAMI_API_URL` and `BOOKRA_UMAMI_API_KEY` optional analytics integration
## Notes
@@ -32,3 +35,40 @@ npm run db:migrate:up
- `sqlc.yaml` is wired through `npm run db:generate`.
- Goose migrations are wired through `npm run db:migrate:*` and use the Neon direct connection URL.
- Reminder dispatch now runs through `POST /v1/internal/jobs/reminders/dispatch` with `X-Bookra-Job-Key`.
## Production Auth
Bookra production auth should use Neon Auth directly:
- frontend uses `VITE_NEON_AUTH_URL`
- backend verifies Neon JWTs with `BOOKRA_NEON_AUTH_URL`
- auth-service may stay deployed for standalone auth/admin workflows, but backend billing and app APIs do not depend on it
Trusted redirect domains in Neon Auth should include your frontend origin such as `https://bookra.eu`, plus local dev origins when needed.
## Paddle Setup
Get these values from Paddle dashboard:
- `BOOKRA_PADDLE_ENV`: `sandbox` for testing, `live` for production
- `BOOKRA_PADDLE_API_KEY`: Developer tools -> Authentication
- `BOOKRA_PADDLE_WEBHOOK_SECRET`: Notification settings -> destination secret key
- `BOOKRA_PADDLE_*_PRICE_ID`: Catalog -> each SaaS plan recurring price ID
Create one recurring price per plan/currency you support:
- `starter` `czk`
- `starter` `usd`
- `pro` `czk`
- `pro` `usd`
- `business` `czk`
- `business` `usd`
Set your webhook destination to:
```text
POST /v1/webhooks/paddle
POST /api/paddle_webhook
```
Use Paddle webhook simulator for event testing.
+27
View File
@@ -11,14 +11,37 @@ import (
"bookra/apps/backend/internal/api"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
sentry "github.com/getsentry/sentry-go"
)
func initSentry(cfg config.Config) {
if cfg.SentryDSN == "" {
log.Println("Sentry DSN not configured - skipping initialization")
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: cfg.SentryDSN,
Environment: cfg.Environment,
Release: "bookra@1.0.0",
// Set TracesSampleRate to 1.0 to capture 100% of transactions for testing
TracesSampleRate: 1.0,
})
if err != nil {
log.Fatalf("Sentry initialization failed: %v", err)
}
log.Println("Sentry initialized")
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
initSentry(cfg)
pools, err := db.NewPools(cfg)
if err != nil {
log.Fatalf("create database pools: %v", err)
@@ -29,6 +52,10 @@ func main() {
if err != nil {
log.Fatalf("create server: %v", err)
}
defer server.Close()
// Start background job for trial ending emails
go server.StartBackgroundJobs()
httpServer := &http.Server{
Addr: ":" + cfg.Port,
+6 -1
View File
@@ -4,12 +4,13 @@ go 1.26.2
require (
github.com/MicahParks/keyfunc/v3 v3.8.0
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/stripe/stripe-go/v83 v83.2.1
github.com/stripe/stripe-go/v81 v81.0.0
golang.org/x/time v0.9.0
)
@@ -20,12 +21,16 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getsentry/sentry-go v0.46.2 // indirect
github.com/ggicci/httpin v0.20.3 // indirect
github.com/ggicci/owl v0.8.2 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+20 -2
View File
@@ -2,6 +2,8 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0 h1:HJabVGWEsDyogj1Ib/ZGcMcP9Vc/e2tqIDYx0xZA+qI=
github.com/PaddleHQ/paddle-go-sdk/v5 v5.2.0/go.mod h1:nlFfZYf7MovyHTE67+u7BARbMiBdMMLXGuQMbCVm9ss=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -15,6 +17,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/ggicci/httpin v0.20.3 h1:qy93bUsF/eGbX8WJfXEjB3bjgUrv7MKZ3qVWR73DIGY=
github.com/ggicci/httpin v0.20.3/go.mod h1:ppHGT8xt99mRnDUuehLLWl2RAVLKG+VGn48GjK5xaLA=
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -40,6 +48,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -50,6 +60,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -81,8 +93,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
github.com/stripe/stripe-go/v81 v81.0.0 h1:7xqKVXIjhFoSEUzXXPON7oYFRupOyhDG5R7tRVyrgeE=
github.com/stripe/stripe-go/v81 v81.0.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -95,17 +107,23 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+243
View File
@@ -0,0 +1,243 @@
package admin
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/gin-gonic/gin"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden: admin access required")
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
)
type Service struct {
repo db.Repository
adminEmail string
adminKey string
}
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
return &Service{
repo: repo,
adminEmail: adminEmail,
adminKey: adminKey,
}
}
// IsConfigured returns true if admin credentials are set
func (s *Service) IsConfigured() bool {
return s.adminEmail != "" && s.adminKey != ""
}
// ValidateAdminLogin checks if the provided credentials match the admin credentials
// Uses constant-time comparison to prevent timing attacks
func (s *Service) ValidateAdminLogin(email, key string) bool {
if !s.IsConfigured() {
return false
}
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
return emailMatch && keyMatch
}
// RequireAdmin is middleware that checks for admin authentication
// It supports two modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
// 2. Session-based auth where the user has role "admin" or "superadmin"
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
return func(c *gin.Context) {
// Check for admin header credentials (direct admin login)
adminEmail := c.GetHeader("X-Admin-Email")
adminKey := c.GetHeader("X-Admin-Key")
if adminEmail != "" && adminKey != "" {
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
c.Set("isAdmin", true)
c.Set("adminMode", "credentials")
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
return
}
// Check for Bearer token with admin role
auth := c.GetHeader("Authorization")
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
// The auth middleware should have already validated the token
// and set the user info in context
userID, exists := c.Get("userID")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
return
}
if isAdmin {
c.Set("isAdmin", true)
c.Set("adminMode", "session")
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
}
}
// GetDashboardStats returns platform-wide statistics for admin dashboard
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
stats, err := s.repo.GetPlatformStats(ctx)
if err != nil {
return domain.AdminDashboardStats{}, err
}
return domain.AdminDashboardStats{
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
RevenueThisMonthCents: stats.RevenueThisMonth,
}, nil
}
// ListTenants returns paginated list of all tenants
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
if err != nil {
return domain.AdminTenantList{}, err
}
result := domain.AdminTenantList{
Total: total,
Page: page,
PageSize: pageSize,
Tenants: make([]domain.AdminTenant, len(tenants)),
}
for i, t := range tenants {
result.Tenants[i] = domain.AdminTenant{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
SubscriptionStatus: t.SubscriptionStatus,
BillingProvider: t.BillingProvider,
}
}
return result, nil
}
// ListUsers returns paginated list of all users
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
if err != nil {
return domain.AdminUserList{}, err
}
result := domain.AdminUserList{
Total: total,
Page: page,
PageSize: pageSize,
Users: make([]domain.AdminUser, len(users)),
}
for i, u := range users {
result.Users[i] = domain.AdminUser{
ID: u.ID.String(),
Email: u.Email,
Name: stringPtrToStr(u.Name),
EmailVerified: u.EmailVerified,
Provider: u.Provider,
Role: u.Role,
CreatedAt: u.CreatedAt,
}
}
return result, nil
}
// UpdateUserRole changes a user's role
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
// Validate role
validRoles := map[string]bool{
"user": true,
"admin": true,
"superadmin": true,
}
if !validRoles[newRole] {
return errors.New("invalid role")
}
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
return err
}
// Log the action
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
AdminUserID: adminUserID,
Action: "update_user_role",
ResourceType: "user",
ResourceID: targetUserID,
Details: map[string]any{
"newRole": newRole,
},
IPAddress: ip,
UserAgent: userAgent,
})
}
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
// This will be called from the billing service
return nil
}
func stringPtrToStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func init() {
// Ensure time package is imported
_ = time.Now()
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"bookra/apps/backend/internal/config"
)
func TestDispatchReminderJobsRequiresJobRunnerKey(t *testing.T) {
server, err := NewServer(config.Config{
Environment: "development",
FrontendURL: "http://localhost:3000",
APIURL: "http://localhost:8080",
DemoMode: true,
}, nil)
if err != nil {
t.Fatalf("new server: %v", err)
}
defer server.Close()
request := httptest.NewRequest(http.MethodPost, "/v1/internal/jobs/reminders/dispatch", nil)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestDispatchReminderJobsAcceptsConfiguredJobRunnerKey(t *testing.T) {
server, err := NewServer(config.Config{
Environment: "development",
FrontendURL: "http://localhost:3000",
APIURL: "http://localhost:8080",
JobRunnerKey: "job-secret",
DemoMode: true,
}, nil)
if err != nil {
t.Fatalf("new server: %v", err)
}
defer server.Close()
request := httptest.NewRequest(http.MethodPost, "/v1/internal/jobs/reminders/dispatch", nil)
request.Header.Set("X-Bookra-Job-Key", "job-secret")
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", recorder.Code, recorder.Body.String())
}
}
+16 -1
View File
@@ -12,8 +12,23 @@ import (
const principalContextKey = "principal"
func RequireAuth(verifier *Verifier, repo db.Repository) gin.HandlerFunc {
// DemoPrincipal is the auto-authenticated user in demo mode
var DemoPrincipal = domain.Principal{
Subject: "demo-owner",
Email: "demo@bookra.dev",
Name: "Demo User",
Role: "owner",
}
func RequireAuth(verifier *Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc {
return func(c *gin.Context) {
// In demo mode, auto-authenticate as the demo user
if demoMode {
c.Set(principalContextKey, DemoPrincipal)
c.Next()
return
}
if verifier == nil || !verifier.Enabled() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
return
+29 -3
View File
@@ -16,13 +16,18 @@ type Verifier struct {
jwks keyfunc.Keyfunc
expectedIssuer string
enabled bool
localSecret []byte
cancel context.CancelFunc
}
func NewVerifier(neonAuthURL string) (*Verifier, error) {
trimmed := strings.TrimSpace(neonAuthURL)
func NewVerifier(neonAuthURL string, localJWTSecret string) (*Verifier, error) {
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
if trimmed == "" {
return &Verifier{enabled: false}, nil
secret := strings.TrimSpace(localJWTSecret)
return &Verifier{
enabled: secret != "",
localSecret: []byte(secret),
}, nil
}
parsed, err := url.Parse(trimmed)
@@ -45,6 +50,7 @@ func NewVerifier(neonAuthURL string) (*Verifier, error) {
jwks: jwks,
expectedIssuer: expectedIssuer,
enabled: true,
localSecret: []byte(strings.TrimSpace(localJWTSecret)),
cancel: cancel,
}, nil
}
@@ -64,6 +70,26 @@ func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
return nil, errors.New("neon auth verifier is disabled")
}
if len(v.localSecret) > 0 && v.jwks == nil {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return v.localSecret, nil
}, jwt.WithIssuer("bookra-auth"), jwt.WithAudience("bookra"), jwt.WithLeeway(15*time.Second))
if err != nil {
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
}
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
jwt.WithIssuer(v.expectedIssuer),
jwt.WithValidMethods([]string{"EdDSA"}),
+319
View File
@@ -0,0 +1,319 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"bookra/apps/backend/internal/db"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const (
accessTokenTTL = 24 * time.Hour
refreshTokenTTL = 30 * 24 * time.Hour
magicLinkTTL = 15 * time.Minute
passwordResetTTL = 30 * time.Minute
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken = errors.New("invalid or expired token")
ErrUserNotFound = errors.New("user not found")
ErrEmailAlreadyExists = errors.New("email already exists")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrMagicLinkExpired = errors.New("magic link expired")
ErrMagicLinkUsed = errors.New("magic link already used")
ErrInvalidResetToken = errors.New("invalid or expired reset token")
)
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
TokenType string `json:"tokenType"`
ExpiresIn int `json:"expiresIn"`
}
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Role string `json:"role"`
Type string `json:"type"`
jwt.RegisteredClaims
}
type Service struct {
repo db.Repository
jwtSecret []byte
}
func NewService(repo db.Repository, jwtSecret string) *Service {
return &Service{
repo: repo,
jwtSecret: []byte(jwtSecret),
}
}
// RegisterWithPassword creates a new user with email and password
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*db.UserRecord, *TokenPair, error) {
if len(password) < 8 {
return nil, nil, ErrPasswordTooShort
}
// Check if user exists
existing, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, nil, err
}
if existing != nil {
return nil, nil, ErrEmailAlreadyExists
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create user
user, err := s.repo.CreateUser(ctx, email, string(hash), name, "email", "user")
if err != nil {
return nil, nil, err
}
// Generate tokens
tokens, err := s.generateTokenPair(user.ID.String(), email, name, "user")
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// LoginWithPassword authenticates a user with email and password
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*db.UserRecord, *TokenPair, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrInvalidCredentials
}
return nil, nil, err
}
if user.PasswordHash == nil {
return nil, nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
// Update last login
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// CreateMagicLink generates a magic link for passwordless auth
func (s *Service) CreateMagicLink(ctx context.Context, email string) (string, error) {
// Get or create user
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
if user == nil {
user, err = s.repo.CreateUser(ctx, email, "", "", "email", "user")
if err != nil {
return "", err
}
}
// Generate token
token := generateRandomToken(32)
expiresAt := time.Now().Add(magicLinkTTL)
if err := s.repo.CreateMagicLink(ctx, token, user.ID.String(), email, expiresAt); err != nil {
return "", err
}
return token, nil
}
// VerifyMagicLink validates a magic link and returns tokens
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*db.UserRecord, *TokenPair, error) {
ml, err := s.repo.GetMagicLink(ctx, token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrInvalidToken
}
return nil, nil, err
}
if ml.Used {
return nil, nil, ErrMagicLinkUsed
}
if time.Now().After(ml.ExpiresAt) {
return nil, nil, ErrMagicLinkExpired
}
// Mark as used
if err := s.repo.MarkMagicLinkUsed(ctx, token); err != nil {
return nil, nil, err
}
// Get user
user, err := s.repo.GetUserByID(ctx, ml.UserID.String())
if err != nil {
return nil, nil, err
}
// Mark email as verified
if err := s.repo.MarkEmailVerified(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
// Update last login
if err := s.repo.UpdateLastLogin(ctx, user.ID.String()); err != nil {
// Log but don't fail
}
tokens, err := s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// RefreshToken refreshes an access token using a refresh token
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, ErrInvalidToken
}
if claims.Type != "refresh" {
return nil, ErrInvalidToken
}
user, err := s.repo.GetUserByID(ctx, claims.UserID)
if err != nil {
return nil, ErrUserNotFound
}
return s.generateTokenPair(user.ID.String(), user.Email, derefString(user.Name), user.Role)
}
// ValidateToken validates a JWT token and returns claims
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, 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 s.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, ErrInvalidToken
}
// GetUser retrieves a user by ID
func (s *Service) GetUser(ctx context.Context, userID string) (*db.UserRecord, error) {
return s.repo.GetUserByID(ctx, userID)
}
// IsAdmin checks if the user has admin role
func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
return user.Role == "admin" || user.Role == "superadmin", nil
}
// generateTokenPair creates access and refresh tokens
func (s *Service) generateTokenPair(userID, email, name, role string) (*TokenPair, error) {
now := time.Now()
// Access token
accessClaims := Claims{
UserID: userID,
Email: email,
Name: name,
Role: role,
Type: "access",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(s.jwtSecret)
if err != nil {
return nil, err
}
// Refresh token
refreshClaims := Claims{
UserID: userID,
Email: email,
Role: role,
Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString(s.jwtSecret)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
}, nil
}
func generateRandomToken(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
File diff suppressed because it is too large Load Diff
+108 -34
View File
@@ -9,13 +9,21 @@ import (
"bookra/apps/backend/internal/domain"
)
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
func testConfig() config.Config {
return config.Config{
FrontendURL: "http://localhost:3000",
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddleWebhookKey: "pdl_ntf_123",
PaddlePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
},
}, db.NewMemoryRepository())
}
}
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
Subject: "demo-owner",
@@ -25,51 +33,117 @@ func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
t.Fatalf("get subscription: %v", err)
}
if snapshot.PlanCode != "growth" {
t.Fatalf("expected growth, got %s", snapshot.PlanCode)
if snapshot.PlanCode != "pro" {
t.Fatalf("expected pro, got %s", snapshot.PlanCode)
}
if snapshot.Provider != "paddle" {
t.Fatalf("expected paddle provider, got %s", snapshot.Provider)
}
if snapshot.Entitlements.MaxLocations != 3 {
t.Fatalf("expected 3 locations, got %d", snapshot.Entitlements.MaxLocations)
}
}
func TestCreateCheckoutUsesMockURLWithoutStripeKey(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
},
}, db.NewMemoryRepository())
func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
cfg := testConfig()
cfg.PaddleAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "growth")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
if response.URL == "" {
t.Fatal("expected checkout url")
}, "pro", "czk", "monthly")
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
}
}
func TestRefreshReturnsSnapshotWithoutStripeKey(t *testing.T) {
service := NewService(config.Config{
FrontendURL: "http://localhost:3000",
StripePriceIDs: map[string]string{
"growth": "price_growth_123",
},
}, db.NewMemoryRepository())
func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
}, "pro", "czk", "monthly")
if err != nil {
t.Fatalf("create checkout: %v", err)
}
if response.PriceID != "pri_pro_czk" {
t.Fatalf("expected pri_pro_czk, got %s", response.PriceID)
}
if response.CustomData["tenantId"] == "" {
t.Fatal("expected tenantId in customData")
}
if response.SuccessRedirectURL == "" || response.CancelRedirectURL == "" {
t.Fatal("expected redirect URLs")
}
}
func TestRefreshRequiresPaddleKeyWhenCustomerExists(t *testing.T) {
cfg := testConfig()
cfg.PaddleAPIKey = ""
service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.Refresh(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != nil {
t.Fatalf("refresh: %v", err)
}
if snapshot.Status != "active" {
t.Fatalf("expected active status, got %s", snapshot.Status)
if err != ErrPaddleNotConfigured {
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err)
}
}
func TestGetSubscriptionDisablesCheckoutWhenWebhookMissing(t *testing.T) {
cfg := testConfig()
cfg.PaddleWebhookKey = ""
service := NewService(cfg, db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != nil {
t.Fatalf("get subscription: %v", err)
}
if snapshot.CheckoutURLAvailable {
t.Fatal("expected checkout unavailable without webhook secret")
}
}
func TestGetSubscriptionEnablesCheckoutWhenPaddleConfigured(t *testing.T) {
service := NewService(testConfig(), db.NewMemoryRepository())
snapshot, err := service.GetSubscription(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != nil {
t.Fatalf("get subscription: %v", err)
}
if !snapshot.CheckoutURLAvailable {
t.Fatal("expected checkout available when paddle is configured")
}
if !snapshot.PortalAvailable {
t.Fatal("expected portal available when customer exists")
}
}
func TestCreatePortalSessionRequiresCustomer(t *testing.T) {
repo := db.NewMemoryRepository()
membership, err := repo.GetTenantMembershipByUserID(context.Background(), "demo-owner")
if err != nil {
t.Fatalf("get membership: %v", err)
}
if err := repo.UpdateTenantBillingCustomerID(context.Background(), membership.Tenant.ID, ""); err != nil {
t.Fatalf("clear billing customer: %v", err)
}
service := NewService(testConfig(), repo)
_, err = service.CreatePortalSession(context.Background(), domain.Principal{
Subject: "demo-owner",
Email: "owner@bookra.dev",
})
if err != ErrBillingCustomerMissing {
t.Fatalf("expected ErrBillingCustomerMissing, got %v", err)
}
}
@@ -0,0 +1,200 @@
package bookings
import (
"context"
"errors"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
)
var (
ErrInvalidReschedule = errors.New("invalid reschedule request")
ErrBookingCancelled = errors.New("booking already cancelled")
ErrUnauthorized = errors.New("unauthorized access")
)
type CustomerService struct {
repo db.Repository
notifier CustomerNotifier
}
type CustomerNotifier interface {
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
func NewCustomerService(repo db.Repository, notifier CustomerNotifier) *CustomerService {
if notifier == nil {
notifier = &customerNoopNotifier{}
}
return &CustomerService{repo: repo, notifier: notifier}
}
type customerNoopNotifier struct{}
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
// GetBookingByReference returns booking details for customer management link
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil {
return domain.CustomerBookingView{}, ErrBookingNotFound
}
// Get tenant details for business name
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return domain.CustomerBookingView{}, err
}
return domain.CustomerBookingView{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service", // Would get from service ID in full implementation
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: "Location", // Would get from location ID in full implementation
Status: booking.Status,
}, nil
}
// RescheduleBooking allows customers to reschedule their booking
func (s *CustomerService) RescheduleBooking(ctx context.Context, reference string, req domain.RescheduleBookingRequest, token string) error {
booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil {
return ErrBookingNotFound
}
if booking.Status == "cancelled" {
return ErrBookingCancelled
}
newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt)
if err != nil {
return ErrInvalidReschedule
}
newEndsAt, err := time.Parse(time.RFC3339, req.NewEndsAt)
if err != nil {
return ErrInvalidReschedule
}
if !newEndsAt.After(newStartsAt) {
return ErrInvalidReschedule
}
if newStartsAt.Before(time.Now().UTC()) {
return ErrInvalidReschedule
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
return err
}
// Send reschedule email (async)
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
return nil
}
// CancelBooking allows customers to cancel their booking
func (s *CustomerService) CancelBooking(ctx context.Context, reference string, token string) error {
booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil {
return ErrBookingNotFound
}
if booking.Status == "cancelled" {
return ErrBookingCancelled
}
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
return err
}
// Send cancellation email (async)
go s.sendCancellationEmail(booking)
return nil
}
func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStartsAt, newEndsAt time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return
}
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
brandColor := brand.PrimaryColor
if brandColor == "" {
brandColor = "#a65c3e"
}
managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference
emailData := notifications.BookingEmailData{
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service",
Location: "Location",
Reference: booking.Reference,
StartsAt: newStartsAt,
EndsAt: newEndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: managementURL,
}
s.notifier.SendBookingReschedule(ctx, emailData)
}
func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return
}
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
brandColor := brand.PrimaryColor
if brandColor == "" {
brandColor = "#a65c3e"
}
emailData := notifications.BookingEmailData{
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service",
Location: "Location",
Reference: booking.Reference,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: "",
}
s.notifier.SendBookingCancellation(ctx, emailData)
}
+215 -24
View File
@@ -4,11 +4,15 @@ import (
"context"
"errors"
"fmt"
"net/mail"
"sort"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/shared"
"github.com/jackc/pgx/v5"
)
@@ -18,14 +22,34 @@ var (
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflict")
ErrTenantMembership = errors.New("tenant membership not found")
ErrBookingNotFound = errors.New("booking not found")
)
type Service struct {
repo db.Repository
repo db.Repository
notifier Notifier
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
type Notifier interface {
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error
}
func NewService(repo db.Repository, notifier Notifier) *Service {
if notifier == nil {
notifier = &noopNotifier{}
}
return &Service{repo: repo, notifier: notifier}
}
type noopNotifier struct{}
func (n *noopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error {
return nil
}
func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) {
@@ -92,6 +116,26 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if !endsAt.After(startsAt) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be after startsAt", ErrInvalidBooking)
}
if startsAt.Before(time.Now().UTC()) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidBooking)
}
customerName := strings.TrimSpace(request.CustomerName)
customerEmail := strings.TrimSpace(request.CustomerEmail)
customerPhone := strings.TrimSpace(request.CustomerPhone)
notes := strings.TrimSpace(request.Notes)
if len(customerName) < 2 || len(customerName) > 120 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
}
if _, err := mail.ParseAddress(customerEmail); err != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
}
if customerPhone != "" && len(customerPhone) > 30 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerPhone must be at most 30 characters", ErrInvalidBooking)
}
if len(notes) > 1000 {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
}
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
if err != nil {
@@ -99,23 +143,22 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}
status := "confirmed"
if request.BookingMode == "class" && request.ClassSessionID != nil {
classBookings := countClassBookings(existing, *request.ClassSessionID)
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, startsAt.Add(-1*time.Minute), 16)
switch request.BookingMode {
case "appointment":
if request.ServiceID == nil || request.ClassSessionID != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment bookings require serviceId only", ErrInvalidBooking)
}
service, ok, err := s.serviceForRequest(ctx, tenant.ID, *request.ServiceID)
if err != nil {
return domain.CreateBookingResponse{}, err
}
sessionCapacity := int32(0)
for _, session := range classSessions {
if session.ID == *request.ClassSessionID {
sessionCapacity = session.Capacity
break
}
if !ok {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
}
if sessionCapacity > 0 && classBookings >= sessionCapacity {
status = "waitlisted"
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
if !startsAt.Add(expectedDuration).Equal(endsAt) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
}
} else {
for _, booking := range existing {
if booking.Status == "cancelled" {
continue
@@ -124,6 +167,26 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
return domain.CreateBookingResponse{}, ErrBookingConflict
}
}
case "class":
if request.ClassSessionID == nil || request.ServiceID != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class bookings require classSessionId only", ErrInvalidBooking)
}
session, ok, err := s.classSessionForRequest(ctx, tenant.ID, *request.ClassSessionID, startsAt)
if err != nil {
return domain.CreateBookingResponse{}, err
}
if !ok {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: classSessionId is not available for tenant", ErrInvalidBooking)
}
if !sameSecond(session.StartsAt, startsAt) || !sameSecond(session.EndsAt, endsAt) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking)
}
classBookings := countClassBookings(existing, *request.ClassSessionID)
if session.Capacity > 0 && classBookings >= session.Capacity {
status = "waitlisted"
}
default:
return domain.CreateBookingResponse{}, fmt.Errorf("%w: bookingMode must be appointment or class", ErrInvalidBooking)
}
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
@@ -133,13 +196,14 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
StaffID: request.StaffID,
LocationID: request.LocationID,
BookingMode: request.BookingMode,
CustomerName: request.CustomerName,
CustomerEmail: request.CustomerEmail,
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: status,
Reference: db.Reference("BK", time.Now()),
Notes: request.Notes,
Notes: notes,
})
if err != nil {
return domain.CreateBookingResponse{}, err
@@ -150,8 +214,8 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: tenant.ID,
ClassSessionID: *request.ClassSessionID,
CustomerName: request.CustomerName,
CustomerEmail: request.CustomerEmail,
CustomerName: customerName,
CustomerEmail: customerEmail,
Position: waitlistPosition,
}); err != nil {
return domain.CreateBookingResponse{}, err
@@ -169,6 +233,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}
}
// Send confirmation emails (async - don't fail booking if email fails)
go s.sendBookingConfirmationEmails(tenant, created, customerName, customerEmail, startsAt, endsAt)
return domain.CreateBookingResponse{
BookingID: created.ID,
Reference: created.Reference,
@@ -176,6 +243,65 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}, nil
}
func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, customerName, customerEmail string, startsAt, endsAt time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Get brand profile for business details
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
brandColor := brand.PrimaryColor
if brandColor == "" {
brandColor = "#a65c3e" // Default terra color
}
managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference)
emailData := notifications.BookingEmailData{
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: customerName,
CustomerEmail: customerEmail,
Service: "Service", // Would lookup from booking.ServiceID in full implementation
Location: "Location", // Would lookup from booking.LocationID in full implementation
Reference: booking.Reference,
StartsAt: startsAt,
EndsAt: endsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: managementURL,
}
// Send to customer
s.notifier.SendBookingConfirmation(ctx, emailData)
}
func (s *Service) serviceForRequest(ctx context.Context, tenantID string, serviceID string) (db.ServiceRecord, bool, error) {
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
if err != nil {
return db.ServiceRecord{}, false, err
}
for _, service := range services {
if service.ID == serviceID {
return service, true, nil
}
}
return db.ServiceRecord{}, false, nil
}
func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, classSessionID string, startsAt time.Time) (db.ClassSessionRecord, bool, error) {
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, startsAt.Add(-1*time.Minute), 64)
if err != nil {
return db.ClassSessionRecord{}, false, err
}
for _, session := range sessions {
if session.ID == classSessionID {
return session, true, nil
}
}
return db.ClassSessionRecord{}, false, nil
}
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
@@ -191,20 +317,81 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if err != nil {
return domain.DashboardSummary{}, err
}
upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd)
if err != nil {
return domain.DashboardSummary{}, err
}
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
for _, booking := range upcomingRecords {
upcoming = append(upcoming, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
}
if len(upcoming) > 5 {
upcoming = upcoming[:5]
}
// Fetch all bookings for the last 30 days + next 30 days for chart and bookings page
allFrom := now.AddDate(0, 0, -30)
allTo := now.AddDate(0, 0, 30)
allRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, allFrom, allTo)
if err != nil {
return domain.DashboardSummary{}, err
}
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range allRecords {
allBookings = append(allBookings, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
}
return domain.DashboardSummary{
TenantName: membership.Tenant.Name,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
PublicBookingURL: "/book/" + membership.Tenant.Slug,
SetupCompletion: 100,
KPIs: []domain.DashboardKPI{
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
},
UpcomingBookings: upcoming,
AllBookings: allBookings,
WidgetSnippets: widgetSnippets(membership.Tenant),
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
}, nil
}
func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet {
path := "/book/" + tenant.Slug
return []domain.WidgetSnippet{
{Kind: "html", Code: fmt.Sprintf(`<iframe src="%s" title="%s booking" style="width:100%%;min-height:760px;border:0;"></iframe>`, path, tenant.Name)},
{Kind: "react", Code: fmt.Sprintf(`export function BookraWidget() { return <iframe src="%s" title="%s booking" style={{ width: "100%%", minHeight: 760, border: 0 }} />; }`, path, tenant.Name)},
{Kind: "typescript", Code: fmt.Sprintf(`const bookraWidgetUrl = new URL("%s", window.location.origin);`, path)},
}
}
func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRecord) domain.TrackingStatus {
brand, err := repo.GetBrandProfile(ctx, tenant.ID)
if err != nil || strings.TrimSpace(brand.UmamiSiteID) == "" {
return domain.TrackingStatus{Provider: "umami", Connected: false, Message: "Umami tracking is not connected."}
}
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
}
func generateAppointmentSlots(
tenant db.TenantRecord,
services []db.ServiceRecord,
@@ -318,6 +505,10 @@ func sameResource(left *string, right *string) bool {
return *left == *right
}
func sameSecond(left time.Time, right time.Time) bool {
return left.UTC().Truncate(time.Second).Equal(right.UTC().Truncate(time.Second))
}
func countClassBookings(bookings []db.BookingRecord, classSessionID string) int32 {
var total int32
for _, booking := range bookings {
+41 -4
View File
@@ -2,6 +2,7 @@ package bookings
import (
"context"
"errors"
"testing"
"time"
@@ -11,7 +12,7 @@ import (
func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
service := NewService(repo, nil)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -68,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
service := NewService(repo, nil)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -123,9 +124,45 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
}
}
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
CustomerName: "Missing Service",
CustomerEmail: "missing@example.com",
StartsAt: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(25 * time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCreateClassRequiresExistingSession(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
missingSessionID := "11111111-1111-1111-1111-111111111111"
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &missingSessionID,
CustomerName: "Missing Session",
CustomerEmail: "missing@example.com",
StartsAt: time.Now().UTC().Add(48 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(49 * time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
service := NewService(repo, nil)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -148,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo)
service := NewService(repo, nil)
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
+501 -3
View File
@@ -1,7 +1,505 @@
package catalog
type Service struct{}
import (
"context"
"errors"
"fmt"
"time"
func NewService() *Service {
return &Service{}
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var (
ErrLocationNotFound = errors.New("location not found")
ErrBlockedDayNotFound = errors.New("blocked day not found")
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrInvalidBooking = errors.New("invalid booking request")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
)
type Service struct {
repo db.Repository
billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}
notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
}
}
func NewService(repo db.Repository, billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
}) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
// ============================================
// LOCATION / ZONE MANAGEMENT
// ============================================
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
locations := make([]domain.Location, len(records))
for i, rec := range records {
locations[i] = domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: "room", // Default type
Capacity: 10, // Default capacity
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
}
return locations, nil
}
func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
// Check plan entitlements for location limit
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
// Count existing locations
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
if err == nil && len(locations) >= entitlements.MaxLocations {
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
}
}
}
params := db.CreateLocationParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
Timezone: membership.Tenant.Timezone,
}
rec, err := s.repo.CreateLocation(ctx, params)
if err != nil {
return domain.Location{}, err
}
// Send usage warning if at 80%+ of limit
if s.notificationService != nil && s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
if usagePercent >= 80 {
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
}
}
}
return domain.Location{
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) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
// Verify location belongs to tenant
loc, err := s.repo.GetLocationByID(ctx, locationID)
if err != nil {
return domain.Location{}, ErrLocationNotFound
}
if loc.TenantID != membership.Tenant.ID {
return domain.Location{}, ErrLocationNotFound
}
params := db.UpdateLocationParams{}
if req.Name != "" {
params.Name = &req.Name
}
rec, err := s.repo.UpdateLocation(ctx, locationID, params)
if err != nil {
return domain.Location{}, err
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
}
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
loc, err := s.repo.GetLocationByID(ctx, locationID)
if err != nil {
return ErrLocationNotFound
}
if loc.TenantID != membership.Tenant.ID {
return ErrLocationNotFound
}
return s.repo.DeleteLocation(ctx, locationID)
}
// ============================================
// BLOCKED DAYS MANAGEMENT
// ============================================
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)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
if err != nil {
return nil, err
}
blockedDays := make([]domain.BlockedDay, len(records))
for i, rec := range records {
blockedDays[i] = domain.BlockedDay{
ID: rec.ID,
TenantID: rec.TenantID,
Date: rec.StartsAt,
Reason: rec.Reason,
Type: rec.Kind,
StaffID: rec.StaffID,
CreatedAt: rec.CreatedAt,
}
}
return blockedDays, nil
}
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
date, err := time.Parse(time.RFC3339, req.Date)
if err != nil {
return domain.BlockedDay{}, ErrInvalidBooking
}
params := db.CreateBlockedDayParams{
TenantID: membership.Tenant.ID,
StaffID: req.StaffID,
StartsAt: date,
EndsAt: date.Add(24 * time.Hour),
Kind: req.Type,
Reason: req.Reason,
}
rec, err := s.repo.CreateBlockedDay(ctx, params)
if err != nil {
return domain.BlockedDay{}, err
}
return domain.BlockedDay{
ID: rec.ID,
TenantID: rec.TenantID,
Date: rec.StartsAt,
Reason: rec.Reason,
Type: rec.Kind,
StaffID: rec.StaffID,
CreatedAt: rec.CreatedAt,
}, nil
}
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)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
// Verify blocked day belongs to tenant
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
if err != nil {
return domain.BlockedDay{}, err
}
found := false
for _, b := range bd {
if b.ID == blockedDayID {
found = true
break
}
}
if !found {
return domain.BlockedDay{}, ErrBlockedDayNotFound
}
params := db.UpdateBlockedDayParams{}
if req.Reason != "" {
params.Reason = &req.Reason
}
if req.Type != "" {
params.Kind = &req.Type
}
rec, err := s.repo.UpdateBlockedDay(ctx, blockedDayID, params)
if err != nil {
return domain.BlockedDay{}, err
}
return domain.BlockedDay{
ID: rec.ID,
TenantID: rec.TenantID,
Date: rec.StartsAt,
Reason: rec.Reason,
Type: rec.Kind,
StaffID: rec.StaffID,
CreatedAt: rec.CreatedAt,
}, nil
}
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
// Verify blocked day belongs to tenant
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
if err != nil {
return err
}
found := false
for _, b := range bd {
if b.ID == blockedDayID {
found = true
break
}
}
if !found {
return ErrBlockedDayNotFound
}
return s.repo.DeleteBlockedDay(ctx, blockedDayID)
}
// ============================================
// CUSTOMER MANAGEMENT
// ============================================
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)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListCustomersByTenant(ctx, membership.Tenant.ID, limit, offset)
if err != nil {
return nil, err
}
customers := make([]domain.Customer, len(records))
for i, rec := range records {
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
customers[i] = domain.Customer{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Email: rec.Email,
Phone: rec.Phone,
Status: rec.Status,
BookingsCount: bookingsCount,
LastBookingAt: lastBooking,
CreatedAt: rec.CreatedAt,
Notes: "",
}
if rec.Notes != nil {
customers[i].Notes = *rec.Notes
}
}
return customers, nil
}
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
status := req.Status
if status == "" {
status = "active"
}
params := db.CreateCustomerParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Status: status,
Notes: nil,
}
rec, err := s.repo.CreateCustomer(ctx, params)
if err != nil {
return domain.Customer{}, err
}
return domain.Customer{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Email: rec.Email,
Phone: rec.Phone,
Status: rec.Status,
CreatedAt: rec.CreatedAt,
Notes: "",
}, nil
}
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)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
// Verify customer belongs to tenant
cust, err := s.repo.GetCustomerByID(ctx, customerID)
if err != nil {
return domain.Customer{}, ErrCustomerNotFound
}
if cust.TenantID != membership.Tenant.ID {
return domain.Customer{}, ErrCustomerNotFound
}
params := db.UpdateCustomerParams{}
if req.Name != "" {
params.Name = &req.Name
}
if req.Email != "" {
params.Email = &req.Email
}
if req.Phone != nil {
params.Phone = req.Phone
}
if req.Status != "" {
params.Status = &req.Status
}
if req.Notes != "" {
params.Notes = &req.Notes
}
rec, err := s.repo.UpdateCustomer(ctx, customerID, params)
if err != nil {
return domain.Customer{}, err
}
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
return domain.Customer{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Email: rec.Email,
Phone: rec.Phone,
Status: rec.Status,
BookingsCount: bookingsCount,
LastBookingAt: lastBooking,
CreatedAt: rec.CreatedAt,
Notes: "",
}, nil
}
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
cust, err := s.repo.GetCustomerByID(ctx, customerID)
if err != nil {
return ErrCustomerNotFound
}
if cust.TenantID != membership.Tenant.ID {
return ErrCustomerNotFound
}
return s.repo.DeleteCustomer(ctx, customerID)
}
// ============================================
// WORKING HOURS MANAGEMENT
// ============================================
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
hours := make([]domain.WorkingHours, len(records))
for i, rec := range records {
hours[i] = domain.WorkingHours{
DayOfWeek: rec.DayOfWeek,
Open: rec.StartsLocal,
Close: rec.EndsLocal,
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
}
}
return hours, nil
}
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
params := db.UpdateWorkingHoursParams{}
if req.Open != "" {
params.StartsLocal = &req.Open
}
if req.Close != "" {
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
}
+230 -30
View File
@@ -2,57 +2,257 @@ package config
import (
"errors"
"fmt"
"os"
"strings"
"bookra/apps/backend/internal/shared"
)
type Config struct {
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
JobRunnerKey string
EmailFrom string
SMSFrom string
StripeSecretKey string
StripeWebhookKey string
StripePriceIDs map[string]string
Environment string
Port string
APIURL string
FrontendURL string
DatabaseURL string
DatabaseDirectURL string
NeonAuthURL string
AuthJWTSecret string
JobRunnerKey string
EmailFrom string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
PaddleEnvironment string
PaddleAPIKey string
PaddleWebhookKey string
PaddlePriceMatrix map[string]map[string]string
StripeAPIKey string
StripeWebhookKey string
StripePriceMatrix map[string]map[string]string
AdminEmail string
AdminKey string
UmamiAPIURL string
UmamiAPIKey string
SentryDSN string
DemoMode bool
SMSManagerAPIKey string
SMSManagerBaseURL string
StripeSMSPriceMatrix map[string]string // currency -> price ID (czk, usd, eur, gbp, ...)
}
func Load() (Config, error) {
cfg := Config{
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
SMSFrom: valueOrDefault("BOOKRA_SMS_FROM", "Bookra"),
StripeSecretKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SECRET_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceIDs: map[string]string{
"starter": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_STARTER_PRICE_ID")),
"growth": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_GROWTH_PRICE_ID")),
"multi-location": strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_MULTI_LOCATION_PRICE_ID")),
},
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
StripePriceMatrix: stripePriceMatrixFromEnv(),
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
SMSManagerAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_SMSMANAGER_API_KEY")),
SMSManagerBaseURL: valueOrDefault("BOOKRA_SMSMANAGER_BASE_URL", "https://api.smsmngr.com/v2"),
StripeSMSPriceMatrix: smsPriceMatrixFromEnv(),
}
if cfg.FrontendURL == "" {
return Config{}, errors.New("BOOKRA_FRONTEND_URL is required")
}
if err := cfg.validateRuntimeRequirements(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (cfg Config) validateRuntimeRequirements() error {
if cfg.Environment == "development" || cfg.Environment == "test" {
return nil
}
missing := make([]string, 0, 3)
if cfg.DatabaseURL == "" {
missing = append(missing, "BOOKRA_DATABASE_URL")
}
if cfg.NeonAuthURL == "" {
missing = append(missing, "BOOKRA_NEON_AUTH_URL")
}
if cfg.JobRunnerKey == "" {
missing = append(missing, "BOOKRA_JOB_RUNNER_KEY")
}
if cfg.SMTPHost == "" {
missing = append(missing, "BOOKRA_SMTP_HOST")
}
if len(missing) > 0 {
return fmt.Errorf("%s required when BOOKRA_APP_ENV=%s", strings.Join(uniqueStrings(missing), ", "), cfg.Environment)
}
return nil
}
func (cfg Config) PaddleConfigured() bool {
return strings.TrimSpace(cfg.PaddleAPIKey) != ""
}
func (cfg Config) PaddleWebhookConfigured() bool {
return strings.TrimSpace(cfg.PaddleWebhookKey) != ""
}
func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) StripeConfigured() bool {
return strings.TrimSpace(cfg.StripeAPIKey) != ""
}
func (cfg Config) StripeWebhookConfigured() bool {
return strings.TrimSpace(cfg.StripeWebhookKey) != ""
}
func (cfg Config) StripeCheckoutConfigured(planCode string) bool {
planCode = shared.NormalizePlanCode(planCode)
return cfg.StripeConfigured() && cfg.StripeWebhookConfigured() && cfg.StripePriceMatrix[planCode]["czk"] != "" && cfg.StripePriceMatrix[planCode]["usd"] != ""
}
func (cfg Config) BillingProvider() string {
if cfg.StripeConfigured() {
return "stripe"
}
return "paddle"
}
func (cfg Config) BillingConfigured() bool {
return cfg.StripeConfigured() || cfg.PaddleConfigured()
}
func (cfg Config) BillingWebhookConfigured() bool {
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
}
func (cfg Config) SMSConfigured() bool {
return strings.TrimSpace(cfg.SMSManagerAPIKey) != ""
}
func (cfg Config) StripeSMSConfigured() bool {
return cfg.StripeConfigured() && cfg.StripeSMSPriceMatrix["czk"] != ""
}
func (cfg Config) StripeSMSPriceID(currency string) string {
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
if id := cfg.StripeSMSPriceMatrix[c]; id != "" {
return id
}
return cfg.StripeSMSPriceMatrix["czk"]
}
func paddlePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
"pro": {},
"business": {},
}
for _, planCode := range []string{"starter", "pro", "business"} {
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_" + envPlan + "_USD_PRICE_ID"))
}
return matrix
}
func stripePriceMatrixFromEnv() map[string]map[string]string {
matrix := map[string]map[string]string{
"starter": {},
"pro": {},
"business": {},
}
for _, planCode := range []string{"starter", "pro", "business"} {
envPlan := strings.ToUpper(strings.ReplaceAll(planCode, "-", "_"))
// Monthly prices
matrix[planCode][planCode+":czk:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_MONTHLY_PRICE_ID"))
matrix[planCode][planCode+":usd:monthly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_MONTHLY_PRICE_ID"))
matrix[planCode][planCode+":czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode][planCode+":usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
matrix[planCode]["czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_PRICE_ID"))
matrix[planCode]["usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_PRICE_ID"))
// Yearly prices
matrix[planCode][planCode+":czk:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
matrix[planCode][planCode+":usd:yearly"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
matrix[planCode]["yearly:czk"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_CZK_YEARLY_PRICE_ID"))
matrix[planCode]["yearly:usd"] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_" + envPlan + "_USD_YEARLY_PRICE_ID"))
}
return matrix
}
func normalizePaddleEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "live", "production":
return "live"
default:
return "sandbox"
}
}
func valueOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func boolFromEnv(key string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
value = strings.ToLower(value)
return value == "true" || value == "1" || value == "yes" || value == "on"
}
func smsPriceMatrixFromEnv() map[string]string {
matrix := map[string]string{}
for _, currency := range []string{"czk", "usd", "eur", "gbp", "pln"} {
upper := strings.ToUpper(currency)
matrix[currency] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SMS_" + upper + "_PRICE_ID"))
}
return matrix
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
@@ -0,0 +1,32 @@
package config
import "testing"
func TestPaddleCheckoutConfigured(t *testing.T) {
cfg := Config{
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddleWebhookKey: "pdl_ntf_123",
PaddlePriceMatrix: map[string]map[string]string{
"starter": {"czk": "pri_starter_czk", "usd": "pri_starter_usd"},
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
"business": {"czk": "pri_business_czk", "usd": "pri_business_usd"},
},
}
if !cfg.PaddleCheckoutConfigured("pro") {
t.Fatal("expected pro checkout configured")
}
}
func TestPaddleCheckoutConfiguredRequiresWebhook(t *testing.T) {
cfg := Config{
PaddleAPIKey: "pdl_sdbx_apikey_123",
PaddlePriceMatrix: map[string]map[string]string{
"pro": {"czk": "pri_pro_czk", "usd": "pri_pro_usd"},
},
}
if cfg.PaddleCheckoutConfigured("pro") {
t.Fatal("expected checkout disabled without webhook key")
}
}
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"context"
"encoding/json"
)
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
FROM tenants
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var tenants []TenantRecord
for rows.Next() {
var t TenantRecord
if err := rows.Scan(
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
&t.BillingCustomerID, &t.BillingSubscription,
); err != nil {
return nil, 0, err
}
tenants = append(tenants, t)
}
return tenants, total, nil
}
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var u UserRecord
if err := rows.Scan(
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
&u.CreatedAt, &u.LastLoginAt,
); err != nil {
return nil, 0, err
}
users = append(users, u)
}
return users, total, nil
}
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
var stats PlatformStats
// Total tenants
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
// Total users
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
// Active subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status IN ('active', 'trialing')
`).Scan(&stats.ActiveSubscriptions)
// Trial subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status = 'trialing'
`).Scan(&stats.TrialSubscriptions)
// Bookings this month
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings
WHERE created_at >= date_trunc('month', CURRENT_DATE)
`).Scan(&stats.BookingsThisMonth)
return stats, nil
}
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
var detailsJSON []byte
var err error
if params.Details != nil {
detailsJSON, err = json.Marshal(params.Details)
if err != nil {
return err
}
}
_, err = r.pool.Exec(ctx, `
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
return err
}
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
`, role, userID)
return err
}
func nullableUUID(s string) interface{} {
if s == "" {
return nil
}
return s
}
+137
View File
@@ -0,0 +1,137 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
var user UserRecord
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
VALUES ($1, $2, $3, $4, $5, false)
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err != nil {
return nil, err
}
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET last_login_at = NOW() WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET email_verified = true WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO magic_links (token, user_id, email, expires_at)
VALUES ($1, $2, $3, $4)
`, token, userID, email, expiresAt)
return err
}
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
var ml MagicLinkRecord
err := r.pool.QueryRow(ctx, `
SELECT token, user_id, email, used, expires_at, created_at
FROM magic_links
WHERE token = $1
`, token).Scan(
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &ml, nil
}
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx, `
UPDATE magic_links SET used = true WHERE token = $1
`, token)
return err
}
func nullableString(s string) interface{} {
if s == "" {
return nil
}
return s
}
+72
View File
@@ -0,0 +1,72 @@
package db
import (
"context"
)
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
var record BillingSnapshotRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, COALESCE(currency, 'czk'), price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
FROM billing_snapshots
WHERE tenant_id = $1
`, tenantID).Scan(
&record.TenantID,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscriptionID,
&record.Status,
&record.PlanCode,
&record.Currency,
&record.PriceID,
&record.CancelAtPeriodEnd,
&record.CurrentPeriodStart,
&record.CurrentPeriodEnd,
&record.PaymentMethodBrand,
&record.PaymentMethodLast4,
&record.LastSyncedAt,
)
return record, err
}
func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO billing_snapshots (
tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, currency, price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (tenant_id) DO UPDATE SET
billing_provider = EXCLUDED.billing_provider,
billing_customer_id = EXCLUDED.billing_customer_id,
billing_subscription_id = EXCLUDED.billing_subscription_id,
status = EXCLUDED.status,
plan_code = EXCLUDED.plan_code,
currency = EXCLUDED.currency,
price_id = EXCLUDED.price_id,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
payment_method_brand = EXCLUDED.payment_method_brand,
payment_method_last4 = EXCLUDED.payment_method_last4,
last_synced_at = EXCLUDED.last_synced_at,
updated_at = now()
`, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err
}
func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error) {
result, err := r.pool.Exec(ctx, `
INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING
`, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
+175
View File
@@ -0,0 +1,175 @@
package db
import (
"context"
"time"
)
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BookingRecord
for rows.Next() {
var record BookingRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.ServiceID,
&record.ClassSessionID,
&record.StaffID,
&record.LocationID,
&record.CustomerName,
&record.CustomerEmail,
&record.CustomerPhone,
&record.StartsAt,
&record.EndsAt,
&record.Status,
&record.Reference,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
var created CreatedBooking
err := r.pool.QueryRow(ctx, `
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
status, reference, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, reference, status
`,
params.TenantID,
params.ServiceID,
params.ClassSessionID,
params.StaffID,
params.LocationID,
params.BookingMode,
params.CustomerName,
params.CustomerEmail,
params.CustomerPhone,
params.StartsAt,
params.EndsAt,
params.Status,
params.Reference,
params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status)
return created, err
}
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
VALUES ($1,$2,$3,$4,$5)
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
VALUES ($1, $2, $3, $4)
`, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor)
return err
}
func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
rj.booking_id, rj.channel, rj.scheduled_for,
b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status
FROM reminder_jobs rj
INNER JOIN bookings b ON b.id = rj.booking_id
INNER JOIN tenants t ON t.id = rj.tenant_id
WHERE rj.status = 'pending' AND rj.scheduled_for <= $1
ORDER BY rj.scheduled_for ASC
LIMIT $2
`, dueBefore, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ReminderJobRecord
for rows.Next() {
var record ReminderJobRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TenantName,
&record.Locale,
&record.Timezone,
&record.BookingID,
&record.Channel,
&record.ScheduledFor,
&record.CustomerName,
&record.CustomerEmail,
&record.Reference,
&record.StartsAt,
&record.Status,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs
SET status = $2, dispatched_at = $3
WHERE id = $1
`, reminderJobID, status, dispatchedAt)
return err
}
func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO notification_delivery_logs (
tenant_id, reminder_job_id, channel, provider, recipient,
delivery_status, external_id, error_message
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''))
`, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient,
params.Status, params.ExternalID, params.ErrorMessage)
return err
}
func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
var metrics DashboardMetrics
err := r.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count,
COALESCE(
ROUND(
100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed')
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
),
0
)::integer AS utilization_percent
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
&metrics.BookingsCount,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
)
return metrics, err
}
+152
View File
@@ -0,0 +1,152 @@
package db
import (
"context"
"time"
)
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE tenant_id = $1
ORDER BY name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []LocationRecord
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE id = $1
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, timezone, created_at
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
UPDATE locations
SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone),
updated_at = now()
WHERE id = $1
RETURNING id, tenant_id, name, timezone, created_at
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteLocation(ctx context.Context, locationID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, locationID)
return err
}
func (r *PGRepository) ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
FROM availability_exceptions
WHERE tenant_id = $1 AND starts_at <= $3 AND ends_at >= $2
ORDER BY starts_at
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BlockedDayRecord
for rows.Next() {
var rec BlockedDayRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO availability_exceptions (tenant_id, staff_id, starts_at, ends_at, kind, reason)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, params.TenantID, params.StaffID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
UPDATE availability_exceptions
SET starts_at = COALESCE($2, starts_at),
ends_at = COALESCE($3, ends_at),
kind = COALESCE($4, kind),
reason = COALESCE($5, reason)
WHERE id = $1
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, blockedDayID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM availability_exceptions WHERE id = $1`, blockedDayID)
return err
}
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkingHoursRecord
for rows.Next() {
var rec WorkingHoursRecord
if err := rows.Scan(&rec.TenantID, &rec.StaffID, &rec.DayOfWeek, &rec.StartsLocal, &rec.EndsLocal); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
_, err := r.pool.Exec(ctx, `
UPDATE availability_rules
SET starts_local = COALESCE($3, starts_local),
ends_local = COALESCE($4, ends_local)
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
return err
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
package db
import (
"context"
"time"
)
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ServiceRecord
for rows.Next() {
var record ServiceRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1
ORDER BY day_of_week ASC, starts_local ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []AvailabilityRuleRecord
for rows.Next() {
var record AvailabilityRuleRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.StaffID,
&record.DayOfWeek,
&record.StartsLocal,
&record.EndsLocal,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
ORDER BY cs.starts_at ASC
LIMIT $3
`, tenantID, from, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassSessionRecord
for rows.Next() {
var record ClassSessionRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
+280
View File
@@ -0,0 +1,280 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ============================================
// SMS SETTINGS - PG REPOSITORY
// ============================================
func (r *PGRepository) GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
var rec TenantSMSSettingsRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, enabled, COALESCE(sender_name, ''), COALESCE(monthly_limit, 0), COALESCE(stripe_subscription_item_id, '')
FROM tenant_sms_settings
WHERE tenant_id = $1
`, tenantID).Scan(&rec.TenantID, &rec.Enabled, &rec.SenderName, &rec.MonthlyLimit, &rec.StripeSubscriptionItemID)
if err != nil {
if err == pgx.ErrNoRows {
return TenantSMSSettingsRecord{TenantID: tenantID}, nil
}
return TenantSMSSettingsRecord{}, err
}
return rec, nil
}
func (r *PGRepository) UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO tenant_sms_settings (tenant_id, enabled, sender_name, monthly_limit, stripe_subscription_item_id, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (tenant_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
sender_name = EXCLUDED.sender_name,
monthly_limit = EXCLUDED.monthly_limit,
stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
updated_at = now()
`, params.TenantID, params.Enabled, params.SenderName, params.MonthlyLimit, params.StripeSubscriptionItemID)
return err
}
// ============================================
// SMS USAGE LOGS - PG REPOSITORY
// ============================================
func (r *PGRepository) CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `
INSERT INTO sms_usage_logs (tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, sent_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, params.TenantID, params.RecipientPhone, params.MessageBody, params.ExternalMessageID, params.ExternalRequestID, params.Status, params.CostCents, params.SentAt).Scan(&id)
return id, err
}
func (r *PGRepository) GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error) {
var summary SMSUsageSummary
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND date_trunc('month', created_at) = date_trunc('month', now())
`, tenantID).Scan(&summary.MessageCount, &summary.TotalCostCents)
return summary, err
}
func (r *PGRepository) GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
var rec SMSMonthlyReportRecord
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND to_char(created_at, 'YYYY-MM') = $2
`, tenantID, yearMonth).Scan(&rec.MessageCount, &rec.TotalCostCents)
if err != nil {
return SMSMonthlyReportRecord{}, err
}
rec.TenantID = tenantID
rec.YearMonth = yearMonth
return rec, nil
}
func (r *PGRepository) ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, created_at
FROM sms_usage_logs
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSUsageLogRecord
for rows.Next() {
var rec SMSUsageLogRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.RecipientPhone, &rec.MessageBody, &rec.ExternalMessageID, &rec.ExternalRequestID, &rec.Status, &rec.CostCents, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id, invoice_sent_at, created_at
FROM sms_monthly_reports
WHERE tenant_id = $1
ORDER BY year_month DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSMonthlyReportRecord
for rows.Next() {
var rec SMSMonthlyReportRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.YearMonth, &rec.MessageCount, &rec.TotalCostCents, &rec.StripeInvoiceID, &rec.InvoiceSentAt, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO sms_monthly_reports (tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, year_month) DO UPDATE SET
message_count = EXCLUDED.message_count,
total_cost_cents = EXCLUDED.total_cost_cents,
stripe_invoice_id = EXCLUDED.stripe_invoice_id,
created_at = now()
`, params.TenantID, params.YearMonth, params.MessageCount, params.TotalCostCents, params.StripeInvoiceID)
return err
}
func (r *PGRepository) MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error {
_, err := r.pool.Exec(ctx, `
UPDATE sms_monthly_reports
SET invoice_sent_at = now()
WHERE tenant_id = $1 AND year_month = $2
`, tenantID, yearMonth)
return err
}
func (r *PGRepository) ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id
FROM tenants t
JOIN tenant_sms_settings s ON s.tenant_id = t.id AND s.enabled = true
JOIN sms_usage_logs l ON l.tenant_id = t.id AND to_char(l.created_at, 'YYYY-MM') = $1
`, yearMonth)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TenantRecord
for rows.Next() {
var rec TenantRecord
if err := rows.Scan(&rec.ID, &rec.Slug, &rec.Name, &rec.Preset, &rec.Locale, &rec.Timezone, &rec.PlanCode, &rec.SubscriptionStatus,
&rec.BillingProvider, &rec.BillingCustomerID, &rec.BillingSubscription); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
// ============================================
// SMS SETTINGS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) GetTenantSMSSettings(_ context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
if tenantID != r.tenant.ID {
return TenantSMSSettingsRecord{}, pgx.ErrNoRows
}
return r.smsSettings, nil
}
func (r *MemoryRepository) UpsertTenantSMSSettings(_ context.Context, params TenantSMSSettingsRecord) error {
r.smsSettings = params
return nil
}
func (r *MemoryRepository) CreateSMSUsageLog(_ context.Context, params SMSUsageLogRecord) (string, error) {
params.ID = fmt.Sprintf("sms-%d", len(r.smsLogs))
params.CreatedAt = time.Now().UTC()
r.smsLogs = append([]SMSUsageLogRecord{params}, r.smsLogs...)
return params.ID, nil
}
func (r *MemoryRepository) GetSMSUsageThisMonth(_ context.Context, tenantID string) (SMSUsageSummary, error) {
if tenantID != r.tenant.ID {
return SMSUsageSummary{}, nil
}
now := time.Now().UTC()
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Year() == now.Year() && log.CreatedAt.Month() == now.Month() {
count++
cost += log.CostCents
}
}
return SMSUsageSummary{MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) GetSMSUsageForMonth(_ context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return SMSMonthlyReportRecord{}, nil
}
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Format("2006-01") == yearMonth {
count++
cost += log.CostCents
}
}
return SMSMonthlyReportRecord{TenantID: tenantID, YearMonth: yearMonth, MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) ListSMSUsageLogs(_ context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsLogs) {
limit = len(r.smsLogs)
}
return r.smsLogs[:limit], nil
}
func (r *MemoryRepository) ListSMSMonthlyReports(_ context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsReports) {
limit = len(r.smsReports)
}
return r.smsReports[:limit], nil
}
func (r *MemoryRepository) UpsertSMSMonthlyReport(_ context.Context, params SMSMonthlyReportRecord) error {
for i, rep := range r.smsReports {
if rep.TenantID == params.TenantID && rep.YearMonth == params.YearMonth {
r.smsReports[i] = params
return nil
}
}
r.smsReports = append([]SMSMonthlyReportRecord{params}, r.smsReports...)
return nil
}
func (r *MemoryRepository) MarkSMSReportInvoiceSent(_ context.Context, tenantID string, yearMonth string) error {
now := time.Now().UTC()
for i, rep := range r.smsReports {
if rep.TenantID == tenantID && rep.YearMonth == yearMonth {
r.smsReports[i].InvoiceSentAt = &now
return nil
}
}
return nil
}
func (r *MemoryRepository) ListTenantsWithSMSUsage(_ context.Context, yearMonth string) ([]TenantRecord, error) {
for _, log := range r.smsLogs {
if log.TenantID == r.tenant.ID && log.CreatedAt.Format("2006-01") == yearMonth && r.smsSettings.Enabled {
return []TenantRecord{r.tenant}, nil
}
}
return nil, nil
}
+210
View File
@@ -0,0 +1,210 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
)
func (r *PGRepository) GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE slug = $1
`, slug).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE id = $1
`, tenantID).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE billing_customer_id = $1
`, customerID).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, ''))
ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name)
`, subject, email, displayName)
return err
}
func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return TenantMembershipRecord{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
var tenantID string
err = tx.QueryRow(ctx, `
INSERT INTO tenants (id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 'starter', 'inactive', '')
RETURNING id
`, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan(&tenantID)
if err != nil {
return TenantMembershipRecord{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
VALUES ($1, $2, NULLIF($3,''), NULLIF($4,''), NULLIF($5,''))
`, tenantID, params.BrandName, params.SiteURL, params.LogoURL, params.PrimaryColor)
if err != nil {
return TenantMembershipRecord{}, err
}
locationID := ""
if params.LocationName != "" {
err = tx.QueryRow(ctx, `
INSERT INTO locations (id, tenant_id, name, timezone)
VALUES (gen_random_uuid(), $1, $2, $3)
RETURNING id
`, tenantID, params.LocationName, params.Timezone).Scan(&locationID)
if err != nil {
return TenantMembershipRecord{}, err
}
}
if params.ServiceName != "" && params.DurationMinutes > 0 {
_, err = tx.Exec(ctx, `
INSERT INTO services (id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 0)
`, tenantID, params.ServiceName, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes)
if err != nil {
return TenantMembershipRecord{}, err
}
}
for _, block := range params.AvailabilityBlocks {
_, err = tx.Exec(ctx, `
INSERT INTO availability_rules (id, tenant_id, day_of_week, starts_local, ends_local)
VALUES (gen_random_uuid(), $1, $2, $3, $4)
`, tenantID, block.DayOfWeek, block.StartsLocal, block.EndsLocal)
if err != nil {
return TenantMembershipRecord{}, err
}
}
for _, invite := range params.TeamInvites {
_, _ = tx.Exec(ctx, `
INSERT INTO team_invites (tenant_id, email, role, expires_at)
VALUES ($1, $2, $3, now() + interval '7 days')
`, tenantID, invite.Email, invite.Role)
}
_, err = tx.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, $2, $3)
ON CONFLICT (neon_subject) DO NOTHING
`, params.Subject, params.Subject+"@users.bookra.invalid", "")
if err != nil {
return TenantMembershipRecord{}, err
}
var userID string
err = tx.QueryRow(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now()
FROM users u WHERE u.neon_subject = $2
ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner'
RETURNING id
`, tenantID, params.Subject).Scan(&userID)
if err != nil {
return TenantMembershipRecord{}, err
}
err = tx.Commit(ctx)
if err != nil {
return TenantMembershipRecord{}, err
}
return TenantMembershipRecord{
Tenant: TenantRecord{
ID: tenantID,
Slug: params.Slug,
Name: params.Name,
Preset: params.Preset,
Locale: params.Locale,
Timezone: params.Timezone,
},
UserID: userID,
Role: "owner",
}, nil
}
func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error) {
var record BrandProfileRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
FROM brand_profiles WHERE tenant_id = $1
`, tenantID).Scan(&record.TenantID, &record.Name, &record.SiteURL, &record.LogoURL, &record.PrimaryColor, &record.UmamiSiteID)
return record, err
}
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
var record TenantMembershipRecord
err := r.pool.QueryRow(ctx, `
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id,
tm.user_neon_subject, tm.role
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
JOIN users u ON u.neon_subject = tm.user_neon_subject
WHERE u.id = $1
ORDER BY tm.joined_at DESC
LIMIT 1
`, userID).Scan(
&record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset,
&record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider, &record.Tenant.BillingCustomerID, &record.Tenant.BillingSubscription,
&record.UserID, &record.Role,
)
return record, err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
WHERE id = $1
`, tenantID, customerID)
return err
}
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
return err
}
+418 -34
View File
@@ -15,30 +15,94 @@ type DashboardKPI struct {
Value string `json:"value"`
}
type UpcomingBooking struct {
Reference string `json:"reference"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Status string `json:"status"`
Label string `json:"label,omitempty"`
}
type WidgetSnippet struct {
Kind string `json:"kind"`
Code string `json:"code"`
}
type TrackingStatus struct {
Provider string `json:"provider"`
Connected bool `json:"connected"`
SiteID string `json:"siteId,omitempty"`
Message string `json:"message,omitempty"`
}
type DashboardSummary struct {
TenantName string `json:"tenantName"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
PlanCode string `json:"planCode"`
KPIs []DashboardKPI `json:"kpis"`
TenantName string `json:"tenantName"`
TenantSlug string `json:"tenantSlug"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
PlanCode string `json:"planCode"`
PublicBookingURL string `json:"publicBookingUrl"`
SetupCompletion int `json:"setupCompletion"`
KPIs []DashboardKPI `json:"kpis"`
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
AllBookings []UpcomingBooking `json:"allBookings"`
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
Tracking TrackingStatus `json:"tracking"`
}
type BrandProfile struct {
Name string `json:"name"`
SiteURL string `json:"siteUrl,omitempty"`
LogoURL string `json:"logoUrl,omitempty"`
PrimaryColor string `json:"primaryColor,omitempty"`
}
type TenantBootstrap struct {
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
Preset string `json:"preset"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
PlanCode string `json:"planCode,omitempty"`
CurrentUser Principal `json:"currentUser"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
TenantSlug string `json:"tenantSlug,omitempty"`
Preset string `json:"preset"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
PlanCode string `json:"planCode,omitempty"`
OnboardingCompleted bool `json:"onboardingCompleted"`
Brand BrandProfile `json:"brand"`
CurrentUser Principal `json:"currentUser"`
}
type TeamInviteRequest struct {
Email string `json:"email"`
Role string `json:"role,omitempty"`
}
type AvailabilityBlockRequest struct {
DayOfWeek int `json:"dayOfWeek"`
StartsLocal string `json:"startsLocal"`
EndsLocal string `json:"endsLocal"`
Busy bool `json:"busy,omitempty"`
}
type BookingDefaultsRequest struct {
ServiceName string `json:"serviceName"`
DurationMinutes int `json:"durationMinutes"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
BufferAfterMinutes int `json:"bufferAfterMinutes"`
CancelWindowHours int `json:"cancelWindowHours"`
}
type OnboardTenantRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Preset string `json:"preset"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
Name string `json:"name"`
Slug string `json:"slug"`
Preset string `json:"preset"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
Brand BrandProfile `json:"brand"`
LocationName string `json:"locationName"`
BookingDefaults BookingDefaultsRequest `json:"bookingDefaults"`
AvailabilityBlocks []AvailabilityBlockRequest `json:"availabilityBlocks"`
TeamInvites []TeamInviteRequest `json:"teamInvites"`
}
type TimeSlot struct {
@@ -69,6 +133,7 @@ type CreateBookingRequest struct {
LocationID *string `json:"locationId,omitempty"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
CustomerPhone string `json:"customerPhone,omitempty"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
@@ -83,32 +148,78 @@ type CreateBookingResponse struct {
type PlanEntitlements struct {
MaxLocations int `json:"maxLocations"`
MaxStaff int `json:"maxStaff"`
SMSAddonAvailable bool `json:"smsAddonAvailable"`
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
EmailReminders bool `json:"emailReminders"`
AdvancedReporting bool `json:"advancedReporting"`
WidgetEmbedding bool `json:"widgetEmbedding"`
UmamiTracking bool `json:"umamiTracking"`
APIAccess bool `json:"apiAccess"`
DedicatedManager bool `json:"dedicatedManager"`
SMSAvailable bool `json:"smsAvailable"`
}
type PlanPricing struct {
MonthlyAmountCents int `json:"monthlyAmountCents"`
YearlyAmountCents int `json:"yearlyAmountCents"`
MonthlyFormatted string `json:"monthlyFormatted"`
YearlyFormatted string `json:"yearlyFormatted"`
YearlySavings string `json:"yearlySavings"`
YearlySavingsPercent int `json:"yearlySavingsPercent"`
}
type PlanDisplayPrice struct {
Currency string `json:"currency"`
AmountCents int `json:"amountCents"`
Formatted string `json:"formatted"`
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
YearlySavings string `json:"yearlySavings,omitempty"`
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
}
type SubscriptionSnapshot struct {
TenantID string `json:"tenantId"`
CustomerID string `json:"customerId"`
SubscriptionID string `json:"subscriptionId"`
Status string `json:"status"`
PlanCode string `json:"planCode"`
PriceID string `json:"priceId"`
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
Entitlements PlanEntitlements `json:"entitlements"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
CheckoutURLAvailable bool `json:"checkoutUrlAvailable,omitempty"`
TenantID string `json:"tenantId"`
Provider string `json:"provider"`
CustomerID string `json:"customerId"`
SubscriptionID string `json:"subscriptionId"`
Status string `json:"status"`
PlanCode string `json:"planCode"`
Currency string `json:"currency"`
PriceID string `json:"priceId"`
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
PaymentMethodBrand string `json:"paymentMethodBrand,omitempty"`
PaymentMethodLast4 string `json:"paymentMethodLast4,omitempty"`
Entitlements PlanEntitlements `json:"entitlements"`
DisplayPrices []PlanDisplayPrice `json:"displayPrices"`
TrialDays int `json:"trialDays"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
SyncAvailable bool `json:"syncAvailable"`
PortalAvailable bool `json:"portalAvailable"`
}
type CheckoutSessionRequest struct {
PlanCode string `json:"planCode"`
PlanCode string `json:"planCode"`
Currency string `json:"currency,omitempty"`
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly", defaults to "monthly"
}
type CheckoutSessionResponse struct {
type CheckoutLaunchResponse struct {
// Stripe checkout
CheckoutURL string `json:"checkoutUrl,omitempty"`
// Paddle checkout
PriceID string `json:"priceId,omitempty"`
CustomerID string `json:"customerId,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
// Common
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
CustomData map[string]string `json:"customData,omitempty"`
}
type PortalSessionResponse struct {
URL string `json:"url"`
}
@@ -121,3 +232,276 @@ type DispatchReminderJobsResponse struct {
SentCount int `json:"sentCount"`
FailedCount int `json:"failedCount"`
}
// ============================================
// LOCATION / ZONE MODELS
// ============================================
type Location struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Type string `json:"type"` // room, private, hall, etc.
Capacity int `json:"capacity"`
Timezone string `json:"timezone"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateLocationRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Capacity int `json:"capacity"`
}
type UpdateLocationRequest struct {
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Capacity int `json:"capacity,omitempty"`
}
// ============================================
// BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS
// ============================================
type BlockedDay struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Date time.Time `json:"date"`
Reason string `json:"reason"`
Type string `json:"type"` // full, partial
StaffID *string `json:"staffId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateBlockedDayRequest struct {
Date string `json:"date" binding:"required"` // RFC3339
Reason string `json:"reason" binding:"required"`
Type string `json:"type" binding:"required"` // full, partial
StaffID *string `json:"staffId,omitempty"`
}
type UpdateBlockedDayRequest struct {
Reason string `json:"reason,omitempty"`
Type string `json:"type,omitempty"`
}
// ============================================
// CUSTOMER MODELS
// ============================================
type Customer struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status"` // active, inactive, vip
BookingsCount int `json:"bookingsCount"`
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Notes string `json:"notes,omitempty"`
}
type CreateCustomerRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"` // defaults to active
Notes string `json:"notes,omitempty"`
}
type UpdateCustomerRequest struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}
// ============================================
// CUSTOMER BOOKING MANAGEMENT MODELS
// ============================================
type CustomerBookingView struct {
Reference string `json:"reference"`
CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"`
Service string `json:"service"`
BusinessName string `json:"businessName"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Location string `json:"location"`
Status string `json:"status"`
Notes string `json:"notes,omitempty"`
}
type RescheduleBookingRequest struct {
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
Reason string `json:"reason,omitempty"`
}
type CancelBookingRequest struct {
Reason string `json:"reason,omitempty"`
}
// ============================================
// ADMIN MODELS
// ============================================
type AdminDashboardStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonthCents int64 `json:"revenueThisMonthCents"`
}
type AdminTenantList struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Tenants []AdminTenant `json:"tenants"`
}
type AdminTenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
PlanCode string `json:"planCode"`
SubscriptionStatus string `json:"subscriptionStatus"`
BillingProvider string `json:"billingProvider"`
}
type AdminUserList struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Users []AdminUser `json:"users"`
}
type AdminUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
EmailVerified bool `json:"emailVerified"`
Provider string `json:"provider"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
}
type AdminLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Key string `json:"key" binding:"required"`
}
type UpdateUserRoleRequest struct {
Role string `json:"role" binding:"required,oneof=user admin superadmin"`
}
// ============================================
// WORKING HOURS MODELS
// ============================================
type WorkingHours struct {
DayOfWeek int `json:"dayOfWeek"` // 0=Sunday, 1=Monday, etc.
Open string `json:"open"` // HH:MM format
Close string `json:"close"` // HH:MM format
IsOpen bool `json:"isOpen"`
}
type UpdateWorkingHoursRequest struct {
Open string `json:"open,omitempty"`
Close string `json:"close,omitempty"`
IsOpen *bool `json:"isOpen,omitempty"`
}
// ============================================
// EMAIL TEMPLATE MODELS
// ============================================
type EmailTemplate struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
Subject string `json:"subject"`
BodyHTML string `json:"bodyHtml"`
BodyText string `json:"bodyText"`
IsEnabled bool `json:"isEnabled"`
}
type SendEmailRequest struct {
To string `json:"to" binding:"required,email"`
Subject string `json:"subject" binding:"required"`
Body string `json:"body" binding:"required"`
Data map[string]string `json:"data,omitempty"` // Template variables
}
type EmailNotification struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
BookingID string `json:"bookingId,omitempty"`
Channel string `json:"channel"` // email, sms
Type string `json:"type"` // confirmation, reminder, cancellation
Recipient string `json:"recipient"`
Status string `json:"status"` // pending, sent, failed
SentAt *time.Time `json:"sentAt,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// ============================================
// SMS MODELS
// ============================================
type SMSSettings struct {
Enabled bool `json:"enabled"`
SenderName string `json:"senderName,omitempty"`
MonthlyLimit int `json:"monthlyLimit,omitempty"`
MessagesSent int `json:"messagesSent"`
TotalCostCents int `json:"totalCostCents"`
Available bool `json:"available"`
}
type UpdateSMSSettingsRequest struct {
Enabled bool `json:"enabled"`
SenderName string `json:"senderName,omitempty"`
MonthlyLimit int `json:"monthlyLimit,omitempty"`
}
type SendSMSRequest struct {
To string `json:"to" binding:"required"`
Body string `json:"body" binding:"required,max=1000"`
}
type SendSMSResponse struct {
LogID string `json:"logId"`
MessageID string `json:"messageId,omitempty"`
RequestID string `json:"requestId,omitempty"`
Status string `json:"status"`
CostCents int `json:"costCents"`
}
type SMSUsageLog struct {
ID string `json:"id"`
RecipientPhone string `json:"recipientPhone"`
MessageBody string `json:"messageBody,omitempty"`
Status string `json:"status"`
CostCents int `json:"costCents"`
CreatedAt time.Time `json:"createdAt"`
}
type SMSUsageReport struct {
YearMonth string `json:"yearMonth"`
MessageCount int `json:"messageCount"`
TotalCostCents int `json:"totalCostCents"`
StripeInvoiceID string `json:"stripeInvoiceId,omitempty"`
InvoiceSentAt *time.Time `json:"invoiceSentAt,omitempty"`
}
type SMSInvoiceBatchResponse struct {
YearMonth string `json:"yearMonth"`
ProcessedCount int `json:"processedCount"`
FailedCount int `json:"failedCount"`
}
@@ -0,0 +1,550 @@
package notifications
import (
"fmt"
"time"
"bookra/apps/backend/internal/db"
)
type EmailType string
const (
EmailTypeConfirmation EmailType = "confirmation"
EmailTypeReminder EmailType = "reminder"
EmailTypeReschedule EmailType = "reschedule"
EmailTypeCancellation EmailType = "cancellation"
EmailTypeBusinessNotify EmailType = "business_notify"
EmailTypeUsageWarning EmailType = "usage_warning"
EmailTypeTrialEnding EmailType = "trial_ending"
)
type BookingEmailData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BusinessPhone string
BusinessAddress string
BrandColor string
CustomerName string
CustomerEmail string
Service string
Location string
Reference string
StartsAt time.Time
EndsAt time.Time
Timezone string
Locale string
Notes string
ManagementURL string
AddToCalendarURL string
}
type UsageNotificationData struct {
Type EmailType
TenantName string
TenantSlug string
BusinessEmail string
BrandColor string
AdminEmail string
Locale string
PlanCode string
LocationCount int
LocationLimit int
UsagePercent int
UpgradeURL string
DashboardURL string
}
func RenderUsageNotificationEmail(data UsageNotificationData) EmailMessage {
subject := renderUsageSubject(data)
htmlBody := renderUsageHTML(data)
textBody := renderUsageText(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.AdminEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func renderUsageSubject(data UsageNotificationData) string {
if data.Locale == "cs" {
switch data.Type {
case EmailTypeUsageWarning:
return "⚠️ Blížíte se limitu lokací - Upgrade na vyšší plán"
case EmailTypeTrialEnding:
return "⏰ Vaše zkušební období končí - Pokračujte s Bookra"
}
}
switch data.Type {
case EmailTypeUsageWarning:
return "⚠️ You're nearing your location limit - Upgrade your plan"
case EmailTypeTrialEnding:
return "⏰ Your trial period is ending - Continue with Bookra"
}
return "Bookra notification"
}
func renderUsageHTML(data UsageNotificationData) string {
cs := data.Locale == "cs"
upgradeBtn := `<a href="` + data.UpgradeURL + `" style="display:inline-block;background:#4f46e5;color:#fff;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Upgradeovat", false: "Upgrade"}[cs] + `</a>`
dashboardBtn := `<a href="` + data.DashboardURL + `" style="display:inline-block;background:#f3f4f6;color:#374151;padding:12px 24px;text-decoration:none;border-radius:8px;font-weight:600;margin:8px 0;">` + map[bool]string{true: "Otevřít dashboard", false: "Open dashboard"}[cs] + `</a>`
if data.Type == EmailTypeUsageWarning {
var msg string
if cs {
msg = fmt.Sprintf("Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
} else {
msg = fmt.Sprintf("Your %s plan allows only %d locations. You're currently using %d (%d%%).", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent)
}
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📍</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Blížíte se limitu lokací", false: "You're nearing your location limit"}[cs] + `</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + msg + `</p>
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
<p style="color:#9ca3af;font-size:14px;text-align:center;">` + map[bool]string{true: "Přidejte další lokace s vyšším plánem", false: "Add more locations with a higher plan"}[cs] + `</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`
}
// Trial ending email
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">🎉</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">` + map[bool]string{true: "Děkujeme, že používáte Bookra!", false: "Thank you for using Bookra!"}[cs] + `</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Vaše zkušební období brzy končí. Pokud se vám naše služba líbí, můžete pokračovat s vybraným plánem.", false: "Your trial period is ending soon. If you like our service, you can continue with your chosen plan."}[cs] + `</p>
<div style="text-align:center;margin-bottom:24px;">` + upgradeBtn + `</div>
<p style="color:#6b7280;text-align:center;margin-bottom:16px;">` + map[bool]string{true: "Pokud se vám služba nelíbí, můžete ji kdykoliv zrušit. Nechceme vám brát peníze, pokud nejste spokojeni.", false: "If you don't like our service, you can cancel anytime. We don't want to take your money if you're not happy."}[cs] + `</p>
<p style="color:#9ca3af;text-align:center;margin-bottom:24px;">` + map[bool]string{true: "Zrušit můžete zde:", false: "Cancel here:"}[cs] + ` ` + dashboardBtn + `</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`
}
func renderUsageText(data UsageNotificationData) string {
cs := data.Locale == "cs"
if data.Type == EmailTypeUsageWarning {
if cs {
return fmt.Sprintf("Blížíte se limitu lokací! Váš plán %s umožňuje pouze %d lokací. Aktuálně používáte %d (%d%%). Upgradeujte na vyšší plán: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
}
return fmt.Sprintf("You're nearing your location limit! Your %s plan allows only %d locations. You're currently using %d (%d%%). Upgrade: %s", data.PlanCode, data.LocationLimit, data.LocationCount, data.UsagePercent, data.UpgradeURL)
}
if cs {
return "Vaše zkušební období končí. Pokud se vám služba líbí, můžete pokračovat. Pokud ne, můžete zrušit. Nechceme vám brát peníze, pokud nejste spokojeni. Dashboard: " + data.DashboardURL
}
return "Your trial period is ending. If you like our service, you can continue. If not, you can cancel - we don't want your money if you're not happy. Dashboard: " + data.DashboardURL
}
func RenderEmailMessage(data BookingEmailData) EmailMessage {
subject := renderSubject(data)
htmlBody := renderHTMLBody(data)
textBody := renderTextBody(data)
return EmailMessage{
From: data.BusinessEmail,
To: data.CustomerEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func renderSubject(data BookingEmailData) string {
localizedTime := formatLocalizedTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
return fmt.Sprintf("Potvrzení rezervace %s - %s", data.Reference, data.TenantName)
}
return fmt.Sprintf("Booking Confirmation %s - %s", data.Reference, data.TenantName)
case EmailTypeReminder:
if data.Locale == "cs" {
return fmt.Sprintf("Připomínka: Máte rezervaci zítra v %s", localizedTime)
}
return fmt.Sprintf("Reminder: You have a booking tomorrow at %s", localizedTime)
case EmailTypeReschedule:
if data.Locale == "cs" {
return fmt.Sprintf("Vaše rezervace byla přesunuta - %s", data.Reference)
}
return fmt.Sprintf("Your booking has been rescheduled - %s", data.Reference)
case EmailTypeCancellation:
if data.Locale == "cs" {
return fmt.Sprintf("Vaše rezervace byla zrušena - %s", data.Reference)
}
return fmt.Sprintf("Your booking has been cancelled - %s", data.Reference)
case EmailTypeBusinessNotify:
if data.Locale == "cs" {
return fmt.Sprintf("Nová rezervace od %s - %s", data.CustomerName, data.Reference)
}
return fmt.Sprintf("New booking from %s - %s", data.CustomerName, data.Reference)
default:
return "Booking Update"
}
}
func renderTextBody(data BookingEmailData) string {
localizedTime := formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale)
switch data.Type {
case EmailTypeConfirmation:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
vaše rezervace byla potvrzena.
Detaily rezervace:
- Služba: %s
- Datum a čas: %s
- Místo: %s
- Reference: %s
Pro správu rezervace navštivte: %s
Děkujeme,
%s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
}
return fmt.Sprintf(`Hello %s,
Your booking has been confirmed.
Booking Details:
- Service: %s
- Date & Time: %s
- Location: %s
- Reference: %s
Manage your booking at: %s
Thank you,
%s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName, data.BusinessEmail)
case EmailTypeReminder:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
připomínáme vám zítřejší rezervaci.
- Služba: %s
- Čas: %s
- Místo: %s
- Reference: %s
Pro správu rezervace: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
}
return fmt.Sprintf(`Hello %s,
This is a reminder for your booking tomorrow.
- Service: %s
- Time: %s
- Location: %s
- Reference: %s
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeReschedule:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
vaše rezervace byla přesunuta na nový termín.
Nové detaily:
- Služba: %s
- Datum a čas: %s
- Místo: %s
- Reference: %s
Pro správu rezervace: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
}
return fmt.Sprintf(`Hello %s,
Your booking has been rescheduled.
New details:
- Service: %s
- Date & Time: %s
- Location: %s
- Reference: %s
Manage booking: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Location, data.Reference, data.ManagementURL, data.TenantName)
case EmailTypeCancellation:
if data.Locale == "cs" {
return fmt.Sprintf(`Dobrý den %s,
vaše rezervace byla zrušena.
Zrušená rezervace:
- Služba: %s
- Datum a čas: %s
- Reference: %s
Pokud jste rezervaci nezrušili vy, kontaktujte nás: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
}
return fmt.Sprintf(`Hello %s,
Your booking has been cancelled.
Cancelled booking:
- Service: %s
- Date & Time: %s
- Reference: %s
If you didn't cancel this, please contact us: %s
%s`, data.CustomerName, data.Service, localizedTime, data.Reference, data.BusinessEmail, data.TenantName)
case EmailTypeBusinessNotify:
if data.Locale == "cs" {
return fmt.Sprintf(`Nová rezervace od %s
Detaily:
- Služba: %s
- Datum a čas: %s
- Reference: %s
- Email: %s
Spravovat v administraci: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
}
return fmt.Sprintf(`New booking from %s
Details:
- Service: %s
- Date & Time: %s
- Reference: %s
- Email: %s
Manage in dashboard: https://bookra.eu/dashboard`, data.CustomerName, data.Service, localizedTime, data.Reference, data.CustomerEmail)
default:
return "Booking update"
}
}
func renderHTMLBody(data BookingEmailData) string {
// For now, return simple HTML version. In production, this would use proper HTML templates
textBody := renderTextBody(data)
// Simple conversion: wrap paragraphs in <p> tags and preserve line breaks
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>", data.BrandColor, data.TenantName)
// Convert text to simple HTML
paragraphs := splitParagraphs(textBody)
for _, p := range paragraphs {
if len(p) > 0 {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
// Add management button
if data.ManagementURL != "" {
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>", data.ManagementURL, data.BrandColor)
}
html += "</div></body></html>"
return html
}
func formatLocalizedTime(t time.Time, timezone, locale string) string {
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
localTime := t.In(loc)
if locale == "cs" {
return localTime.Format("15:04")
}
return localTime.Format("3:04 PM")
}
func formatLocalizedDateTime(t time.Time, timezone, locale string) string {
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
localTime := t.In(loc)
if locale == "cs" {
return localTime.Format("02.01.2006 15:04")
}
return localTime.Format("Jan 02, 2006 3:04 PM")
}
func splitParagraphs(text string) []string {
var paragraphs []string
current := ""
for _, line := range splitLines(text) {
trimmed := trimSpace(line)
if trimmed == "" {
if current != "" {
paragraphs = append(paragraphs, current)
current = ""
}
} else {
if current != "" {
current += " "
}
current += trimmed
}
}
if current != "" {
paragraphs = append(paragraphs, current)
}
return paragraphs
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
func trimSpace(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r' || s[start] == '\n') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r' || s[end-1] == '\n') {
end--
}
return s[start:end]
}
// RenderReminderEmail renders the legacy reminder email from a job record
func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
data := BookingEmailData{
Type: EmailTypeReminder,
TenantName: job.TenantName,
TenantSlug: "", // Not available in job record
CustomerName: job.CustomerName,
CustomerEmail: job.CustomerEmail,
Reference: job.Reference,
StartsAt: job.StartsAt,
Timezone: job.Timezone,
Locale: job.Locale,
Service: "Service", // Legacy
Location: "Location", // Legacy
}
return RenderEmailMessage(data)
}
// ============================================
// SMS USAGE EMAIL TEMPLATE
// ============================================
type SMSUsageEmailData struct {
TenantName string
TenantSlug string
BusinessEmail string
YearMonth string
MessageCount int
TotalCostCents int
Locale string
}
func RenderSMSUsageEmail(data SMSUsageEmailData) EmailMessage {
cs := data.Locale == "cs"
year := data.YearMonth[:4]
month := data.YearMonth[5:]
monthLabel := month + "/" + year
if cs {
monthLabel = month + "." + year
}
totalFormatted := fmt.Sprintf("%.2f Kč", float64(data.TotalCostCents)/100.0)
subject := fmt.Sprintf("Bookra SMS Usage - %s (%s)", monthLabel, totalFormatted)
if cs {
subject = fmt.Sprintf("Bookra SMS Přehled - %s (%s)", monthLabel, totalFormatted)
}
textBody := fmt.Sprintf(
"SMS Usage Summary for %s\n\nPeriod: %s\nMessages sent: %d\nTotal cost: %s\n\nThis amount will be added to your next invoice.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
if cs {
textBody = fmt.Sprintf(
"Přehled SMS pro %s\n\nObdobí: %s\nOdeslaných zpráv: %d\nCelková cena: %s\n\nTato částka bude přidána k vaší další faktuře.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
}
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📱</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">%s</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">%s</p>
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px;">
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%s</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%d</td>
</tr>
<tr>
<td style="padding:12px 0;color:#1f2937;font-weight:600;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:700;font-size:18px;">%s</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:14px;text-align:center;">%s</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`,
ifCS(cs, "SMS Usage Summary", "Přehled SMS využití"),
fmt.Sprintf(ifCS(cs, "Your SMS usage for %s", "Vaše SMS využití za %s"), data.TenantName),
ifCS(cs, "Period", "Období"), monthLabel,
ifCS(cs, "Messages sent", "Odeslaných zpráv"), data.MessageCount,
ifCS(cs, "Total cost (excl. VAT)", "Celková cena (bez DPH)"), totalFormatted,
ifCS(cs, "This amount will be added to your next Stripe invoice.", "Tato částka bude přidána k vaší další faktuře Stripe."),
)
return EmailMessage{
From: "",
To: data.BusinessEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func ifCS(cs bool, en, csText string) string {
if cs {
return csText
}
return en
}
+194 -46
View File
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"bookra/apps/backend/internal/config"
@@ -23,36 +26,30 @@ type EmailMessage struct {
To string
Subject string
Text string
}
type SMSMessage struct {
From string
To string
Text string
HTML string
}
type EmailProvider interface {
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
}
type SMSProvider interface {
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
}
type Service struct {
cfg config.Config
repo db.Repository
emailProvider EmailProvider
smsProvider SMSProvider
now func() time.Time
}
func NewService(cfg config.Config, repo db.Repository) *Service {
emailProvider := EmailProvider(noopEmailProvider{})
if cfg.SMTPHost != "" {
emailProvider = smtpEmailProvider{cfg: cfg}
}
return &Service{
cfg: cfg,
repo: repo,
emailProvider: noopEmailProvider{},
smsProvider: noopSMSProvider{},
emailProvider: emailProvider,
now: func() time.Time { return time.Now().UTC() },
}
}
@@ -86,15 +83,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
provider = receipt.Provider
externalID = receipt.ExternalID
}
case "sms":
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
if sendErr != nil {
status = "failed"
errorMessage = sendErr.Error()
} else {
provider = receipt.Provider
externalID = receipt.ExternalID
}
default:
status = "failed"
errorMessage = ErrUnsupportedChannel.Error()
@@ -103,8 +91,6 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
if provider == "unknown" {
if job.Channel == "email" {
provider = "noop-email"
} else if job.Channel == "sms" {
provider = "noop-sms"
}
}
@@ -134,23 +120,53 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
return response, nil
}
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
subject, body := renderReminderCopy(job)
return EmailMessage{
From: from,
To: job.CustomerEmail,
Subject: subject,
Text: body,
// SendBookingConfirmation sends a booking confirmation email to the customer
func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmailData) error {
if data.BusinessEmail == "" {
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeConfirmation
msg := RenderEmailMessage(data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
subject, body := renderReminderCopy(job)
return SMSMessage{
From: from,
To: job.CustomerEmail,
Text: fmt.Sprintf("%s: %s", subject, body),
// SendBookingReschedule sends a reschedule notification email to the customer
func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailData) error {
if data.BusinessEmail == "" {
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeReschedule
msg := RenderEmailMessage(data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// SendBookingCancellation sends a cancellation notification email to the customer
func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmailData) error {
if data.BusinessEmail == "" {
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeCancellation
msg := RenderEmailMessage(data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// SendBusinessNotification sends a notification to the business about a new booking
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
if businessEmail == "" {
return nil // Skip if no business email configured
}
data.Type = EmailTypeBusinessNotify
msg := RenderEmailMessage(data)
msg.To = businessEmail
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
return RenderReminderEmail(from, job)
}
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
@@ -190,9 +206,6 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
}
func reminderRecipient(job db.ReminderJobRecord) string {
if job.Channel == "email" {
return job.CustomerEmail
}
return job.CustomerEmail
}
@@ -208,14 +221,149 @@ func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (Delivery
}, nil
}
type noopSMSProvider struct{}
type smtpEmailProvider struct {
cfg config.Config
}
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
if message.To == "" {
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
if strings.TrimSpace(message.To) == "" {
return DeliveryReceipt{Provider: "smtp"}, errors.New("missing email recipient")
}
host := strings.TrimSpace(p.cfg.SMTPHost)
if host == "" {
return DeliveryReceipt{Provider: "smtp"}, errors.New("smtp host is not configured")
}
address := net.JoinHostPort(host, strings.TrimSpace(p.cfg.SMTPPort))
var auth smtp.Auth
if p.cfg.SMTPUsername != "" {
auth = smtp.PlainAuth("", p.cfg.SMTPUsername, p.cfg.SMTPPassword, host)
}
// Build multipart email with both plain text and HTML
var payload string
if message.HTML != "" {
boundary := "BOOKRA-BOUNDARY"
payload = strings.Join([]string{
fmt.Sprintf("From: %s", message.From),
fmt.Sprintf("To: %s", message.To),
fmt.Sprintf("Subject: %s", message.Subject),
"MIME-Version: 1.0",
fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary),
"",
fmt.Sprintf("--%s", boundary),
"Content-Type: text/plain; charset=UTF-8",
"",
message.Text,
"",
fmt.Sprintf("--%s", boundary),
"Content-Type: text/html; charset=UTF-8",
"",
message.HTML,
"",
fmt.Sprintf("--%s--", boundary),
}, "\r\n")
} else {
payload = strings.Join([]string{
fmt.Sprintf("From: %s", message.From),
fmt.Sprintf("To: %s", message.To),
fmt.Sprintf("Subject: %s", message.Subject),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
message.Text,
}, "\r\n")
}
if err := smtp.SendMail(address, auth, message.From, []string{message.To}, []byte(payload)); err != nil {
return DeliveryReceipt{Provider: "smtp"}, err
}
return DeliveryReceipt{
Provider: "noop-sms",
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
Provider: "smtp",
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
}, nil
}
// SendContactEmail sends a contact form submission to the business email
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
html := fmt.Sprintf(
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
name, email, message,
)
msg := EmailMessage{
From: s.cfg.EmailFrom,
To: s.cfg.EmailFrom,
Subject: subject,
Text: text,
HTML: html,
}
_, err := s.emailProvider.Send(ctx, msg)
return err
}
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
emailData := UsageNotificationData{
Type: EmailTypeUsageWarning,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
LocationCount: locationCount,
LocationLimit: locationLimit,
UsagePercent: usagePercent,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}
// SendRawEmail sends a pre-built email message
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
if msg.From == "" {
msg.From = s.cfg.EmailFrom
}
return s.emailProvider.Send(ctx, msg)
}
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
emailData := UsageNotificationData{
Type: EmailTypeTrialEnding,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
_, err = s.emailProvider.Send(ctx, msg)
return err
}
@@ -38,7 +38,6 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
service := NewService(config.Config{
Environment: "development",
EmailFrom: "noreply@bookra.dev",
SMSFrom: "Bookra",
}, repo)
response, err := service.DispatchDue(context.Background(), 10)
+17
View File
@@ -0,0 +1,17 @@
package shared
import "strings"
// NormalizePlanCode canonicalizes plan codes from various sources
// (Paddle checkout, webhook payloads, database records) into stable
// internal identifiers used across the billing and tenancy domains.
func NormalizePlanCode(planCode string) string {
switch strings.TrimSpace(planCode) {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return strings.TrimSpace(planCode)
}
}
+80
View File
@@ -0,0 +1,80 @@
package sms
import (
"context"
"fmt"
"strings"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/invoiceitem"
)
type BillingService struct {
cfg config.Config
repo db.Repository
smsPriceIDs map[string]string // currency -> price ID
}
func NewBillingService(cfg config.Config, repo db.Repository) *BillingService {
return &BillingService{
cfg: cfg,
repo: repo,
smsPriceIDs: cfg.StripeSMSPriceMatrix,
}
}
// PriceIDForCurrency returns the Stripe price ID for SMS in the given currency
func (b *BillingService) PriceIDForCurrency(currency string) string {
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
if id := b.smsPriceIDs[c]; id != "" {
return id
}
// Fallback to CZK
return b.smsPriceIDs["czk"]
}
// CreateMonthlyInvoiceItem creates a Stripe InvoiceItem for the total SMS usage of a month.
// This adds a line item to the customer's next invoice — charging all messages together.
func (b *BillingService) CreateMonthlyInvoiceItem(ctx context.Context, customerID string, currency string, yearMonth string, messageCount int, totalCents int) (string, error) {
if customerID == "" {
return "", fmt.Errorf("customer id is empty")
}
priceID := b.PriceIDForCurrency(currency)
c := strings.ToLower(strings.TrimSpace(currency))
if c == "" {
c = "czk"
}
// If a Stripe price is configured, use Price + Quantity for a clean invoice line
if priceID != "" {
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(messageCount)),
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
})
if err != nil {
return "", fmt.Errorf("failed to create invoice item: %w", err)
}
return item.ID, nil
}
// Fallback: explicit amount (for dev/testing when no price configured yet)
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
Customer: stripe.String(customerID),
Amount: stripe.Int64(int64(totalCents)),
Currency: stripe.String(c),
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
})
if err != nil {
return "", fmt.Errorf("failed to create invoice item: %w", err)
}
return item.ID, nil
}
+461
View File
@@ -0,0 +1,461 @@
package sms
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/shared"
)
var (
ErrSMSNotConfigured = errors.New("sms is not configured")
ErrSMSNotEnabled = errors.New("sms is not enabled for this tenant")
ErrSMSPlanNotAllowed = errors.New("sms is only available on pro and business plans")
ErrSMSLimitReached = errors.New("monthly sms limit reached")
ErrSMSInvalidPhone = errors.New("invalid phone number")
ErrSMSMissingAPIKey = errors.New("sms manager api key is missing")
ErrSMSSendFailed = errors.New("sms send failed")
ErrStripeNotConfigured = errors.New("stripe is not configured for sms billing")
ErrNoActiveSubscription = errors.New("no active subscription for sms billing")
)
const smsCostCents = 150 // 1.50 CZK
type Service struct {
cfg config.Config
repo db.Repository
billing *BillingService
client *http.Client
baseURL string
apiKey string
}
func NewService(cfg config.Config, repo db.Repository) *Service {
s := &Service{
cfg: cfg,
repo: repo,
client: &http.Client{Timeout: 15 * time.Second},
baseURL: strings.TrimRight(cfg.SMSManagerBaseURL, "/"),
apiKey: cfg.SMSManagerAPIKey,
}
if cfg.StripeConfigured() && cfg.StripeSMSConfigured() {
s.billing = NewBillingService(cfg, repo)
}
return s
}
func (s *Service) Enabled() bool {
return s.cfg.SMSConfigured()
}
func (s *Service) IsAvailable(ctx context.Context, tenantID string) (bool, error) {
if !s.Enabled() {
return false, nil
}
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return false, err
}
return settings.Enabled, nil
}
func (s *Service) canUseSMS(ctx context.Context, tenantID string) error {
if !s.Enabled() {
return ErrSMSNotConfigured
}
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return err
}
plan := shared.NormalizePlanCode(tenant.PlanCode)
if plan != "pro" && plan != "business" {
return ErrSMSPlanNotAllowed
}
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
return ErrNoActiveSubscription
}
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return err
}
if !settings.Enabled {
return ErrSMSNotEnabled
}
return nil
}
func (s *Service) GetSettings(ctx context.Context, tenantID string) (domain.SMSSettings, error) {
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return domain.SMSSettings{}, err
}
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
if err != nil {
return domain.SMSSettings{}, err
}
return domain.SMSSettings{
Enabled: settings.Enabled,
SenderName: settings.SenderName,
MonthlyLimit: settings.MonthlyLimit,
MessagesSent: usage.MessageCount,
TotalCostCents: usage.TotalCostCents,
Available: s.Enabled(),
}, nil
}
func (s *Service) UpdateSettings(ctx context.Context, tenantID string, req domain.UpdateSMSSettingsRequest) (domain.SMSSettings, error) {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return domain.SMSSettings{}, err
}
plan := shared.NormalizePlanCode(tenant.PlanCode)
if plan != "pro" && plan != "business" {
return domain.SMSSettings{}, ErrSMSPlanNotAllowed
}
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
return domain.SMSSettings{}, ErrNoActiveSubscription
}
if err := s.repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{
TenantID: tenantID,
Enabled: req.Enabled,
SenderName: strings.TrimSpace(req.SenderName),
MonthlyLimit: req.MonthlyLimit,
}); err != nil {
return domain.SMSSettings{}, err
}
return s.GetSettings(ctx, tenantID)
}
func (s *Service) SendMessage(ctx context.Context, tenantID string, req domain.SendSMSRequest) (domain.SendSMSResponse, error) {
if err := s.canUseSMS(ctx, tenantID); err != nil {
return domain.SendSMSResponse{}, err
}
phone := normalizePhone(req.To)
if phone == "" {
return domain.SendSMSResponse{}, ErrSMSInvalidPhone
}
// Check monthly limit
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
if err != nil {
return domain.SendSMSResponse{}, err
}
if settings.MonthlyLimit > 0 {
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
if err != nil {
return domain.SendSMSResponse{}, err
}
if usage.MessageCount >= settings.MonthlyLimit {
return domain.SendSMSResponse{}, ErrSMSLimitReached
}
}
// Send via SMS Manager
resp, err := s.sendToSMSManager(ctx, phone, req.Body, settings.SenderName)
if err != nil {
return domain.SendSMSResponse{}, err
}
// Extract message ID from accepted recipients
messageID := ""
if len(resp.Accepted) > 0 {
messageID = resp.Accepted[0].MessageID
}
// Log usage locally (Stripe billing happens once at month-end)
logID, err := s.repo.CreateSMSUsageLog(ctx, db.SMSUsageLogRecord{
TenantID: tenantID,
RecipientPhone: phone,
MessageBody: req.Body,
ExternalMessageID: messageID,
ExternalRequestID: resp.RequestID,
Status: "sent",
CostCents: smsCostCents,
SentAt: time.Now().UTC(),
})
if err != nil {
return domain.SendSMSResponse{}, fmt.Errorf("failed to log sms usage: %w", err)
}
return domain.SendSMSResponse{
LogID: logID,
MessageID: messageID,
RequestID: resp.RequestID,
Status: "sent",
CostCents: smsCostCents,
}, nil
}
func (s *Service) GetUsage(ctx context.Context, tenantID string, yearMonth string) (domain.SMSUsageReport, error) {
report, err := s.repo.GetSMSUsageForMonth(ctx, tenantID, yearMonth)
if err != nil {
return domain.SMSUsageReport{}, err
}
return domain.SMSUsageReport{
YearMonth: report.YearMonth,
MessageCount: report.MessageCount,
TotalCostCents: report.TotalCostCents,
}, nil
}
func (s *Service) GetUsageHistory(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageLog, error) {
if limit <= 0 {
limit = 50
}
records, err := s.repo.ListSMSUsageLogs(ctx, tenantID, limit)
if err != nil {
return nil, err
}
logs := make([]domain.SMSUsageLog, len(records))
for i, r := range records {
logs[i] = domain.SMSUsageLog{
ID: r.ID,
RecipientPhone: r.RecipientPhone,
MessageBody: r.MessageBody,
Status: r.Status,
CostCents: r.CostCents,
CreatedAt: r.CreatedAt,
}
}
return logs, nil
}
func (s *Service) GetMonthlyReports(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageReport, error) {
if limit <= 0 {
limit = 12
}
records, err := s.repo.ListSMSMonthlyReports(ctx, tenantID, limit)
if err != nil {
return nil, err
}
reports := make([]domain.SMSUsageReport, len(records))
for i, r := range records {
reports[i] = domain.SMSUsageReport{
YearMonth: r.YearMonth,
MessageCount: r.MessageCount,
TotalCostCents: r.TotalCostCents,
StripeInvoiceID: r.StripeInvoiceID,
InvoiceSentAt: r.InvoiceSentAt,
}
}
return reports, nil
}
// GenerateMonthlyInvoices creates/finalizes monthly invoices for SMS usage
func (s *Service) GenerateMonthlyInvoices(ctx context.Context, yearMonth string, notificationSvc *notifications.Service) (domain.SMSInvoiceBatchResponse, error) {
if yearMonth == "" {
now := time.Now().UTC()
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
}
response := domain.SMSInvoiceBatchResponse{YearMonth: yearMonth}
// Get all tenants with SMS enabled that have usage this month
tenants, err := s.repo.ListTenantsWithSMSUsage(ctx, yearMonth)
if err != nil {
return response, err
}
for _, t := range tenants {
report, err := s.repo.GetSMSUsageForMonth(ctx, t.ID, yearMonth)
if err != nil {
response.FailedCount++
continue
}
if report.MessageCount == 0 {
continue
}
// Create Stripe InvoiceItem for total monthly SMS usage
// This adds one line to the customer's next invoice: all messages together
stripeInvoiceItemID := ""
if s.billing != nil {
customerID := ""
if t.BillingCustomerID != nil {
customerID = *t.BillingCustomerID
}
if customerID != "" {
currency := "czk"
if t.BillingProvider != "" {
// Try to infer currency from billing snapshot if available
snap, _ := s.repo.GetSubscriptionSnapshot(ctx, t.ID)
if snap.Currency != "" {
currency = snap.Currency
}
}
itemID, err := s.billing.CreateMonthlyInvoiceItem(ctx, customerID, currency, yearMonth, report.MessageCount, report.TotalCostCents)
if err == nil {
stripeInvoiceItemID = itemID
}
}
}
if err := s.repo.UpsertSMSMonthlyReport(ctx, db.SMSMonthlyReportRecord{
TenantID: t.ID,
YearMonth: yearMonth,
MessageCount: report.MessageCount,
TotalCostCents: report.TotalCostCents,
StripeInvoiceID: stripeInvoiceItemID,
}); err != nil {
response.FailedCount++
continue
}
// Send usage summary email
if notificationSvc != nil {
_ = s.sendUsageSummaryEmail(ctx, t, report, notificationSvc)
}
response.ProcessedCount++
}
return response, nil
}
func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRecord, report db.SMSMonthlyReportRecord, svc *notifications.Service) error {
// Get brand profile for email styling
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
data := notifications.SMSUsageEmailData{
TenantName: brand.Name,
TenantSlug: tenant.Slug,
BusinessEmail: "",
YearMonth: report.YearMonth,
MessageCount: report.MessageCount,
TotalCostCents: report.TotalCostCents,
Locale: tenant.Locale,
}
// Find owner email
membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID)
if err == nil {
user, err := s.repo.GetUserByID(ctx, membership.UserID)
if err == nil {
data.BusinessEmail = user.Email
}
}
if data.BusinessEmail == "" {
return errors.New("no business email found")
}
msg := notifications.RenderSMSUsageEmail(data)
_, err = svc.SendRawEmail(ctx, msg)
if err == nil {
_ = s.repo.MarkSMSReportInvoiceSent(ctx, report.TenantID, report.YearMonth)
}
return err
}
// --- SMS Manager API client ---
type smsManagerMessage struct {
Body string `json:"body"`
To []struct {
PhoneNumber string `json:"phone_number"`
} `json:"to"`
Tag string `json:"tag,omitempty"`
}
type smsManagerResponse struct {
RequestID string `json:"request_id"`
Accepted []struct {
Key string `json:"key"`
MessageID string `json:"message_id"`
} `json:"accepted"`
Rejected []struct {
Key string `json:"key"`
Message string `json:"message,omitempty"`
} `json:"rejected"`
}
func (s *Service) sendToSMSManager(ctx context.Context, phone, body, senderName string) (smsManagerResponse, error) {
if s.apiKey == "" {
return smsManagerResponse{}, ErrSMSMissingAPIKey
}
payload := smsManagerMessage{
Body: body,
To: []struct {
PhoneNumber string `json:"phone_number"`
}{{PhoneNumber: phone}},
Tag: "transactional",
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return smsManagerResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/message", bytes.NewReader(jsonBody))
if err != nil {
return smsManagerResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", s.apiKey)
resp, err := s.client.Do(req)
if err != nil {
return smsManagerResponse{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return smsManagerResponse{}, err
}
if resp.StatusCode != http.StatusOK {
return smsManagerResponse{}, fmt.Errorf("%w: status=%d body=%s", ErrSMSSendFailed, resp.StatusCode, string(respBody))
}
var result smsManagerResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return smsManagerResponse{}, err
}
if len(result.Rejected) > 0 && len(result.Accepted) == 0 {
return result, fmt.Errorf("%w: %v", ErrSMSSendFailed, result.Rejected)
}
return result, nil
}
func normalizePhone(phone string) string {
p := strings.TrimSpace(phone)
p = strings.ReplaceAll(p, " ", "")
p = strings.ReplaceAll(p, "-", "")
p = strings.TrimPrefix(p, "+")
p = strings.TrimPrefix(p, "00")
// Czech default if no country code and 9 digits
if len(p) == 9 {
p = "420" + p
}
return p
}
+91
View File
@@ -0,0 +1,91 @@
package sms
import (
"context"
"testing"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestSMSServiceEnabled(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
svc := NewService(cfg, db.NewMemoryRepository())
if !svc.Enabled() {
t.Fatal("expected SMS service to be enabled")
}
}
func TestSMSServiceDisabledWithoutKey(t *testing.T) {
cfg := config.Config{}
svc := NewService(cfg, db.NewMemoryRepository())
if svc.Enabled() {
t.Fatal("expected SMS service to be disabled")
}
}
func TestGetSettingsForNewTenant(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
// Use the default tenant ID from memory repository
settings, err := svc.GetSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.Enabled {
t.Fatal("expected SMS to be disabled by default")
}
if !settings.Available {
t.Fatal("expected SMS to be available when configured")
}
}
func TestUpdateSettingsRequiresProOrBusiness(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
// Memory repo tenant is "pro" by default. Change to starter by modifying tenant.
_, err := svc.UpdateSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.UpdateSMSSettingsRequest{Enabled: true})
// The memory repo tenant is "pro", so this should succeed
if err != nil {
t.Fatalf("unexpected error for pro tenant: %v", err)
}
}
func TestSendMessageRequiresEnabledSMS(t *testing.T) {
cfg := config.Config{SMSManagerAPIKey: "test-key"}
repo := db.NewMemoryRepository()
svc := NewService(cfg, repo)
ctx := context.Background()
_, err := svc.SendMessage(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.SendSMSRequest{To: "+420777123456", Body: "Hello"})
if err != ErrSMSNotEnabled {
t.Fatalf("expected ErrSMSNotEnabled, got: %v", err)
}
}
func TestNormalizePhone(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"+420 777 123 456", "420777123456"},
{"420777123456", "420777123456"},
{"777123456", "420777123456"},
{" 777-123-456 ", "420777123456"},
{"+49 151 12345678", "4915112345678"},
}
for _, tc := range tests {
result := normalizePhone(tc.input)
if result != tc.expected {
t.Errorf("normalizePhone(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
+156 -18
View File
@@ -9,6 +9,7 @@ import (
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/shared"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@@ -30,6 +31,10 @@ func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) {
return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
}
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
if err != nil {
@@ -52,12 +57,15 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
}
return domain.TenantBootstrap{
TenantID: membership.Tenant.ID,
TenantName: membership.Tenant.Name,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
TenantID: membership.Tenant.ID,
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
OnboardingCompleted: true,
Brand: s.brandProfile(ctx, membership.Tenant),
CurrentUser: domain.Principal{
Subject: principal.Subject,
Email: principal.Email,
@@ -79,6 +87,21 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
preset := strings.TrimSpace(request.Preset)
locale := strings.TrimSpace(request.Locale)
timezone := strings.TrimSpace(request.Timezone)
locationName := strings.TrimSpace(request.LocationName)
brand := request.Brand
if strings.TrimSpace(brand.Name) == "" {
brand.Name = name
}
defaults := request.BookingDefaults
if strings.TrimSpace(defaults.ServiceName) == "" {
defaults.ServiceName = "First appointment"
}
if defaults.DurationMinutes == 0 {
defaults.DurationMinutes = 60
}
if defaults.CancelWindowHours == 0 {
defaults.CancelWindowHours = 24
}
switch {
case len(name) < 2 || len(name) > 80:
@@ -91,18 +114,43 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case timezone == "":
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case len(locationName) > 120:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.DurationMinutes < 15 || defaults.DurationMinutes > 480:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.BufferBeforeMinutes < 0 || defaults.BufferBeforeMinutes > 180:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.BufferAfterMinutes < 0 || defaults.BufferAfterMinutes > 180:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.CancelWindowHours < 1 || defaults.CancelWindowHours > 720:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
}
if _, err := time.LoadLocation(timezone); err != nil {
return domain.TenantBootstrap{}, ErrInvalidOnboarding
}
if err := validateAvailabilityBlocks(request.AvailabilityBlocks); err != nil {
return domain.TenantBootstrap{}, err
}
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
Subject: principal.Subject,
Name: name,
Slug: slug,
Preset: preset,
Locale: locale,
Timezone: timezone,
Subject: principal.Subject,
Name: name,
Slug: slug,
Preset: preset,
Locale: locale,
Timezone: timezone,
BrandName: strings.TrimSpace(brand.Name),
SiteURL: strings.TrimSpace(brand.SiteURL),
LogoURL: strings.TrimSpace(brand.LogoURL),
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
LocationName: locationName,
ServiceName: strings.TrimSpace(defaults.ServiceName),
DurationMinutes: defaults.DurationMinutes,
BufferBeforeMinutes: defaults.BufferBeforeMinutes,
BufferAfterMinutes: defaults.BufferAfterMinutes,
CancelWindowHours: defaults.CancelWindowHours,
AvailabilityBlocks: toAvailabilityBlocks(request.AvailabilityBlocks),
TeamInvites: toTeamInvites(request.TeamInvites),
})
if err != nil {
var pgErr *pgconn.PgError
@@ -113,12 +161,20 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
}
return domain.TenantBootstrap{
TenantID: membership.Tenant.ID,
TenantName: membership.Tenant.Name,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: membership.Tenant.PlanCode,
TenantID: membership.Tenant.ID,
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
OnboardingCompleted: true,
Brand: domain.BrandProfile{
Name: strings.TrimSpace(brand.Name),
SiteURL: strings.TrimSpace(brand.SiteURL),
LogoURL: strings.TrimSpace(brand.LogoURL),
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
},
CurrentUser: domain.Principal{
Subject: principal.Subject,
Email: principal.Email,
@@ -127,3 +183,85 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
},
}, nil
}
func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile {
brand, err := s.repo.GetBrandProfile(ctx, tenant.ID)
if err != nil {
return domain.BrandProfile{Name: tenant.Name}
}
return domain.BrandProfile{
Name: firstNonEmpty(brand.Name, tenant.Name),
SiteURL: brand.SiteURL,
LogoURL: brand.LogoURL,
PrimaryColor: brand.PrimaryColor,
}
}
func validateAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) error {
for _, block := range blocks {
if block.DayOfWeek < 0 || block.DayOfWeek > 6 {
return ErrInvalidOnboarding
}
starts, err := time.Parse("15:04", block.StartsLocal)
if err != nil {
starts, err = time.Parse("15:04:05", block.StartsLocal)
}
if err != nil {
return ErrInvalidOnboarding
}
ends, err := time.Parse("15:04", block.EndsLocal)
if err != nil {
ends, err = time.Parse("15:04:05", block.EndsLocal)
}
if err != nil || !ends.After(starts) {
return ErrInvalidOnboarding
}
}
return nil
}
func toAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) []db.AvailabilityBlockRecord {
records := make([]db.AvailabilityBlockRecord, 0, len(blocks))
for _, block := range blocks {
records = append(records, db.AvailabilityBlockRecord{
DayOfWeek: block.DayOfWeek,
StartsLocal: normalizeClock(block.StartsLocal),
EndsLocal: normalizeClock(block.EndsLocal),
Busy: block.Busy,
})
}
return records
}
func toTeamInvites(invites []domain.TeamInviteRequest) []db.TeamInviteRecord {
records := make([]db.TeamInviteRecord, 0, len(invites))
for _, invite := range invites {
email := strings.TrimSpace(strings.ToLower(invite.Email))
if email == "" {
continue
}
role := strings.TrimSpace(invite.Role)
if role == "" {
role = "staff"
}
records = append(records, db.TeamInviteRecord{Email: email, Role: role})
}
return records
}
func normalizeClock(value string) string {
value = strings.TrimSpace(value)
if len(value) == len("15:04") {
return value + ":00"
}
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+1 -1
View File
@@ -8,7 +8,7 @@ INSERT INTO tenants (
'studio',
'cs',
'Europe/Prague',
'growth',
'pro',
'active'
)
ON CONFLICT (id) DO NOTHING;
@@ -0,0 +1,132 @@
-- +goose Up
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
ALTER TABLE billing_snapshots
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
ALTER TABLE subscription_events
ADD COLUMN IF NOT EXISTS billing_provider text NOT NULL DEFAULT 'paddle';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tenants' AND column_name = 'stripe_customer_id'
) THEN
ALTER TABLE tenants RENAME COLUMN stripe_customer_id TO billing_customer_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tenants' AND column_name = 'stripe_subscription_id'
) THEN
ALTER TABLE tenants RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'billing_snapshots' AND column_name = 'stripe_customer_id'
) THEN
ALTER TABLE billing_snapshots RENAME COLUMN stripe_customer_id TO billing_customer_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'billing_snapshots' AND column_name = 'stripe_subscription_id'
) THEN
ALTER TABLE billing_snapshots RENAME COLUMN stripe_subscription_id TO billing_subscription_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'subscription_events' AND column_name = 'stripe_event_id'
) THEN
ALTER TABLE subscription_events RENAME COLUMN stripe_event_id TO billing_provider_event_id;
END IF;
END $$;
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_stripe_event_id_key;
ALTER TABLE subscription_events
ADD CONSTRAINT subscription_events_provider_event_key UNIQUE (billing_provider, billing_provider_event_id);
-- +goose Down
ALTER TABLE subscription_events DROP CONSTRAINT IF EXISTS subscription_events_provider_event_key;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'subscription_events' AND column_name = 'billing_provider_event_id'
) THEN
ALTER TABLE subscription_events RENAME COLUMN billing_provider_event_id TO stripe_event_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'billing_snapshots' AND column_name = 'billing_subscription_id'
) THEN
ALTER TABLE billing_snapshots RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'billing_snapshots' AND column_name = 'billing_customer_id'
) THEN
ALTER TABLE billing_snapshots RENAME COLUMN billing_customer_id TO stripe_customer_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tenants' AND column_name = 'billing_subscription_id'
) THEN
ALTER TABLE tenants RENAME COLUMN billing_subscription_id TO stripe_subscription_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tenants' AND column_name = 'billing_customer_id'
) THEN
ALTER TABLE tenants RENAME COLUMN billing_customer_id TO stripe_customer_id;
END IF;
END $$;
ALTER TABLE subscription_events DROP COLUMN IF EXISTS billing_provider;
ALTER TABLE billing_snapshots DROP COLUMN IF EXISTS billing_provider;
ALTER TABLE tenants DROP COLUMN IF EXISTS billing_provider;
ALTER TABLE subscription_events
ADD CONSTRAINT subscription_events_stripe_event_id_key UNIQUE (stripe_event_id);
@@ -0,0 +1,97 @@
-- +goose Up
-- +goose StatementBegin
-- Users table for authentication (migrated from auth-service)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255),
password_hash VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
provider VARCHAR(50) NOT NULL DEFAULT 'email',
provider_id VARCHAR(255),
role VARCHAR(50) NOT NULL DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
-- Magic links for passwordless auth
CREATE TABLE IF NOT EXISTS magic_links (
token VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
-- Password reset tokens
CREATE TABLE IF NOT EXISTS password_resets (
token VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id ON password_resets(user_id);
-- OAuth state tokens
CREATE TABLE IF NOT EXISTS oauth_states (
state VARCHAR(255) PRIMARY KEY,
redirect_url VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
-- Admin audit log
CREATE TABLE IF NOT EXISTS admin_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(100),
resource_id VARCHAR(255),
details JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_admin ON admin_audit_log(admin_user_id);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log(created_at);
-- Refresh tokens for JWT
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS admin_audit_log;
DROP TABLE IF EXISTS oauth_states;
DROP TABLE IF EXISTS password_resets;
DROP TABLE IF EXISTS magic_links;
DROP TABLE IF EXISTS users;
-- +goose StatementEnd
@@ -0,0 +1,61 @@
-- +goose Up
ALTER TABLE billing_snapshots
ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'czk';
CREATE TABLE IF NOT EXISTS brand_profiles (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
site_url text,
logo_url text,
primary_color text,
umami_site_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_settings (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
cancel_window_hours integer NOT NULL DEFAULT 24,
onboarding_completed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS team_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email text NOT NULL,
role text NOT NULL DEFAULT 'staff',
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email)
);
UPDATE tenants SET plan_code = 'pro' WHERE plan_code = 'growth';
UPDATE tenants SET plan_code = 'business' WHERE plan_code = 'multi-location';
UPDATE billing_snapshots SET plan_code = 'pro' WHERE plan_code = 'growth';
UPDATE billing_snapshots SET plan_code = 'business' WHERE plan_code = 'multi-location';
INSERT INTO brand_profiles (tenant_id, name)
SELECT id, name
FROM tenants
ON CONFLICT (tenant_id) DO NOTHING;
INSERT INTO tenant_settings (tenant_id, onboarding_completed)
SELECT id, true
FROM tenants
ON CONFLICT (tenant_id) DO NOTHING;
-- +goose Down
UPDATE tenants SET plan_code = 'growth' WHERE plan_code = 'pro';
UPDATE tenants SET plan_code = 'multi-location' WHERE plan_code = 'business';
UPDATE billing_snapshots SET plan_code = 'growth' WHERE plan_code = 'pro';
UPDATE billing_snapshots SET plan_code = 'multi-location' WHERE plan_code = 'business';
DROP TABLE IF EXISTS team_invites;
DROP TABLE IF EXISTS tenant_settings;
DROP TABLE IF EXISTS brand_profiles;
ALTER TABLE billing_snapshots
DROP COLUMN IF EXISTS currency;
@@ -0,0 +1,20 @@
-- +goose Up
-- Create customers table for customer management
CREATE TABLE IF NOT EXISTS customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
email text NOT NULL,
phone text,
status text NOT NULL DEFAULT 'active',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_customers_tenant ON customers (tenant_id);
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers (tenant_id, email);
CREATE INDEX IF NOT EXISTS idx_customers_status ON customers (tenant_id, status);
-- +goose Down
DROP TABLE IF EXISTS customers;
+47
View File
@@ -0,0 +1,47 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS tenant_sms_settings (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
enabled boolean NOT NULL DEFAULT false,
sender_name text NOT NULL DEFAULT '',
monthly_limit integer,
stripe_subscription_item_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sms_usage_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
recipient_phone text NOT NULL,
message_body text NOT NULL,
external_message_id text,
external_request_id text,
status text NOT NULL DEFAULT 'pending',
cost_cents integer NOT NULL DEFAULT 150,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sms_monthly_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
year_month text NOT NULL,
message_count integer NOT NULL DEFAULT 0,
total_cost_cents integer NOT NULL DEFAULT 0,
stripe_invoice_id text,
invoice_sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, year_month)
);
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_month ON sms_usage_logs (tenant_id, date_trunc('month', created_at));
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_created ON sms_usage_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sms_reports_tenant ON sms_monthly_reports (tenant_id, year_month DESC);
-- +goose Down
DROP INDEX IF EXISTS idx_sms_reports_tenant;
DROP INDEX IF EXISTS idx_sms_usage_tenant_created;
DROP INDEX IF EXISTS idx_sms_usage_tenant_month;
DROP TABLE IF EXISTS sms_monthly_reports;
DROP TABLE IF EXISTS sms_usage_logs;
DROP TABLE IF EXISTS tenant_sms_settings;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS customer_phone text;
-- +goose Down
ALTER TABLE bookings DROP COLUMN IF EXISTS customer_phone;
+248 -12
View File
@@ -4,7 +4,7 @@ info:
version: 0.1.0
description: >
Remote-first booking API for Bookra. The Go backend owns business rules,
scheduling logic, tenant isolation, and Stripe-backed plan enforcement.
scheduling logic, tenant isolation, and Paddle-backed plan enforcement.
servers:
- url: http://localhost:8080
tags:
@@ -155,15 +155,17 @@ paths:
$ref: "#/components/schemas/CheckoutSessionRequest"
responses:
"200":
description: Hosted Stripe checkout session
description: Paddle checkout launch payload
content:
application/json:
schema:
$ref: "#/components/schemas/CheckoutSessionResponse"
$ref: "#/components/schemas/CheckoutLaunchResponse"
"400":
description: Invalid request
"401":
description: Unauthorized
"503":
description: Billing provider unavailable
/v1/billing/refresh:
post:
tags: [Billing]
@@ -179,10 +181,31 @@ paths:
$ref: "#/components/schemas/SubscriptionSnapshot"
"401":
description: Unauthorized
/v1/webhooks/stripe:
"503":
description: Billing provider unavailable
/v1/billing/portal:
post:
tags: [Billing]
operationId: handleStripeWebhook
operationId: createBillingPortalSession
security:
- bearerAuth: []
responses:
"200":
description: Paddle customer portal session
content:
application/json:
schema:
$ref: "#/components/schemas/PortalSessionResponse"
"400":
description: Billing customer missing
"401":
description: Unauthorized
"503":
description: Billing provider unavailable
/v1/webhooks/paddle:
post:
tags: [Billing]
operationId: handlePaddleWebhook
requestBody:
required: true
content:
@@ -238,7 +261,7 @@ components:
type: boolean
PublicConfig:
type: object
required: [environment, neonAuthEnabled]
required: [environment, neonAuthEnabled, apiUrl, demoMode]
properties:
environment:
type: string
@@ -247,6 +270,8 @@ components:
apiUrl:
type: string
format: uri
demoMode:
type: boolean
TimeSlot:
type: object
required: [startsAt, endsAt, mode]
@@ -354,20 +379,84 @@ components:
type: string
DashboardSummary:
type: object
required: [tenantName, locale, timezone, planCode, kpis]
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]
properties:
tenantName:
type: string
tenantSlug:
type: string
locale:
type: string
timezone:
type: string
planCode:
type: string
publicBookingUrl:
type: string
setupCompletion:
type: integer
kpis:
type: array
items:
$ref: "#/components/schemas/DashboardKPI"
upcomingBookings:
type: array
items:
$ref: "#/components/schemas/UpcomingBooking"
widgetSnippets:
type: array
items:
$ref: "#/components/schemas/WidgetSnippet"
tracking:
$ref: "#/components/schemas/TrackingStatus"
UpcomingBooking:
type: object
properties:
reference:
type: string
customerName:
type: string
customerEmail:
type: string
startsAt:
type: string
format: date-time
endsAt:
type: string
format: date-time
status:
type: string
label:
type: string
WidgetSnippet:
type: object
properties:
kind:
type: string
code:
type: string
TrackingStatus:
type: object
properties:
provider:
type: string
connected:
type: boolean
siteId:
type: string
message:
type: string
BrandProfile:
type: object
properties:
name:
type: string
siteUrl:
type: string
logoUrl:
type: string
primaryColor:
type: string
TenantBootstrap:
type: object
required: [tenantId, tenantName, preset, locale, timezone, currentUser]
@@ -377,6 +466,8 @@ components:
format: uuid
tenantName:
type: string
tenantSlug:
type: string
preset:
type: string
locale:
@@ -385,6 +476,10 @@ components:
type: string
planCode:
type: string
onboardingCompleted:
type: boolean
brand:
$ref: "#/components/schemas/BrandProfile"
currentUser:
type: object
required: [subject, role]
@@ -414,25 +509,84 @@ components:
enum: [cs, en]
timezone:
type: string
brand:
$ref: "#/components/schemas/BrandProfile"
locationName:
type: string
bookingDefaults:
$ref: "#/components/schemas/BookingDefaultsRequest"
availabilityBlocks:
type: array
items:
$ref: "#/components/schemas/AvailabilityBlockRequest"
teamInvites:
type: array
items:
$ref: "#/components/schemas/TeamInviteRequest"
BookingDefaultsRequest:
type: object
properties:
serviceName:
type: string
durationMinutes:
type: integer
bufferBeforeMinutes:
type: integer
bufferAfterMinutes:
type: integer
cancelWindowHours:
type: integer
AvailabilityBlockRequest:
type: object
required: [dayOfWeek, startsLocal, endsLocal]
properties:
dayOfWeek:
type: integer
minimum: 1
maximum: 7
startsLocal:
type: string
example: "09:00"
endsLocal:
type: string
example: "17:00"
busy:
type: boolean
TeamInviteRequest:
type: object
required: [email]
properties:
email:
type: string
format: email
role:
type: string
PlanEntitlements:
type: object
required: [maxLocations, maxStaff, smsAddonAvailable, advancedReporting]
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
properties:
maxLocations:
type: integer
maxStaff:
type: integer
smsAddonAvailable:
emailReminders:
type: boolean
widgetEmbedding:
type: boolean
umamiTracking:
type: boolean
advancedReporting:
type: boolean
SubscriptionSnapshot:
type: object
required: [tenantId, customerId, subscriptionId, status, planCode, priceId, cancelAtPeriodEnd, entitlements]
required: [tenantId, provider, customerId, subscriptionId, status, planCode, currency, priceId, cancelAtPeriodEnd, entitlements, displayPrices, trialDays, checkoutUrlAvailable, syncAvailable, portalAvailable]
properties:
tenantId:
type: string
format: uuid
provider:
type: string
example: paddle
customerId:
type: string
subscriptionId:
@@ -441,6 +595,9 @@ components:
type: string
planCode:
type: string
currency:
type: string
enum: [czk, usd, eur]
priceId:
type: string
cancelAtPeriodEnd:
@@ -459,20 +616,99 @@ components:
type: string
entitlements:
$ref: "#/components/schemas/PlanEntitlements"
displayPrices:
type: array
items:
$ref: "#/components/schemas/PlanDisplayPrice"
trialDays:
type: integer
lastSyncedAt:
type: string
format: date-time
nullable: true
checkoutUrlAvailable:
type: boolean
syncAvailable:
type: boolean
portalAvailable:
type: boolean
CheckoutSessionRequest:
type: object
required: [planCode]
properties:
planCode:
type: string
enum: [starter, growth, multi-location]
CheckoutSessionResponse:
enum: [starter, pro, business]
description: The plan to subscribe to
currency:
type: string
enum: [czk, usd]
description: Currency for the subscription
billingInterval:
type: string
enum: [monthly, yearly]
default: monthly
description: Billing interval. Yearly gets 17% discount.
PlanDisplayPrice:
type: object
required: [currency, amountCents, formatted]
properties:
currency:
type: string
enum: [czk, usd]
amountCents:
type: integer
description: Monthly price in cents
formatted:
type: string
description: Formatted monthly price string
yearlyAmountCents:
type: integer
description: Yearly price in cents (17% discount)
yearlyFormatted:
type: string
description: Formatted yearly price string
yearlySavings:
type: string
description: Description of yearly savings
yearlySavingsPercent:
type: integer
description: Percentage saved with yearly billing
CheckoutLaunchResponse:
type: object
description: |
Checkout launch response supporting both Stripe and Paddle providers.
For Stripe: checkoutUrl is returned (redirect-based checkout).
For Paddle: priceId, customerId, customerEmail, customData are returned (client-side checkout).
properties:
checkoutUrl:
type: string
format: uri
description: Stripe checkout URL (redirect the user to this URL)
priceId:
type: string
description: Paddle price ID for client-side checkout
customerId:
type: string
description: Paddle customer ID
customerEmail:
type: string
format: email
description: Customer email for Paddle checkout
successRedirectUrl:
type: string
format: uri
description: URL to redirect after successful checkout
cancelRedirectUrl:
type: string
format: uri
description: URL to redirect after cancelled checkout
customData:
type: object
additionalProperties:
type: string
description: Custom metadata for Paddle checkout
PortalSessionResponse:
type: object
required: [url]
properties:
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10,
"healthcheckPath": "/healthz",
"healthcheckTimeout": 30,
"numReplicas": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+1 -2
View File
@@ -1,5 +1,5 @@
-- name: GetTenantBySlug :one
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, stripe_customer_id, stripe_subscription_id, created_at, updated_at
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;
@@ -8,4 +8,3 @@ SELECT tenant_id, user_id, role, created_at
FROM tenant_users
WHERE tenant_id = $1
ORDER BY created_at ASC;
+6 -1
View File
@@ -1,5 +1,10 @@
VITE_BOOKRA_APP_ENV=staging
VITE_BOOKRA_API_URL=http://localhost:8080
VITE_NEON_AUTH_URL=https://example.neonauth.region.aws.neon.tech/neondb/auth
# Primary production auth path: Neon Auth.
VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
VITE_PADDLE_ENV=sandbox
VITE_PADDLE_CLIENT_TOKEN=
VITE_DEFAULT_LOCALE=cs
# Demo Mode: Set to true to show demo UI indicators
VITE_BOOKRA_DEMO_MODE=false
+21 -10
View File
@@ -1,23 +1,34 @@
<!doctype html>
<html lang="en">
<html lang="cs">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Bookra is a remote-first booking SaaS for local service businesses."
/>
<title>Bookra</title>
<meta name="description" content="Bookra — jednoduchý rezervační software pro salony, kliniky a lokální služby. Rychlé nastavení, spolehlivé plánování, automatická připomenutí." />
<meta name="theme-color" content="#f6f4ee" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1816" media="(prefers-color-scheme: dark)" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href="https://bookra.eu" />
<title>Bookra — Jednoduchý rezervační software</title>
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bookra.eu" />
<meta property="og:title" content="Bookra — Jednoduchý rezervační software" />
<meta property="og:description" content="Spravujte rezervace, zákazníky a tým na jednom místě. Bez zbytečné složitosti." />
<meta property="og:locale" content="cs_CZ" />
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Inter font with display optimization -->
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;450;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-canvas text-ink">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
+3
View File
@@ -13,7 +13,10 @@
"@bookra/api-client": "0.1.0",
"@bookra/shared-types": "0.1.0",
"@neondatabase/neon-js": "^0.2.0-beta.1",
"@paddle/paddle-js": "^1.3.2",
"@sentry/react": "^10.52.0",
"@solidjs/router": "^0.15.3",
"@stripe/stripe-js": "^4.0.0",
"solid-js": "^1.9.5"
},
"devDependencies": {
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 284 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 289 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 281 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 290 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 282 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 288 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 280 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 287 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 290 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 280 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 100 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 285 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 281 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#24201d"/>
<path d="M20 16h15.5c7.2 0 11.5 3.6 11.5 9.1 0 3.4-1.7 6.1-4.8 7.5 3.8 1.3 5.8 4.2 5.8 8.1 0 5.9-4.8 9.3-12.4 9.3H20V16Zm14.6 13.6c3.3 0 5.2-1.4 5.2-3.9 0-2.4-1.9-3.7-5.2-3.7h-7.2v7.6h7.2Zm1 14.4c3.5 0 5.5-1.5 5.5-4.2 0-2.8-2-4.3-5.5-4.3h-8.2V44h8.2Z" fill="#f7f2e8"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+31 -15
View File
@@ -1,21 +1,37 @@
import { Route } from "@solidjs/router";
import type { ParentComponent } from "solid-js";
import { createEffect } from "solid-js";
import { useLocation } from "@solidjs/router";
import { AuthProvider } from "./providers/auth-provider";
import { I18nProvider } from "./providers/i18n-provider";
import { ThemeProvider } from "./providers/theme-provider";
import { Shell } from "./components/shell";
import { DashboardRoute } from "./routes/dashboard-route";
import { HomeRoute } from "./routes/home-route";
import { PublicBookingRoute } from "./routes/public-booking-route";
export default function App() {
// ScrollToTop component that resets scroll position on route change
const ScrollToTop = () => {
const location = useLocation();
createEffect(() => {
// Reset scroll position whenever the pathname changes
location.pathname;
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
});
return null;
};
const App: ParentComponent = (props) => {
return (
<I18nProvider>
<AuthProvider>
<Shell>
<Route path="/" component={HomeRoute} />
<Route path="/dashboard" component={DashboardRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
</Shell>
</AuthProvider>
</I18nProvider>
<ThemeProvider>
<I18nProvider>
<AuthProvider>
<Shell>
<ScrollToTop />
{props.children}
</Shell>
</AuthProvider>
</I18nProvider>
</ThemeProvider>
);
}
};
export default App;

Some files were not shown because too many files have changed in this diff Show More