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:
2026-06-25 09:12:39 +02:00
parent a04ad82e73
commit 18e346d67b
11 changed files with 661 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
"""Minimal Google Sheets v4 client using a service-account JWT.
Used for (a) the one-time import of existing Sheet rows into Postgres and
(b) the one-way DB -> Sheets mirror sync. Postgres stays the source of truth;
nothing here treats the Sheet as authoritative except the explicit import.
"""
import json
import time
import threading
import jwt
import requests
SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets"
TOKEN_URL = "https://oauth2.googleapis.com/token"
SCOPE = "https://www.googleapis.com/auth/spreadsheets"
# gid map for the live workbook (tab name -> gid), used when clearing/sizing.
TAB_GIDS = {
"Clients": 470934735,
"Leads": 571719114,
"Bookings": 946084008,
"Projects": 619112786,
"Invoices": 413377229,
"Activity Log": 170940481,
}
class Sheets:
def __init__(self, sa_json_path, sheet_id):
with open(sa_json_path) as f:
self.sa = json.load(f)
self.sheet_id = sheet_id
self._tok = None
self._exp = 0
self._lock = threading.Lock()
def _token(self):
with self._lock:
now = int(time.time())
if self._tok and now < self._exp - 60:
return self._tok
claim = {
"iss": self.sa["client_email"],
"scope": SCOPE,
"aud": TOKEN_URL,
"iat": now,
"exp": now + 3600,
}
assertion = jwt.encode(claim, self.sa["private_key"], algorithm="RS256")
r = requests.post(TOKEN_URL, data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
}, timeout=30)
r.raise_for_status()
self._tok = r.json()["access_token"]
self._exp = now + 3600
return self._tok
def _headers(self):
return {"Authorization": "Bearer " + self._token()}
def read(self, a1_range):
"""Return raw 2D list of cell values for an A1 range (e.g. "Leads!A1:Z")."""
url = f"{SHEETS_API}/{self.sheet_id}/values/{requests.utils.quote(a1_range)}"
r = requests.get(url, headers=self._headers(), timeout=30)
r.raise_for_status()
return r.json().get("values", [])
def read_records(self, tab):
"""Read a whole tab as a list of dicts keyed by the header row."""
rows = self.read(f"{tab}!A1:Z")
if not rows:
return []
header = rows[0]
out = []
for raw in rows[1:]:
if not any(c.strip() for c in raw):
continue
rec = {header[i]: (raw[i] if i < len(raw) else "") for i in range(len(header))}
out.append(rec)
return out
def overwrite(self, tab, header, rows):
"""Replace a tab's contents with header + rows (the DB->Sheets mirror).
Clears the existing value range, then writes the new grid starting at A1.
Postgres is the source of truth; this projects it onto the Sheet.
"""
# clear current values (keeps formatting / the tab itself)
clr = f"{SHEETS_API}/{self.sheet_id}/values/{requests.utils.quote(tab + '!A1:Z')}:clear"
requests.post(clr, headers=self._headers(), timeout=30).raise_for_status()
body = {"values": [header] + rows}
url = (f"{SHEETS_API}/{self.sheet_id}/values/"
f"{requests.utils.quote(tab + '!A1')}?valueInputOption=RAW")
r = requests.put(url, headers=self._headers(), json=body, timeout=60)
r.raise_for_status()
return len(rows)