New-client onboarding: provision resources/services/owner login, drop EA (#24)
Test backoffice (smb-crm) / test (push) Has been cancelled

n8n/onboarding.json now provisions a default resource (with Mon-Sat 09:00-18:00
hours so the public booking page has slots immediately), a starter service, and
an owner-login user for every new client, recording the temp password via the
existing credentials CRM entity -- gated behind an If check so a failed user
creation can't leave a stale credentials row. The EA-provisioning chain
(service/provider creation against Easy!Appointments) is removed entirely.

Adds POST /api/resources, /api/services, /api/owner_users to the backoffice API
for n8n to call, backed by booking_db.py's existing tenancy-safe create_*
helpers. Also adds "slug" to db.py's clients column list -- it was already a DB
column (#17) but the generic /api/clients POST silently dropped it, so
onboarding could never actually set a client's public-facing slug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 13:11:38 +02:00
parent a27ee59125
commit aabd1d56c4
4 changed files with 315 additions and 126 deletions
+75 -1
View File
@@ -7,7 +7,7 @@ machine-to-machine ingest path (n8n) is gated by the CRM_API_TOKEN header.
import os
import re
import time
from datetime import datetime, date, timezone
from datetime import datetime, date, time as dtime, timezone
from decimal import Decimal
from flask import Flask, jsonify, request, Response
@@ -209,6 +209,80 @@ def delete_entity(entity, ident):
return jsonify({"deleted": ident})
def _parse_hours_time(value):
try:
return dtime.fromisoformat(str(value))
except (TypeError, ValueError):
return None
@app.post("/api/resources")
def create_resource_route():
"""Onboarding provisioning (#24): a default bookable resource for a new
client, with opening hours set inline so the public /book/<slug> page has
a slot grid to show immediately -- a resource without resource_hours has
no available slots (booking_api._available_slots)."""
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_resource(client_id, name)
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"))
if opens_at is None or closes_at is None:
continue
bdb.set_resource_hours(client_id, row["resource_id"], h.get("weekday"),
opens_at, closes_at)
with db.connect() as conn, conn.cursor() as cur:
log_activity(cur, client_id, "add resource", f"resource_id={row['resource_id']}")
conn.commit()
return jsonify({"resource_id": row["resource_id"]}), 201
@app.post("/api/services")
def create_service_route():
"""Onboarding provisioning (#24): a starter service for a new client."""
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()
duration_minutes = body.get("duration_minutes")
if not client_id or not name or not duration_minutes:
return jsonify({"error": "client_id, name and duration_minutes required"}), 400
row = bdb.create_service(client_id, name, duration_minutes, price=body.get("price"))
with db.connect() as conn, conn.cursor() as cur:
log_activity(cur, client_id, "add service", f"service_id={row['service_id']}")
conn.commit()
return jsonify({"service_id": row["service_id"]}), 201
@app.post("/api/owner_users")
def create_owner_user_route():
"""Onboarding provisioning (#24): the owner's login for a new client, with
a temp password the caller (n8n) is expected to record via the
credentials CRM entity, same as it did for the retired EA login."""
if not authed():
return jsonify({"error": "forbidden"}), 403
body = request.get_json(force=True, silent=True) or {}
client_id = body.get("client_id")
email = (body.get("email") or "").strip().lower()
password = body.get("password") or ""
if not client_id or not email or not password:
return jsonify({"error": "client_id, email and password required"}), 400
if bdb.find_user_by_email(email) is not None:
return jsonify({"error": "email already in use"}), 409
row = bdb.create_user(client_id, email, password)
with db.connect() as conn, conn.cursor() as cur:
log_activity(cur, client_id, "add owner user", f"user_id={row['user_id']}")
conn.commit()
return jsonify({"user_id": row["user_id"]}), 201
@app.get("/api/owner_users")
def list_owner_users():
"""Owner-accounts list for the CRM dashboard's new tab (#19). Requires