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
+1 -1
View File
@@ -20,7 +20,7 @@ TABLES = {
"cols": ["client_id", "business_name", "owner_name", "email", "phone",
"niche", "tier", "status", "domain", "stack_notes", "vault_ref",
"services", "billing_cycle", "monthly_fee_eur", "start_date",
"renewal_date", "created_at", "notes", "notify_channel"],
"renewal_date", "created_at", "notes", "notify_channel", "slug"],
"dates": ["start_date", "renewal_date"],
"timestamps": ["created_at"],
"numbers": ["monthly_fee_eur"],
+124
View File
@@ -0,0 +1,124 @@
"""Flask test client / real-DB integration tests for the onboarding
provisioning endpoints (#24): POST /api/resources, /api/services, and
/api/owner_users, used by n8n/onboarding.json in place of the retired EA
provider-account chain.
"""
import pytest
import app as app_module
import booking_db as bdb
from app import app as flask_app
CLIENT_A = "C-TEST-PROV-A"
CLIENT_B = "C-TEST-PROV-B"
AUTH = {"X-CRM-Token": "test-crm-token"}
@pytest.fixture
def client(monkeypatch):
flask_app.config["TESTING"] = True
monkeypatch.setattr(app_module, "CRM_TOKEN", "test-crm-token")
return flask_app.test_client()
def _insert_client(client_id):
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO clients (client_id, business_name) VALUES (%s, %s) "
"ON CONFLICT (client_id) DO NOTHING",
(client_id, "Café " + client_id))
conn.commit()
# ---- /api/clients slug (generic entity POST, #24) ----
def test_create_client_persists_slug(client):
resp = client.post("/api/clients", headers=AUTH, json={
"business_name": "Slug Test Client", "slug": "slug-test-abcd"})
assert resp.status_code == 201
client_id = resp.get_json()["added"]
assert bdb.get_client_by_slug("slug-test-abcd")["client_id"] == client_id
# ---- /api/resources ----
def test_create_resource_requires_crm_token(client):
resp = client.post("/api/resources", json={"client_id": CLIENT_A, "name": "Hauptressource"})
assert resp.status_code == 403
def test_create_resource_sets_hours(client):
_insert_client(CLIENT_A)
resp = client.post("/api/resources", headers=AUTH, json={
"client_id": CLIENT_A, "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"]
hours = bdb.get_resource_hours(CLIENT_A, resource_id)
assert 0 in hours
def test_create_resource_requires_name(client):
_insert_client(CLIENT_A)
resp = client.post("/api/resources", headers=AUTH, json={"client_id": CLIENT_A})
assert resp.status_code == 400
# ---- /api/services ----
def test_create_service_requires_crm_token(client):
resp = client.post("/api/services", json={
"client_id": CLIENT_A, "name": "Termin", "duration_minutes": 30})
assert resp.status_code == 403
def test_create_service_creates_active_service(client):
_insert_client(CLIENT_A)
resp = client.post("/api/services", headers=AUTH, json={
"client_id": CLIENT_A, "name": "Termin", "duration_minutes": 30})
assert resp.status_code == 201
service_id = resp.get_json()["service_id"]
row = bdb.get_service(CLIENT_A, service_id)
assert row["active"] is True
assert row["duration_minutes"] == 30
def test_create_service_requires_duration(client):
_insert_client(CLIENT_A)
resp = client.post("/api/services", headers=AUTH,
json={"client_id": CLIENT_A, "name": "Termin"})
assert resp.status_code == 400
# ---- /api/owner_users ----
def test_create_owner_user_requires_crm_token(client):
resp = client.post("/api/owner_users", json={
"client_id": CLIENT_A, "email": "owner@example.com", "password": "pw12345"})
assert resp.status_code == 403
def test_create_owner_user_creates_login(client):
_insert_client(CLIENT_A)
resp = client.post("/api/owner_users", headers=AUTH, json={
"client_id": CLIENT_A, "email": "owner@example.com", "password": "pw12345"})
assert resp.status_code == 201
user = bdb.find_user_by_email("owner@example.com")
assert user["client_id"] == CLIENT_A
assert bdb.verify_password(user, "pw12345")
def test_create_owner_user_rejects_duplicate_email(client):
_insert_client(CLIENT_A)
_insert_client(CLIENT_B)
bdb.create_user(CLIENT_A, "owner@example.com", "pw12345")
resp = client.post("/api/owner_users", headers=AUTH, json={
"client_id": CLIENT_B, "email": "owner@example.com", "password": "pw67890"})
assert resp.status_code == 409
def test_create_owner_user_requires_password(client):
_insert_client(CLIENT_A)
resp = client.post("/api/owner_users", headers=AUTH,
json={"client_id": CLIENT_A, "email": "owner@example.com"})
assert resp.status_code == 400
+101 -110
View File
@@ -22,7 +22,7 @@
},
{
"parameters": {
"jsCode": "const w=$('Onboard webhook').first().json; const body=w.body||w;\nconst s=v=>{v=(v==null?'':String(v));return /^[=+\\-@\\t\\r]/.test(v)?\"'\"+v:v;};\nconst now=new Date();\nfunction pd(v){if(!v)return null;const m=String(v).match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if(m)return new Date(Date.UTC(+m[1],+m[2]-1,+m[3]));const d=new Date(v);return isNaN(d)?null:d;}\nconst cycle=String(body.billing_cycle||'monthly').toLowerCase();\nconst start=pd(body.start_date)||new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()));\nconst startStr=start.toISOString().slice(0,10);\nconst tier=String(body.tier||'A').toUpperCase().includes('B')?'B':'A';\nconst services=String(body.services||'site, booking, leads');\nconst client={business_name:s(body.business_name),owner_name:s(body.owner_name),\n email:s(body.email),phone:s(body.phone),niche:s(body.niche),tier,status:'onboarding',\n domain:s(body.domain),stack_notes:s(body.stack_notes),vault_ref:s(body.vault_ref),\n services:s(services),billing_cycle:cycle,monthly_fee_eur:(body.monthly_fee_eur||''),\n start_date:startStr,notes:s(body.notes)};\nconst checklist=services.split(',').map(x=>x.trim()).filter(Boolean).map(x=>x+'\\u2610').join(' ');\nconst gl=new Date(start);gl.setUTCDate(gl.getUTCDate()+14);const goLive=gl.toISOString().slice(0,10);\nconst project={project_id:'P-'+Date.now(),deliverable:'Onboarding & Setup',tier,checklist,\n go_live_date:goLive,status:'todo'};\nreturn [{json:{client,project,tier,services,cycle,monthly_fee_eur:(body.monthly_fee_eur||''),\n business_name:client.business_name}}];"
"jsCode": "const w=$('Onboard webhook').first().json; const body=w.body||w;\nconst s=v=>{v=(v==null?'':String(v));return /^[=+\\-@\\t\\r]/.test(v)?\"'\"+v:v;};\nconst now=new Date();\nfunction pd(v){if(!v)return null;const m=String(v).match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if(m)return new Date(Date.UTC(+m[1],+m[2]-1,+m[3]));const d=new Date(v);return isNaN(d)?null:d;}\nfunction slugify(v){return String(v||'').toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g,'')\n .replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'')||'client';}\nconst cycle=String(body.billing_cycle||'monthly').toLowerCase();\nconst start=pd(body.start_date)||new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()));\nconst startStr=start.toISOString().slice(0,10);\nconst tier=String(body.tier||'A').toUpperCase().includes('B')?'B':'A';\nconst services=String(body.services||'site, booking, leads');\nconst slug=slugify(body.business_name)+'-'+Math.random().toString(36).slice(2,6);\nconst client={business_name:s(body.business_name),owner_name:s(body.owner_name),\n email:s(body.email),phone:s(body.phone),niche:s(body.niche),tier,status:'onboarding',\n domain:s(body.domain),stack_notes:s(body.stack_notes),vault_ref:s(body.vault_ref),\n services:s(services),billing_cycle:cycle,monthly_fee_eur:(body.monthly_fee_eur||''),\n start_date:startStr,notes:s(body.notes),slug};\nconst checklist=services.split(',').map(x=>x.trim()).filter(Boolean).map(x=>x+'\\u2610').join(' ');\nconst gl=new Date(start);gl.setUTCDate(gl.getUTCDate()+14);const goLive=gl.toISOString().slice(0,10);\nconst project={project_id:'P-'+Date.now(),deliverable:'Onboarding & Setup',tier,checklist,\n go_live_date:goLive,status:'todo'};\nreturn [{json:{client,project,tier,services,cycle,monthly_fee_eur:(body.monthly_fee_eur||''),\n business_name:client.business_name}}];"
},
"id": "fe9a0f5d-c6f8-4328-a98e-edc022582ec3",
"name": "Compute",
@@ -133,10 +133,10 @@
},
{
"parameters": {
"jsCode": "const cid=$('Save client').first().json.added;\nconst biz=$('Compute').first().json.business_name||'Kunde';\nconst slug=String(cid).toLowerCase().replace(/[^a-z0-9]/g,'');\nconst password=Math.random().toString(36).slice(2,10)+Math.random().toString(36).slice(2,6);\nconst serviceBody={name:'Termin',duration:30,price:0,currency:'EUR',availabilitiesType:'flexible',attendantsNumber:1,isPrivate:false};\nreturn [{json:{cid,biz,username:slug,password,serviceBody}}];"
"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}}];"
},
"id": "ea-ea-build-service",
"name": "EA build service",
"id": "prov-build-provisioning",
"name": "Build provisioning",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
@@ -148,13 +148,44 @@
"parameters": {
"options": {},
"method": "POST",
"url": "http://easyappointments/index.php/api/v1/services",
"url": "http://smb-crm:8080/api/resources",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Basic __EA_AUTH__"
"name": "X-CRM-Token",
"value": "__CRM_TOKEN__"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.resourceBody) }}"
},
"id": "prov-create-resource",
"name": "Create resource",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
420
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"continueOnFail": true
},
{
"parameters": {
"options": {},
"method": "POST",
"url": "http://smb-crm:8080/api/services",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-CRM-Token",
"value": "__CRM_TOKEN__"
}
]
},
@@ -162,8 +193,8 @@
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.serviceBody) }}"
},
"id": "ea-ea-create-service",
"name": "EA create service",
"id": "prov-create-service",
"name": "Create service",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
@@ -175,68 +206,11 @@
"waitBetweenTries": 2000,
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const prep=$('EA build service').first().json;\nconst svcId=$json.id;\nconst c=($('Compute').first().json.client)||{};\nconst parts=String(prep.biz).trim().split(/\\s+/);\nconst firstName=parts[0]||prep.biz;\nconst lastName=parts.slice(1).join(' ')||'Studio';\nconst wp={start:'09:00',end:'18:00',breaks:[]};\nconst providerBody={firstName,lastName,email:'provider+'+prep.username+'@booking.mivanchenko.de',\n phone:c.phone||'',services:[svcId],isPrivate:false,timezone:'Europe/Berlin',notes:prep.cid,\n settings:{username:prep.username,password:prep.password,\n workingPlan:{monday:wp,tuesday:wp,wednesday:wp,thursday:wp,friday:wp,saturday:wp,sunday:null}}};\nreturn [{json:{providerBody,svcId,cid:prep.cid,username:prep.username,biz:prep.biz}}];"
},
"id": "ea-ea-build-provider",
"name": "EA build provider",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
520
]
},
{
"parameters": {
"options": {},
"method": "POST",
"url": "http://easyappointments/index.php/api/v1/providers",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Basic __EA_AUTH__"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($('EA build provider').item.json.providerBody) }}"
},
"id": "ea-ea-create-provider",
"name": "EA create provider",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1340,
520
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const bp=$('EA build provider').first().json;\nconst provId=$json.id;\nconst embed='https://booking.mivanchenko.de/index.php/booking?service='+bp.svcId+'&provider='+provId;\nconst stack_notes='Buchung-Embed: '+embed+' | EA provider '+provId+' / svc '+bp.svcId+' / login '+bp.username;\nconst password=bp.providerBody.settings.password;\nreturn [{json:{cid:bp.cid,embed,provId,username:bp.username,password,patch:{stack_notes}}}];"
},
"id": "ea-ea-booking-info",
"name": "EA booking info",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1560,
520
]
},
{
"parameters": {
"options": {},
"method": "PATCH",
"url": "=http://smb-crm:8080/api/clients/{{ $json.cid }}",
"url": "http://smb-crm:8080/api/owner_users",
"sendHeaders": true,
"headerParameters": {
"parameters": [
@@ -248,21 +222,54 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.patch) }}"
"jsonBody": "={{ JSON.stringify({ client_id: $json.cid, email: $json.userEmail, password: $json.userPassword }) }}"
},
"id": "ea-ea-update-client",
"name": "EA update client",
"id": "prov-create-owner-user",
"name": "Create owner user",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1780,
520
900,
620
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"continueOnFail": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "c0000000-1111-2222-3333-aaaaaaaaaaaa",
"leftValue": "={{ $json.user_id }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "prov-user-created-check",
"name": "User created?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1120,
620
]
},
{
"parameters": {
"options": {},
@@ -279,15 +286,15 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ client_id: $json.cid, label: 'Easy!Appointments Provider-Login', username: $json.username, secret: $json.password }) }}"
"jsonBody": "={{ JSON.stringify({ client_id: $('Build provisioning').item.json.cid, label: 'Owner-Login', username: $('Build provisioning').item.json.userEmail, secret: $('Build provisioning').item.json.userPassword }) }}"
},
"id": "ea-save-credential",
"id": "prov-save-credential",
"name": "Save credential",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1780,
680
1340,
620
],
"retryOnFail": true,
"maxTries": 3,
@@ -327,7 +334,7 @@
"index": 0
},
{
"node": "EA build service",
"node": "Build provisioning",
"type": "main",
"index": 0
}
@@ -356,64 +363,48 @@
]
]
},
"EA build service": {
"Build provisioning": {
"main": [
[
{
"node": "EA create service",
"node": "Create resource",
"type": "main",
"index": 0
},
{
"node": "Create service",
"type": "main",
"index": 0
},
{
"node": "Create owner user",
"type": "main",
"index": 0
}
]
]
},
"EA create service": {
"Create owner user": {
"main": [
[
{
"node": "EA build provider",
"node": "User created?",
"type": "main",
"index": 0
}
]
]
},
"EA build provider": {
"User created?": {
"main": [
[
{
"node": "EA create provider",
"type": "main",
"index": 0
}
]
]
},
"EA create provider": {
"main": [
[
{
"node": "EA booking info",
"type": "main",
"index": 0
}
]
]
},
"EA booking info": {
"main": [
[
{
"node": "EA update client",
"type": "main",
"index": 0
},
{
"node": "Save credential",
"type": "main",
"index": 0
}
]
],
[]
]
}
},