Back office: Postgres source-of-truth + read dashboard
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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user