Checkboxes above the week view, one per active resource -- client-side
filtering (no reload needed), remembered across week navigation via
localStorage since each week change is a full page load. Lets the
owner view one location, the other, or both at once.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed fixed: /owner/_debug_headers showed scheme=https and all
three X-Forwarded-* headers present after the Waitress trusted_proxy
fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
clear_untrusted isn't a real Adjustments option -- caused the previous
deploy to crash-loop smb-crm. The real option, clear_untrusted_proxy_headers,
already defaults to True, so it doesn't need to be passed at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause of the ICS-link-uses-http bug (#22): Caddy was already
sending X-Forwarded-Proto/-Host/-For correctly (confirmed via the
Caddy admin API's compiled route config), but Waitress strips
untrusted X-Forwarded-* headers by default before they ever reach
the WSGI environ -- so the earlier ProxyFix middleware had nothing
to read. This was the actual fix; ProxyFix was necessary but not
sufficient.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diagnosis done: Caddy wasn't forwarding X-Forwarded-Proto at all,
fixed on the Caddy side (header_up in the smb_crm_fwd snippet), not
a Flask/ProxyFix issue.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
url_for(_external=True) (e.g. the owner ICS feed link, #22) was always
guessing "http" since Flask has no way to know the original request was
HTTPS when Caddy proxies to us over plain internal HTTP.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Easy!Appointments re-fires appointment_save on reschedule with the same
EA-<id> booking_id, so add_entity's plain INSERT 500s on the PK conflict.
Switch to ON CONFLICT (pk) DO UPDATE for every entity except activity_log
(no client-supplied pk), logging add vs update accordingly. Extended to
all keyed entities per the issue body, while preserving created_at/
received_at on redelivery for leads/clients/credentials so a redelivered
webhook can't clobber the original creation time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
n8n/onboarding.json now provisions a default resource (with Mon-Sat 09:00-18:00
hours so the public booking page has slots immediately), a starter service, and
an owner-login user for every new client, recording the temp password via the
existing credentials CRM entity -- gated behind an If check so a failed user
creation can't leave a stale credentials row. The EA-provisioning chain
(service/provider creation against Easy!Appointments) is removed entirely.
Adds POST /api/resources, /api/services, /api/owner_users to the backoffice API
for n8n to call, backed by booking_db.py's existing tenancy-safe create_*
helpers. Also adds "slug" to db.py's clients column list -- it was already a DB
column (#17) but the generic /api/clients POST silently dropped it, so
onboarding could never actually set a client's public-facing slug.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
- 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>