In-house multi-tenant booking module (replaces Easy!Appointments) #14
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-crmPostgres/Flask stack, bridged into it via an n8n webhook sync (
booking-sync). This split createsreal 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 viaa
notesfield holding the provider'sclient_id; the booking UI is a generic EA embed that can'tbe 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-crmFlask app, backed by the same Postgres databasethat already holds
clients,bookings, etc. Two Flask blueprints:/book/<slug>booking page per client, embedded viaiframe into the client's landing page, offering real-time availability and confirmation with no
customer account required.
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, andn8n/booking-sync.jsonare decommissionedonce 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)
that I can pick the one I want.
business's opening hours and the service's duration, so that I only see times that are actually
bookable.
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.
resource, so that the business has turnaround time.
the business knows who's coming.
immediately, so that I know right away that my appointment is locked in.
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.
manage it, so that I have a record and a way to act on it without creating an account.
can free the slot if my plans change.
different available slot, so that I don't have to cancel and rebook from scratch.
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.
jarring embedded frame), so that the experience feels trustworthy and professional.
that it doesn't feel like a bolted-on third-party tool.
reasonable privacy expectations, even though the concrete retention/deletion policy is decided
later (tracked separately, out of scope here).
Owner dashboard
that I only ever see my own bookings, never another client's.
day) showing who's booked when, so that I can plan my day without a drag-and-drop calendar UI
getting in the way.
customer) directly from my calendar view, so that not every booking has to come through the
public page.
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.
manual bookings, so that I can't accidentally book two customers into the same resource at the
same time.
can handle changes customers call in about.
are auto-confirmed, so that the flow matches how I run my business.
so that the public booking page reflects what I actually offer.
window, and buffer time, so that the availability shown to customers matches reality.
only, with the setting in place for future channels), so that I hear about bookings the way I
actually check messages.
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.
so that I'm not locked out waiting on the operator.
my behalf, so that I have a path to recovery even if my own registered email is unreachable at
that moment.
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)
dashboard, so that I have visibility into who has booking access.
dashboard, so that I can help a client who's locked out without needing their login.
starter service, and owner user account for booking, so that I don't have to configure booking
by hand for every new client.
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-crmtables)clientsgains:slug(text, unique, public URL identifier, owner-editable, defaults to anormalized 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_channelalready exists onclientsand will be reused/wired through (currently unusedby the sync path in practice) — valid value at launch:
telegram.resourcestable:resource_id(pk),client_id,name,active. One row per bookableunit (staff/chair). Single-provider clients get exactly one row; the schema does not assume
single-resource.
servicestable:service_id(pk),client_id,name,duration_minutes,price,active.availabilityconfig (either columns onresourcesor a small per-resource/per-clienttable): opening hours per weekday,
min_notice_minutes,max_advance_days,buffer_minutes. Exact shape (per-client vs. per-resource hours) to be finalized duringimplementation; per-resource is the safer default since multi-resource clients may have staff
with different hours.
bookingsgains aresource_idforeign key (nullable during transition, required goingforward) and a
duringgeneratedtstzrangecolumn (fromstart_time/end_time) with aPostgres
EXCLUDE USING gist (resource_id WITH =, during WITH &&)constraint — this is thesingle source of truth for "no double-booking," enforced on both
INSERT(public/owner create)and
UPDATE(reschedule), not just checked in application code.bookings.statusgainspendingas a valid value (alongside existingconfirmed/cancelledetc.) for the manual-confirm path.
userstable:user_id(pk),client_id,email(unique),password_hash, timestamps.Owner login is scoped by joining through
client_id, same isolation pattern as every othertable.
password_reset_tokenstable:token(random, signed/hashed at rest),user_id,expires_at,used_at. Single mechanism used by both the self-service "forgot password" flowand the operator-triggered "send reset link" action in the CRM dashboard.
bookings.icsfeed moves from one sharedICS_TOKENto a per-client secret token (newcolumn, e.g.
clients.ics_token), closing the current cross-tenant leak where any holder of theshared token can view any
client_id's feed by changing the query param.booking_idand an expiry — no customer account, no separate token table needed for these(unlike password reset, which needs revocation/single-use tracking).
Architecture
backoffice/app). New blueprints: public booking(
/book/<slug>/...) and owner auth/dashboard (/owner/...or similar), alongside the existingoperator API/dashboard routes.
booking/resource/service/user tables. Every function in it requires
client_id(or resolves itfrom 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 wouldhave to be introduced, and the single place it'd be caught in review or tests.
users.password_hash(e.g. werkzeug'spassword hashing, consistent with Flask norms). No new auth framework.
"boring stack" convention (no SPA framework, no build pipeline).
JS calendar library. Reschedule and cancel actions are server-rendered forms/htmx actions, not
drag-and-drop.
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 (CSSvariable or query param) for theming, mirroring
deploy/booking/frontend.css's--bs-primaryapproach.the booking form (hidden field that must stay empty; bots that fill every field get silently
rejected). No CAPTCHA.
UPDATEon the existing booking'sstart_time/end_time, protected by the sameEXCLUDEconstraint), available to both the customer(via signed token link) and the owner (via dashboard) — not modeled as cancel-then-rebook.
(so the
EXCLUDEconstraint always applies) but is passed an explicit override flag that skipsthe opening-hours/min-notice/max-advance/buffer checks. The overlap constraint is never
overridable.
(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 owndomain is provisioned in mailcow; fallback sender is a
mivanchenko.deaddress for clientswithout 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.
payload shape as today's n8n
booking-sync→ Telegram step) so the existing n8n Telegram-sendlogic can be reused/re-pointed rather than reimplemented;
clients.notify_channelselects thechannel (only
telegramis a real, working channel at launch).n8n/onboarding.json) is extended to insert a defaultresourcesrow,a starter
servicesrow, and ausersrow (temp password) for booking, alongside its existingEA-provisioning step (which is removed as part of the same change, once EA is decommissioned).
Migration / cutover
placeholder data and can be discarded.
deploy/booking/(EA + MariaDB containers) andn8n/booking-sync.jsonhappensas 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.bookings are written directly with
EXCLUDE-constraint semantics instead of synced in viawebhook.
Testing Decisions
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
EXCLUDEconstraint, 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.txthas no testdependencies), so this establishes the pattern for the module.
a client/resource/service), assert the response status/body and the resulting rows — not that a
particular internal function was called.
B's bookings/resources/services, even when IDs are guessed/enumerated.
let one succeed (via the
EXCLUDEconstraint surfacing as a clean error, not a 500).max-advance, and buffer correctly, including at least one DST-transition date.
and reschedule via signed token succeed/fail appropriately (expired/reused token, wrong
booking).
client_id, password reset (both self-service andoperator-triggered) end to end.
.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
notify_channelis built tosupport it later without a schema change).
necessarily a monetized tier when it does happen, just a later enhancement).
email, tracked as its own ops item, not part of this module's tickets.
processor) — a business/legal decision to be made separately; no auto-purge/anonymization is
built now.
client_idisolation; may become a future paid exception but is not part of this work.height-matching/theming precedent from the EA integration.
Further Notes
branded slot-grid widget) — both are made moot by replacing EA outright rather than isolating or
reskinning it.
docs/booking-module-brief.md./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).