Files
Bookra/project.md
T
Tomas DvorakandDevin e680e0c2b8 feat: scheduled report digests + per-session capacity (M33)
POST /v1/internal/jobs/reports/dispatch emails each tenant owner a
trailing-window digest (bookings, revenue, per-service and per-staff
breakdown) localized to the tenant locale; zero-activity tenants are
skipped. Metrics cores extracted to tenant-scoped variants so the job
needs no principal.

PATCH /v1/catalog/class-sessions/:id sets a session's seat count with
a confirmed-bookings floor — shrinking below it needs allowOverCapacity,
matching the waitlist-promotion convention. Session rows gain an inline
capacity editor with an overbook confirm state.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 21:29:44 +02:00

47 KiB
Raw Blame History

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
  • [done] Milestone 33: Scheduled report digests + per-session capacity

    • POST /v1/internal/jobs/reports/dispatch?days=&tenantId= (job-runner key) emails each tenant owner a trailing-window digest — totals, per-service bookings/revenue, per-staff utilization — localized to the tenant locale, dashboard link included; tenants with zero activity are skipped (a silent week is signal, not noise), failed sends are counted and logged to the activity feed
    • bookings.Service gains tenant-scoped StaffMetricsForTenant/ServiceMetricsForTenant cores so jobs need no principal; the principal-facing methods delegate
    • POST /v1/internal/jobs/reports/dispatch accepts tenantId for single-tenant runs (manual retry, per-tenant crons); cadence lives with the external scheduler — the job itself is deliberately stateless
    • PATCH /v1/catalog/class-sessions/:id sets a session's seat count; shrinking below confirmed bookings returns 409 unless allowOverCapacity — same explicit-override convention as waitlist promotion; session rows gain an inline capacity editor (CS/EN) with an overbook confirm state
    • verified live: dispatch authorized by job key, sent 1 digest for the seeded tenant; capacity PATCH grew/shrank sessions and rejected below-floor values; tests cover localized render, zero-activity skip, send-failure counting, and the capacity floor
    • 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.
  • 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.
  • Digest preferences: M33's digest is on-or-nothing per tenant — a per-tenant opt-out/cadence setting (or per-member recipients) belongs in notification settings if owners ask for control.
  • Digest attachments: the digest is text-only; attaching the CSV exports (M28) needs multipart/mixed in the SMTP provider — small lift, defer until asked.

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