From 456ca3872fe174646b3afd36f5ea299a6fc041a4 Mon Sep 17 00:00:00 2001 From: mivanchenko Date: Sat, 12 Sep 2026 03:02:56 +0200 Subject: [PATCH] Add locations (Filialen) as a grouping layer above resources 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 --- backoffice/app/app.py | 29 ++++- backoffice/app/booking_db.py | 77 ++++++++++++- backoffice/app/owner_booking.py | 12 +- backoffice/app/owner_settings.py | 51 ++++++++- backoffice/app/public_booking.py | 18 ++- backoffice/app/templates/book.html | 61 +++++++++- backoffice/app/templates/owner/agenda.html | 22 ++-- backoffice/app/templates/owner/settings.html | 110 +++++++++++++------ backoffice/app/tests/conftest.py | 2 +- backoffice/app/tests/test_booking_api.py | 3 +- backoffice/app/tests/test_booking_db.py | 43 ++++---- backoffice/app/tests/test_bookings_ics.py | 3 +- backoffice/app/tests/test_manage_booking.py | 5 +- backoffice/app/tests/test_owner_booking.py | 3 +- backoffice/app/tests/test_owner_settings.py | 80 +++++++++++++- backoffice/app/tests/test_provisioning.py | 43 +++++++- backoffice/app/tests/test_public_booking.py | 30 ++++- backoffice/db/init.sql | 42 ++++++- n8n/onboarding.json | 50 ++++++++- 19 files changed, 585 insertions(+), 99 deletions(-) diff --git a/backoffice/app/app.py b/backoffice/app/app.py index b7a516e..69e13ca 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -254,6 +254,25 @@ def _parse_hours_time(value): return None +@app.post("/api/locations") +def create_location_route(): + """Onboarding provisioning: a default location (Filiale) for a new + client, created before its first resource -- create_resource_route + requires a location_id.""" + if not authed(): + return jsonify({"error": "forbidden"}), 403 + body = request.get_json(force=True, silent=True) or {} + client_id = body.get("client_id") + name = (body.get("name") or "").strip() + if not client_id or not name: + return jsonify({"error": "client_id and name required"}), 400 + row = bdb.create_location(client_id, name) + with db.connect() as conn, conn.cursor() as cur: + log_activity(cur, client_id, "add location", f"location_id={row['location_id']}") + conn.commit() + return jsonify({"location_id": row["location_id"]}), 201 + + @app.post("/api/resources") def create_resource_route(): """Onboarding provisioning (#24): a default bookable resource for a new @@ -264,10 +283,14 @@ def create_resource_route(): return jsonify({"error": "forbidden"}), 403 body = request.get_json(force=True, silent=True) or {} client_id = body.get("client_id") + location_id = body.get("location_id") name = (body.get("name") or "").strip() - if not client_id or not name: - return jsonify({"error": "client_id and name required"}), 400 - row = bdb.create_resource(client_id, name) + if not client_id or not location_id or not name: + return jsonify({"error": "client_id, location_id and name required"}), 400 + try: + row = bdb.create_resource(client_id, location_id, name) + except bdb.UnknownLocation: + return jsonify({"error": "location not found"}), 404 for h in body.get("hours") or []: opens_at = _parse_hours_time(h.get("opens_at")) closes_at = _parse_hours_time(h.get("closes_at")) diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index 8b1beac..a37fe37 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -26,6 +26,12 @@ class UnknownResource(Exception): guards against a booking write smuggling in another tenant's resource.""" +class UnknownLocation(Exception): + """Raised when a location_id doesn't belong to the given client_id -- + guards create_resource against a location_id smuggled in from another + tenant.""" + + def new_id(prefix): return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}" @@ -38,17 +44,80 @@ def _list_active(table, client_id): return cur.fetchall() +# ---- locations ---- + +def create_location(client_id, name, active=True): + location_id = new_id("LOC") + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO locations (location_id, client_id, name, active) " + "VALUES (%s, %s, %s, %s) RETURNING *", + (location_id, client_id, name, active)) + row = cur.fetchone() + conn.commit() + return row + + +def get_location(client_id, location_id): + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "SELECT * FROM locations WHERE client_id = %s AND location_id = %s", + (client_id, location_id)) + return cur.fetchone() + + +def list_active_locations(client_id): + return _list_active("locations", client_id) + + +def list_locations(client_id): + """All of client_id's locations, active or not -- for the owner settings + page, same reasoning as list_resources/list_services.""" + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "SELECT * FROM locations WHERE client_id = %s ORDER BY name", + (client_id,)) + return cur.fetchall() + + +_LOCATION_UPDATABLE = {"name", "active"} + + +def update_location(client_id, location_id, **fields): + """Update a location's own fields, scoped to client_id. Returns the + updated row, or None if no such location exists for this client.""" + bad = set(fields) - _LOCATION_UPDATABLE + if bad: + raise ValueError(f"not updatable: {', '.join(sorted(bad))}") + if not fields: + return get_location(client_id, location_id) + setsql = ", ".join(f"{c} = %s" for c in fields) + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + f"UPDATE locations SET {setsql} WHERE client_id = %s AND location_id = %s " + "RETURNING *", + [*fields.values(), client_id, location_id]) + row = cur.fetchone() + conn.commit() + return row + + # ---- resources ---- -def create_resource(client_id, name, active=True, min_notice_minutes=60, +def create_resource(client_id, location_id, name, active=True, min_notice_minutes=60, max_advance_days=30, buffer_minutes=0): + """location_id must be one of client_id's own locations -- resources + (individual bookable staff) always belong to a Filiale. Raises + UnknownLocation if it isn't.""" + if get_location(client_id, location_id) is None: + raise UnknownLocation(f"no location {location_id} for client {client_id}") resource_id = new_id("RS") with db.connect() as conn, conn.cursor() as cur: cur.execute( - "INSERT INTO resources (resource_id, client_id, name, active, " + "INSERT INTO resources (resource_id, client_id, location_id, name, active, " "min_notice_minutes, max_advance_days, buffer_minutes) " - "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *", - (resource_id, client_id, name, active, min_notice_minutes, + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING *", + (resource_id, client_id, location_id, name, active, min_notice_minutes, max_advance_days, buffer_minutes)) row = cur.fetchone() conn.commit() diff --git a/backoffice/app/owner_booking.py b/backoffice/app/owner_booking.py index b9a8766..7b63210 100644 --- a/backoffice/app/owner_booking.py +++ b/backoffice/app/owner_booking.py @@ -62,7 +62,9 @@ def _agenda_days(client_id, client, monday): end_utc = datetime.combine(monday + timedelta(days=7), datetime.min.time(), tzinfo=tz).astimezone(timezone.utc) rows = bdb.list_bookings_between(client_id, start_utc, end_utc) - resource_names = {r["resource_id"]: r["name"] for r in bdb.list_resources(client_id)} + all_resources = bdb.list_resources(client_id) + resource_names = {r["resource_id"]: r["name"] for r in all_resources} + resource_locations = {r["resource_id"]: r["location_id"] for r in all_resources} by_date = {monday + timedelta(days=i): [] for i in range(7)} for row in rows: @@ -71,6 +73,7 @@ def _agenda_days(client_id, client, monday): continue # a booking straddling the window edge in another tz row = dict(row) row["resource_name"] = resource_names.get(row["resource_id"], row["resource_id"]) + row["location_id"] = resource_locations.get(row["resource_id"]) row["start_local"] = row["start_time"].astimezone(tz) by_date[local_date].append(row) return sorted(by_date.items()) @@ -83,13 +86,18 @@ def agenda(): client = bdb.get_client(client_id) monday = _week_start(request.args.get("week")) today = datetime.now(_tz(client)).date() + locations = bdb.list_active_locations(client_id) + resources = bdb.list_active_resources(client_id) + resources_by_location = {} + for r in resources: + resources_by_location.setdefault(r["location_id"], []).append(r) return render_template( "owner/agenda.html", client=client, days=_agenda_days(client_id, client, monday), week_start=monday, today=today, prev_week=(monday - timedelta(days=7)).isoformat(), next_week=(monday + timedelta(days=7)).isoformat(), this_week=_week_start(None).isoformat(), - resources=bdb.list_active_resources(client_id), + locations=locations, resources_by_location=resources_by_location, services=bdb.list_active_services(client_id), error=request.args.get("error")) diff --git a/backoffice/app/owner_settings.py b/backoffice/app/owner_settings.py index fa5f03d..5f24832 100644 --- a/backoffice/app/owner_settings.py +++ b/backoffice/app/owner_settings.py @@ -57,18 +57,60 @@ def _parse_time(value): def settings(): client_id = session["client_id"] client = bdb.get_client(client_id) + locations = bdb.list_locations(client_id) resources = bdb.list_resources(client_id) + resources_by_location = {} + for r in resources: + resources_by_location.setdefault(r["location_id"], []).append(r) hours_by_resource = { r["resource_id"]: bdb.get_resource_hours(client_id, r["resource_id"]) for r in resources} ics_token = bdb.ensure_ics_token(client_id) return render_template( "owner/settings.html", client=client, services=bdb.list_services(client_id), - resources=resources, hours_by_resource=hours_by_resource, weekdays=WEEKDAYS, + locations=locations, resources_by_location=resources_by_location, + hours_by_resource=hours_by_resource, weekdays=WEEKDAYS, ics_url=url_for("bookings_ics", token=ics_token, _external=True), error=request.args.get("error")) +@bp.post("/locations") +@login_required +def create_location(): + client_id = session["client_id"] + name = (request.form.get("name") or "").strip() + if not name: + return _redirect(error="invalid_location") + bdb.create_location(client_id, name) + return _redirect() + + +@bp.post("/locations/") +@login_required +def update_location(location_id): + client_id = session["client_id"] + name = (request.form.get("name") or "").strip() + active = request.form.get("active") == "on" + if not name: + return _redirect(error="invalid_location") + row = bdb.update_location(client_id, location_id, name=name, active=active) + if row is None: + return _redirect(error="not_found") + return _redirect() + + +@bp.post("/resources") +@login_required +def create_resource(): + client_id = session["client_id"] + location_id = request.form.get("location_id") + name = (request.form.get("name") or "").strip() + if not name or bdb.get_location(client_id, location_id) is None: + return _redirect(error="invalid_resource") + bdb.create_resource(client_id, location_id, name) + return _redirect() + + @bp.post("/services") @login_required def create_service(): @@ -104,9 +146,13 @@ def update_service(service_id): @login_required def update_resource(resource_id): client_id = session["client_id"] + name = (request.form.get("name") or "").strip() + active = request.form.get("active") == "on" min_notice_minutes = _parse_int(request.form.get("min_notice_minutes")) max_advance_days = _parse_int(request.form.get("max_advance_days")) buffer_minutes = _parse_int(request.form.get("buffer_minutes")) + if not name: + return _redirect(error="invalid_resource") if min_notice_minutes is None or min_notice_minutes < 0: return _redirect(error="invalid_resource") if max_advance_days is None or max_advance_days < 0: @@ -114,7 +160,8 @@ def update_resource(resource_id): if buffer_minutes is None or buffer_minutes < 0: return _redirect(error="invalid_resource") row = bdb.update_resource( - client_id, resource_id, min_notice_minutes=min_notice_minutes, + client_id, resource_id, name=name, active=active, + min_notice_minutes=min_notice_minutes, max_advance_days=max_advance_days, buffer_minutes=buffer_minutes) if row is None: return _redirect(error="not_found") diff --git a/backoffice/app/public_booking.py b/backoffice/app/public_booking.py index 767fa07..3f329d2 100644 --- a/backoffice/app/public_booking.py +++ b/backoffice/app/public_booking.py @@ -22,17 +22,25 @@ def book_page(slug): client = bdb.get_client_by_slug(slug) if client is None: abort(404) - resources = bdb.list_active_resources(client["client_id"]) - services = bdb.list_active_services(client["client_id"]) - if not resources or not services: + client_id = client["client_id"] + locations = bdb.list_active_locations(client_id) + active_location_ids = {l["location_id"] for l in locations} + # A resource whose Filiale was deactivated shouldn't stay bookable even if + # the resource row itself is still active. + resources = [r for r in bdb.list_active_resources(client_id) + if r["location_id"] in active_location_ids] + services = bdb.list_active_services(client_id) + if not locations or not resources or not services: # No bookable services/resources configured yet -- nothing to show a # customer rather than a broken/empty booking form. abort(404) return render_template( "book.html", - client_id=client["client_id"], + client_id=client_id, business_name=client.get("business_name") or slug, - resources=[{"resource_id": r["resource_id"], "name": r["name"]} for r in resources], + locations=[{"location_id": l["location_id"], "name": l["name"]} for l in locations], + resources=[{"resource_id": r["resource_id"], "name": r["name"], + "location_id": r["location_id"]} for r in resources], services=[{"service_id": s["service_id"], "name": s["name"], "duration_minutes": s["duration_minutes"], "price": float(s["price"]) if s["price"] is not None else None} diff --git a/backoffice/app/templates/book.html b/backoffice/app/templates/book.html index ad8752b..4ab6529 100644 --- a/backoffice/app/templates/book.html +++ b/backoffice/app/templates/book.html @@ -48,6 +48,17 @@
+ {% if locations|length > 1 %} +
+

Filiale

+
+ {% for l in locations %} + + {% endfor %} +
+
+ {% endif %} +

Leistung

@@ -61,11 +72,12 @@
{% if resources|length > 1 %} -
+

Mitarbeiter

{% for r in resources %} - + {% endfor %}
@@ -108,12 +120,45 @@