Public booking API now rejects a 6th active booking from the same
customer_contact within 24h (429), stopping one contact from filling
every slot on every resource, while owner-entered manual bookings stay
unaffected.
Add POST /api/contact: client sites can reach their own owner's inbox
directly (via their existing login email) for general inquiries,
separate from the agency's leads/Telegram pipeline (n8n/lead-intake.json),
which stays reserved for actual prospects contacting the agency itself.
Paris Barber Shop's contact form and Rückruf widget now point here; the
Rückruf floating widget itself has been removed from the site.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Enables multiple barbers/staff bookable at the same location and time
-- previously "resource" conflated "location" and "the thing that
can't double-book itself" into one row, so a Filiale could only ever
have exactly one bookable slot at once.
- New `locations` table; `resources.location_id` with a generic,
idempotent backfill migration (any resource without a location gets
one auto-created matching its name -- not a one-off for any single
client, protects any future resource stuck in the old flat shape too)
- `resources`/`resource_hours`/services keep everything they already
had (hours, min-notice, max-advance, buffer, the no-overlap
constraint) scoped to resource_id, not location_id -- two barbers at
one location must stay independently bookable at the same time
- booking_db.py: new locations CRUD mirroring the existing
resources/services pattern; create_resource now requires a
location_id, guarded the same way every other tenant check here is
(get_location existence check, no real FK -- matches this schema's
existing no-FK convention throughout)
- app.py: new POST /api/locations provisioning route; POST
/api/resources now requires location_id
- owner_settings.py + settings.html: new self-service "add a Filiale"
/ "add a barber" UI -- there was previously no way to create a
resource at all outside the CRM/n8n provisioning API
- public_booking.py + book.html: new Filiale picker (reuses the
existing wireOptionGroup button-group pattern), filtering the
Mitarbeiter picker to the selected location -- a single-location
client sees no extra click, same as before Filialen existed
- owner_booking.py + agenda.html: the Filiale show/hide toggle and
hide-cancelled toggle (shipped earlier this session) now key off
location_id instead of resource_id, so hiding a Filiale hides every
barber's bookings at it; manual-booking dropdown grouped by Filiale
- n8n/onboarding.json: default provisioning now creates a "Hauptfiliale"
location before its resource (inert until re-imported into the live
n8n instance)
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>
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 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>