aabd1d56c4
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>
158 lines
4.7 KiB
Python
158 lines
4.7 KiB
Python
"""Postgres access + the table/column contract shared across the app.
|
|
|
|
The TABLES map is the single definition of which entities exist, their columns,
|
|
and their primary key. Read/CRUD endpoints all derive from it so they can
|
|
never drift apart.
|
|
"""
|
|
import os
|
|
import re
|
|
from datetime import datetime, date
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
|
|
DATABASE_URL = os.environ["DATABASE_URL"]
|
|
|
|
# entity -> (primary key, ordered columns)
|
|
TABLES = {
|
|
"clients": {
|
|
"pk": "client_id",
|
|
"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", "slug"],
|
|
"dates": ["start_date", "renewal_date"],
|
|
"timestamps": ["created_at"],
|
|
"numbers": ["monthly_fee_eur"],
|
|
"bools": [],
|
|
},
|
|
"leads": {
|
|
"pk": "lead_id",
|
|
"cols": ["lead_id", "received_at", "client_id", "source", "name",
|
|
"contact", "service_interest", "message", "status", "notified"],
|
|
"dates": [],
|
|
"timestamps": ["received_at"],
|
|
"numbers": [],
|
|
"bools": ["notified"],
|
|
},
|
|
"projects": {
|
|
"pk": "project_id",
|
|
"cols": ["project_id", "client_id", "deliverable", "tier", "checklist",
|
|
"go_live_date", "status"],
|
|
"dates": ["go_live_date"],
|
|
"timestamps": [],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
"bookings": {
|
|
"pk": "booking_id",
|
|
"cols": ["booking_id", "created_at", "client_id", "customer_name",
|
|
"customer_contact", "service", "start_time", "end_time",
|
|
"source", "status"],
|
|
"dates": [],
|
|
"timestamps": ["created_at", "start_time", "end_time"],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
"invoices": {
|
|
"pk": "invoice_id",
|
|
"cols": ["invoice_id", "client_id", "issued_date", "due_date",
|
|
"amount_eur", "period", "status", "paid_date"],
|
|
"dates": ["issued_date", "due_date", "paid_date"],
|
|
"timestamps": [],
|
|
"numbers": ["amount_eur"],
|
|
"bools": [],
|
|
},
|
|
"activity_log": {
|
|
"pk": "id",
|
|
"cols": ["ts", "workflow", "client_id", "action", "detail", "result"],
|
|
"dates": [],
|
|
"timestamps": ["ts"],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
"credentials": {
|
|
# Secrets stay in Postgres only.
|
|
"pk": "cred_id",
|
|
"cols": ["cred_id", "client_id", "label", "username", "secret", "notes", "created_at"],
|
|
"dates": [],
|
|
"timestamps": ["created_at"],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
}
|
|
|
|
|
|
def connect():
|
|
return psycopg.connect(DATABASE_URL, row_factory=dict_row)
|
|
|
|
|
|
# ---- coercion: JSON request values -> typed Python for Postgres ----
|
|
|
|
def parse_date(v):
|
|
if v in (None, ""):
|
|
return None
|
|
if isinstance(v, date):
|
|
return v
|
|
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", str(v))
|
|
return date(int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
|
|
|
|
|
|
def parse_ts(v):
|
|
if v in (None, ""):
|
|
return None
|
|
if isinstance(v, datetime):
|
|
return v
|
|
s = str(v).strip()
|
|
# ISO-8601 / RFC3339 (incl. trailing Z and +hh:mm offsets — what Google
|
|
# Calendar / booking tools emit). fromisoformat handles Z on Python 3.11+.
|
|
try:
|
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
pass
|
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S",
|
|
"%Y-%m-%dT%H:%M", "%Y-%m-%d"):
|
|
try:
|
|
return datetime.strptime(s, fmt)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def parse_num(v):
|
|
if v in (None, ""):
|
|
return None
|
|
s = str(v).replace("€", "").replace(",", ".").strip()
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_bool(v):
|
|
if v in (None, ""):
|
|
return None
|
|
if isinstance(v, bool):
|
|
return v
|
|
return str(v).strip().lower() in ("true", "1", "yes", "ja", "wahr")
|
|
|
|
|
|
def coerce_row(entity, rec):
|
|
"""Return a dict of column -> typed value for the given entity."""
|
|
spec = TABLES[entity]
|
|
out = {}
|
|
for col in spec["cols"]:
|
|
v = rec.get(col, None)
|
|
if isinstance(v, str):
|
|
v = v.strip() or None
|
|
if col in spec["dates"]:
|
|
v = parse_date(v)
|
|
elif col in spec["timestamps"]:
|
|
v = parse_ts(v)
|
|
elif col in spec["numbers"]:
|
|
v = parse_num(v)
|
|
elif col in spec["bools"]:
|
|
v = parse_bool(v)
|
|
out[col] = v
|
|
return out
|