diff --git a/app/app.py b/app/app.py index faa02ad..f2ab38e 100644 --- a/app/app.py +++ b/app/app.py @@ -578,8 +578,12 @@ def sheet_append(rec): sheet.append_row(SHEET_HEADERS) elif len(vals[0]) == 7: sheet.update_cell(1, 8, "Comments") + # The submission time, not the time of the write. A replay months later must + # not relabel a lead as arriving today. + stamp = (rec.get("ts") or "")[:16].replace("T", " ") \ + or datetime.datetime.now().strftime("%Y-%m-%d %H:%M") sheet.append_row([ - datetime.datetime.now().strftime("%Y-%m-%d %H:%M"), + stamp, rec["parent_name"], rec["email"], rec["phone"], rec["children"], rec["interested_in"], rec["source"], rec["message"], ]) @@ -653,6 +657,12 @@ def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = message=rec["message"], payload=rec, submitted_at=rec["ts"]) except Exception as e: print("STORE WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True) + # Register every mirror as pending BEFORE attempting it. If the process dies + # or a call never runs, the gap is visible and replayable instead of the row + # simply having no mirror record at all. + if record_id: + for _t in store.TARGETS: + store.set_mirror_pending(record_id, _t) sheet_error = None try: sheet_append(rec) diff --git a/app/store.py b/app/store.py index b59947c..5d8a0c2 100644 --- a/app/store.py +++ b/app/store.py @@ -148,6 +148,28 @@ def insert_lead(parent_name, email, phone=None, interested_in=None, children=Non return rid +def set_mirror_pending(record_id, target): + """Register a mirror as owed, before it is attempted. + + Without this a mirror that never ran leaves no row at all, which reads + identically to a mirror that was never owed. That is how two leads went + missing from the Sheet on 2026-08-26 with a clean `failed_mirrors: {}`. + Never downgrades an existing row - a mirror already ok or failed has been + attempted, and its outcome stands. + """ + con = connect() + try: + con.execute( + "INSERT INTO mirrors (record_id, target, state, attempts, last_attempt_at)" + " VALUES (?,?,'pending',0,NULL)" + " ON CONFLICT(record_id, target) DO NOTHING", + (record_id, target), + ) + con.commit() + finally: + con.close() + + def set_mirror(record_id, target, ok, error=None): """Record the outcome of an attempt to copy a row to an external target.""" ts = _now() @@ -241,17 +263,26 @@ def summary(): " ORDER BY c DESC")}, "failed_mirrors": {r["target"]: r["c"] for r in con.execute( "SELECT target, COUNT(*) c FROM mirrors WHERE state='failed' GROUP BY target")}, + "pending_mirrors": {r["target"]: r["c"] for r in con.execute( + "SELECT target, COUNT(*) c FROM mirrors WHERE state='pending' GROUP BY target")}, } finally: con.close() def failed_mirror_records(target, limit=50): + """Rows owing a copy to `target`: attempted and failed, or never attempted. + + `pending` is included deliberately. A mirror that never ran needs replaying + just as much as one that ran and failed, and it is the case that hid two + leads on 2026-08-26. + """ con = connect() try: return [_row(r) for r in con.execute( "SELECT l.* FROM join_leads l JOIN mirrors m ON m.record_id=l.id" - " WHERE m.target=? AND m.state='failed' ORDER BY l.submitted_at LIMIT ?", + " WHERE m.target=? AND m.state IN ('failed','pending')" + " ORDER BY l.submitted_at LIMIT ?", (target, limit))] finally: con.close()