Publish milestones per channel and add Instagram

Consent is bound to the channel it was given for: approving a post on X says
nothing about Instagram. Publishing state moves from the SocialMilestone row
into SocialMilestonePost, one row per channel, where a missing row means no
consent. The dialog asks per channel, shows the text each one will publish and
keeps a separate handle for each; Instagram captions end in hashtags because a
link there is not clickable.

Also fixes three problems in the existing X path:

- A QR code already past several thresholds produced one prompt per threshold,
  and since the post quotes the current scan count, every one of them would
  have published the same number. Only the highest threshold is announced now.
- Detection ran after every unique scan and re-read the QR code's full scan
  history just to hit skipDuplicates. Known milestones are filtered first.
- A failed post stayed failed forever because the consent dialog only opens
  once. The queue now retries three times on its own, spaces first attempts by
  SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per
  channel with restart and revoke.

The worker no longer renders the card itself; it downloads the image the app
renders at /s/m/<token>/og, which also serves the new square and portrait
formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and
SOCIAL_WORKER_CHANNELS both name it.

Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before
deploying this version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:46:13 +02:00
parent 55c04761ce
commit eb932ebdaa
22 changed files with 1159 additions and 298 deletions

View File

@@ -0,0 +1,42 @@
-- Per-channel publishing for milestone posts. Run once against the target
-- database BEFORE deploying the application version that uses this table.
--
-- Consent is channel-bound: agreeing to a post on X says nothing about
-- Instagram - different audience, different disclosure. Publishing state
-- therefore moves out of the SocialMilestone row into one row per channel.
-- The old "brand*" columns stay in place as a fallback and are backfilled
-- below; nothing reads them any more.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "instagramHandle" TEXT;
CREATE TABLE IF NOT EXISTS "SocialMilestonePost" (
"id" TEXT PRIMARY KEY,
"milestoneId" TEXT NOT NULL REFERENCES "SocialMilestone"("id") ON DELETE CASCADE,
"channel" TEXT NOT NULL, -- 'x' | 'instagram'
"status" TEXT NOT NULL DEFAULT 'approved', -- approved | processing | posted | failed | revoked
-- The text the customer read before consenting. Published verbatim.
"consentText" TEXT NOT NULL,
"handle" TEXT,
"approvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"postedAt" TIMESTAMP(3),
"postUrl" TEXT,
"error" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3),
-- A row exists only where consent exists. No row means: not approved.
CONSTRAINT "SocialMilestonePost_milestone_channel_key" UNIQUE ("milestoneId", "channel")
);
CREATE INDEX IF NOT EXISTS "SocialMilestonePost_channel_status_approvedAt_idx"
ON "SocialMilestonePost" ("channel", "status", "approvedAt");
-- Existing X consents keep working. `processing` becomes `approved` again: the
-- publisher reconciles against the timeline before it posts, so a re-claim
-- cannot duplicate a post that already went out.
INSERT INTO "SocialMilestonePost" ("id", "milestoneId", "channel", "status", "consentText", "approvedAt", "postedAt", "postUrl", "error", "attempts")
SELECT gen_random_uuid()::text, "id", 'x',
CASE WHEN "brandStatus" = 'processing' THEN 'approved' ELSE "brandStatus" END,
"consentText", COALESCE("brandApprovedAt", "detectedAt"), "brandPostedAt", "brandPostUrl", "brandPostError", "attempts"
FROM "SocialMilestone"
WHERE "consentText" IS NOT NULL AND "brandStatus" IN ('approved', 'processing', 'posted', 'failed', 'revoked')
ON CONFLICT ("milestoneId", "channel") DO NOTHING;

View File

@@ -0,0 +1,15 @@
-- Milestone publishing retries. Run once against the target database before
-- deploying the application version that uses these columns.
--
-- docker-compose exec db psql -U postgres -d qrmaster -f - < sql/2026-08-16_social_milestone_retries.sql
--
-- A failed X post used to stay failed forever: the consent dialog only opens
-- for freshly detected milestones, so nobody ever saw the retry button again.
-- The publisher now re-queues a failed attempt on its own until it runs out of
-- attempts, and the customer can retry a permanently failed post from Settings.
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "nextAttemptAt" TIMESTAMP(3);
-- The publisher polls for the oldest approved milestone that is due.
CREATE INDEX IF NOT EXISTS "SocialMilestone_brandStatus_brandApprovedAt_idx"
ON "SocialMilestone" ("brandStatus", "brandApprovedAt");