From ea22ed53e7ba253b6c2a6e193246f010f81b864f Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 19:53:08 -0400 Subject: [PATCH] fb_posts: past its publish time, a scheduled post reads as published Facebook does not call back and the reconciliation was never built, so a row that stayed scheduled forever was a lie of omission. Decided by Mike: once the time passes with nothing else reported, the post is published. status keeps what the publisher last said; state is what a reader acts on, and the open-only list and cancel use state. Cancel on a post whose time has passed says so. --- app/admin_api.py | 5 +++-- app/store.py | 31 +++++++++++++++++++++++++------ tests/smoke_admin.py | 8 ++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/admin_api.py b/app/admin_api.py index e0983b3..bcb42fd 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -1025,8 +1025,9 @@ def cancel_fb_post(request: Request, pid: str, x_admin_token: str = Header(None) rec = store.get_fb_post(pid) if not rec: raise HTTPException(404, "no such post") - if rec["status"] not in ("scheduled",): - raise HTTPException(409, "post is %s, not scheduled" % rec["status"]) + if rec["state"] != "scheduled": + raise HTTPException(409, "post is %s, not scheduled" % rec["state"] + + (" (its publish time has passed)" if rec["status"] == "scheduled" else "")) if not rec.get("cancel_url"): raise HTTPException(409, "no cancel link was recorded for this post") import urllib.request as _ur, urllib.error as _ue diff --git a/app/store.py b/app/store.py index 48b5030..4d84a1b 100644 --- a/app/store.py +++ b/app/store.py @@ -1191,25 +1191,44 @@ def _image_size(data, mime): return None, None +def fb_state(rec, now=None): + """The effective state. A scheduled post whose publish time has passed is + published unless something said otherwise: Facebook does not call back, + and a row that stays 'scheduled' forever is a lie of omission. Decided + by Mike 2026-09-04. `status` stays what was last reported; `state` is + what a reader should act on.""" + st = rec.get("status") + if st == "scheduled" and rec.get("scheduled_for") and rec["scheduled_for"] <= (now or _now()): + return "published" + return st + + def list_fb_posts(limit=100, include_done=True): con = connect() try: - sql = "SELECT * FROM fb_posts" - if not include_done: - sql += " WHERE status IN ('drafted','scheduled','handed_off')" - sql += " ORDER BY COALESCE(scheduled_for, updated_at) DESC LIMIT ?" - return [dict(r) for r in con.execute(sql, (max(1, min(int(limit), 500)),))] + rows = [dict(r) for r in con.execute( + "SELECT * FROM fb_posts ORDER BY COALESCE(scheduled_for, updated_at) DESC LIMIT ?", + (max(1, min(int(limit), 500)),))] finally: con.close() + now = _now() + for r in rows: + r["state"] = fb_state(r, now) + if not include_done: + rows = [r for r in rows if r["state"] in ("drafted", "scheduled", "handed_off")] + return rows def get_fb_post(pid): con = connect() try: r = con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone() - return dict(r) if r else None finally: con.close() + if not r: + return None + d = dict(r); d["state"] = fb_state(d) + return d def mark_fb_cancelled(pid, by): diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 194e465..3cf140e 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -402,6 +402,14 @@ check("cancel recorded", c["status"] == "cancelled" and c["cancelled_by"] == "mi and not any(p["id"] == c["id"] for p in S.list_fb_posts(include_done=False))) r3 = S.upsert_fb_post({"id": "troop-73/2026-09-12-canoe", "status": "cancelled", "cancelled_by": "ntfy button"}) check("publisher-reported cancel stamps cancelled_at", r3["cancelled_at"] and r3["cancelled_by"] == "ntfy button") +past = S.upsert_fb_post({"id": "pack-73/2020-01-01-old", "status": "scheduled", "scheduled_for": "2020-01-01T12:00:00+00:00"}) +future = S.upsert_fb_post({"id": "pack-73/2036-01-01-new", "status": "scheduled", "scheduled_for": "2036-01-01T12:00:00+00:00"}) +check("a scheduled post past its time reads as published; a future one stays scheduled", + S.get_fb_post("pack-73/2020-01-01-old")["state"] == "published" and S.get_fb_post("pack-73/2036-01-01-new")["state"] == "scheduled" + and S.get_fb_post("pack-73/2020-01-01-old")["status"] == "scheduled") +check("open-only list drops the past one and keeps the future one", + [p["id"] for p in S.list_fb_posts(include_done=False) if p["id"].startswith("pack-73/20")] == ["pack-73/2036-01-01-new"]) +check("cancelled beats the clock", S.fb_state({"status": "cancelled", "scheduled_for": "2020-01-01T00:00:00+00:00"}) == "cancelled") check("fbposts caps: read for leaders, ingest admin-only and scopable", "fbposts:read" in I.CAPS["leader"] and "fbposts:ingest" in I.CAPS["admin"] and "fbposts:ingest" not in I.KEY_UNSCOPABLE) con = S.connect(); con.execute("DELETE FROM fb_posts"); con.commit(); con.close()