# Bookra ## Delivery Tracker - `[done]` Milestone 1: Remote-first monorepo scaffold completed - SolidJS frontend shell - Go + Gin backend shell - OpenAPI-driven TS client generation - Neon Auth adapter boundary - remote-first env layout for Vercel, Railway, and Neon - `[done]` Milestone 2: Repository-backed booking baseline completed - DB/in-memory repository boundary added - public availability now generated through backend service logic - public booking creation now goes through real service validation - appointment conflict detection implemented - class capacity and waitlist fallback implemented - backend tests added for booking and slot behavior - frontend public booking page can submit a demo booking - `[done/partial]` Milestone 3: Tenant bootstrap, dashboard hydration, and Neon-authenticated API flows - tenant bootstrap endpoint now resolves through repository-backed membership lookup - dashboard summary endpoint now resolves through repository-backed metrics lookup - frontend dashboard now hydrates from API when a Neon Auth JWT is available - frontend dashboard degrades to contract-shaped preview mode when auth is unavailable - Neon Auth token acquisition remains adapter-isolated and still needs production validation against live Neon Auth - `[done/partial]` Milestone 4: Stripe subscription sync and entitlement baseline completed - normalized billing snapshot model added to backend and OpenAPI contract - backend-owned subscription state now syncs through one overwrite path - Stripe checkout launch endpoint added - Stripe webhook endpoint added with signature verification and event filtering - billing snapshot refresh endpoint added - customer portal endpoint added - plan entitlements now derive from normalized plan state - dashboard now surfaces billing status, entitlements, and checkout actions - mock/no-key fallback still works for local development - `[done/partial]` Milestone 5: Reminder jobs, notification delivery abstractions, and remote job-runner baseline completed - booking creation now schedules reminder jobs for qualifying upcoming bookings - notification service now dispatches due jobs through replaceable provider interfaces - localized reminder copy now renders with tenant locale/timezone context - notification delivery log persistence added - internal reminder dispatch endpoint added for Railway-style scheduled execution - shared job-runner key added for remote job execution - current providers are noop-safe abstractions; real email/SMS vendors still need production integration - `[done/partial]` Milestone 6: Production auth completion and tenant identity sync baseline completed - verified Neon JWTs now sync app-side users automatically on protected requests - app-side membership resolution now maps by `users.neon_subject` instead of assuming JWT subject equals DB primary key - frontend auth provider now reads JWT from the Neon session contract instead of a fallback token hook - bootstrap payload now returns user identity fields needed for authenticated shells - Neon subject schema migration added for production databases - live Neon Auth / staging validation still needs to be completed against real infrastructure - `[done/partial]` Milestone 7: First-tenant onboarding flow added to the dashboard - authenticated users without membership now see a real workspace creation flow in `/dashboard` - protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone - onboarding now creates the tenant, owner membership, and first default location - dashboard can transition from empty bootstrap shell to a provisioned tenant state - post-onboarding entity screens for locations, services, staff, blocked days, working hours, and class templates/sessions now exist (Milestones 8 and 10) - `[done/partial]` Milestone 8: Catalog management shipped; live staging validation and operational hardening remain - service CRUD end-to-end: repository (pgx + in-memory), catalog service with validation and tenant isolation, protected `/v1/catalog/services` routes, OpenAPI schemas, dashboard services page - staff CRUD end-to-end with active toggle, optional location assignment, and `MaxStaff` entitlement enforcement; dashboard staff page with upgrade prompt - dashboard summary now resolves `staffName` on bookings and computes `setupCompletion` from real state (brand, location, service, staff, schedule) - booking modal service field is a select bound to the tenant's real services; blocked-day creation supports optional per-staff scoping - deleting a service or staff member preserves bookings (FK `SET NULL`) and removes the member's availability rules (`CASCADE`), mirrored in the in-memory repository - live staging validation, real Neon Auth/Stripe/SMS provider checks, and operational hardening are still open - `[done/partial]` Milestone 9: Public booking flow with real service/staff selection - public booking page is now a stepped flow: pick service → optional staff preference → pick day → pick time; class sessions still listed below - `GET /v1/public/tenants/{slug}/catalog` exposes bookable services and active staff for the booking page - availability endpoint accepts `serviceId`, `staffId`, and `date` filters; default response is unchanged for the embeddable widget - slots now carry `staffName`, respect tenant-wide and per-staff blocked days, and are generated per-day without the preview cap when `date` is given - booking creation hardened: appointment requests must fit inside a matching availability rule, not overlap a blocked day, and staff/location IDs must belong to the tenant with staff active - remaining: live staging validation - `[done/partial]` Milestone 10: Per-staff availability and class/session management - working-hours repository now accepts a nullable staff scope on list/update (pgx + in-memory); staff-scoped rules live in `availability_rules` and directly drive public slot generation - protected routes added: `GET/PUT /v1/catalog/staff/{id}/working-hours[/{day}]` with tenant-ownership checks - class template + session CRUD end-to-end: repository (`classes.go`), catalog service with validation, protected `/v1/catalog/class-templates` and `/v1/catalog/class-sessions` routes, OpenAPI + regenerated client - sessions derive `endsAt` from template duration, default capacity from the template, validate future start, tenant-owned template and optional location; list resolves template title, location name, and live booked count - deletion semantics mirror the schema: deleting a template cascades its sessions; deleting a session preserves bookings via `class_session_id SET NULL` (mirrored in the in-memory repo) - dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling - normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer - remaining: live staging validation - `[done/partial]` Milestone 11: Booking lifecycle — dashboard cancellation and waitlist promotion - businesses can cancel bookings from the dashboard (`POST /v1/catalog/bookings/{id}/cancel`), tenant-checked, with a two-step confirm in the booking detail modal - cancelling a class booking (dashboard or customer self-service) now promotes the earliest waitlisted bookings while free capacity remains, ordered by booking `created_at`; promoted customers receive a confirmation email, cancelled customers a cancellation email - shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion - bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically - `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface - waitlist position display landed in Milestone 16; manual promotion controls landed in Milestone 20 - remaining: live staging validation - `[done/partial]` Milestone 12: Real dashboard business metrics - `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories - `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points) - `DashboardKPI` carries optional `change`/`trend` (`up|down|neutral`); the KPI card already rendered these, so real data now drives the trend arrows instead of the previous `neutral`/`—` fallback - KPI labels are localized in the frontend via a `code` → i18n-key map (`dashboard.kpi.bookingsThisWeek`, `cancellations`, `utilization`; `completed` reuses the existing key); backend labels stay English as a fallback - the booking-trend chart was already bucketing real `allBookings` into the current week — confirmed, no change needed - demo mode intentionally keeps curated static KPI values; real tenants get the computed set - remaining: live staging validation; "new clients" KPI still has no backend metric (customer entity lacks first-booking tracking) - `[done/partial]` Milestone 13: Reminder pipeline reliability - reminder jobs are lifecycle-aware: customer and dashboard cancellation mark pending jobs `cancelled`; reschedule deletes pending jobs and re-arms one via `reminderSchedule` for the new time (no job when moved inside the no-reminder horizon) - dispatch now joins `bookings.status` and skips non-confirmed bookings (`skipped` job status, `skippedCount` in the response contract) — cancelled/waitlisted customers no longer receive reminders for bookings that are not happening - reschedule hardened with the same validation as booking creation: appointment reschedules must fit an availability rule and avoid blocked days (`validateAppointmentTarget` is now a shared package-level helper); class bookings keep session-defined times - new repository methods `CancelPendingReminderJobsForBooking` / `DeletePendingReminderJobsForBooking` in pgx and in-memory implementations - remaining: live staging validation; SMS reminder channel exists in schema (`channel` column) but only `email` is dispatched — SMS reminders are a separate opt-in feature - `[done/partial]` Milestone 14: Metrics completion + reminder dispatch scheduling - `DashboardMetrics` gained `NewClientsCount` — distinct customer emails whose earliest-ever booking (`starts_at`) lands in the window; a fifth KPI `new_clients` completes the set the demo data promised - KPI grid rebalanced for five cards (`lg:grid-cols-3 xl:grid-cols-5`); `new_clients` maps to the existing `dashboard.kpi.newClients` i18n key - reminder dispatch now has a real trigger: `.github/workflows/reminder-dispatch.yml` calls `POST /v1/internal/jobs/reminders/dispatch` every 15 minutes with `X-Bookra-Job-Key` from secrets (`BOOKRA_API_URL`, `BOOKRA_JOB_RUNNER_KEY`); jobs are timestamp-based so scheduler lateness delays but never skips - remaining: live staging validation; the workflow no-ops gracefully until the two secrets are configured - `[done/partial]` Milestone 15: SMS reminder channel - booking creation arms a second reminder job on `channel: sms` when the tenant has SMS enabled (`tenant_sms_settings.enabled`) and the customer left a phone number; email jobs are unchanged - `notifications.Service` gained an injected `smsSender` function adapter (wired to `sms.Service.SendMessage` in the API server, keeping the package dependency-free); dispatch routes `channel: sms` through it with localized body text and the customer phone as recipient - `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason - reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels - remaining: live staging validation against a real SMS Manager key - `[done/partial]` Milestone 16: Waitlist position display - `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1 - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) - manual promotion controls landed in Milestone 20 - remaining: live staging validation - `[done/partial]` Milestone 17: Class template editing - `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client - classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance - remaining: live staging validation - `[done/partial]` Milestone 18: Public class list polish - class sessions on the public booking page are now grouped by day (localized weekday + date headers) with compact timetable-style rows instead of one tall card per session — consistent with the stepped appointment picker above - capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking) - reminder arming extracted into a shared `armReminderJobs` helper (email + opt-in SMS) used by booking creation and waitlist promotion — a booking promoted after its reminder already dispatched-as-skipped now gets re-armed instead of going unreminded - remaining: live staging validation - `[done]` Milestone 19: Double-booking race hardening (validated on local Postgres) - migration `00014_booking_no_overlap.sql`: `btree_gist` extension plus three partial exclusion constraints on `bookings` mirroring `resourcesCollide` semantics — same staff, same location, or both-unassigned appointments may not overlap (`tstzrange`, `[)` bounds so abutting slots are legal); `cancelled` rows and `booking_mode='class'` are excluded - `db.IsExclusionViolation` maps SQLSTATE 23P01 to `ErrBookingConflict` at all three write paths (public create, customer reschedule, dashboard manual create) — a lost race now surfaces as a proper 409 instead of a silent double-booking - validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean - local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it - remaining: live staging validation on Neon - `[done/partial]` Milestone 20: Class roster + manual waitlist promotion - `GET /v1/catalog/class-sessions/{id}/roster` returns session metadata plus `confirmed` and `waitlist` entry lists (booking id, reference, customer name/email, status, `waitlistPosition`, `createdAt`); `ClassSession` now carries `waitlistCount` so session rows can badge queue length without an extra request - `POST /v1/catalog/bookings/{id}/promote` manually confirms a waitlisted booking: `409` when the session is full unless the caller passes `allowOverCapacity`, `400` for non-waitlisted bookings, `404` for missing or foreign bookings; promotion reuses the shared confirm path so waitlist ledger rows resolve and reminders re-arm automatically - `ListBookingsBySession` repository method (pgx + memory) backs the roster and simplifies `PromoteWaitlist`; `UpcomingBooking` carries `classSessionId` + `waitlistPosition`, computed per session in the dashboard summary - classes tab session rows expand into a two-column roster panel (attendees vs waitlist with `#N` position badges) with per-entry promote buttons; a full session arms an explicit "confirm anyway" over-capacity step instead of silently overriding - the booking detail modal shows the waitlist position and offers the same promote action for waitlisted bookings; the bookings list badges waitlisted rows with `#N` - demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing - verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 21: Customer self-service rebooking - `CustomerBookingView` now carries `tenantSlug`, `serviceId`, `staffId`, `classSessionId` — everything the manage page needs to query real availability; class bookings resolve the session title as their service label instead of rendering blank - `RescheduleBooking` hardened: rejects `waitlisted`/`completed` bookings and any class booking (session-bound times cannot move to arbitrary windows; session-to-session moves landed in Milestone 23); the previously dead `ClassSessionID == nil` branch collapsed into a straight appointment validation - manage page reschedule dialog replaced free-form date+time inputs with a real slot picker: pick a date → `GET /v1/public/tenants/{slug}/availability` (filtered by the booking's service/staff) → time buttons; the slot carries its own duration so the hardcoded 60-minute assumption is gone; 409 surfaces "slot just taken" instead of a generic failure - action buttons are now status-gated: reschedule shows only for confirmed/pending appointments, cancel for any live booking, and class bookings get an explanatory note instead of a broken button - `CustomerBookingView` schema added to OpenAPI and referenced from `GET /v1/public/bookings/{reference}`; the reschedule contract documents the class-booking 400 and slot-conflict 409 - verified live: booked an appointment via public API, rescheduled through the real manage page in a browser (slot grid → 200 → card shows new time); class booking reschedule returns 400 - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 22: ICS calendar feeds - `GET /v1/public/calendar/{token}` serves an RFC 5545 feed — token-as-secret (Google/Outlook private-link model), `.ics` suffix tolerated, bookings from −30d to +180d; cancelled → `STATUS:CANCELLED`, waitlisted/pending → `TENTATIVE` + `TRANSP:TRANSPARENT`, waitlist entries marked in the summary - per-staff tokens (`staff.feed_token`) and one tenant-wide token (`tenants.feed_token`, migration 00015); staff feeds contain only their bookings, tenant feeds tag each event with the staff member; summaries resolve service or class-session titles, descriptions carry localized contact labels (cs/en by tenant locale), events get location names - custom ICS renderer — no dependency: TEXT escaping, CRLF, 75-octet folding on rune boundaries - `GET /v1/calendar/feed` lists tenant + staff tokens behind auth; `POST /v1/calendar/feed/rotate` invalidates a leaked URL (tenant or staff scope, staff verified against the tenant) - settings page "Kalendář (ICS)" card: copyable feed URLs per business/staff member, two-step rotate with confirmation, demo-mode placeholders pointing at real seeded tokens - verified live: feed info returns tenant+staff tokens, tenant feed renders the seeded booking with staff name in the summary, staff feed omits it, bogus token 404s, rotation kills the old URL and the new one serves the same feed - remaining: live staging validation on Neon + pgx repository path; Google Calendar two-way sync stays deferred (the ICS read direction covers the common ask) - `[done/partial]` Milestone 23: Class session-to-session move - class bookings are session-bound, so rebooking is a move between sessions of the same template rather than a free reschedule — `GET /v1/public/bookings/{reference}/sessions` lists future same-template targets with capacity/confirmed/waitlist counts, `POST /v1/public/bookings/{reference}/move` performs the move under the manage token - move semantics: confirmed when the target has a free seat, waitlisted when full; the old waitlist entry resolves, reminders re-arm for the new time, and freeing a confirmed seat drains the old session's queue through the existing promotion path - rejects appointment bookings (they use the slot picker), cancelled/completed bookings, same-session moves, foreign-template or past sessions - manage page: "Change session" / "Změnit lekci" action opens a target picker — date/time/location rows with free-seat or waitlist badges, per-row selection, localized success/error states; the current session is excluded from targets - `SessionMoveTarget` schema + both endpoints documented in OpenAPI; generated client regenerated - verified live + browser: created a second session of the demo template, moved a confirmed booking over HTTP (200 + new `classSessionId`), then repeated through the real manage page in Czech and English — success banner, updated date/time, clean console - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 24: Per-tenant activity feed - `activity_events` table (migration 00016) + `GET /v1/activity` — tenant-scoped audit feed, newest first, `limit` capped at 100; `ActivityEvent`/`ActivityFeedResponse` in OpenAPI - actor model distinguishes `staff` (dashboard user, principal name/subject), `customer` (self-service via manage token), and `system` (automatic waitlist promotion) - booking lifecycle writes land at every transition: public create → `booking.created`, dashboard cancel + manual promote → staff events, customer cancel/reschedule/move → customer events, `PromoteWaitlist` auto-drain → system event; all writes are best-effort (`_ =`) so the feed can never fail a booking - summaries are human-readable `{customer} — {service/session title}` resolved at write time; the frontend never needs secondary lookups - notification dropdown now leads with the real feed when it returns events (derived recent-bookings guesses remain as fallback); unknown future actions degrade to the raw action string instead of a broken i18n key; CS/EN labels for all five actions - demo repo seeds three varied events so the dropdown has content on first open - verified live: feed returns seeds newest-first, public booking create lands `booking.created` with entity id, customer cancel lands `booking.cancelled`; browser-checked both locales against the demo dashboard, console clean - remaining: live staging validation on Neon + pgx repository path; settings/CRUD edits can join the feed when a concrete need appears - `[done/partial]` Milestone 25: CSV export - `GET /v1/export/bookings.csv` + `GET /v1/export/customers.csv` — authed, tenant-scoped, `Content-Disposition: attachment`; bookings accept optional `from`/`to` (YYYY-MM-DD, `to` inclusive) - UTF-8 BOM prefix so Czech diacritics open correctly in Excel; stdlib `encoding/csv` handles all quoting/escaping — no dependency - service labels resolve at export time (service name for appointments, session title for classes, staff and location names via lookup maps — no N+1); customers export pages through the repository in 500-row chunks - responses buffer in memory before headers — membership failures return clean JSON errors instead of a truncated stream - shared `ExportCsvButton` (fetch + blob + object-URL download; the generated client is JSON-typed so it can't carry file responses) on the bookings and customers page headers, CS/EN label - verified live: real booking row with comma-in-name and quotes-in-notes escaped correctly, diacritics preserved, range bounds honored (inclusive `to`), three seeded customers exported; browser download produced the real file on the bookings page, customers button present, console clean - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 26: Staff performance view - `GET /v1/metrics/staff?days=` — authed, tenant-scoped; trailing window defaults to 30 days, invalid/out-of-range values normalize to 30, max 365 - appointment bookings only — class sessions carry no `staff_id`, so class attendance is honestly excluded rather than silently misattributed; noted in the OpenAPI description - per-staff metrics: bookings, completed (confirmed with `ends_at < now`), cancelled, utilization percent (confirmed share of the window), revenue cents from linked service prices over non-cancelled bookings - every active staff member appears in the response including zero-booking rows — the service merges repository aggregates with the full staff list - dashboard staff page gains a Performance panel above the member list: per-staff stat grid (bookings/completed/cancelled/utilization/revenue), CZK revenue formatting via `Intl.NumberFormat` per locale, CS/EN labels, responsive 2→5 column grid; demo mode fabricates rows matching the fabricated staff list for page coherence - demo repository seeds four trailing-window bookings (3 confirmed + 1 cancelled) via `NewRepository`'s demo branch only — unit tests constructing `NewMemoryRepository` directly are unaffected - verified live: endpoint returns 4/3/1, 75% utilization, 3 600 Kč; `days=7` and `days=999` normalize correctly; browser-checked both locales and mobile wrap, console clean; focused aggregation test passes - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 27: Persistent notification center - `notification_cursors` table (migration 00017) — one read-cursor per (tenant, user_subject); per-event flags rejected deliberately, the feed is an audit list not a per-item inbox - `GET /v1/activity` now returns `unreadCount` + `readAt` alongside events; `POST /v1/activity/read` persists the cursor and returns it — mark-all-read survives reloads and sessions - delivery history joins the feed: `DispatchDue` appends `reminder.sent` / `reminder.failed` system events (entity = booking, summary = `{customer} — {channel}`) after each delivery-log write, best-effort so notifications never fail on audit - dropdown derives per-item read state from `createdAt <= readAt` — only post-cursor events carry the unread dot; per-item clicks still mark locally for the session; the billing/trial nag stays session-scoped (state badge, not an event) - verified live: 3 unread → mark → 0 → new booking → exactly 1 unread; browser-confirmed the badge and dimming persist across reload; focused tests cover cursor lifecycle and the reminder event - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 28: Reporting phase — capacity utilization + report exports - utilization is now capacity-based: `bookedMinutes` (sum of non-cancelled booking durations) over `capacityMinutes` (sum of the member's availability-rule minutes across the window, evaluated on local calendar dates in the tenant timezone); the old confirmed-share measure was honest about cancellations but not workload - `StaffMetric` exposes `bookedMinutes`/`capacityMinutes` so the basis is inspectable; the performance panel shows "booked h / capacity h" under the percentage - export service now takes `bookings.Service` — `WriteStaffReportCSV` reuses `StaffMetrics` directly, no re-aggregation - `GET /v1/export/staff-report.csv?days=` — per-staff summary rows; `GET /v1/export/service-report.csv?days=` — bookings/cancelled/revenue per catalog service, zero-filled, class bookings roll into a "class sessions" row; `days` normalizes like the metrics endpoint (default 30, max 365) - `ExportCsvButton` on the staff Performance panel and the services page header - verified live: staff report shows 4/3/1, 180/10560 minutes, 1% utilization, 3 600 Kč; service report aggregates correctly; browser download produced the real file; focused tests cover both reports and the capacity relationship - remaining: live staging validation on Neon + pgx repository path - `[done/partial]` Milestone 29: Recurring class schedules - `class_schedules` table (migration 00018): weekly rules — template, optional location, `day_of_week` (Go weekday, Sun=0), `starts_local` time, `active`; sessions gained a nullable `schedule_id` link with a partial unique index on `(schedule_id, starts_at)` for idempotent regeneration - no cron/batch scheduler: rules materialize concrete session rows lazily on read — `ListClassSessions` (dashboard) and `Availability` (public booking) both call `MaterializeScheduleSessions(tenant, now, 28d)`; PG does it in a single INSERT…SELECT with `ON CONFLICT DO NOTHING`, memory repo mirrors it; a live-verify bug (HH:MM:SS parse) was caught and fixed - deleting a rule stops future sessions; sessions already materialized stay in place with their bookings — per-session capacity overrides and manual deletes survive regeneration - `GET/POST/DELETE /v1/catalog/class-schedules` with tenant scoping, template/location ownership checks, weekday 0-6 and HH:MM validation - dashboard: "Opakující se lekce" card between templates and sessions on the classes tab — rule form (template, weekday, time, optional location) + rule list; sessions expose `scheduleId` so rule-generated sessions are identifiable - verified live: seeded Wed 10:00 + Sat 09:30 rules generated 8 sessions at correct local times; create → 4 Monday sessions materialized immediately; delete → rule gone, sessions remain; dayOfWeek 9 rejected; public availability converts local→UTC correctly; focused tests cover materialization, idempotency, validation - remaining: live staging validation on Neon + pgx repository path; no bulk series edit (delete-and-recreate by design) - `[done/partial]` Milestone 30: Structured intake questions - services declare up to 8 intake fields (`text` / `yesno`, required flag); `services.intake_fields` JSONB (migration 00019), bookings gain `intake` JSONB for canonical answers - `shared/intake.go` is the single normalization point: slug keys (≤40), labels ≤80, duplicate/unknown keys rejected on declaration, unknown answer keys silently dropped, answers trimmed ≤500, yes/no normalized - booking create validates against the service's declared fields — missing required → 400 naming the field label; catalog create/update reuses the same normalizer - intake surfaces: public catalog (`PublicService.intakeFields`), dashboard upcoming bookings (`UpcomingBooking.intake`), customer manage view (`CustomerBookingView.intake`) - services page gains a compact intake editor (label + type + required per row, max 8, client-side slug key generation mirroring the backend); public form renders declared questions under contact details with required markers; dashboard booking modal and manage view list answers (yes/no localized, keys shown normalized — labels are not stored with answers by design) - verified live + in-browser: catalog exposes fields, booking with unknown key stores only declared answers, missing required → 400, UI submit → stored answers visible in dashboard summary and manage view; focused `internal/shared` tests cover normalization edge cases - remaining: live staging validation on Neon + pgx repository path; label join landed in M31 - `[done]` Milestone 31: Data surfacing — labels, service metrics, honest capacity - booking views gain `intakeLabels` (`shared.IntakeLabels` maps declared keys → labels); dashboard modal and customer manage view now render real question labels with slug fallback, closing M30's remaining loop - `GET /v1/metrics/services?days=` aggregates bookings/completed/cancelled/revenue per service plus a separate `classBookings` count; `WriteServiceReportCSV` now delegates to `bookings.ServiceMetrics` (single aggregation point, same as the staff report) - services page gains a "Service performance" card mirroring the staff panel (per-service stat grid, CZK revenue per locale, class-bookings footer note); demo rows match the fabricated service list - staff capacity utilization now subtracts blocked days — tenant-wide blocks and staff-scoped blocks each remove that day's rule minutes via the shared `blockedAt` overlap semantics - `service-report.csv` gains a `completed` column - verified live: `/v1/metrics/services` returns correct aggregates for seeded trailing-window bookings; `intakeLabels` present in dashboard summary; modal renders declared labels in CS; focused tests cover blocked-day capacity (tenant + staff scope) and service aggregation - remaining: live staging validation on Neon + pgx repository path - `[done]` Milestone 32: Series-aware schedule edits + waitlist ledger removal - `PATCH /v1/catalog/class-schedules/:id` patches a recurring rule (`dayOfWeek`, `startsLocal`, `locationId`, `active`); absent fields are kept, `locationId: ""` clears - `applyToFuture` retimes already-materialized future sessions to the new local start time on their existing calendar dates (tenant-timezone aware), applies the new location, moves confirmed/waitlisted bookings to the new window, and re-arms reminders via exported `bookings.RearmReminderJobs` - a `dayOfWeek` change steers only sessions materialized afterwards — existing dates stay put (documented semantics) - zones-page schedule rows gain an inline editor (weekday/time/location + active toggle + apply-to-future checkbox) in CS and EN - `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at` - verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths - remaining: live staging validation on Neon + pgx repository path ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. The product is multi-tenant, calm in scope, and optimized for production quality rather than a fast prototype. ## Product Direction Bookra uses one unified booking core with tenant presets. This means: - one platform for appointments and simple classes - different business presets for salon, clinic, repair, massage, sports/studio - shared scheduling, reminders, dashboard, and billing logic - no industry-specific forked products in v1 ## Launch Scope ### Market - Czech Republic first - English included at launch - EU-friendly hosting and data handling defaults ### Supported booking models - Appointments - Capacity-based classes ### Explicitly out of scope for v1 - Customer payments for bookings - Deposits or prepaid services - Memberships, passes, or package accounting - Regulated healthcare workflows - Medical records or sensitive health data - Marketplace/discovery layer ## Customer Experience - Public booking page per business - Guest booking supported by default - Optional customer accounts for history and faster rebooking - Confirmation, cancellation, and reschedule flow - Czech and English UI from day one - Timezone-aware booking and reminders ## Business Experience Each business can manage: - multiple locations - multiple staff members - services - class sessions - business hours and exceptions - buffer times - blackout periods - reminder settings - branding basics - dashboard metrics Owner dashboard should show: - bookings this week - cancellations - utilization - simple revenue forecast based on scheduled services ## Core Scheduling Rules The booking engine must support: - multi-tenant isolation - staff and location assignment - conflict detection - buffer times - timezone-safe date handling - waitlists - class capacity limits - exception-based availability rules Appointments and classes should share the same scheduling foundation where possible. ## Notifications v1 reminder strategy: - email is first-class - SMS is optional and treated as a paid add-on - SMS must be abstracted behind a provider interface - Czech-first provider support is preferred, with room for generic fallback providers later ## Billing Bookra uses Stripe for B2B SaaS subscription billing. (Migrated from Paddle; the Paddle integration was removed.) Stripe scope in v1: - tenant subscription plans - plan upgrades/downgrades - add-ons such as SMS or expanded limits - webhook-based subscription state sync - customer self-serve billing portal Stripe is not used for end-customer booking checkout in v1. ## Remote-First Architecture Bookra follows the `tdvorak-fullstack` profile with remote deployment overrides. ### Stack - Frontend: SolidJS + Vite + TypeScript + Tailwind - Backend: Go + Gin - Database: Neon Postgres + goose (handwritten pgx repositories; sqlc removed) - Auth: Neon Auth - API contract: OpenAPI as source of truth - Frontend deployment: Vercel - Backend deployment: Railway - Local development: direct app processes against remote services ### Structure - modular monolith first - monorepo layout - generated TypeScript API client from OpenAPI - strong tenant boundaries - background jobs for reminders and waitlist handling - no Docker Compose and no self-hosted infra path in v1 Suggested layout: - `/apps/frontend` - `/apps/backend` - `/packages/api-client` - `/packages/shared-types` ## Auth And Identity - Neon Auth handles sign-up, sign-in, sessions, and browser auth state - frontend owns browser auth flows - Go backend verifies Neon Auth JWTs through JWKS - backend treats Neon `sub` as the authenticated principal - backend now syncs `neon_subject` into app-side `users` records on authenticated requests - JWT usage is isolated to frontend-to-backend API calls across domains - Neon-specific auth code must stay behind replaceable adapters because Neon Auth is beta ## Database And Environments - Neon is the only database platform - pooled connections for application traffic - direct connections for migrations and admin tasks - staging and production only in v1 - no per-PR backend preview environments in v1 ## Main Domain Model Key entities: - tenants - tenant users - customer accounts - locations - staff - services - class templates - class sessions - availability rules - availability exceptions - bookings - waitlist entries - reminder jobs - subscription accounts - subscription events ## v1 Delivery Phases ### Phase 1 - monorepo setup - Neon Auth integration shell - tenant foundation - locations, staff, services - i18n foundation - OpenAPI baseline - status: completed ### Phase 2 - appointment booking flow - availability rules - conflict detection - cancellation and rescheduling - timezone-safe scheduling - status: in progress - current implementation: - public availability endpoint returns generated appointment and class slots - public booking creation validates tenant existence, timestamps, conflicts, and class capacity - frontend public route can trigger booking creation from live slots - repository pattern supports Neon-backed persistence with memory fallback ### Phase 3 - class sessions - capacity limits - waitlists - email reminders - dashboard metrics - status: materially implemented - current implementation: - class sessions appear in public availability - class capacity and waitlist status are enforced at booking time - dashboard metrics endpoint is repository-backed but still minimal in scope - booking creation now schedules reminder jobs - due reminder jobs can now be dispatched through the notifications service - delivery logging now exists for reminder execution ### Phase 4 - Stripe subscriptions - SMS add-on support - plan enforcement - hardening, logging, and operational tooling - status: in progress - current implementation: - billing snapshot persistence added - Stripe checkout route added (Checkout Sessions) - Stripe webhook route added with signature verification - Stripe portal route added (customer portal sessions) - subscription refresh route added - entitlement mapping is now available to the frontend - API edge now includes security headers and public route rate limiting - remote job-runner endpoint added for reminder execution - goose commands are wired into repeatable npm scripts ## Current Implementation Notes - Backend is no longer static-demo-only for booking flows. - The repository layer supports: - Neon-backed pgx access when `BOOKRA_DATABASE_URL` is configured - deterministic in-memory fallback when Neon is not configured - OpenAPI remains the frontend/backend contract source. - Frontend public booking flow is now interactive, not just presentational. - Route structure is now explicit: - `/` is the primary marketing landing page - `/dashboard` is the main application entry - `/book/:tenantSlug` stays public and customer-facing - Neon Auth remains intentionally isolated behind adapters because the managed product is still beta. - Billing now follows a backend-owned snapshot model rather than trusting incremental webhook payloads. - `apps/auth-service` is now retired from active runtime and docs. - Reminder delivery now has a working runtime path instead of just a queued schema. - goose migration execution is runnable through workspace scripts. - Protected backend requests now perform automatic app-side identity sync before tenant membership resolution. - `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users. - Catalog management now covers services and staff end-to-end; staff creation enforces `MaxStaff` entitlements and staff location assignment validates tenant ownership. - Dashboard summary computes `setupCompletion` from real configured state (brand, location, service, staff, availability rules) rather than a static value. - Public booking flow offers real service and staff selection via a stepped picker backed by `GET /v1/public/tenants/{slug}/catalog` and filtered availability (`serviceId`, `staffId`, `date`). - Appointment creation validates that requested times fit inside availability rules, skip blocked days, and reference tenant-owned staff and locations. ## Current Gaps - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. - Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants. - Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, customers, and class templates/sessions with rosters; per-staff availability rules still have no management UI. - Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures. - Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops. - The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup. ## Next Build Targets - replace preview-mode dashboard hydration with fully validated Neon Auth JWT flow - sync Neon Auth identities into tenant membership records and onboarding bootstrap - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure - replace noop notification providers with the first production email transport and a gated SMS transport - add management UI for per-staff availability rules so staff schedules are editable after onboarding ## Future Phases Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle: - **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request. - **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach. - **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints. - **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists. - **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested. - **Per-session capacity override**: session capacity still comes only from the template; letting staff override capacity on one session (or one rule) is a small schema addition if a real need appears. ## Product Principles - calm, premium, structured UX - production-grade architecture - no generic SaaS clutter - deterministic scheduling behavior - minimal operational complexity in v1 - build for maintainability, not feature sprawl ## Success Criteria For v1 Bookra is successful when a small business can: - create its business profile - configure staff, locations, and services - publish a booking page - accept bookings without double-booking - send reminders reliably - manage cancellations and waitlists - view simple business health metrics - subscribe to a paid plan through Stripe ## Final Positioning Bookra is not an ERP, not a medical system, and not a marketplace. It is a focused booking operating layer for small local service businesses that need: - simple setup - reliable scheduling - clear reminders - lightweight reporting - clean SaaS billing