Compare commits
9 Commits
035ac8ddb5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2039669e2c | |||
| da5ba13eab | |||
| 9d63fa7620 | |||
| 3b6f46828b | |||
| 7d3e3448cf | |||
| 164a37e997 | |||
| cf3315e8fc | |||
| 48c3e15a38 | |||
| d854614a87 |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
bin
|
||||
coverage
|
||||
tmp
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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=
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetSubscriptionFallsBackToSnapshotAndEntitlements(t *testing.T) {
|
||||
service := NewService(config.Config{
|
||||
func testConfig() config.Config {
|
||||
return config.Config{
|
||||
FrontendURL: "http://localhost:3000",
|
||||
StripePriceIDs: map[string]string{
|
||||
"growth": "price_growth_123",
|
||||
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 err != ErrPaddleNotConfigured {
|
||||
t.Fatalf("expected ErrPaddleNotConfigured, got snapshot=%v err=%v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.Status != "active" {
|
||||
t.Fatalf("expected active status, got %s", snapshot.Status)
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if sessionCapacity > 0 && classBookings >= sessionCapacity {
|
||||
status = "waitlisted"
|
||||
}
|
||||
} 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,
|
||||
TenantSlug: membership.Tenant.Slug,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"bookra/apps/backend/internal/shared"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -14,12 +17,29 @@ type Config struct {
|
||||
DatabaseURL string
|
||||
DatabaseDirectURL string
|
||||
NeonAuthURL string
|
||||
AuthJWTSecret string
|
||||
JobRunnerKey string
|
||||
EmailFrom string
|
||||
SMSFrom string
|
||||
StripeSecretKey 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
|
||||
StripePriceIDs map[string]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) {
|
||||
@@ -31,28 +51,208 @@ func Load() (Config, error) {
|
||||
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"),
|
||||
SMSFrom: valueOrDefault("BOOKRA_SMS_FROM", "Bookra"),
|
||||
StripeSecretKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SECRET_KEY")),
|
||||
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")),
|
||||
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")),
|
||||
},
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
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"`
|
||||
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"`
|
||||
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,16 +148,43 @@ 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"`
|
||||
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"`
|
||||
@@ -100,15 +192,34 @@ type SubscriptionSnapshot struct {
|
||||
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,omitempty"`
|
||||
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
|
||||
SyncAvailable bool `json:"syncAvailable"`
|
||||
PortalAvailable bool `json:"portalAvailable"`
|
||||
}
|
||||
|
||||
type CheckoutSessionRequest struct {
|
||||
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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
|
||||
type smtpEmailProvider struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -54,10 +59,13 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
|
||||
return domain.TenantBootstrap{
|
||||
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: membership.Tenant.PlanCode,
|
||||
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,10 +114,23 @@ 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,
|
||||
@@ -103,6 +139,18 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
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
|
||||
@@ -115,10 +163,18 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
|
||||
return domain.TenantBootstrap{
|
||||
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: membership.Tenant.PlanCode,
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -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 |
|
After Width: | Height: | Size: 58 KiB |
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<Shell>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/dashboard" component={DashboardRoute} />
|
||||
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
|
||||
<ScrollToTop />
|
||||
{props.children}
|
||||
</Shell>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||