Add credentials store to the CRM, docs cleanup, deploy pipeline TODO

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>
This commit is contained in:
2026-07-15 14:13:34 +02:00
parent 8eba724428
commit 94ae2d578d
9 changed files with 183 additions and 27 deletions
+12 -3
View File
@@ -53,9 +53,9 @@ def _cell(v):
def mirror_entity(entity):
if SH is None:
return 0
spec = db.TABLES[entity]
if SH is None or spec.get("mirror") is False:
return 0
cols = spec["cols"]
order = LIST_ORDER.get(entity, spec["pk"])
with db.connect() as conn, conn.cursor() as cur:
@@ -87,7 +87,7 @@ threading.Thread(target=_mirror_worker, daemon=True).start()
def mirror_async(entity):
if SH is not None:
if SH is not None and db.TABLES[entity].get("mirror") is not False:
_mirror_q.put(entity)
# Static dashboard, with the CRM token injected so the (basic-auth-gated)
@@ -113,6 +113,7 @@ LIST_ORDER = {
"bookings": "start_time DESC NULLS LAST",
"invoices": "issued_date DESC NULLS LAST",
"activity_log": "ts DESC NULLS LAST",
"credentials": "client_id",
}
@@ -143,6 +144,11 @@ def healthz():
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}")
@@ -198,6 +204,9 @@ def add_entity(entity):
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"]