diff --git a/backoffice/.env.example b/backoffice/.env.example index 7722eba..ba80c15 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -15,3 +15,8 @@ SMTP_PASSWORD= MAIL_FALLBACK_FROM=noreply@mivanchenko.de # Base URL the manage-booking link in the confirmation email is built from. PUBLIC_BASE_URL=https://onboard.mivanchenko.de +# Owner Telegram notification (#23). Points at n8n's "Owner notify webhook" +# node (n8n/booking-sync.json), e.g. https://n8n.mivanchenko.de/webhook/owner-notify. +# Left blank, sending is skipped (logged, not fatal) -- same convention as +# SMTP_HOST above. +OWNER_NOTIFY_WEBHOOK_URL= diff --git a/backoffice/app/booking_api.py b/backoffice/app/booking_api.py index edacaf4..81de4af 100644 --- a/backoffice/app/booking_api.py +++ b/backoffice/app/booking_api.py @@ -15,6 +15,7 @@ from flask import Blueprint, jsonify, request import availability import booking_db as bdb import booking_mail +import owner_notify bp = Blueprint("booking_api", __name__, url_prefix="/api/booking") @@ -161,6 +162,7 @@ def create_booking_row(client_id, resource_id, service_id, start_time, # owner manual-entry (#20) included -- calling bdb.create_booking() # directly would bypass it. booking_mail.send_booking_confirmation(client, booking, token) + owner_notify.notify(client, booking, "created") # #23 return booking, token @@ -174,7 +176,9 @@ def cancel_booking_row(client_id, booking_id): raise NotFound() if existing["status"] == "cancelled": raise AlreadyCancelled() - return bdb.update_booking(client_id, booking_id, status="cancelled") + updated = bdb.update_booking(client_id, booking_id, status="cancelled") + owner_notify.notify(bdb.get_client(client_id), updated, "cancelled") # #23 + return updated def reschedule_booking_row(client_id, booking_id, new_start): @@ -203,10 +207,12 @@ def reschedule_booking_row(client_id, booking_id, new_start): raise SlotUnavailable() try: - return bdb.update_booking(client_id, booking_id, start_time=new_start, - end_time=new_end) + updated = bdb.update_booking(client_id, booking_id, start_time=new_start, + end_time=new_end) except bdb.BookingConflict: raise SlotTaken() from None + owner_notify.notify(client, updated, "rescheduled") # #23 + return updated class _BadDuration(ValueError): diff --git a/backoffice/app/owner_notify.py b/backoffice/app/owner_notify.py new file mode 100644 index 0000000..0e5b440 --- /dev/null +++ b/backoffice/app/owner_notify.py @@ -0,0 +1,79 @@ +"""Owner Telegram notification webhook (#23): fires an internal webhook call +on booking create/cancel/reschedule, with the same {booking, business_name, +notify_channel} payload shape n8n/booking-sync.json's "Build booking row" +step used to hand the Telegram node -- plus an "event" field (that node +never needed, since it only ever fired for a new EA booking) so the message +text can say what actually happened instead of always "new booking". + +Fire-and-forget over HTTP, mirroring mailer.py's background-thread queue: a +slow/unreachable webhook must never block or fail a booking mutation. +""" +import json +import os +import queue +import threading +import traceback +import urllib.request + +# Left blank, sending is skipped (logged, not fatal) -- same convention as +# mailer.py's SMTP_HOST. Channel routing (only "telegram" does anything at +# launch) lives on the n8n side of this webhook, not here -- see #23. +NOTIFY_WEBHOOK_URL = os.environ.get("OWNER_NOTIFY_WEBHOOK_URL", "") + +_queue = queue.Queue() + + +def _serialize_booking(booking): + return {k: (v.isoformat() if hasattr(v, "isoformat") else v) + for k, v in booking.items()} + + +def _send_now(payload): + if not NOTIFY_WEBHOOK_URL: + print(f"[owner_notify] OWNER_NOTIFY_WEBHOOK_URL not configured, " + f"skipping notify for booking {payload['booking'].get('booking_id')}", + flush=True) + return + req = urllib.request.Request( + NOTIFY_WEBHOOK_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10): + pass + + +def _worker(): + while True: + payload = _queue.get() + try: + _send_now(payload) + except Exception: # noqa: BLE001 + print(f"[owner_notify] send for booking " + f"{payload['booking'].get('booking_id')} failed:", flush=True) + traceback.print_exc() + finally: + _queue.task_done() + + +threading.Thread(target=_worker, daemon=True).start() + + +def notify(client, booking, event): + """Queue a best-effort owner notification for a booking create/cancel/ + reschedule (#23). event is "created"/"cancelled"/"rescheduled" -- booking + status alone can't distinguish a fresh booking from a rescheduled one + (both land as "confirmed"), and the n8n message text needs to say which + happened. Fires for every notify_channel value, including one that isn't + "telegram" (or is unset) -- that's not an error, the n8n workflow this + webhook feeds is what decides whether a given channel actually sends + anything, which keeps this call site the same regardless of how many + channels exist in the future.""" + payload = { + "event": event, + "booking": _serialize_booking(booking), + "business_name": (client or {}).get("business_name") or "", + "notify_channel": (client or {}).get("notify_channel"), + } + _queue.put(payload) diff --git a/backoffice/app/tests/test_booking_api.py b/backoffice/app/tests/test_booking_api.py index 4e57366..07b973d 100644 --- a/backoffice/app/tests/test_booking_api.py +++ b/backoffice/app/tests/test_booking_api.py @@ -102,6 +102,37 @@ def test_create_booking_via_public_endpoint_sends_confirmation_email(client, mon assert "Haircut" in sent[0]["html"] +def test_create_booking_notifies_owner(client, monkeypatch): + """#23's acceptance criterion: creating a booking via the public API + triggers the owner notification webhook, carrying the client's + notify_channel and business_name alongside the booking.""" + notified = [] + monkeypatch.setattr( + "owner_notify.notify", + lambda client, booking, event: notified.append((client, booking, event))) + resource, service = _setup_resource_and_service( + auto_confirm=True, min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slots = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + + resp = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slots[0], + "customer_name": "Nia", "customer_contact": "nia@example.com"}) + assert resp.status_code == 201 + booking_id = resp.get_json()["booking_id"] + + assert len(notified) == 1 + notified_client, notified_booking, notified_event = notified[0] + assert notified_client["client_id"] == CLIENT_A + assert notified_booking["booking_id"] == booking_id + assert notified_booking["customer_name"] == "Nia" + assert notified_event == "created" + + def test_create_booking_auto_confirm_false_yields_pending(client): resource, service = _setup_resource_and_service( auto_confirm=False, min_notice_minutes=0, max_advance_days=365) @@ -180,6 +211,64 @@ def test_cancel_with_valid_token_cancels_booking(client): assert resp3.status_code == 400 +def test_cancel_notifies_owner(client, monkeypatch): + notified = [] + monkeypatch.setattr( + "owner_notify.notify", + lambda client, booking, event: notified.append((client, booking, event))) + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0] + created = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot, + "customer_name": "Omar", "customer_contact": "o@example.com"}).get_json() + notified.clear() # drop the create-time notification, isolate the cancel one + + resp = client.post("/api/booking/cancel", json={"token": created["token"]}) + assert resp.status_code == 200 + + assert len(notified) == 1 + notified_client, notified_booking, notified_event = notified[0] + assert notified_client["client_id"] == CLIENT_A + assert notified_booking["booking_id"] == created["booking_id"] + assert notified_booking["status"] == "cancelled" + assert notified_event == "cancelled" + + +def test_reschedule_notifies_owner(client, monkeypatch): + notified = [] + monkeypatch.setattr( + "owner_notify.notify", + lambda client, booking, event: notified.append((client, booking, event))) + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot_list = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + created = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot_list[0], + "customer_name": "Priya", "customer_contact": "p@example.com"}).get_json() + notified.clear() # drop the create-time notification, isolate the reschedule one + + resp = client.post("/api/booking/reschedule", json={ + "token": created["token"], "start_time": slot_list[2]}) + assert resp.status_code == 200 + + assert len(notified) == 1 + notified_client, notified_booking, notified_event = notified[0] + assert notified_client["client_id"] == CLIENT_A + assert notified_booking["start_time"].isoformat() == slot_list[2] + assert notified_event == "rescheduled" + + def test_cancel_with_expired_token_is_rejected(client): import jwt as pyjwt from booking_api import TOKEN_SECRET diff --git a/backoffice/app/tests/test_owner_notify.py b/backoffice/app/tests/test_owner_notify.py new file mode 100644 index 0000000..93ed534 --- /dev/null +++ b/backoffice/app/tests/test_owner_notify.py @@ -0,0 +1,88 @@ +"""Unit tests for owner_notify.py's webhook-call shape (#23). Calls +_send_now directly rather than going through the background-thread queue, +so assertions are synchronous -- the queue itself is just plumbing. +""" +from datetime import datetime, timezone + +import pytest + +import owner_notify + + +class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _booking(): + return { + "booking_id": "BK-1", + "client_id": "C-1", + "customer_name": "Alice", + "customer_contact": "alice@example.com", + "service": "Haircut", + "start_time": datetime(2026, 8, 10, 9, 0, tzinfo=timezone.utc), + "end_time": datetime(2026, 8, 10, 10, 0, tzinfo=timezone.utc), + "source": "public", + "status": "confirmed", + } + + +def test_send_now_skips_when_webhook_url_not_configured(monkeypatch): + monkeypatch.setattr(owner_notify, "NOTIFY_WEBHOOK_URL", "") + calls = [] + monkeypatch.setattr(owner_notify.urllib.request, "urlopen", + lambda *a, **kw: calls.append((a, kw)) or _FakeResponse()) + owner_notify._send_now({"booking": _booking(), "business_name": "Happy Nails", + "notify_channel": "telegram"}) + assert calls == [] + + +def test_send_now_posts_json_when_configured(monkeypatch): + monkeypatch.setattr(owner_notify, "NOTIFY_WEBHOOK_URL", "https://n8n.example.com/webhook/owner-notify") + captured = [] + + def fake_urlopen(req, timeout=None): + captured.append(req) + return _FakeResponse() + + monkeypatch.setattr(owner_notify.urllib.request, "urlopen", fake_urlopen) + payload = {"booking": owner_notify._serialize_booking(_booking()), + "business_name": "Happy Nails", "notify_channel": "telegram"} + owner_notify._send_now(payload) + assert len(captured) == 1 + req = captured[0] + assert req.full_url == "https://n8n.example.com/webhook/owner-notify" + assert req.get_header("Content-type") == "application/json" + import json + body = json.loads(req.data) + assert body["booking"]["booking_id"] == "BK-1" + assert body["booking"]["start_time"] == "2026-08-10T09:00:00+00:00" + assert body["business_name"] == "Happy Nails" + assert body["notify_channel"] == "telegram" + + +def test_notify_enqueues_regardless_of_channel(monkeypatch): + """Channel routing (only "telegram" sends anything real) lives on the + n8n side of this webhook, not in notify() -- an unset/other channel is + still queued, not silently dropped here (#23).""" + captured = [] + monkeypatch.setattr(owner_notify._queue, "put", lambda p: captured.append(p)) + client = {"business_name": "Happy Nails", "notify_channel": "email"} + owner_notify.notify(client, _booking(), "created") + assert len(captured) == 1 + assert captured[0]["notify_channel"] == "email" + assert captured[0]["business_name"] == "Happy Nails" + assert captured[0]["event"] == "created" + + +def test_notify_defaults_business_name_and_channel_when_client_missing(monkeypatch): + captured = [] + monkeypatch.setattr(owner_notify._queue, "put", lambda p: captured.append(p)) + owner_notify.notify(None, _booking(), "cancelled") + assert captured[0]["business_name"] == "" + assert captured[0]["notify_channel"] is None + assert captured[0]["event"] == "cancelled" diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml index f68fd71..36e1368 100644 --- a/backoffice/docker-compose.yml +++ b/backoffice/docker-compose.yml @@ -32,6 +32,7 @@ services: SMTP_PASSWORD: ${SMTP_PASSWORD} MAIL_FALLBACK_FROM: ${MAIL_FALLBACK_FROM} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL} + OWNER_NOTIFY_WEBHOOK_URL: ${OWNER_NOTIFY_WEBHOOK_URL} depends_on: smb-db: condition: service_healthy diff --git a/n8n/booking-sync.json b/n8n/booking-sync.json index 195ba9d..4f97671 100644 --- a/n8n/booking-sync.json +++ b/n8n/booking-sync.json @@ -68,7 +68,7 @@ "resource": "message", "operation": "sendMessage", "chatId": "5499280257", - "text": "=📅 Neue Buchung\nBetrieb: {{ $('Build booking row').item.json.business_name || $('Build booking row').item.json.booking.client_id }}\nKunde: {{ $('Build booking row').item.json.booking.customer_name || '—' }}\nLeistung: {{ $('Build booking row').item.json.booking.service || '—' }}\nWann: {{ $('Build booking row').item.json.booking.start_time || '—' }}\nKontakt: {{ $('Build booking row').item.json.booking.customer_contact || '—' }}\nQuelle: {{ $('Build booking row').item.json.booking.source }}", + "text": "=📅 {{ $json.body.event === 'cancelled' ? 'Buchung storniert' : $json.body.event === 'rescheduled' ? 'Buchung verschoben' : 'Neue Buchung' }}\nBetrieb: {{ $json.body.business_name || $json.body.booking.client_id }}\nKunde: {{ $json.body.booking.customer_name || '—' }}\nLeistung: {{ $json.body.booking.service || '—' }}\nWann: {{ $json.body.booking.start_time || '—' }}\nKontakt: {{ $json.body.booking.customer_contact || '—' }}\nQuelle: {{ $json.body.booking.source }}", "additionalFields": { "appendAttribution": false } @@ -79,7 +79,7 @@ "typeVersion": 1.2, "position": [ 900, - 300 + 500 ], "credentials": { "telegramApi": { @@ -87,6 +87,57 @@ "name": "SMB Telegram (leads bot)" } } + }, + { + "parameters": { + "httpMethod": "POST", + "path": "owner-notify", + "responseMode": "onReceived", + "options": { + "allowedOrigins": "*" + } + }, + "id": "b0000000-1111-2222-3333-888888888888", + "name": "Owner notify webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 240, + 500 + ], + "webhookId": "smb-owner-notify-webhook-0001" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "b0000000-1111-2222-3333-aaaaaaaaaaaa", + "leftValue": "={{ $json.body.notify_channel }}", + "rightValue": "telegram", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "b0000000-1111-2222-3333-999999999999", + "name": "Telegram channel?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 460, + 500 + ] } ], "connections": { @@ -112,7 +163,18 @@ ] ] }, - "Save to CRM (bookings)": { + "Owner notify webhook": { + "main": [ + [ + { + "node": "Telegram channel?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Telegram channel?": { "main": [ [ {