Each client now gets their own clients.ics_token (lazily generated on first /owner/settings visit), which both authenticates and scopes /api/bookings.ics -- closing the gap where any shared-token holder could view another client's bookings by swapping the client_id query param. The owner settings page now surfaces a copyable subscribe URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
smb-online — Documentation
Operator-facing documentation for the whole system: what exists, how the pieces talk to each
other, and where to look when something needs changing. For pitch/positioning copy see
README.md; for open work see TODO.md; for day-to-day operator procedure see playbooks/.
1. What this is
A productized service that gets local small businesses (SMBs) online: landing page + online booking + lead capture + light automation, run by one operator on a homelab. Every lead, client, booking and invoice is tracked centrally so nothing falls through the cracks.
Originally scoped as two fixed delivery tiers (Tier A = free Google stack, Tier B =
self-hosted/privacy). That coupling is being unwound (see TODO.md): tier is now an internal
effort/price signal chosen per client, not a property baked into the public demos. In practice,
booking has already converged on one shared self-hosted service (Easy!Appointments, §4.3) for
every client regardless of tier.
2. Architecture
┌────────────────────────────────────────────┐
│ Postgres (smb-db) │
│ clients · leads · projects · bookings · │
│ invoices · activity_log · credentials — │
│ SOLE SOURCE OF TRUTH │
└───────────────▲──────────────────────────────┘
│ SQL
JSON API │
┌────────────────────┴───────┐
│ smb-crm (Flask, backoffice)│
│ onboard.mivanchenko.de/crm │
│ dashboard + CRUD + iCal │
└────────────────────▲─────────┘
│ HTTP (CRM_API_TOKEN)
│
┌────────────────────┴─────────────────────────────┐
│ n8n │
│ lead-intake · onboarding · booking-sync · │
│ renewal-reminder (n8n.mivanchenko.de) │
└───────────▲───────────────────────▲──────────────┘
│ webhook │ webhook
┌──────────────┴───────────┐ ┌─────────┴────────────────┐
│ Landing pages / demos │ │ Easy!Appointments │
│ (lead form, callback │ │ booking.mivanchenko.de │
│ widget) — demos.*, and │ │ (shared, one instance, │
│ each client's own site │ │ one service+provider │
│ (client-<slug> container)│ │ per client) │
└───────────────────────────┘ └───────────────────────────┘
Postgres is the sole source of truth. Google Sheets used to be authoritative, then (from
2026-06-25) became a best-effort downstream mirror; that mirror was removed entirely (#13) —
there is no second data path anymore. See backoffice/app/db.py (schema/contract).
Everything routes through Caddy on the homelab (TLS + basic-auth + reverse proxy) and a
shared external proxy Docker network. Compose groups (§4) are independent projects that only
share that network — see deploy/clients/README.md for the full picture.
3. Data model
Six entities, defined once in backoffice/app/db.py::TABLES and mirrored 1:1 into
backoffice/db/init.sql (Postgres):
| Entity | PK | Purpose |
|---|---|---|
clients |
client_id (C-####) |
The client roster — tier, status (lead→onboarding→active→paused→churned), billing, renewal date, vault_ref (pointer into Vaultwarden, never a password), stack_notes (free text — hosting/booking details, e.g. the EA embed URL). |
leads |
lead_id (L-<epoch-ms>) |
Every inbound lead/callback request across all sites. status: new→contacted→qualified→won/lost. |
projects |
project_id (P-<epoch-ms>) |
Per-client deliverable/checklist tracking, auto-created on onboarding. |
bookings |
booking_id |
Every appointment, synced from Easy!Appointments via booking-sync. |
invoices |
invoice_id |
Billing records (manual today — no automated invoicing workflow yet). |
activity_log |
id (serial) |
Append-only audit trail; every mutation (API or workflow) writes one row. |
credentials |
cred_id (CR-<epoch-ms>) |
Per-client login credentials (e.g. the auto-generated Easy!Appointments provider login) — client_id, label, username, secret, notes. Reading it requires X-CRM-Token (every other entity's list is read-only-open behind Caddy basic-auth alone), so secrets never leave Postgres. Surfaced in the dashboard's Credentials tab with a masked value, a reveal toggle, and a copy-to-clipboard button. |
db.coerce_row() is the single place that types/normalizes incoming values (dates, timestamps,
numbers, booleans) so the CRUD API and the n8n ingest path can never drift on types.
4. Components
4.1 backoffice/ — CRM API + operator dashboard
Flask app (app.py) + waitress, backed by Postgres (smb-db, postgres:16-alpine).
GET /api/<entity>— list (no auth; page itself sits behind Caddy basic-auth).POST /api/<entity>— create. Requires headerX-CRM-Token: $CRM_API_TOKEN. Auto-assignsclient_id/lead_id/project_id,created_at/received_at, defaultstatus, and computesrenewal_datefromstart_date+billing_cycle(monthly/yearly) when omitted.PATCH /api/<entity>/<id>— partial update, same auth.DELETE /api/<entity>/<id>— delete, same auth.GET /api/bookings.ics— read-only iCal feed for calendar apps (Apple/Google Calendar can't send custom headers, so this is gated by a?token=query param instead of the header token). The token is per-client (clients.ics_token, lazily generated on first visit to/owner/settings), so it both authenticates and scopes the feed to that one client — there's no separateclient_idparam to swap.GET /healthz— DB connectivity check.GET /— the dashboard (static/index.html; Leads, Clients and Credentials tabs — read + add (+Neu) + edit (✎) + delete (🗑), all token-gated, all audit-logged. The Credentials tab masks thesecretcolumn by default with a per-row 👁 reveal toggle and a 📋 copy-to- clipboard button).
Every write is audit-logged to activity_log.
Run locally: docker compose -f backoffice/docker-compose.yml up (needs .env from
backoffice/.env.example).
4.2 templates/landing/ — landing pages & demos
Static, self-contained HTML (no build step). templates/landing/index.html is the public
chooser (also carries the "Rückruf" callback-request popup, wired to the lead-intake webhook).
demo-tier-a/,demo-tier-b/,demo-pizzeria/— the three neutralized (stack-agnostic) outreach demos: barbershop, physio practice, pizzeria w/ online ordering.preview-*/— one-off rebrands used as personalised outreach hooks (seeplaybooks/outreach.md) or as seeds for a real client site (seenew-client.sh).- Lead/callback forms POST directly (client-side
fetch) tohttps://n8n.mivanchenko.de/webhook/lead-intake; booking widgets embed/link to the shared Easy!Appointments instance. - Publicly hosted at
demos.mivanchenko.de(see §4.4).
4.3 deploy/booking/ — shared booking service
One shared Easy!Appointments instance (PHP + MariaDB) for all clients — a client is
modeled as an EA "service" + "provider" pair, not a separate stack. Routed at
booking.mivanchenko.de. Ships with two host overrides applied read-only into the container:
frontend.css (recolors the widget to match brand, compacts layout for iframe embedding) and
booking_layout.js (reports rendered height to the embedding page for auto-fit). Both must be
re-synced from upstream whenever the EA image is upgraded, since production
(DEBUG_MODE=FALSE) serves the .min.* bundles that these files override.
A booking event fires a webhook → n8n booking-sync → POST /api/bookings on the CRM → Telegram
notification to the operator.
4.4 deploy/ — hosting topology
Everything below shares the external proxy Docker network (Caddy reaches each container by its
Compose service name):
| Compose group | What | Domain | Cardinality |
|---|---|---|---|
backoffice/docker-compose.yml |
smb-db + smb-crm |
onboard.mivanchenko.de/crm |
one, shared |
| (external, not in this repo) | n8n + Postgres + Redis | n8n.mivanchenko.de |
one, shared |
deploy/booking/ |
Easy!Appointments + MariaDB | booking.mivanchenko.de |
one, shared |
deploy/smb-demos/ |
Apache serving templates/landing/ |
demos.mivanchenko.de |
prospect previews |
deploy/clients/<slug>/ |
one nginx:alpine static site |
client's own domain (wildcard *.mivanchenko.de or client-owned) |
one per signed client |
Only the client-facing sites multiply — everything else stays a single shared instance.
deploy/clients/new-client.sh <slug> <domain> [source-site-dir] scaffolds a new isolated client
folder from deploy/clients/_template/ (fills .env, seeds site/) and prints the Caddy block
- deploy commands.
docker compose downin a client's folder removes exactly that client and nothing else (clean offboarding).
deploy/backup/smb-db-backup.sh — daily cron job on the homelab: pg_dumps smb-db, gzips,
keeps the 14 most recent dumps in /home/mivanchenko/backups/smb-crm/.
4.5 n8n/ — automation workflows (exported JSON)
| Workflow | Trigger | Does |
|---|---|---|
lead-intake.json |
webhook | Normalize a lead payload → POST /api/leads → Telegram notify. Used by every demo/client lead form and the callback widget. |
onboarding.json |
webhook (onboard.mivanchenko.de form) |
Compute client+project rows → POST /api/clients → POST /api/projects → Telegram notify → provision Easy!Appointments (create service, create provider with a generated login, build the booking embed URL) → PATCH the client's stack_notes with that embed URL + EA username → POST /api/credentials with the EA username and password. Fully automates "sign a client" end to end, including capturing the generated password so it isn't lost (it used to be discarded after the EA API call — see TODO.md). |
booking-sync.json |
webhook (EA) | Normalize a booking event → POST /api/bookings → Telegram notify. |
renewal-reminder.json |
daily 08:00 schedule | GET /api/clients → find renewals due soon → Telegram notify → POST /api/activity_log. |
n8n itself (the workflow engine + its own Postgres/Redis) is not part of this repo — it's a separate, already-running compose stack on the homelab; only the exported workflow definitions live here.
4.6 playbooks/ — operator procedure
outreach.md— how to find prospects, build a personalised preview in ~20 min, and reach out (email/DM/phone scripts in German).lead-to-customer.md— the full lifecycle once a lead exists: qualify → close → run the onboarding form → build/ship the real site → tune booking → hand over → go live → track renewal.tier-a-google.md/tier-b-selfhosted.md— per-tier setup checklists and suggested pricing.
5. Environments, secrets, and how to run things
Secrets are never committed (.env, *.secret, .secrets/, credentials/,
backoffice/secrets/, stacks/**/.env are gitignored — see .gitignore). Each deployable has
its own .env.example to copy from:
| File | Fills |
|---|---|
backoffice/.env.example |
DB_PASSWORD, CRM_API_TOKEN |
deploy/booking/.env.example |
EA_DB_PASSWORD, EA_DB_ROOT_PASSWORD |
deploy/clients/_template/.env.example |
CLIENT_SLUG, CLIENT_DOMAIN (per-client, generated by new-client.sh) |
Local dev (no build tooling needed anywhere in this repo):
# Demos — self-contained HTML, open directly or serve the folder:
python3 -m http.server 8080 --directory templates/landing
# Back office (needs Postgres):
docker compose -f backoffice/docker-compose.yml up
Production deploys are docker compose up -d per Compose group on the homelab, fronted by Caddy;
see deploy/clients/README.md and each group's own compose file for the exact routing.
6. Security notes
- Browser-facing surfaces (
onboard.mivanchenko.de, dashboards) sit behind Caddy basic-auth. - Machine-to-machine writes (n8n → CRM) are gated by the
CRM_API_TOKENheader, checked inbackoffice/app/app.py::authed(). - The iCal feed is gated by a per-client query-string token (
clients.ics_token) since calendar clients can't send custom headers — treat that token as effectively public-linkable and rotate it (clear the column, a fresh one is generated on next/owner/settingsvisit) if a feed URL leaks. - Credentials for client-owned accounts are recorded two ways:
clients.vault_refpoints to a Vaultwarden item for anything the operator manually stashes there; thecredentialstable holds secrets the system itself generates (currently: the Easy!Appointments provider login created during onboarding), gated byX-CRM-Tokeneven to read. Stored as plaintext in Postgres today — same trust boundary as the rest of the CRM (Caddy basic-auth + host security); revisit with column-level encryption (pgcrypto) if the dashboard is ever exposed more broadly (seeTODO.md).
7. Legal / positioning constraint
The operator is doing a Cloud Engineer Ausbildung at NETWAYS. This offering deliberately stays
out of NETWAYS's lane (no managed cloud hosting / monitoring / OpenStack-K8s hosting sold to
clients) — see the compliance note in README.md. This shapes real decisions in the code/infra:
e.g. Tier B is pitched as "I host a small website + booking for you," not managed
infrastructure (playbooks/tier-b-selfhosted.md).
8. Where to look next
- Open work, known gaps, and rationale for recent pivots:
TODO.md. - Positioning/pitch copy and quickstart:
README.md. - Exact table/column contract:
backoffice/app/db.py. - Exact API behavior:
backoffice/app/app.py.