9b2ef47f69
Test backoffice (smb-crm) / test (push) Successful in 1m34s
Confirmed fixed: /owner/_debug_headers showed scheme=https and all three X-Forwarded-* headers present after the Waitress trusted_proxy fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
421 lines
17 KiB
Python
421 lines
17 KiB
Python
"""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
|
||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||
|
||
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="")
|
||
# Caddy terminates TLS and proxies to us over plain HTTP, forwarding
|
||
# X-Forwarded-Proto/-Host; without this, url_for(_external=True) (e.g. the
|
||
# owner's ICS feed link, #22) always guesses "http" since Flask has no other
|
||
# way to know the original request was HTTPS. One hop of proxy (Caddy).
|
||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
||
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__":
|
||
# Waitress strips X-Forwarded-* by default unless the proxy is declared
|
||
# trusted -- without this, ProxyFix upstream never sees them, and every
|
||
# url_for(_external=True) link (e.g. the owner's ICS feed, #22) silently
|
||
# falls back to "http". Port 8080 is never published to the host, so the
|
||
# only thing that can dial us at all is Caddy on the shared `proxy`
|
||
# Docker network -- trusting "*" here doesn't widen who can reach us.
|
||
serve(app, host="0.0.0.0", port=8080, trusted_proxy="*",
|
||
trusted_proxy_headers={"x-forwarded-for", "x-forwarded-proto", "x-forwarded-host"})
|