Add locations (Filialen) as a grouping layer above resources
Test backoffice (smb-crm) / test (push) Successful in 1m46s
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:
+26
-3
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 & Benachrichtigung</h2>
|
||||
<form class="row" method="post" action="{{ url_for('owner_settings.update_client') }}">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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": [
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user