Files
smb-online/backoffice/app/app.py
T
mivanchenko 1b8f8c1391
Test backoffice (smb-crm) / test (push) Has been cancelled
Upsert keyed entities on POST /api/<entity> (#1)
Easy!Appointments re-fires appointment_save on reschedule with the same
EA-<id> booking_id, so add_entity's plain INSERT 500s on the PK conflict.
Switch to ON CONFLICT (pk) DO UPDATE for every entity except activity_log
(no client-supplied pk), logging add vs update accordingly. Extended to
all keyed entities per the issue body, while preserving created_at/
received_at on redelivery for leads/clients/credentials so a redelivered
webhook can't clobber the original creation time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:42:00 +02:00

408 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""smb-crm back-office service.
Postgres is the source of truth for the operator dashboard + a small JSON API.
Browser access is gated by Caddy basic-auth on onboard.mivanchenko.de; the
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, time as dtime, timezone
from decimal import Decimal
from flask import Flask, jsonify, request, Response
from waitress import serve
import booking_db as bdb
import db
import owner_mail
from booking_api import bp as booking_bp
from public_booking import bp as public_booking_bp
from manage_booking import bp as manage_booking_bp
from owner_auth import bp as owner_auth_bp
from owner_booking import bp as owner_booking_bp
from owner_settings import bp as owner_settings_bp
app = Flask(__name__, static_folder="static", static_url_path="")
app.register_blueprint(booking_bp)
app.register_blueprint(public_booking_bp)
app.register_blueprint(manage_booking_bp)
app.register_blueprint(owner_auth_bp)
app.register_blueprint(owner_booking_bp)
app.register_blueprint(owner_settings_bp)
# Dedicated secret for the owner-login session cookie -- deliberately not
# shared with CRM_API_TOKEN or BOOKING_TOKEN_SECRET (#19), same reasoning as
# booking_api.py's own token secret: rotating one must never silently affect
# the others.
app.secret_key = os.environ.get("SESSION_SECRET_KEY", "")
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
# Static dashboard, with the CRM token injected so the (basic-auth-gated)
# operator page can call the token-protected mutation endpoints.
with open(os.path.join(os.path.dirname(__file__), "static", "index.html")) as _f:
INDEX_HTML = _f.read().replace("__CRM_TOKEN__", CRM_TOKEN)
def authed():
return bool(CRM_TOKEN) and request.headers.get("X-CRM-Token") == CRM_TOKEN
def log_activity(cur, client_id, action, detail, result="ok"):
cur.execute(
"INSERT INTO activity_log (ts, workflow, client_id, action, detail, result) "
"VALUES (now(), %s, %s, %s, %s, %s)",
("back-office", client_id, action, detail, result))
LIST_ORDER = {
"leads": "received_at DESC NULLS LAST",
"clients": "client_id",
"projects": "project_id",
"bookings": "start_time DESC NULLS LAST",
"invoices": "issued_date DESC NULLS LAST",
"activity_log": "ts DESC NULLS LAST",
"credentials": "client_id",
}
def jsonable(v):
if isinstance(v, (datetime, date)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
return v
def rows_json(rows):
return [{k: jsonable(v) for k, v in r.items()} for r in rows]
@app.get("/healthz")
def healthz():
try:
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return Response("ok\n", mimetype="text/plain")
except Exception as e: # noqa: BLE001
return Response(f"db error: {e}\n", status=500, mimetype="text/plain")
@app.get("/api/<entity>")
def list_entity(entity):
if entity not in db.TABLES:
return jsonify({"error": "unknown entity"}), 404
# Credentials carry secrets, not just business metadata — require the
# mutation-level token even to read, on top of the Caddy basic-auth every
# other entity's (read-only) list relies on alone.
if entity == "credentials" and not authed():
return jsonify({"error": "forbidden"}), 403
order = LIST_ORDER.get(entity, db.TABLES[entity]["pk"])
with db.connect() as conn, conn.cursor() as cur:
cur.execute(f"SELECT * FROM {entity} ORDER BY {order}")
rows = cur.fetchall()
return jsonify({"entity": entity, "count": len(rows), "rows": rows_json(rows)})
def compute_renewal(start, cycle):
"""Mirror the onboarding workflow: monthly -> +1 month, yearly -> +1 year."""
cycle = (cycle or "monthly").lower()
if cycle == "monthly":
m = start.month % 12 + 1
y = start.year + (1 if start.month == 12 else 0)
d = min(start.day, [31, 29 if y % 4 == 0 and (y % 100 or not y % 400) else 28,
31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1])
return date(y, m, d)
if cycle == "yearly":
return date(start.year + 1, start.month, start.day)
return None
def next_client_id(cur):
cur.execute("SELECT client_id FROM clients")
mx = 0
for r in cur.fetchall():
m = re.match(r"C-(\d+)$", r["client_id"] or "")
if m:
mx = max(mx, int(m.group(1)))
return "C-%04d" % (mx + 1)
# Columns that add_entity defaults to "now" only when the caller omits them
# (see the per-entity blocks below) -- a re-delivered webhook that omits the
# same column on a redelivery must not have it upserted back to a fresh
# "now", clobbering the original creation time of the row it's updating.
INSERT_ONLY_COLS = {
"leads": {"received_at"},
"clients": {"created_at"},
"credentials": {"created_at"},
}
@app.post("/api/<entity>")
def add_entity(entity):
if entity not in db.TABLES:
return jsonify({"error": "unknown entity"}), 404
if not authed():
return jsonify({"error": "forbidden"}), 403
spec = db.TABLES[entity]
pk = spec["cols"][0] if entity == "activity_log" else spec["pk"]
body = request.get_json(force=True, silent=True) or {}
row = db.coerce_row(entity, body)
with db.connect() as conn, conn.cursor() as cur:
if entity == "leads":
row["lead_id"] = row.get("lead_id") or "L-" + str(int(time.time() * 1000))
row["received_at"] = row.get("received_at") or datetime.now(timezone.utc)
row["status"] = row.get("status") or "new"
if row.get("notified") is None:
row["notified"] = False
elif entity == "clients":
row["client_id"] = row.get("client_id") or next_client_id(cur)
row["created_at"] = row.get("created_at") or datetime.now(timezone.utc)
row["status"] = row.get("status") or "lead"
if not row.get("renewal_date") and row.get("start_date"):
row["renewal_date"] = compute_renewal(
row["start_date"], row.get("billing_cycle"))
elif entity == "credentials":
row["cred_id"] = row.get("cred_id") or "CR-" + str(int(time.time() * 1000))
row["created_at"] = row.get("created_at") or datetime.now(timezone.utc)
elif not row.get(pk):
return jsonify({"error": f"{pk} required"}), 400
cols = spec["cols"]
ph = ", ".join(["%s"] * len(cols))
collist = ", ".join(cols)
if entity == "activity_log":
# activity_log has no client-supplied primary key (its real pk,
# a serial "id", isn't even in cols) -- always a fresh row.
cur.execute(f"INSERT INTO {entity} ({collist}) VALUES ({ph})",
[row.get(c) for c in cols])
updated = False
else:
# Upsert by pk (#1): re-delivered webhooks -- e.g. Easy!Appointments
# re-firing appointment_save with the same EA-<id> booking_id on
# reschedule -- must update the existing row instead of 500ing on
# a PK conflict.
skip = INSERT_ONLY_COLS.get(entity, set()) | {pk}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c not in skip)
cur.execute(
f"INSERT INTO {entity} ({collist}) VALUES ({ph}) "
f"ON CONFLICT ({pk}) DO UPDATE SET {updates} "
f"RETURNING (xmax = 0) AS inserted",
[row.get(c) for c in cols])
updated = not cur.fetchone()["inserted"]
log_activity(cur, row.get("client_id"),
f"{'update' if updated else 'add'} {entity}", f"{pk}={row.get(pk)}")
conn.commit()
if updated:
return jsonify({"updated": row.get(pk)}), 200
return jsonify({"added": row.get(pk)}), 201
@app.patch("/api/<entity>/<path:ident>")
def edit_entity(entity, ident):
if entity not in db.TABLES:
return jsonify({"error": "unknown entity"}), 404
if not authed():
return jsonify({"error": "forbidden"}), 403
spec = db.TABLES[entity]
pk = spec["pk"]
body = request.get_json(force=True, silent=True) or {}
setcols = [c for c in body if c in spec["cols"] and c != pk]
if not setcols:
return jsonify({"error": "no editable fields"}), 400
typed = db.coerce_row(entity, body)
setsql = ", ".join(f"{c} = %s" for c in setcols)
vals = [typed[c] for c in setcols] + [ident]
with db.connect() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE {entity} SET {setsql} WHERE {pk} = %s RETURNING {pk}", vals)
if not cur.fetchone():
return jsonify({"error": "not found"}), 404
log_activity(cur, ident if entity == "clients" else None,
f"edit {entity}", f"{pk}={ident}: {', '.join(setcols)}")
conn.commit()
return jsonify({"updated": ident, "fields": setcols})
@app.delete("/api/<entity>/<path:ident>")
def delete_entity(entity, ident):
if entity not in db.TABLES:
return jsonify({"error": "unknown entity"}), 404
if not authed():
return jsonify({"error": "forbidden"}), 403
pk = db.TABLES[entity]["pk"]
with db.connect() as conn, conn.cursor() as cur:
cur.execute(f"DELETE FROM {entity} WHERE {pk} = %s RETURNING {pk}", (ident,))
if not cur.fetchone():
return jsonify({"error": "not found"}), 404
log_activity(cur, ident if entity == "clients" else None,
f"delete {entity}", f"{pk}={ident}")
conn.commit()
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
the CRM token to read, same as credentials -- these rows identify who can
log into a client's owner portal."""
if not authed():
return jsonify({"error": "forbidden"}), 403
rows = bdb.list_users()
return jsonify({"entity": "owner_users", "count": len(rows), "rows": rows_json(rows)})
@app.post("/api/owner_users/<user_id>/reset-password")
def trigger_owner_password_reset(user_id):
"""Operator-triggered reset (#19): sends the same reset email an owner
would send themselves via /owner/forgot-password."""
if not authed():
return jsonify({"error": "forbidden"}), 403
user = bdb.get_user(user_id)
if user is None:
return jsonify({"error": "not found"}), 404
client = bdb.get_client(user["client_id"])
tok = bdb.create_password_reset_token(user["user_id"])
owner_mail.send_password_reset(client, user, tok["token"])
with db.connect() as conn, conn.cursor() as cur:
log_activity(cur, user["client_id"], "owner password reset",
f"user_id={user_id} (operator-triggered)")
conn.commit()
return jsonify({"sent": user_id})
def _ics_dt(v):
if isinstance(v, datetime):
u = v if v.tzinfo else v.replace(tzinfo=timezone.utc)
return u.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return None
def _ics_esc(t):
return (str(t or "").replace("\\", "\\\\").replace(";", "\\;")
.replace(",", "\\,").replace("\n", "\\n"))
@app.get("/api/bookings.ics")
def bookings_ics():
"""Read-only iCal feed for Apple/Google Calendar subscription, scoped to
one client via their own clients.ics_token (#22). No header auth, since
calendar apps can't send one -- gated by the query token instead, but
unlike the old shared ICS_TOKEN + client_id pair, the token itself
resolves the client, so there's no separate client_id param that could
be swapped to view another tenant's bookings."""
client = bdb.get_client_by_ics_token(request.args.get("token"))
if client is None:
return Response("forbidden\n", status=403, mimetype="text/plain")
cid = client["client_id"]
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,))
rows = cur.fetchall()
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//smb-crm//bookings//DE",
"CALSCALE:GREGORIAN", "METHOD:PUBLISH",
"X-WR-CALNAME:" + _ics_esc("Buchungen " + cid)]
for r in rows:
st = _ics_dt(r.get("start_time"))
if not st:
continue
summary = r.get("service") or "Termin"
if r.get("customer_name"):
summary += " " + r["customer_name"]
out += ["BEGIN:VEVENT",
"UID:%s@smb-crm" % (r.get("booking_id") or now),
"DTSTAMP:%s" % (_ics_dt(r.get("created_at")) or now),
"DTSTART:%s" % st]
en = _ics_dt(r.get("end_time"))
if en:
out.append("DTEND:%s" % en)
out.append("SUMMARY:" + _ics_esc(summary))
if r.get("customer_contact"):
out.append("DESCRIPTION:" + _ics_esc("Kontakt: " + r["customer_contact"]))
out += ["STATUS:CONFIRMED", "END:VEVENT"]
out.append("END:VCALENDAR")
return Response("\r\n".join(out) + "\r\n", mimetype="text/calendar")
@app.get("/")
def index():
return Response(INDEX_HTML, mimetype="text/html")
if __name__ == "__main__":
serve(app, host="0.0.0.0", port=8080)