18e346d67b
Stand up the DB-first CRM backbone (architecture pivot: Postgres is the source of truth, Google Sheets becomes a one-way downstream mirror). - backoffice/ stack: smb-db (Postgres 16) + smb-crm (Flask/waitress service). - Schema mirrors the six Sheet tabs (clients, leads, projects, activity_log, bookings, invoices) with typed columns + updated_at triggers. - Service-account Sheets client (PyJWT) for the one-time import + future mirror. - import_from_sheets.py: idempotent seed of Postgres from the live Sheets. - Read dashboard (Leads & Clients tables) at onboard.mivanchenko.de/crm, behind the existing Caddy basic-auth; JSON API reads straight from Postgres. Deployed + verified: import seeded DB, dashboard/API live, no-auth blocked, onboarding form unaffected. Add/edit/delete + DB->Sheets sync + n8n ingest swap are the next steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
70 lines
2.0 KiB
Python
70 lines
2.0 KiB
Python
"""smb-crm back-office service.
|
|
|
|
Postgres is the source of truth. This serves the operator dashboard + a small
|
|
JSON API (read now; add/edit/delete and the DB->Sheets mirror layered on next).
|
|
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
|
|
from datetime import datetime, date
|
|
from decimal import Decimal
|
|
|
|
from flask import Flask, jsonify, request, Response, send_from_directory
|
|
from waitress import serve
|
|
|
|
import db
|
|
|
|
app = Flask(__name__, static_folder="static", static_url_path="")
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
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
|
|
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)})
|
|
|
|
|
|
@app.get("/")
|
|
def index():
|
|
return send_from_directory(app.static_folder, "index.html")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
serve(app, host="0.0.0.0", port=8080)
|