Add locations (Filialen) as a grouping layer above resources
Test backoffice (smb-crm) / test (push) Successful in 1m46s

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>
This commit is contained in:
2026-09-12 03:02:56 +02:00
parent a56a238e81
commit 456ca3872f
19 changed files with 585 additions and 99 deletions
+26 -3
View File
@@ -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"))
+73 -4
View File
@@ -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()
+10 -2
View File
@@ -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"))
+49 -2
View File
@@ -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/<location_id>")
@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")
+13 -5
View File
@@ -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}
+58 -3
View File
@@ -48,6 +48,17 @@
<div class="error" id="error-banner"></div>
{% if locations|length > 1 %}
<section>
<h2>Filiale</h2>
<div class="options" id="location-options">
{% for l in locations %}
<button type="button" class="opt" data-location-id="{{ l.location_id }}">{{ l.name }}</button>
{% endfor %}
</div>
</section>
{% endif %}
<section>
<h2>Leistung</h2>
<div class="options" id="service-options">
@@ -61,11 +72,12 @@
</section>
{% if resources|length > 1 %}
<section>
<section id="resource-section">
<h2>Mitarbeiter</h2>
<div class="options" id="resource-options">
{% for r in resources %}
<button type="button" class="opt" data-resource-id="{{ r.resource_id }}">{{ r.name }}</button>
<button type="button" class="opt" data-resource-id="{{ r.resource_id }}"
data-location-id="{{ r.location_id }}">{{ r.name }}</button>
{% endfor %}
</div>
</section>
@@ -108,12 +120,45 @@
<script>
(function () {
var CLIENT_ID = {{ client_id|tojson }};
var LOCATIONS = {{ locations|tojson }};
var RESOURCES = {{ resources|tojson }};
var services = {{ services|tojson }};
var selectedService = null;
var selectedLocation = LOCATIONS.length === 1 ? LOCATIONS[0].location_id : null;
var selectedResource = RESOURCES.length === 1 ? RESOURCES[0].resource_id : null;
var selectedSlot = null;
var resourceSection = document.getElementById('resource-section');
var resourceOptionsEl = document.getElementById('resource-options');
// Filters the pre-rendered Mitarbeiter buttons down to the selected
// Filiale (all of them if there's only one Filiale, i.e. selectedLocation
// is null), auto-selecting/hiding the whole section when exactly one
// barber matches -- same "no extra click" behavior as the single-
// resource case already had before Filialen existed.
function applyLocationFilter() {
if (!resourceOptionsEl) {
return;
}
var visible = [];
resourceOptionsEl.querySelectorAll('.opt').forEach(function (btn) {
var matches = !selectedLocation || btn.getAttribute('data-location-id') === selectedLocation;
btn.hidden = !matches;
if (matches) {
visible.push(btn);
}
});
if (resourceSection) {
resourceSection.style.display = visible.length > 1 ? '' : 'none';
}
if (visible.length === 1) {
selectedResource = visible[0].getAttribute('data-resource-id');
} else if (selectedResource && !visible.some(function (b) {
return b.getAttribute('data-resource-id') === selectedResource;
})) {
selectedResource = null;
}
}
var errorBanner = document.getElementById('error-banner');
var dateInput = document.getElementById('date-input');
@@ -155,18 +200,28 @@
});
}
wireOptionGroup(document.getElementById('location-options'), function (btn) {
selectedLocation = btn.getAttribute('data-location-id');
selectedResource = null;
selectedSlot = null;
applyLocationFilter();
loadSlots();
});
wireOptionGroup(document.getElementById('service-options'), function (btn) {
selectedService = btn.getAttribute('data-service-id');
selectedSlot = null;
loadSlots();
});
wireOptionGroup(document.getElementById('resource-options'), function (btn) {
wireOptionGroup(resourceOptionsEl, function (btn) {
selectedResource = btn.getAttribute('data-resource-id');
selectedSlot = null;
loadSlots();
});
applyLocationFilter();
dateInput.addEventListener('change', function () {
selectedSlot = null;
loadSlots();
+12 -8
View File
@@ -56,10 +56,10 @@
<h1>{{ client.business_name if client else 'Kalender' }}</h1>
<div class="filiale-filter" id="filialeFilter">
{% if resources|length > 1 %}
{% if locations|length > 1 %}
<p class="flabel">Filialen:</p>
{% for r in resources %}
<label><input type="checkbox" class="res-toggle" value="{{ r.resource_id }}" checked> {{ r.name }}</label>
{% for loc in locations %}
<label><input type="checkbox" class="loc-toggle" value="{{ loc.location_id }}" checked> {{ loc.name }}</label>
{% endfor %}
{% endif %}
<label><input type="checkbox" id="showCancelled"> Stornierte anzeigen</label>
@@ -98,7 +98,7 @@
<thead><tr><th>Zeit</th><th>Kunde</th><th>Leistung</th><th>Ressource</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for b in bookings %}
<tr class="status-{{ b.status }}" data-resource="{{ b.resource_id }}">
<tr class="status-{{ b.status }}" data-resource="{{ b.resource_id }}" data-location="{{ b.location_id }}">
<td>{{ b.start_local.strftime('%H:%M') }}</td>
<td>{{ b.customer_name }}</td>
<td>{{ b.service }}</td>
@@ -135,9 +135,13 @@
<div class="field">
<label>Ressource</label>
<select name="resource_id" required>
{% for r in resources %}
{% for loc in locations %}
<optgroup label="{{ loc.name }}">
{% for r in resources_by_location.get(loc.location_id, []) %}
<option value="{{ r.resource_id }}">{{ r.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="field">
@@ -174,7 +178,7 @@
(function () {
var RES_KEY = 'ownerAgendaFilialeFilter';
var CANCELLED_KEY = 'ownerAgendaShowCancelled';
var checkboxes = document.querySelectorAll('.res-toggle');
var checkboxes = document.querySelectorAll('.loc-toggle');
var showCancelledCb = document.getElementById('showCancelled');
if (!checkboxes.length && !showCancelledCb) return;
@@ -197,9 +201,9 @@
var showCancelled = !!(showCancelledCb && showCancelledCb.checked);
document.querySelectorAll('table tbody tr[data-resource]').forEach(function (tr) {
var resOk = !checkboxes.length || active.indexOf(tr.getAttribute('data-resource')) !== -1;
var locOk = !checkboxes.length || active.indexOf(tr.getAttribute('data-location')) !== -1;
var statusOk = showCancelled || !tr.classList.contains('status-cancelled');
tr.classList.toggle('res-hidden', !(resOk && statusOk));
tr.classList.toggle('res-hidden', !(locOk && statusOk));
});
document.querySelectorAll('.day').forEach(function (dayEl) {
var table = dayEl.querySelector('table');
+51 -3
View File
@@ -46,7 +46,9 @@
{% if error == "invalid_service" %}
<div class="error">Bitte Name und eine gültige Dauer angeben.</div>
{% elif error == "invalid_resource" %}
<div class="error">Vorlaufzeit, Vorausbuchung und Puffer müssen 0 oder größer sein.</div>
<div class="error">Bitte einen Namen angeben; Vorlaufzeit, Vorausbuchung und Puffer müssen 0 oder größer sein.</div>
{% elif error == "invalid_location" %}
<div class="error">Bitte einen Namen für die Filiale angeben.</div>
{% elif error == "invalid_hours" %}
<div class="error">Bitte gültige Öffnungszeiten angeben (Ende nach Beginn).</div>
{% elif error == "invalid_notify_channel" %}
@@ -95,10 +97,34 @@
</form>
</section>
{% for r in resources %}
{% for loc in locations %}
<section>
<h2>Verfügbarkeit — {{ r.name }}</h2>
<h2 style="font-size:1.05rem;color:var(--ink);">Filiale — {{ loc.name }}</h2>
<form class="row" method="post" action="{{ url_for('owner_settings.update_location', location_id=loc.location_id) }}">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ loc.name }}" required />
</div>
<div class="field">
<label>Aktiv</label>
<input type="checkbox" name="active" {{ 'checked' if loc.active }} />
</div>
<button type="submit" class="btn btn-small">Filiale speichern</button>
</form>
{% for r in resources_by_location.get(loc.location_id, []) %}
<div style="border-top:1px solid var(--line); margin-top:14px; padding-top:14px;">
<h2>Mitarbeiter — {{ r.name }}</h2>
<form class="row" method="post" action="{{ url_for('owner_settings.update_resource', resource_id=r.resource_id) }}">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ r.name }}" required />
</div>
<div class="field">
<label>Aktiv</label>
<input type="checkbox" name="active" {{ 'checked' if r.active }} />
</div>
<div class="field">
<label>Vorlaufzeit (Min.)</label>
<input type="number" name="min_notice_minutes" value="{{ r.min_notice_minutes }}" min="0" required />
@@ -134,9 +160,31 @@
{% endfor %}
<button type="submit" class="btn btn-small">Öffnungszeiten speichern</button>
</form>
</div>
{% endfor %}
<form class="row" method="post" action="{{ url_for('owner_settings.create_resource') }}" style="margin-top:14px;">
<input type="hidden" name="location_id" value="{{ loc.location_id }}" />
<div class="field">
<label>Neuer Mitarbeiter</label>
<input type="text" name="name" placeholder="Name" required />
</div>
<button type="submit" class="btn">Hinzufügen</button>
</form>
</section>
{% endfor %}
<section>
<h2>Neue Filiale</h2>
<form class="row" method="post" action="{{ url_for('owner_settings.create_location') }}">
<div class="field">
<label>Name</label>
<input type="text" name="name" placeholder="z. B. Innenstadt" required />
</div>
<button type="submit" class="btn">Hinzufügen</button>
</form>
</section>
<section>
<h2>Buchungen &amp; Benachrichtigung</h2>
<form class="row" method="post" action="{{ url_for('owner_settings.update_client') }}">
+1 -1
View File
@@ -69,7 +69,7 @@ os.environ["DATABASE_URL"] = DATABASE_URL
def _clean_tables():
with psycopg.connect(DATABASE_URL) as conn, conn.cursor() as cur:
cur.execute(
"TRUNCATE resources, services, bookings, users, "
"TRUNCATE locations, resources, services, bookings, users, "
"password_reset_tokens RESTART IDENTITY CASCADE")
conn.commit()
yield
+2 -1
View File
@@ -34,7 +34,8 @@ def _setup_resource_and_service(client_id=CLIENT_A, auto_confirm=True, **resourc
"auto_confirm = EXCLUDED.auto_confirm",
(client_id, "Europe/Berlin", auto_confirm))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1", **resource_kwargs)
location = bdb.create_location(client_id, "Main")
resource = bdb.create_resource(client_id, location["location_id"], "Chair 1", **resource_kwargs)
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
+24 -19
View File
@@ -13,10 +13,14 @@ def _dt(hour, minute=0):
return datetime(2026, 8, 3, hour, minute, tzinfo=timezone.utc)
def _loc(client_id):
return bdb.create_location(client_id, "Main")["location_id"]
# ---- resources / services ----
def test_create_and_get_resource():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
assert r["client_id"] == CLIENT_A
assert r["name"] == "Chair 1"
assert r["active"] is True
@@ -24,7 +28,7 @@ def test_create_and_get_resource():
def test_get_resource_is_tenant_scoped():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
assert bdb.get_resource(CLIENT_B, r["resource_id"]) is None
@@ -42,7 +46,7 @@ def test_get_service_is_tenant_scoped():
# ---- bookings: double-booking protection ----
def test_create_booking_succeeds():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "alice@example.com",
"Haircut", _dt(10), _dt(11))
assert b["status"] == "confirmed"
@@ -50,7 +54,7 @@ def test_create_booking_succeeds():
def test_overlapping_booking_same_resource_raises_conflict():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
with pytest.raises(bdb.BookingConflict):
@@ -63,7 +67,7 @@ def test_truly_concurrent_overlapping_inserts_only_one_wins():
overlapping slot -- not just a sequential second-request-fails check.
The Postgres EXCLUDE constraint (not app-level locking) is what has to
serialize this."""
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
start_barrier = threading.Barrier(2)
results = {}
@@ -92,7 +96,7 @@ def test_truly_concurrent_overlapping_inserts_only_one_wins():
def test_adjacent_non_overlapping_bookings_both_succeed():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
b2 = bdb.create_booking(CLIENT_A, r["resource_id"], "Bob", "b@x.com",
@@ -101,15 +105,15 @@ def test_adjacent_non_overlapping_bookings_both_succeed():
def test_create_booking_rejects_resource_from_another_client():
other = bdb.create_resource(CLIENT_B, "Chair 1")
other = bdb.create_resource(CLIENT_B, _loc(CLIENT_B), "Chair 1")
with pytest.raises(bdb.UnknownResource):
bdb.create_booking(CLIENT_A, other["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
def test_update_booking_rejects_moving_to_another_clients_resource():
r = bdb.create_resource(CLIENT_A, "Chair 1")
other = bdb.create_resource(CLIENT_B, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
other = bdb.create_resource(CLIENT_B, _loc(CLIENT_B), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
with pytest.raises(bdb.UnknownResource):
@@ -117,15 +121,16 @@ def test_update_booking_rejects_moving_to_another_clients_resource():
def test_create_pending_booking():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11), status="pending")
assert bdb.get_booking(CLIENT_A, b["booking_id"])["status"] == "pending"
def test_overlap_on_different_resource_succeeds():
r1 = bdb.create_resource(CLIENT_A, "Chair 1")
r2 = bdb.create_resource(CLIENT_A, "Chair 2")
loc = _loc(CLIENT_A)
r1 = bdb.create_resource(CLIENT_A, loc, "Chair 1")
r2 = bdb.create_resource(CLIENT_A, loc, "Chair 2")
bdb.create_booking(CLIENT_A, r1["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
b2 = bdb.create_booking(CLIENT_A, r2["resource_id"], "Bob", "b@x.com",
@@ -134,7 +139,7 @@ def test_overlap_on_different_resource_succeeds():
def test_reschedule_into_conflict_raises_and_leaves_original_untouched():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
b2 = bdb.create_booking(CLIENT_A, r["resource_id"], "Bob", "b@x.com",
@@ -147,7 +152,7 @@ def test_reschedule_into_conflict_raises_and_leaves_original_untouched():
def test_reschedule_to_free_slot_succeeds():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
updated = bdb.update_booking(CLIENT_A, b["booking_id"], start_time=_dt(14),
@@ -156,7 +161,7 @@ def test_reschedule_to_free_slot_succeeds():
def test_update_booking_rejects_non_updatable_field():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
with pytest.raises(ValueError):
@@ -166,14 +171,14 @@ def test_update_booking_rejects_non_updatable_field():
# ---- bookings: tenancy isolation ----
def test_get_booking_is_tenant_scoped_even_with_correct_id():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
assert bdb.get_booking(CLIENT_B, b["booking_id"]) is None
def test_update_booking_cannot_touch_other_clients_booking():
r = bdb.create_resource(CLIENT_A, "Chair 1")
r = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
result = bdb.update_booking(CLIENT_B, b["booking_id"], status="cancelled")
@@ -182,8 +187,8 @@ def test_update_booking_cannot_touch_other_clients_booking():
def test_list_bookings_only_returns_own_client():
ra = bdb.create_resource(CLIENT_A, "Chair 1")
rb = bdb.create_resource(CLIENT_B, "Chair 1")
ra = bdb.create_resource(CLIENT_A, _loc(CLIENT_A), "Chair 1")
rb = bdb.create_resource(CLIENT_B, _loc(CLIENT_B), "Chair 1")
bdb.create_booking(CLIENT_A, ra["resource_id"], "Alice", "a@x.com",
"Haircut", _dt(10), _dt(11))
bdb.create_booking(CLIENT_B, rb["resource_id"], "Zoe", "z@x.com",
+2 -1
View File
@@ -29,7 +29,8 @@ def _setup(client_id, business_name):
"business_name = EXCLUDED.business_name, ics_token = NULL",
(client_id, business_name, "Europe/Berlin", True))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1")
location = bdb.create_location(client_id, "Main")
resource = bdb.create_resource(client_id, location["location_id"], "Chair 1")
start = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=1)
booking = bdb.create_booking(
client_id, resource["resource_id"], "Ivy", "ivy@example.com",
+3 -2
View File
@@ -34,8 +34,9 @@ def _setup_resource_and_service(client_id=CLIENT_A):
"auto_confirm = EXCLUDED.auto_confirm",
(client_id, "Europe/Berlin", True))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1", min_notice_minutes=0,
max_advance_days=365)
location = bdb.create_location(client_id, "Main")
resource = bdb.create_resource(client_id, location["location_id"], "Chair 1",
min_notice_minutes=0, max_advance_days=365)
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
+2 -1
View File
@@ -35,7 +35,8 @@ def _setup(client_id=CLIENT_A, **resource_kwargs):
"auto_confirm = EXCLUDED.auto_confirm",
(client_id, "Europe/Berlin", True))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1", **resource_kwargs)
location = bdb.create_location(client_id, "Main")
resource = bdb.create_resource(client_id, location["location_id"], "Chair 1", **resource_kwargs)
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
+76 -4
View File
@@ -30,7 +30,8 @@ def _setup(client_id=CLIENT_A, **resource_kwargs):
"notify_channel = EXCLUDED.notify_channel",
(client_id, "Europe/Berlin", True, None))
conn.commit()
resource = bdb.create_resource(client_id, "Chair 1", **resource_kwargs)
location = bdb.create_location(client_id, "Main")
resource = bdb.create_resource(client_id, location["location_id"], "Chair 1", **resource_kwargs)
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
@@ -110,7 +111,8 @@ def test_owner_can_update_resource_notice_and_buffer(client):
resource, service = _setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
"min_notice_minutes": "120", "max_advance_days": "14", "buffer_minutes": "15"})
"name": "Chair 1", "min_notice_minutes": "120", "max_advance_days": "14",
"buffer_minutes": "15"})
assert "error" not in resp.headers["Location"]
updated = bdb.get_resource(CLIENT_A, resource["resource_id"])
assert updated["min_notice_minutes"] == 120
@@ -118,11 +120,38 @@ def test_owner_can_update_resource_notice_and_buffer(client):
assert updated["buffer_minutes"] == 15
def test_owner_can_rename_resource_and_toggle_active(client):
resource, service = _setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
"name": "Anna", "min_notice_minutes": "60", "max_advance_days": "30",
"buffer_minutes": "0"})
assert "error" not in resp.headers["Location"]
updated = bdb.get_resource(CLIENT_A, resource["resource_id"])
assert updated["name"] == "Anna"
assert updated["active"] is False # checkbox omitted == unchecked
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
"name": "Anna", "active": "on", "min_notice_minutes": "60",
"max_advance_days": "30", "buffer_minutes": "0"})
assert bdb.get_resource(CLIENT_A, resource["resource_id"])["active"] is True
def test_update_resource_rejects_missing_name(client):
resource, service = _setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
"name": "", "min_notice_minutes": "0", "max_advance_days": "14",
"buffer_minutes": "15"})
assert "error=invalid_resource" in resp.headers["Location"]
def test_update_resource_rejects_negative_values(client):
resource, service = _setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post(f"/owner/settings/resources/{resource['resource_id']}", data={
"min_notice_minutes": "-1", "max_advance_days": "14", "buffer_minutes": "15"})
"name": "Chair 1", "min_notice_minutes": "-1", "max_advance_days": "14",
"buffer_minutes": "15"})
assert "error=invalid_resource" in resp.headers["Location"]
@@ -131,11 +160,54 @@ def test_owner_cannot_update_another_tenants_resource(client):
_setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post(f"/owner/settings/resources/{resource_b['resource_id']}", data={
"min_notice_minutes": "0", "max_advance_days": "1", "buffer_minutes": "0"})
"name": "Hijacked", "min_notice_minutes": "0", "max_advance_days": "1",
"buffer_minutes": "0"})
assert "error=not_found" in resp.headers["Location"]
assert bdb.get_resource(CLIENT_B, resource_b["resource_id"])["max_advance_days"] != 1
# ---- locations / resources self-service ----
def test_owner_can_create_location(client):
_setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post("/owner/settings/locations", data={"name": "Zweite Filiale"})
assert "error" not in resp.headers["Location"]
names = {l["name"] for l in bdb.list_locations(CLIENT_A)}
assert "Zweite Filiale" in names
def test_create_location_rejects_missing_name(client):
_setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post("/owner/settings/locations", data={"name": ""})
assert "error=invalid_location" in resp.headers["Location"]
def test_owner_can_add_a_second_barber_to_a_location(client):
resource, service = _setup(CLIENT_A)
_login(client, CLIENT_A)
location_id = resource["location_id"]
resp = client.post("/owner/settings/resources", data={
"location_id": location_id, "name": "Jonas"})
assert "error" not in resp.headers["Location"]
names = {r["name"] for r in bdb.list_resources(CLIENT_A)}
assert {"Chair 1", "Jonas"} <= names
jonas = [r for r in bdb.list_resources(CLIENT_A) if r["name"] == "Jonas"][0]
assert jonas["location_id"] == location_id
def test_create_resource_rejects_another_tenants_location(client):
resource_b, service_b = _setup(CLIENT_B)
_setup(CLIENT_A)
_login(client, CLIENT_A)
resp = client.post("/owner/settings/resources", data={
"location_id": resource_b["location_id"], "name": "Hijacked"})
assert "error=invalid_resource" in resp.headers["Location"]
names = {r["name"] for r in bdb.list_resources(CLIENT_B)}
assert "Hijacked" not in names
def test_owner_can_set_opening_hours(client):
resource, service = _setup(CLIENT_A)
_login(client, CLIENT_A)
+41 -2
View File
@@ -40,6 +40,28 @@ def test_create_client_persists_slug(client):
assert bdb.get_client_by_slug("slug-test-abcd")["client_id"] == client_id
# ---- /api/locations ----
def test_create_location_requires_crm_token(client):
resp = client.post("/api/locations", json={"client_id": CLIENT_A, "name": "Hauptfiliale"})
assert resp.status_code == 403
def test_create_location_creates_row(client):
_insert_client(CLIENT_A)
resp = client.post("/api/locations", headers=AUTH,
json={"client_id": CLIENT_A, "name": "Hauptfiliale"})
assert resp.status_code == 201
location_id = resp.get_json()["location_id"]
assert bdb.get_location(CLIENT_A, location_id)["name"] == "Hauptfiliale"
def test_create_location_requires_name(client):
_insert_client(CLIENT_A)
resp = client.post("/api/locations", headers=AUTH, json={"client_id": CLIENT_A})
assert resp.status_code == 400
# ---- /api/resources ----
def test_create_resource_requires_crm_token(client):
@@ -49,8 +71,9 @@ def test_create_resource_requires_crm_token(client):
def test_create_resource_sets_hours(client):
_insert_client(CLIENT_A)
location = bdb.create_location(CLIENT_A, "Hauptfiliale")
resp = client.post("/api/resources", headers=AUTH, json={
"client_id": CLIENT_A, "name": "Hauptressource",
"client_id": CLIENT_A, "location_id": location["location_id"], "name": "Hauptressource",
"hours": [{"weekday": 0, "opens_at": "09:00", "closes_at": "18:00"}]})
assert resp.status_code == 201
resource_id = resp.get_json()["resource_id"]
@@ -60,10 +83,26 @@ def test_create_resource_sets_hours(client):
def test_create_resource_requires_name(client):
_insert_client(CLIENT_A)
resp = client.post("/api/resources", headers=AUTH, json={"client_id": CLIENT_A})
location = bdb.create_location(CLIENT_A, "Hauptfiliale")
resp = client.post("/api/resources", headers=AUTH,
json={"client_id": CLIENT_A, "location_id": location["location_id"]})
assert resp.status_code == 400
def test_create_resource_requires_location_id(client):
_insert_client(CLIENT_A)
resp = client.post("/api/resources", headers=AUTH,
json={"client_id": CLIENT_A, "name": "Hauptressource"})
assert resp.status_code == 400
def test_create_resource_rejects_unknown_location(client):
_insert_client(CLIENT_A)
resp = client.post("/api/resources", headers=AUTH, json={
"client_id": CLIENT_A, "location_id": "LOC-does-not-exist", "name": "Hauptressource"})
assert resp.status_code == 404
# ---- /api/services ----
def test_create_service_requires_crm_token(client):
+26 -4
View File
@@ -40,8 +40,9 @@ def _make_client(client_id=CLIENT_A, slug="happynails", business_name="Happy Nai
def _setup_bookable_client(client_id=CLIENT_A, slug="happynails"):
_make_client(client_id, slug)
resource = bdb.create_resource(client_id, "Chair 1", min_notice_minutes=0,
max_advance_days=365)
location = bdb.create_location(client_id, "Main")
resource = bdb.create_resource(client_id, location["location_id"], "Chair 1",
min_notice_minutes=0, max_advance_days=365)
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
@@ -64,7 +65,7 @@ def test_book_page_404s_for_unknown_slug(client):
def test_book_page_404s_when_client_has_no_active_service(client):
_make_client(CLIENT_B, slug="no-services-client")
bdb.create_resource(CLIENT_B, "Chair 1")
bdb.create_resource(CLIENT_B, bdb.create_location(CLIENT_B, "Main")["location_id"], "Chair 1")
# No services created for this client.
resp = client.get("/book/no-services-client")
assert resp.status_code == 404
@@ -72,12 +73,33 @@ def test_book_page_404s_when_client_has_no_active_service(client):
def test_book_page_404s_when_client_has_only_inactive_service(client):
_make_client(CLIENT_B, slug="inactive-service-client")
bdb.create_resource(CLIENT_B, "Chair 1")
bdb.create_resource(CLIENT_B, bdb.create_location(CLIENT_B, "Main")["location_id"], "Chair 1")
bdb.create_service(CLIENT_B, "Haircut", 60, active=False)
resp = client.get("/book/inactive-service-client")
assert resp.status_code == 404
def test_book_page_includes_location_in_context(client):
resource, service = _setup_bookable_client()
resp = client.get("/book/happynails")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert "LOCATIONS" in body
assert resource["location_id"] in body
def test_book_page_404s_when_only_location_is_inactive(client):
"""A resource whose Filiale was deactivated must not stay bookable even
though the resource row itself is still active."""
_make_client(CLIENT_B, slug="inactive-location-client")
location = bdb.create_location(CLIENT_B, "Main")
bdb.create_resource(CLIENT_B, location["location_id"], "Chair 1")
bdb.create_service(CLIENT_B, "Haircut", 60)
bdb.update_location(CLIENT_B, location["location_id"], active=False)
resp = client.get("/book/inactive-location-client")
assert resp.status_code == 404
def test_honeypot_filled_silently_rejects_booking(client):
resource, service = _setup_bookable_client()
day = _next_monday(date.today())
+41 -1
View File
@@ -101,6 +101,25 @@ CREATE TABLE IF NOT EXISTS credentials (
-- Booking module (#15): resources, services, and the users / password-reset
-- tables the owner-login tickets build on. All access goes through
-- app/booking_db.py — see that module for the tenancy-safe data-access layer.
--
-- Locations (Filialen) group resources for display/selection purposes only --
-- the actual bookable/concurrency-safe unit stays `resources` (a client with
-- several staff at one location needs each staff member independently
-- bookable at the same time, so hours/notice/buffer/the no-overlap
-- constraint all stay keyed on resource_id, not location_id). No FK to
-- clients or from resources.location_id to here, matching this whole
-- schema's convention: no real FKs anywhere, tenant/existence integrity is
-- enforced in app/booking_db.py via get_location()/get_resource()-style
-- checks before every write.
CREATE TABLE IF NOT EXISTS locations (
location_id text PRIMARY KEY,
client_id text NOT NULL,
name text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS resources (
resource_id text PRIMARY KEY,
client_id text NOT NULL,
@@ -190,6 +209,25 @@ ALTER TABLE clients ADD COLUMN IF NOT EXISTS timezone text NOT NULL DEFAULT
ALTER TABLE clients ADD COLUMN IF NOT EXISTS auto_confirm boolean NOT NULL DEFAULT true;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS ics_token text;
ALTER TABLE resources ADD COLUMN IF NOT EXISTS location_id text;
-- Generic, permanent backfill (not a one-off for any single client): any
-- resource stuck in the old flat shape (location_id IS NULL) gets a brand
-- new location auto-created with its own name and is attached to it. Keeps
-- protecting against a future resource ending up without a location -- a
-- provisioning bug, a manual INSERT, a restored backup -- since a resource
-- with location_id already set is never touched again by this block.
DO $$
DECLARE r RECORD; new_loc_id text;
BEGIN
FOR r IN SELECT resource_id, client_id, name FROM resources WHERE location_id IS NULL LOOP
new_loc_id := 'LOC-' || floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|| '-' || substr(md5(random()::text), 1, 6);
INSERT INTO locations (location_id, client_id, name) VALUES (new_loc_id, r.client_id, r.name);
UPDATE resources SET location_id = new_loc_id WHERE resource_id = r.resource_id;
END LOOP;
END $$;
-- keep updated_at fresh on row changes
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
@@ -204,7 +242,7 @@ DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['clients','leads','projects','bookings','invoices',
'credentials','resources','services','users'] LOOP
'credentials','locations','resources','services','users'] LOOP
EXECUTE format(
'CREATE OR REPLACE TRIGGER %I_touch BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION touch_updated_at()',
t, t);
@@ -215,7 +253,9 @@ CREATE INDEX IF NOT EXISTS leads_received_idx ON leads (received_at DESC);
CREATE INDEX IF NOT EXISTS clients_status_idx ON clients (status);
CREATE INDEX IF NOT EXISTS activity_ts_idx ON activity_log (ts DESC);
CREATE INDEX IF NOT EXISTS credentials_client_idx ON credentials (client_id);
CREATE INDEX IF NOT EXISTS locations_client_idx ON locations (client_id);
CREATE INDEX IF NOT EXISTS resources_client_idx ON resources (client_id);
CREATE INDEX IF NOT EXISTS resources_location_idx ON resources (location_id);
CREATE INDEX IF NOT EXISTS services_client_idx ON services (client_id);
CREATE INDEX IF NOT EXISTS bookings_client_idx ON bookings (client_id);
CREATE INDEX IF NOT EXISTS users_client_idx ON users (client_id);
+46 -4
View File
@@ -133,7 +133,7 @@
},
{
"parameters": {
"jsCode": "const cid=$('Save client').first().json.added;\nconst c=($('Compute').first().json.client)||{};\nconst password=Math.random().toString(36).slice(2,10)+Math.random().toString(36).slice(2,6);\nconst hours=[0,1,2,3,4,5].map(weekday=>({weekday,opens_at:'09:00',closes_at:'18:00'}));\nconst resourceBody={client_id:cid,name:'Hauptressource',hours};\nconst serviceBody={client_id:cid,name:'Termin',duration_minutes:30,price:0};\nreturn [{json:{cid,resourceBody,serviceBody,userEmail:c.email,userPassword:password}}];"
"jsCode": "const cid=$('Save client').first().json.added;\nconst c=($('Compute').first().json.client)||{};\nconst password=Math.random().toString(36).slice(2,10)+Math.random().toString(36).slice(2,6);\nconst hours=[0,1,2,3,4,5].map(weekday=>({weekday,opens_at:'09:00',closes_at:'18:00'}));\nconst locationBody={client_id:cid,name:'Hauptfiliale'};\nconst resourceBody={client_id:cid,name:'Hauptressource',hours};\nconst serviceBody={client_id:cid,name:'Termin',duration_minutes:30,price:0};\nreturn [{json:{cid,locationBody,resourceBody,serviceBody,userEmail:c.email,userPassword:password}}];"
},
"id": "prov-build-provisioning",
"name": "Build provisioning",
@@ -144,6 +144,37 @@
520
]
},
{
"parameters": {
"options": {},
"method": "POST",
"url": "http://smb-crm:8080/api/locations",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-CRM-Token",
"value": "__CRM_TOKEN__"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.locationBody) }}"
},
"id": "prov-create-location",
"name": "Create location",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
340
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"continueOnFail": true
},
{
"parameters": {
"options": {},
@@ -160,14 +191,14 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.resourceBody) }}"
"jsonBody": "={{ JSON.stringify(Object.assign({}, $('Build provisioning').item.json.resourceBody, { location_id: $json.location_id })) }}"
},
"id": "prov-create-resource",
"name": "Create resource",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
1120,
420
],
"retryOnFail": true,
@@ -367,7 +398,7 @@
"main": [
[
{
"node": "Create resource",
"node": "Create location",
"type": "main",
"index": 0
},
@@ -384,6 +415,17 @@
]
]
},
"Create location": {
"main": [
[
{
"node": "Create resource",
"type": "main",
"index": 0
}
]
]
},
"Create owner user": {
"main": [
[