94ae2d578d
Adds a `credentials` entity to the back office (never mirrored to Sheets, gated by the CRM token even to read) so client logins like the auto-generated Easy!Appointments provider password can be viewed and copied from the dashboard instead of getting lost — the actual cause of the happynails password going missing. Onboarding now saves that generated password instead of discarding it. Also adds Documentation.md, brings README/TODO in line with the current Postgres-first architecture, and tidies the backlog. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
165 lines
5.0 KiB
Python
165 lines
5.0 KiB
Python
"""Postgres access + the table/column contract shared across the app.
|
|
|
|
The TABLES map is the single definition of which entities exist, their columns,
|
|
and their primary key. Read/CRUD endpoints, the importer and the Sheets mirror
|
|
all derive from it so they can never drift apart.
|
|
"""
|
|
import os
|
|
import re
|
|
from datetime import datetime, date
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
|
|
DATABASE_URL = os.environ["DATABASE_URL"]
|
|
|
|
# entity -> (sheet tab, primary key, ordered columns)
|
|
TABLES = {
|
|
"clients": {
|
|
"tab": "Clients",
|
|
"pk": "client_id",
|
|
"cols": ["client_id", "business_name", "owner_name", "email", "phone",
|
|
"niche", "tier", "status", "domain", "stack_notes", "vault_ref",
|
|
"services", "billing_cycle", "monthly_fee_eur", "start_date",
|
|
"renewal_date", "created_at", "notes", "notify_channel"],
|
|
"dates": ["start_date", "renewal_date"],
|
|
"timestamps": ["created_at"],
|
|
"numbers": ["monthly_fee_eur"],
|
|
"bools": [],
|
|
},
|
|
"leads": {
|
|
"tab": "Leads",
|
|
"pk": "lead_id",
|
|
"cols": ["lead_id", "received_at", "client_id", "source", "name",
|
|
"contact", "service_interest", "message", "status", "notified"],
|
|
"dates": [],
|
|
"timestamps": ["received_at"],
|
|
"numbers": [],
|
|
"bools": ["notified"],
|
|
},
|
|
"projects": {
|
|
"tab": "Projects",
|
|
"pk": "project_id",
|
|
"cols": ["project_id", "client_id", "deliverable", "tier", "checklist",
|
|
"go_live_date", "status"],
|
|
"dates": ["go_live_date"],
|
|
"timestamps": [],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
"bookings": {
|
|
"tab": "Bookings",
|
|
"pk": "booking_id",
|
|
"cols": ["booking_id", "created_at", "client_id", "customer_name",
|
|
"customer_contact", "service", "start_time", "end_time",
|
|
"source", "status"],
|
|
"dates": [],
|
|
"timestamps": ["created_at", "start_time", "end_time"],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
"invoices": {
|
|
"tab": "Invoices",
|
|
"pk": "invoice_id",
|
|
"cols": ["invoice_id", "client_id", "issued_date", "due_date",
|
|
"amount_eur", "period", "status", "paid_date"],
|
|
"dates": ["issued_date", "due_date", "paid_date"],
|
|
"timestamps": [],
|
|
"numbers": ["amount_eur"],
|
|
"bools": [],
|
|
},
|
|
"activity_log": {
|
|
"tab": "Activity Log",
|
|
"pk": "id",
|
|
"cols": ["ts", "workflow", "client_id", "action", "detail", "result"],
|
|
"dates": [],
|
|
"timestamps": ["ts"],
|
|
"numbers": [],
|
|
"bools": [],
|
|
},
|
|
"credentials": {
|
|
# No "tab": never mirrored to Sheets (see "mirror" below) — secrets stay in Postgres only.
|
|
"pk": "cred_id",
|
|
"cols": ["cred_id", "client_id", "label", "username", "secret", "notes", "created_at"],
|
|
"dates": [],
|
|
"timestamps": ["created_at"],
|
|
"numbers": [],
|
|
"bools": [],
|
|
"mirror": False,
|
|
},
|
|
}
|
|
|
|
|
|
def connect():
|
|
return psycopg.connect(DATABASE_URL, row_factory=dict_row)
|
|
|
|
|
|
# ---- coercion: Sheet strings / JSON values -> typed Python for Postgres ----
|
|
|
|
def parse_date(v):
|
|
if v in (None, ""):
|
|
return None
|
|
if isinstance(v, date):
|
|
return v
|
|
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", str(v))
|
|
return date(int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
|
|
|
|
|
|
def parse_ts(v):
|
|
if v in (None, ""):
|
|
return None
|
|
if isinstance(v, datetime):
|
|
return v
|
|
s = str(v).strip()
|
|
# ISO-8601 / RFC3339 (incl. trailing Z and +hh:mm offsets — what Google
|
|
# Calendar / booking tools emit). fromisoformat handles Z on Python 3.11+.
|
|
try:
|
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
pass
|
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S",
|
|
"%Y-%m-%dT%H:%M", "%Y-%m-%d"):
|
|
try:
|
|
return datetime.strptime(s, fmt)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def parse_num(v):
|
|
if v in (None, ""):
|
|
return None
|
|
s = str(v).replace("€", "").replace(",", ".").strip()
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_bool(v):
|
|
if v in (None, ""):
|
|
return None
|
|
if isinstance(v, bool):
|
|
return v
|
|
return str(v).strip().lower() in ("true", "1", "yes", "ja", "wahr")
|
|
|
|
|
|
def coerce_row(entity, rec):
|
|
"""Return a dict of column -> typed value for the given entity."""
|
|
spec = TABLES[entity]
|
|
out = {}
|
|
for col in spec["cols"]:
|
|
v = rec.get(col, None)
|
|
if isinstance(v, str):
|
|
v = v.strip() or None
|
|
if col in spec["dates"]:
|
|
v = parse_date(v)
|
|
elif col in spec["timestamps"]:
|
|
v = parse_ts(v)
|
|
elif col in spec["numbers"]:
|
|
v = parse_num(v)
|
|
elif col in spec["bools"]:
|
|
v = parse_bool(v)
|
|
out[col] = v
|
|
return out
|