In-house multi-tenant booking module (replaces Easy!Appointments) #14

Open
opened 2026-07-23 13:56:18 +02:00 by mivanchenko · 0 comments
Owner

In-house multi-tenant booking module (replaces Easy!Appointments)

Problem Statement

Booking today runs on a shared Easy!Appointments (EA) instance sitting outside the smb-crm
Postgres/Flask stack, bridged into it via an n8n webhook sync (booking-sync). This split creates
real problems: a reschedule re-fires the same EA-<id> and hits a primary-key conflict (#1,
plain INSERT, no upsert); EA is not multi-tenant-native, so per-client isolation is bolted on via
a notes field holding the provider's client_id; the booking UI is a generic EA embed that can't
be made to feel native inside a client's landing page; and every booking write has to survive two
systems (EA's MariaDB, then the CRM's Postgres) instead of one. Business owners also have no way to
log in and see their own bookings — the CRM's operator dashboard is Caddy-basic-auth + a single
shared API token, with no per-client identity at all.

Solution

Build booking natively into the existing smb-crm Flask app, backed by the same Postgres database
that already holds clients, bookings, etc. Two Flask blueprints:

  • Public: an unauthenticated, rate-limited /book/<slug> booking page per client, embedded via
    iframe into the client's landing page, offering real-time availability and confirmation with no
    customer account required.
  • Owner: an authenticated, client_id-scoped dashboard where the business owner logs in
    (email + password) and sees an agenda-style calendar of who's booked when, can add/cancel/
    reschedule bookings themselves, and can subscribe an external calendar app to their bookings.

Easy!Appointments (deploy/booking/), its MariaDB, and n8n/booking-sync.json are decommissioned
once cutover is complete. No historical EA data is migrated (existing bookings are placeholder
data). This closes #1 (the bug is structurally impossible once bookings are written directly,
with ON CONFLICT/EXCLUDE-constraint semantics, instead of synced from a second system).

User Stories

Public booking (customer)

  1. As a customer, I want to open a client's booking page and see which services are offered, so
    that I can pick the one I want.
  2. As a customer, I want to see available time slots for my chosen service, computed from the
    business's opening hours and the service's duration, so that I only see times that are actually
    bookable.
  3. As a customer, I want slots that are too soon (inside the business's minimum-notice window) or
    too far out (beyond the maximum advance-booking window) to not be offered, so that I don't book
    something the business can't honor.
  4. As a customer, I want a buffer to be respected between my booking and the next one on the same
    resource, so that the business has turnaround time.
  5. As a customer, I want to submit my name and contact details along with my chosen slot, so that
    the business knows who's coming.
  6. As a customer, if the business has auto-confirm enabled, I want my booking to be confirmed
    immediately, so that I know right away that my appointment is locked in.
  7. As a customer, if the business has manual-confirm enabled, I want my booking to start as
    pending and be told the business will confirm it, so that I'm not misled into thinking it's
    final before the owner has seen it.
  8. As a customer, I want to receive a confirmation email with my booking details and a link to
    manage it, so that I have a record and a way to act on it without creating an account.
  9. As a customer, I want to click a link in my confirmation email to cancel my booking, so that I
    can free the slot if my plans change.
  10. As a customer, I want to click a link in my confirmation email to reschedule my booking to a
    different available slot, so that I don't have to cancel and rebook from scratch.
  11. As a customer, if two people try to book the exact same slot on the same resource at the same
    time, I want only one of us to succeed and the other to see a clear "that slot was just taken"
    message, so that the business never ends up double-booked.
  12. As a customer, I want the booking page to feel like part of the business's own website (not a
    jarring embedded frame), so that the experience feels trustworthy and professional.
  13. As a customer, I want the booking page's colors to visually match the business's branding, so
    that it doesn't feel like a bolted-on third-party tool.
  14. As a customer, I want my booking data handled by a business operating in Germany to respect
    reasonable privacy expectations, even though the concrete retention/deletion policy is decided
    later (tracked separately, out of scope here).

Owner dashboard

  1. As a business owner, I want to log in with an email and password scoped to my own business, so
    that I only ever see my own bookings, never another client's.
  2. As a business owner, I want an agenda-style calendar view (e.g. today / this week, grouped by
    day) showing who's booked when, so that I can plan my day without a drag-and-drop calendar UI
    getting in the way.
  3. As a business owner, I want to add a booking manually (e.g. for a phone-in or walk-in
    customer) directly from my calendar view, so that not every booking has to come through the
    public page.
  4. As a business owner, when I add a booking manually, I want to be able to override opening
    hours, minimum-notice, and buffer rules (e.g. to fit in a regular as a favor), so that those
    rules are conveniences for the public flow, not hard constraints on me.
  5. As a business owner, I want the double-booking protection to apply to me too when adding
    manual bookings, so that I can't accidentally book two customers into the same resource at the
    same time.
  6. As a business owner, I want to cancel or reschedule a booking from my calendar view, so that I
    can handle changes customers call in about.
  7. As a business owner, I want to choose whether new public bookings require my confirmation or
    are auto-confirmed, so that the flow matches how I run my business.
  8. As a business owner, I want to configure my services (name, duration, price, active/inactive),
    so that the public booking page reflects what I actually offer.
  9. As a business owner, I want to configure my opening hours, minimum-notice, maximum-advance
    window, and buffer time, so that the availability shown to customers matches reality.
  10. As a business owner, I want to choose how I'm notified of new bookings (for launch: Telegram
    only, with the setting in place for future channels), so that I hear about bookings the way I
    actually check messages.
  11. As a business owner, I want a subscribable calendar feed (iCal) scoped only to my own
    bookings, protected by a token unique to my business, so that I can see my bookings in my
    phone's or desktop's calendar app without exposing or being able to see any other client's
    bookings.
  12. As a business owner, if I forget my password, I want to request a reset link by email myself,
    so that I'm not locked out waiting on the operator.
  13. As a business owner, I want the operator to be able to trigger a password-reset email to me on
    my behalf, so that I have a path to recovery even if my own registered email is unreachable at
    that moment.
  14. As a new client being onboarded, I want a working booking page from day one (default resource,
    a starter service, sane default hours) even before I've logged in to customize anything, so
    that my landing page's booking button isn't broken while I get around to configuring it.

Operator (CRM back office)

  1. As the operator, I want to see a list of owner accounts (one per client) in the existing CRM
    dashboard, so that I have visibility into who has booking access.
  2. As the operator, I want to trigger a password-reset email for any owner account from the CRM
    dashboard, so that I can help a client who's locked out without needing their login.
  3. As the operator, I want new-client onboarding to automatically create a default resource,
    starter service, and owner user account for booking, so that I don't have to configure booking
    by hand for every new client.
  4. As the operator, I want Easy!Appointments and its sync workflow fully decommissioned once
    cutover is verified, so that there's no parallel legacy system left running or being paid
    attention to.

Implementation Decisions

Data model (Postgres, same database as existing smb-crm tables)

  • clients gains: slug (text, unique, public URL identifier, owner-editable, defaults to a
    normalized business name), timezone (text, default 'Europe/Berlin'), auto_confirm
    (boolean, default true — per-client toggle for confirm-on-create vs. pending-until-owner-confirms).
    notify_channel already exists on clients and will be reused/wired through (currently unused
    by the sync path in practice) — valid value at launch: telegram.
  • New resources table: resource_id (pk), client_id, name, active. One row per bookable
    unit (staff/chair). Single-provider clients get exactly one row; the schema does not assume
    single-resource.
  • New services table: service_id (pk), client_id, name, duration_minutes, price,
    active.
  • New availability config (either columns on resources or a small per-resource/per-client
    table): opening hours per weekday, min_notice_minutes, max_advance_days,
    buffer_minutes. Exact shape (per-client vs. per-resource hours) to be finalized during
    implementation; per-resource is the safer default since multi-resource clients may have staff
    with different hours.
  • bookings gains a resource_id foreign key (nullable during transition, required going
    forward) and a during generated tstzrange column (from start_time/end_time) with a
    Postgres EXCLUDE USING gist (resource_id WITH =, during WITH &&) constraint — this is the
    single source of truth for "no double-booking," enforced on both INSERT (public/owner create)
    and UPDATE (reschedule), not just checked in application code.
  • bookings.status gains pending as a valid value (alongside existing confirmed/cancelled
    etc.) for the manual-confirm path.
  • New users table: user_id (pk), client_id, email (unique), password_hash, timestamps.
    Owner login is scoped by joining through client_id, same isolation pattern as every other
    table.
  • New password_reset_tokens table: token (random, signed/hashed at rest), user_id,
    expires_at, used_at. Single mechanism used by both the self-service "forgot password" flow
    and the operator-triggered "send reset link" action in the CRM dashboard.
  • bookings.ics feed moves from one shared ICS_TOKEN to a per-client secret token (new
    column, e.g. clients.ics_token), closing the current cross-tenant leak where any holder of the
    shared token can view any client_id's feed by changing the query param.
  • Cancel/reschedule links use signed tokens (PyJWT, already a dependency) embedding the
    booking_id and an expiry — no customer account, no separate token table needed for these
    (unlike password reset, which needs revocation/single-use tracking).

Architecture

  • Extend the existing single Flask app (backoffice/app). New blueprints: public booking
    (/book/<slug>/...) and owner auth/dashboard (/owner/... or similar), alongside the existing
    operator API/dashboard routes.
  • Tenancy safety: a new thin data-access module is the only place that runs raw SQL against
    booking/resource/service/user tables. Every function in it requires client_id (or resolves it
    from an authenticated session/token) and injects the filter itself — route handlers never write
    WHERE client_id = ... directly. This is the single seam where a missing-isolation bug would
    have to be introduced, and the single place it'd be caught in review or tests.
  • Owner session auth: standard Flask session backed by users.password_hash (e.g. werkzeug's
    password hashing, consistent with Flask norms). No new auth framework.
  • Public booking page frontend: htmx / light JS for the slot grid, matching the project's existing
    "boring stack" convention (no SPA framework, no build pipeline).
  • Owner calendar view: server-rendered htmx agenda view (today / this week, grouped by day), not a
    JS calendar library. Reschedule and cancel actions are server-rendered forms/htmx actions, not
    drag-and-drop.
  • Embedding: iframe, following the proven EA pattern already in this repo — a small JS file posted
    into the parent frame reports rendered height (avoiding an inner scrollbar, mirroring
    deploy/booking/booking_layout.js), and the booking page accepts a brand color (CSS
    variable or query param) for theming, mirroring deploy/booking/frontend.css's
    --bs-primary approach.
  • Abuse protection on the public endpoint: Caddy-level IP rate limiting plus a honeypot field in
    the booking form (hidden field that must stay empty; bots that fill every field get silently
    rejected). No CAPTCHA.
  • Reschedule is a first-class API action (UPDATE on the existing booking's start_time/
    end_time, protected by the same EXCLUDE constraint), available to both the customer
    (via signed token link) and the owner (via dashboard) — not modeled as cancel-then-rebook.
  • Owner manual booking creation reuses the same booking-creation code path as the public flow
    (so the EXCLUDE constraint always applies) but is passed an explicit override flag that skips
    the opening-hours/min-notice/max-advance/buffer checks. The overlap constraint is never
    overridable.
  • Email sending: SMTP via self-hosted mailcow. Outbound relayed through a VPS with a static IP
    (PTR record + matching A record, used as mailcow's HELO for all domains) since mailcow's home
    IP has no PTR support and gets outbound mail rejected otherwise; inbound mail continues to hit
    the home IP directly. Per-client sending domain (noreply@<client-domain>) once a client's own
    domain is provisioned in mailcow; fallback sender is a mivanchenko.de address for clients
    without one yet. This mail-relay setup is an infra prerequisite, tracked separately (see Out of
    Scope / triage), not built as part of this module's own tickets — the module just needs an SMTP
    endpoint to send through once it exists.
  • Owner notification on new/changed bookings: the Flask app fires an internal webhook call (same
    payload shape as today's n8n booking-sync → Telegram step) so the existing n8n Telegram-send
    logic can be reused/re-pointed rather than reimplemented; clients.notify_channel selects the
    channel (only telegram is a real, working channel at launch).
  • New-client onboarding (n8n/onboarding.json) is extended to insert a default resources row,
    a starter services row, and a users row (temp password) for booking, alongside its existing
    EA-provisioning step (which is removed as part of the same change, once EA is decommissioned).

Migration / cutover

  • No data migration from Easy!Appointments — existing EA bookings (including happynails') are
    placeholder data and can be discarded.
  • Decommission of deploy/booking/ (EA + MariaDB containers) and n8n/booking-sync.json happens
    as the final ticket, after the new module is live and client iframes are repointed at the new
    /book/<slug> pages — no extended parallel-run period.
  • Closes #1 (EA reschedule PK-conflict bug) as a side effect — the failure mode doesn't exist once
    bookings are written directly with EXCLUDE-constraint semantics instead of synced in via
    webhook.

Testing Decisions

  • Test seam: the Flask test client, issuing real HTTP requests against the app's routes, backed by
    a real (test) Postgres database — not unit tests of individual data-access functions in
    isolation. Tests assert on HTTP response + resulting DB state, exercising the tenancy layer, the
    EXCLUDE constraint, and the booking lifecycle exactly as a real client/browser/owner would.
    This is the highest available seam; there is no existing app-level test harness in the repo to
    extend (no test suite currently exists — backoffice/app/requirements.txt has no test
    dependencies), so this establishes the pattern for the module.
  • Good tests here check externally observable behavior only: given a request (and DB fixtures for
    a client/resource/service), assert the response status/body and the resulting rows — not that a
    particular internal function was called.
  • Priority coverage:
    • Tenancy isolation: a request authenticated/scoped to client A can never read or write client
      B's bookings/resources/services, even when IDs are guessed/enumerated.
    • Double-booking: two concurrent booking requests for the same resource/overlapping time only
      let one succeed (via the EXCLUDE constraint surfacing as a clean error, not a 500).
    • Availability generation: slots respect opening hours, service duration, min-notice,
      max-advance, and buffer correctly, including at least one DST-transition date.
    • Booking lifecycle: auto-confirm vs. pending-confirm branches produce the right status; cancel
      and reschedule via signed token succeed/fail appropriately (expired/reused token, wrong
      booking).
    • Owner auth: login, session scoping to client_id, password reset (both self-service and
      operator-triggered) end to end.
    • ICS feed: per-client token scoping — client A's token cannot read client B's feed.
  • No existing test-suite prior art to point to in this repo; the CI pipeline (.gitea/workflows/)
    currently only handles deploy, not test execution — adding a test-run step to CI is implied but
    is an infra/ops concern for the tickets, not a testing-decision itself.

Out of Scope

  • WhatsApp bot as a notification channel (Telegram only at launch; notify_channel is built to
    support it later without a schema change).
  • Customer reminder emails before an appointment (confirmation email only at launch).
  • Drag-and-drop / visual premium calendar view for owners (htmx agenda view only at launch; not
    necessarily a monetized tier when it does happen, just a later enhancement).
  • Outbound mail relay VPS setup (PTR/A/HELO configuration) — an infra prerequisite for mailcow-based
    email, tracked as its own ops item, not part of this module's tickets.
  • GDPR customer-data retention/deletion policy (what period, who's responsible as controller vs.
    processor) — a business/legal decision to be made separately; no auto-purge/anonymization is
    built now.
  • Per-client physical isolation (separate DB/stack) — explicitly rejected in favor of logical
    client_id isolation; may become a future paid exception but is not part of this work.
  • A JS-snippet (non-iframe) embedding model — iframe was chosen as sufficient given the existing
    height-matching/theming precedent from the EA integration.
  • CAPTCHA on the public booking form (rate limiting + honeypot judged sufficient).

Further Notes

  • This spec supersedes the previously-closed #9 (per-client EA isolation) and #10 (custom
    branded slot-grid widget) — both are made moot by replacing EA outright rather than isolating or
    reskinning it.
  • Full context and the original decision-seed brief for this work live at
    docs/booking-module-brief.md.
  • Suggested ticket shape for /to-tickets (blockers-first): schema + tenancy data-access layer →
    availability engine → public booking page (+ iframe embed) → owner auth + calendar view →
    manual/owner booking creation + reschedule → notifications (confirmation email + Telegram
    webhook re-point) → onboarding automation update → EA/n8n decommission (last).
# In-house multi-tenant booking module (replaces Easy!Appointments) ## Problem Statement Booking today runs on a shared Easy!Appointments (EA) instance sitting outside the `smb-crm` Postgres/Flask stack, bridged into it via an n8n webhook sync (`booking-sync`). This split creates real problems: a reschedule re-fires the same `EA-<id>` and hits a primary-key conflict (#1, plain `INSERT`, no upsert); EA is not multi-tenant-native, so per-client isolation is bolted on via a `notes` field holding the provider's `client_id`; the booking UI is a generic EA embed that can't be made to feel native inside a client's landing page; and every booking write has to survive two systems (EA's MariaDB, then the CRM's Postgres) instead of one. Business owners also have no way to log in and see their own bookings — the CRM's operator dashboard is Caddy-basic-auth + a single shared API token, with no per-client identity at all. ## Solution Build booking natively into the existing `smb-crm` Flask app, backed by the same Postgres database that already holds `clients`, `bookings`, etc. Two Flask blueprints: - **Public**: an unauthenticated, rate-limited `/book/<slug>` booking page per client, embedded via iframe into the client's landing page, offering real-time availability and confirmation with no customer account required. - **Owner**: an authenticated, `client_id`-scoped dashboard where the business owner logs in (email + password) and sees an agenda-style calendar of who's booked when, can add/cancel/ reschedule bookings themselves, and can subscribe an external calendar app to their bookings. Easy!Appointments (`deploy/booking/`), its MariaDB, and `n8n/booking-sync.json` are decommissioned once cutover is complete. No historical EA data is migrated (existing bookings are placeholder data). This closes #1 (the bug is structurally impossible once bookings are written directly, with `ON CONFLICT`/`EXCLUDE`-constraint semantics, instead of synced from a second system). ## User Stories ### Public booking (customer) 1. As a customer, I want to open a client's booking page and see which services are offered, so that I can pick the one I want. 2. As a customer, I want to see available time slots for my chosen service, computed from the business's opening hours and the service's duration, so that I only see times that are actually bookable. 3. As a customer, I want slots that are too soon (inside the business's minimum-notice window) or too far out (beyond the maximum advance-booking window) to not be offered, so that I don't book something the business can't honor. 4. As a customer, I want a buffer to be respected between my booking and the next one on the same resource, so that the business has turnaround time. 5. As a customer, I want to submit my name and contact details along with my chosen slot, so that the business knows who's coming. 6. As a customer, if the business has auto-confirm enabled, I want my booking to be confirmed immediately, so that I know right away that my appointment is locked in. 7. As a customer, if the business has manual-confirm enabled, I want my booking to start as pending and be told the business will confirm it, so that I'm not misled into thinking it's final before the owner has seen it. 8. As a customer, I want to receive a confirmation email with my booking details and a link to manage it, so that I have a record and a way to act on it without creating an account. 9. As a customer, I want to click a link in my confirmation email to cancel my booking, so that I can free the slot if my plans change. 10. As a customer, I want to click a link in my confirmation email to reschedule my booking to a different available slot, so that I don't have to cancel and rebook from scratch. 11. As a customer, if two people try to book the exact same slot on the same resource at the same time, I want only one of us to succeed and the other to see a clear "that slot was just taken" message, so that the business never ends up double-booked. 12. As a customer, I want the booking page to feel like part of the business's own website (not a jarring embedded frame), so that the experience feels trustworthy and professional. 13. As a customer, I want the booking page's colors to visually match the business's branding, so that it doesn't feel like a bolted-on third-party tool. 14. As a customer, I want my booking data handled by a business operating in Germany to respect reasonable privacy expectations, even though the concrete retention/deletion policy is decided later (tracked separately, out of scope here). ### Owner dashboard 15. As a business owner, I want to log in with an email and password scoped to my own business, so that I only ever see my own bookings, never another client's. 16. As a business owner, I want an agenda-style calendar view (e.g. today / this week, grouped by day) showing who's booked when, so that I can plan my day without a drag-and-drop calendar UI getting in the way. 17. As a business owner, I want to add a booking manually (e.g. for a phone-in or walk-in customer) directly from my calendar view, so that not every booking has to come through the public page. 18. As a business owner, when I add a booking manually, I want to be able to override opening hours, minimum-notice, and buffer rules (e.g. to fit in a regular as a favor), so that those rules are conveniences for the public flow, not hard constraints on me. 19. As a business owner, I want the double-booking protection to apply to me too when adding manual bookings, so that I can't accidentally book two customers into the same resource at the same time. 20. As a business owner, I want to cancel or reschedule a booking from my calendar view, so that I can handle changes customers call in about. 21. As a business owner, I want to choose whether new public bookings require my confirmation or are auto-confirmed, so that the flow matches how I run my business. 22. As a business owner, I want to configure my services (name, duration, price, active/inactive), so that the public booking page reflects what I actually offer. 23. As a business owner, I want to configure my opening hours, minimum-notice, maximum-advance window, and buffer time, so that the availability shown to customers matches reality. 24. As a business owner, I want to choose how I'm notified of new bookings (for launch: Telegram only, with the setting in place for future channels), so that I hear about bookings the way I actually check messages. 25. As a business owner, I want a subscribable calendar feed (iCal) scoped only to my own bookings, protected by a token unique to my business, so that I can see my bookings in my phone's or desktop's calendar app without exposing or being able to see any other client's bookings. 26. As a business owner, if I forget my password, I want to request a reset link by email myself, so that I'm not locked out waiting on the operator. 27. As a business owner, I want the operator to be able to trigger a password-reset email to me on my behalf, so that I have a path to recovery even if my own registered email is unreachable at that moment. 28. As a new client being onboarded, I want a working booking page from day one (default resource, a starter service, sane default hours) even before I've logged in to customize anything, so that my landing page's booking button isn't broken while I get around to configuring it. ### Operator (CRM back office) 29. As the operator, I want to see a list of owner accounts (one per client) in the existing CRM dashboard, so that I have visibility into who has booking access. 30. As the operator, I want to trigger a password-reset email for any owner account from the CRM dashboard, so that I can help a client who's locked out without needing their login. 31. As the operator, I want new-client onboarding to automatically create a default resource, starter service, and owner user account for booking, so that I don't have to configure booking by hand for every new client. 32. As the operator, I want Easy!Appointments and its sync workflow fully decommissioned once cutover is verified, so that there's no parallel legacy system left running or being paid attention to. ## Implementation Decisions ### Data model (Postgres, same database as existing `smb-crm` tables) - `clients` gains: `slug` (text, unique, public URL identifier, owner-editable, defaults to a normalized business name), `timezone` (text, default `'Europe/Berlin'`), `auto_confirm` (boolean, default true — per-client toggle for confirm-on-create vs. pending-until-owner-confirms). `notify_channel` already exists on `clients` and will be reused/wired through (currently unused by the sync path in practice) — valid value at launch: `telegram`. - New `resources` table: `resource_id` (pk), `client_id`, `name`, `active`. One row per bookable unit (staff/chair). Single-provider clients get exactly one row; the schema does not assume single-resource. - New `services` table: `service_id` (pk), `client_id`, `name`, `duration_minutes`, `price`, `active`. - New `availability` config (either columns on `resources` or a small per-resource/per-client table): opening hours per weekday, `min_notice_minutes`, `max_advance_days`, `buffer_minutes`. Exact shape (per-client vs. per-resource hours) to be finalized during implementation; per-resource is the safer default since multi-resource clients may have staff with different hours. - `bookings` gains a `resource_id` foreign key (nullable during transition, required going forward) and a `during` generated `tstzrange` column (from `start_time`/`end_time`) with a Postgres `EXCLUDE USING gist (resource_id WITH =, during WITH &&)` constraint — this is the single source of truth for "no double-booking," enforced on both `INSERT` (public/owner create) and `UPDATE` (reschedule), not just checked in application code. - `bookings.status` gains `pending` as a valid value (alongside existing `confirmed`/`cancelled` etc.) for the manual-confirm path. - New `users` table: `user_id` (pk), `client_id`, `email` (unique), `password_hash`, timestamps. Owner login is scoped by joining through `client_id`, same isolation pattern as every other table. - New `password_reset_tokens` table: `token` (random, signed/hashed at rest), `user_id`, `expires_at`, `used_at`. Single mechanism used by both the self-service "forgot password" flow and the operator-triggered "send reset link" action in the CRM dashboard. - `bookings.ics` feed moves from one shared `ICS_TOKEN` to a **per-client** secret token (new column, e.g. `clients.ics_token`), closing the current cross-tenant leak where any holder of the shared token can view any `client_id`'s feed by changing the query param. - Cancel/reschedule links use signed tokens (PyJWT, already a dependency) embedding the `booking_id` and an expiry — no customer account, no separate token table needed for these (unlike password reset, which needs revocation/single-use tracking). ### Architecture - Extend the existing single Flask app (`backoffice/app`). New blueprints: public booking (`/book/<slug>/...`) and owner auth/dashboard (`/owner/...` or similar), alongside the existing operator API/dashboard routes. - **Tenancy safety**: a new thin data-access module is the *only* place that runs raw SQL against booking/resource/service/user tables. Every function in it requires `client_id` (or resolves it from an authenticated session/token) and injects the filter itself — route handlers never write `WHERE client_id = ...` directly. This is the single seam where a missing-isolation bug would have to be introduced, and the single place it'd be caught in review or tests. - Owner session auth: standard Flask session backed by `users.password_hash` (e.g. werkzeug's password hashing, consistent with Flask norms). No new auth framework. - Public booking page frontend: htmx / light JS for the slot grid, matching the project's existing "boring stack" convention (no SPA framework, no build pipeline). - Owner calendar view: server-rendered htmx agenda view (today / this week, grouped by day), not a JS calendar library. Reschedule and cancel actions are server-rendered forms/htmx actions, not drag-and-drop. - Embedding: iframe, following the proven EA pattern already in this repo — a small JS file posted into the parent frame reports rendered height (avoiding an inner scrollbar, mirroring `deploy/booking/booking_layout.js`), and the booking page accepts a brand color (CSS variable or query param) for theming, mirroring `deploy/booking/frontend.css`'s `--bs-primary` approach. - Abuse protection on the public endpoint: Caddy-level IP rate limiting plus a honeypot field in the booking form (hidden field that must stay empty; bots that fill every field get silently rejected). No CAPTCHA. - Reschedule is a first-class API action (`UPDATE` on the existing booking's `start_time`/ `end_time`, protected by the same `EXCLUDE` constraint), available to both the customer (via signed token link) and the owner (via dashboard) — not modeled as cancel-then-rebook. - Owner manual booking creation reuses the same booking-creation code path as the public flow (so the `EXCLUDE` constraint always applies) but is passed an explicit override flag that skips the opening-hours/min-notice/max-advance/buffer checks. The overlap constraint is never overridable. - Email sending: SMTP via self-hosted mailcow. Outbound relayed through a VPS with a static IP (PTR record + matching A record, used as mailcow's HELO for all domains) since mailcow's home IP has no PTR support and gets outbound mail rejected otherwise; inbound mail continues to hit the home IP directly. Per-client sending domain (`noreply@<client-domain>`) once a client's own domain is provisioned in mailcow; fallback sender is a `mivanchenko.de` address for clients without one yet. This mail-relay setup is an infra prerequisite, tracked separately (see Out of Scope / triage), not built as part of this module's own tickets — the module just needs an SMTP endpoint to send through once it exists. - Owner notification on new/changed bookings: the Flask app fires an internal webhook call (same payload shape as today's n8n `booking-sync` → Telegram step) so the existing n8n Telegram-send logic can be reused/re-pointed rather than reimplemented; `clients.notify_channel` selects the channel (only `telegram` is a real, working channel at launch). - New-client onboarding (`n8n/onboarding.json`) is extended to insert a default `resources` row, a starter `services` row, and a `users` row (temp password) for booking, alongside its existing EA-provisioning step (which is removed as part of the same change, once EA is decommissioned). ### Migration / cutover - No data migration from Easy!Appointments — existing EA bookings (including happynails') are placeholder data and can be discarded. - Decommission of `deploy/booking/` (EA + MariaDB containers) and `n8n/booking-sync.json` happens as the final ticket, after the new module is live and client iframes are repointed at the new `/book/<slug>` pages — no extended parallel-run period. - Closes #1 (EA reschedule PK-conflict bug) as a side effect — the failure mode doesn't exist once bookings are written directly with `EXCLUDE`-constraint semantics instead of synced in via webhook. ## Testing Decisions - Test seam: the Flask test client, issuing real HTTP requests against the app's routes, backed by a real (test) Postgres database — not unit tests of individual data-access functions in isolation. Tests assert on HTTP response + resulting DB state, exercising the tenancy layer, the `EXCLUDE` constraint, and the booking lifecycle exactly as a real client/browser/owner would. This is the highest available seam; there is no existing app-level test harness in the repo to extend (no test suite currently exists — `backoffice/app/requirements.txt` has no test dependencies), so this establishes the pattern for the module. - Good tests here check externally observable behavior only: given a request (and DB fixtures for a client/resource/service), assert the response status/body and the resulting rows — not that a particular internal function was called. - Priority coverage: - Tenancy isolation: a request authenticated/scoped to client A can never read or write client B's bookings/resources/services, even when IDs are guessed/enumerated. - Double-booking: two concurrent booking requests for the same resource/overlapping time only let one succeed (via the `EXCLUDE` constraint surfacing as a clean error, not a 500). - Availability generation: slots respect opening hours, service duration, min-notice, max-advance, and buffer correctly, including at least one DST-transition date. - Booking lifecycle: auto-confirm vs. pending-confirm branches produce the right status; cancel and reschedule via signed token succeed/fail appropriately (expired/reused token, wrong booking). - Owner auth: login, session scoping to `client_id`, password reset (both self-service and operator-triggered) end to end. - ICS feed: per-client token scoping — client A's token cannot read client B's feed. - No existing test-suite prior art to point to in this repo; the CI pipeline (`.gitea/workflows/`) currently only handles deploy, not test execution — adding a test-run step to CI is implied but is an infra/ops concern for the tickets, not a testing-decision itself. ## Out of Scope - WhatsApp bot as a notification channel (Telegram only at launch; `notify_channel` is built to support it later without a schema change). - Customer reminder emails before an appointment (confirmation email only at launch). - Drag-and-drop / visual premium calendar view for owners (htmx agenda view only at launch; not necessarily a monetized tier when it does happen, just a later enhancement). - Outbound mail relay VPS setup (PTR/A/HELO configuration) — an infra prerequisite for mailcow-based email, tracked as its own ops item, not part of this module's tickets. - GDPR customer-data retention/deletion policy (what period, who's responsible as controller vs. processor) — a business/legal decision to be made separately; no auto-purge/anonymization is built now. - Per-client physical isolation (separate DB/stack) — explicitly rejected in favor of logical `client_id` isolation; may become a future paid exception but is not part of this work. - A JS-snippet (non-iframe) embedding model — iframe was chosen as sufficient given the existing height-matching/theming precedent from the EA integration. - CAPTCHA on the public booking form (rate limiting + honeypot judged sufficient). ## Further Notes - This spec supersedes the previously-closed #9 (per-client EA isolation) and #10 (custom branded slot-grid widget) — both are made moot by replacing EA outright rather than isolating or reskinning it. - Full context and the original decision-seed brief for this work live at `docs/booking-module-brief.md`. - Suggested ticket shape for `/to-tickets` (blockers-first): schema + tenancy data-access layer → availability engine → public booking page (+ iframe embed) → owner auth + calendar view → manual/owner booking creation + reschedule → notifications (confirmation email + Telegram webhook re-point) → onboarding automation update → EA/n8n decommission (last).
mivanchenko added the bookingready-for-agentenhancement labels 2026-07-23 13:56:18 +02:00
Sign in to join this conversation.