diff --git a/backoffice/app/app.py b/backoffice/app/app.py index c07552c..0b09461 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -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/ 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 diff --git a/backoffice/app/db.py b/backoffice/app/db.py index 1a86063..1fb2c0b 100644 --- a/backoffice/app/db.py +++ b/backoffice/app/db.py @@ -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"], diff --git a/backoffice/app/tests/test_provisioning.py b/backoffice/app/tests/test_provisioning.py new file mode 100644 index 0000000..cf19e0b --- /dev/null +++ b/backoffice/app/tests/test_provisioning.py @@ -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 diff --git a/n8n/onboarding.json b/n8n/onboarding.json index 6974236..760d36f 100644 --- a/n8n/onboarding.json +++ b/n8n/onboarding.json @@ -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", - "type": "main", - "index": 0 - } - ] - ] - }, - "EA create service": { - "main": [ - [ - { - "node": "EA build provider", - "type": "main", - "index": 0 - } - ] - ] - }, - "EA build provider": { - "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", + "node": "Create resource", "type": "main", "index": 0 }, + { + "node": "Create service", + "type": "main", + "index": 0 + }, + { + "node": "Create owner user", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create owner user": { + "main": [ + [ + { + "node": "User created?", + "type": "main", + "index": 0 + } + ] + ] + }, + "User created?": { + "main": [ + [ { "node": "Save credential", "type": "main", "index": 0 } - ] + ], + [] ] } },