Flask fires a fire-and-forget internal webhook (owner_notify.py, mirroring
mailer.py's background-thread queue) on booking create/cancel/reschedule,
carrying the same {booking, business_name, notify_channel} shape the old
EA-driven "Build booking row" node produced, plus an event field so the
Telegram message can say what actually happened. n8n/booking-sync.json gets
a new webhook + IF node feeding the existing Telegram node directly, so it
no longer needs EA's API to build the notification payload; channel routing
(only telegram sends for now) lives in that IF node.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Postgres is now the sole source of truth: delete sheets.py and
import_from_sheets.py, strip mirror_entity/mirror_async/_mirror_worker and
POST /api/sync from app.py, drop the tab/mirror keys from db.py's TABLES.
Re-point n8n/renewal-reminder.json at the CRM's own HTTP API (GET
/api/clients, POST /api/activity_log) instead of the Sheets nodes, and drop
SHEET_ID/GOOGLE_SA_JSON from deploy env/compose and requests from
requirements.txt (PyJWT stays — still used by booking_api.py). Updates
docs/README/playbooks accordingly and closes the old #5 (atomic mirror) as
moot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a session-authenticated /owner/settings blueprint for services CRUD
(create/edit/deactivate), per-resource opening hours + min-notice/max-advance/
buffer, and client-level auto_confirm/notify_channel — all scoped to the
logged-in owner's own client_id. Extends booking_db.py with the missing
tenant-scoped update_service/update_resource/update_client writes, mirroring
the existing update_booking allowlist pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds .gitea/workflows/test-backoffice.yml (paths-filtered to
backoffice/app/** and backoffice/db/**, self-hosted homelab runner,
plain git clone matching deploy-backoffice.yml's conventions) so a
regression no longer needs a human to notice it.
The psycopg[binary]==3.2.1 pin turned out to be correct, not stale: it
only ships wheels through cp312, so it resolves cleanly under the
project's python:3.12-slim target but not under newer interpreters.
Documented in requirements.txt and docs/agents/testing.md, along with
the fact that the suite spins up its own throwaway postgres:16-alpine
container (tests/conftest.py) with no manual DB setup required.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an owner-authenticated weekly agenda (grouped by day, today highlighted)
with manual walk-in/phone booking creation, cancel, and reschedule -- all
routed through booking_api.py's create/cancel/reschedule logic (refactored
into shared helpers) so the EXCLUDE overlap constraint and confirmation
email stay on the single existing code path. Manual creation can skip the
opening-hours/min-notice/max-advance/buffer checks via an explicit override,
but never the overlap constraint itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flask-session login scoped to one client_id (never a request param),
self-service + operator-triggered password reset via single-use tokens,
and an Owner accounts tab on the CRM dashboard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py)
on booking creation, with a manage-booking link embedding the ticket-2 signed
token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses
the existing slot-picker against booking_api's create/cancel/reschedule API,
distinguishing an invalid/expired link from an already-cancelled one. Sender
address uses the client's own domain when configured, falling back to a
mivanchenko.de address otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the customer-facing /book/<slug> page: service/slot picker, booking
form, and confirmation screen, built on #16's existing booking JSON API.
Includes iframe auto-fit height reporting (mirroring
deploy/booking/booking_layout.js's eaBookingHeight message), brand-color
theming via a ?color= query param, a honeypot field with a fake-success
response indistinguishable from a real booking, and a clear "just taken"
message on slot-conflict. Caddy per-IP rate limiting is documented in
deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is
tracked in this repo).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the plumbing that makes "can a customer actually get booked" true
end to end at the API layer, on top of #15's schema/tenancy layer.
- resource_hours table + min_notice_minutes/max_advance_days/buffer_minutes
on resources -- config #15 didn't include but #16 depends on.
- availability.py: pure slot-generation function, correct across a
Europe/Berlin DST transition (tested both directions).
- booking_api.py: JSON blueprint for slot listing, booking creation
(auto_confirm -> confirmed/pending), and signed-JWT cancel/reschedule,
registered into app.py.
- booking_db.py gains resource-hours CRUD, a tenant-scoped busy-bookings
query for buffer/slot validation, and a read-only client lookup.
A true concurrent-threads test (not just sequential requests) surfaced a
real gap: Postgres can raise DeadlockDetected instead of ExclusionViolation
when two overlapping inserts race the exclusion constraint directly, which
went uncaught and would have 500'd instead of giving the clean 4xx the
ticket requires -- now caught alongside ExclusionViolation.
Also fixed: reschedule used the request's raw UTC offset to pick the
business day instead of the client's own timezone (could pick the wrong
day's hours/bookings near local midnight); the cancel/reschedule JWT no
longer falls back to reusing CRM_API_TOKEN as its signing secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the plumbing every other booking ticket builds on: resources,
services, users, and password_reset_tokens tables, plus the clients
columns (slug, timezone, auto_confirm, ics_token) and the bookings
resource_id/EXCLUDE-constraint double-booking protection described in #14.
booking_db.py is the only place raw SQL runs against these tables --
every function takes client_id and injects the tenant filter itself,
and create/update_booking additionally verify the resource_id belongs
to that client before writing, closing a guessed-ID cross-tenant hole.
Tests spin up a real throwaway Postgres 16 container (matching prod) and
exercise the EXCLUDE constraint, tenancy isolation, and password-reset
single-use semantics end to end, per #14's "real Postgres, no mocking"
testing decision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scaffolds the per-repo config the mattpocock engineering skills expect:
- AGENTS.md with the ## Agent skills block (issue tracker, triage labels, domain docs)
- docs/agents/issue-tracker.md — Gitea REST API workflow (gh/glab don't apply)
- docs/agents/triage-labels.md — canonical role -> Gitea label mapping (1:1)
- docs/agents/domain.md — single-context CONTEXT.md/ADR consumer rules
Triage state labels (needs-triage, needs-info, ready-for-agent, ready-for-human,
wontfix) created in the tracker; all open issues seeded with needs-triage.
Run triage with /mattpocock-skills:triage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgbgipE41xwPcQJnhnq1J1
Reconcile the stashed rewrite with the newer 2026-07-15 state (credentials
store, deploy pipeline). Backlog is now tracked as Gitea issues #1–#12; TODO.md
is the high-level map. Notes: #8 (booking-sync) closed as already done via the
shared Easy!Appointments instance; #4 narrowed (smb-db pg_dump exists, n8n/Gitea/
offsite/restore remain). Added a "manual operator follow-ups" section for the
human-only items (n8n re-import, credential username, deploy-pipeline test, etc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgbgipE41xwPcQJnhnq1J1
inputs.ref resolves fully-qualified (refs/heads/main) rather than the
declared default "main", so git clone --branch was failing with
"Remote branch refs/heads/main not found".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
actions/checkout@v4 failed with "Cannot find: node in PATH" (the
runner intentionally has no Node toolchain). Replaced with a plain
git clone authenticated via a new DEPLOY_TOKEN Gitea Actions secret,
since the repo needs auth even for a read-only clone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Manual (workflow_dispatch) pipeline that syncs backoffice/db and
backoffice/app to the homelab, re-applies the idempotent schema,
rebuilds/restarts smb-crm, and health-checks it — the same sequence
that was previously run by hand over SSH.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a `credentials` entity to the back office (never mirrored to
Sheets, gated by the CRM token even to read) so client logins like
the auto-generated Easy!Appointments provider password can be viewed
and copied from the dashboard instead of getting lost — the actual
cause of the happynails password going missing. Onboarding now saves
that generated password instead of discarding it. Also adds
Documentation.md, brings README/TODO in line with the current
Postgres-first architecture, and tidies the backlog.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Header button on the CRM linking back to the operator onboarding page at "/"
(same host, same basic-auth).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the 1280px content cap and the table's overflow scroll; let cells wrap
(word-break) instead of forcing a horizontal scrollbar, so rows span the full
screen width and show full values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening the dashboard at /crm (no trailing slash) made the relative `api/leads`
fetch resolve to /api/leads -> 404. Derive the API base from
location.pathname so it works at both /crm and /crm/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both flows now POST into the smb-crm DB API instead of appending to Sheets, so
Postgres stays the source of truth and the DB->Sheets mirror keeps the Sheet
current. Telegram notifications unchanged.
- lead-intake: Webhook -> Build lead row -> POST /api/leads -> Telegram
(dropped the direct Sheets append + the redundant activity-log nodes; the DB
service logs + mirrors activity itself).
- onboarding: Webhook -> Compute -> POST /api/clients (DB assigns C-id) ->
Build project w/ returned id -> POST /api/projects -> Telegram. DB computes
renewal_date; go-live = start + 14d.
- Token injected at deploy time; repo keeps the __CRM_TOKEN__ placeholder.
Verified end-to-end on the live webhooks: lead and client+project land in the
DB and mirror to Sheets; test data cleaned; DB/Sheets consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- After each mutation, the affected tab + activity_log are pushed to Sheets by
a single serialized background worker (Postgres stays source of truth; a
mirror failure never fails the DB write). Concurrency race fixed by
serializing through one worker; PYTHONUNBUFFERED for visible logs.
- _cell() formats dates/timestamps/numbers/bools and neutralises formula
injection. POST /api/sync does a full DB->Sheets resync of every tab.
Verified: add/edit/delete propagate to the Sheet; full resync aligns all tabs;
DB and Sheet consistent after cleanup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Token-protected mutation endpoints (POST/PATCH/DELETE), audit-logged to
activity_log. Token injected into the basic-auth-gated dashboard.
- Add auto-generates IDs (next C-#### / L-<epoch>), sets created/received/status
defaults, and computes client renewal_date from start + billing cycle
(parity with the onboarding workflow).
- Dashboard: per-row edit (✎) and delete (🗑), "+ Neu" modal form per entity.
Verified end-to-end: add lead/client, edit, delete, renewal compute, token
gating (403), 404s. DB-only for now; DB->Sheets mirror is the next step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stand up the DB-first CRM backbone (architecture pivot: Postgres is the
source of truth, Google Sheets becomes a one-way downstream mirror).
- backoffice/ stack: smb-db (Postgres 16) + smb-crm (Flask/waitress service).
- Schema mirrors the six Sheet tabs (clients, leads, projects, activity_log,
bookings, invoices) with typed columns + updated_at triggers.
- Service-account Sheets client (PyJWT) for the one-time import + future mirror.
- import_from_sheets.py: idempotent seed of Postgres from the live Sheets.
- Read dashboard (Leads & Clients tables) at onboard.mivanchenko.de/crm,
behind the existing Caddy basic-auth; JSON API reads straight from Postgres.
Deployed + verified: import seeded DB, dashboard/API live, no-auth blocked,
onboarding form unaffected. Add/edit/delete + DB->Sheets sync + n8n ingest
swap are the next steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Demo bars now read "Beispielseite · So könnte Ihr Online-Auftritt aussehen"
(was "Tier A (Google-Stack)" / "Tier B (Self-Hosted)" / "für Gäste").
- Booking widgets relabelled generic: "Online-Terminbuchung" / "Sichere Buchung"
(dropped Google Kalender + branded dots, Cal.com, self-hosted).
- Removed Tier/Google/Cal.com refs from rendered copy AND HTML comments.
- Demo booking dates now render on the current week via JS (no stale dates).
- TODO.md: tick off completed items; add onboard back-office (Leads/Clients CRUD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New demo-pizzeria (Bella Napoli): interactive menu -> cart (qty +/-,
live total) -> order form (pickup/delivery, address, time, notes) that
POSTs the order summary to the lead-intake webhook (client_id
DEMO-PIZZA, source order-pizzeria). Verified end-to-end (~5s).
- Fix barbershop "Google Kalender" header: the 3 dots were faked via
box-shadow with no layout width, so text overlapped them. Reserve space.
- Chooser: client-friendly wording (no "self-hosted"/Cal.com jargon, no
"~0 EUR"), reframed as example businesses, 3-up responsive layout.
- TODO.md: decouple tier from business type; demos as stack-agnostic
starters; hosting (Google vs self-host) decided per client. Plus notes
on n8n auth hardening and a real Orders flow/tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Floating button + centered modal on every page, opened from the demo
bar and a fixed FAB. Minimal required fields (name + phone), optional
business / what-they-want / message. POSTs to the lead-intake webhook
(client_id DEMO/DEMO-A/DEMO-B, source callback-*). Also refreshes the
stale "noch nicht aktiv" notices now that the forms are live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-contained admin form that POSTs to the /webhook/onboard endpoint
(creates Clients + Projects + Activity Log rows). Hosted behind HTTP
basic auth at onboard.mivanchenko.de (Caddy basic_auth -> smb-demos
container). Not linked from the public demos; creates billable records
so it stays operator-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Webhook /webhook/onboard → read Clients → compute next C-#### →
append Clients row (status onboarding, renewal_date auto from billing
cycle) → seed Projects row (checklist from services, +14d go-live) →
log Activity → Telegram confirmation. Sheets writes retry on fail.
Verified end-to-end (C-0002 created across all three tabs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy/smb-demos: compose + runbook for the static demo host
(httpd on the proxy network, routed by Caddy at demos.mivanchenko.de).
Pending a DNS A record before Caddy can issue TLS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add retryOnFail (4 tries, 3s apart) to all Google Sheets nodes in
lead-intake and renewal-reminder so a transient API throttle/race
re-tries instead of silently dropping a row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- demo-tier-a / demo-tier-b: forms POST JSON to /webhook/lead-intake
(hidden client_id + source, named fields), async submit with
success/error toast.
- lead-intake webhook: enable CORS (allowedOrigins=*) so the static
demos (different origin) can submit; re-export n8n/lead-intake.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Daily 08:00 schedule → read Clients → flag active clients whose
renewal_date is due (14/7/3/1/0 days out or overdue) → Telegram
summary → log to Activity Log. Quiet on days with nothing due.
Verified end-to-end via CLI run against a seeded due client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Notify Telegram node (bot @smb_leads_bot) between Leads append and
Activity Log; notified flag set TRUE. Verified end-to-end (lead →
Telegram + Leads row + Activity Log).
- Re-export n8n/lead-intake.json with the notify node.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- templates/landing: two self-contained demos (Tier A barbershop / Google stack,
Tier B physio / self-hosted) + chooser page
- sheets/SCHEMA.md: Google Sheets CRM workbook spec
- playbooks: per-client setup checklists for both tiers
- README + .gitignore
- Demos avoid unverifiable claims (no "DSGVO-konform", no "Ende-zu-Ende")
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>