Skip to content

feat(suggestions): supported/unsupported ack statuses + gated requester notice - #226

Open
BSalaeddin wants to merge 1 commit into
feat/site-suggestions-capture-requesterfrom
feat/site-suggestions-status-and-requester-notice
Open

BSalaeddin wants to merge 1 commit into
feat/site-suggestions-capture-requesterfrom
feat/site-suggestions-status-and-requester-notice

Conversation

@BSalaeddin

Copy link
Copy Markdown
Collaborator

Stacked on #225 (feat/site-suggestions-capture-requester) — review that one first; this PR's base is that branch, not dev.

#225 made a "please support this store" request answerable: it recorded WHO asked and gave the coupons pipeline a read/ack door. This PR is the answer itself, and the notice that closes the loop with the person who asked.

The product rule

Nothing emails a user automatically until the owner decides. The ack endpoint is driven by an automated pipeline, so it only ever MARKS a row. Turning a mark into mail is an explicit act.

1. Statuses on the ack

POST /api/ingest/site-suggestions/ack body is now { ids, status? }, status defaulting to imported.

status meaning
imported the pipeline took the row (the original, unchanged behaviour)
rejected the URL does not name a store; nothing to support
unsupported we tried and cannot support it
supported the store is live — the one the requester cares about

A closed row (rejected/unsupported/supported) can never be dragged back to imported. Without that rule, a re-drain of stale ids would re-open every store we had already decided about, and would send supported rows round the loop to re-notify their requesters. Terminal-to-terminal IS allowed and is a real operator path (unsupported later becoming supported).

The guard lives in the UPDATE's own WHERE clause (one updateManyAndReturn), so it is enforced by Postgres rather than by the read that preceded it; a row someone else moved in between is reported raced, never counted as changed. The pre-read exists only to tell the refusal reasons apart — a plain count could not say whether an id was unknown, already there, or being dragged backwards.

status_changed_at is stamped on every transition. imported_at stays the legacy stamp for the FIRST hand-over only, so a later answer cannot rewrite the day the pipeline took the row.

2. The requester notice, gated

SITE_SUGGESTIONS_AUTO_NOTIFYdefault false, a strict 'true' | 'false' enum (a typo fails boot with a named variable rather than being guessed at). With it off, a supported ack marks the rows, leaves notified_at NULL, and reports the pending count.

POST /api/ingest/site-suggestions/notify with { ids } is the owner's send. It works regardless of the switch — the switch gates the ACK, never the owner.

Idempotency is a claim, not a check: notified_at is stamped BEFORE the mail goes out under a notifiedAt: null guard, so two callers racing the same row cannot both send. A send that FAILS releases its claim and is reported failed, so the notice stays visibly pending and the same command retries it — a row saying the person was told when nobody told them would bury the notice forever, because nothing re-reads a stamped row.

One mail per requester email per domain, case-folded, across calls. Duplicate suggestions are stamped with the instant the person was ACTUALLY told, not with now.

3. Ops notice on supported

One email to the same inbox as the suggestion mail (now ONE shared constant, so the two cannot drift apart on a deploy-env edit), listing the domains, requesters with an email, how many were auto-notified, how many await a decision, and the exact command with the pending ids already filled in.

Contract for caramel-coupons

The shipped {ids}-only caller (coupons PR #126) is untouched: default imported, only new rows move, acknowledged still the count that really flipped. New response keys are additive.

POST /api/ingest/site-suggestions/ack     Authorization: Bearer $INGEST_API_KEY
  body { "ids": ["..."], "status": "imported" | "rejected" | "unsupported" | "supported" }
  200  { "ok": true, "status": "supported", "acknowledged": 2,
         "changed": ["id1","id2"],
         "refused": [{ "id": "id3", "from": "supported", "reason": "backwards" }],
         "notified": { "sent": 0, "pending": 2, "failed": 0 },
         "opsNotified": true }
  refused reason: not_found | already | backwards | raced

POST /api/ingest/site-suggestions/notify  Authorization: Bearer $INGEST_API_KEY
  body { "ids": ["..."] }
  200  { "ok": true, "sent": 1, "alreadyNotified": 0, "notEligible": 0, "failed": 0,
         "results": [{ "id": "id1", "outcome": "sent" }] }
  outcome: sent | already_notified | not_eligible | failed   (reason on the last two)

Verification

gate result
tsc --noEmit clean
unit (vitest run) 770 passed
extension unit 894 passed
integration (real Postgres) 38 passed, 10 in this file
eslint / oxlint / prettier / knip clean
prisma migrate reset from empty full chain replays, columns present

Red-proofed in four arms, each reverting one half of the change:

arm result
closed-row guard removed reds exactly the backwards PAIR
switch read as a truthiness default-ON reds exactly the not-permission pin
already-told sibling lookup removed reds exactly the one-mail-per-domain pin
failed send keeps its claim reds exactly the 2 release/retry pins

The in-memory table under the unit suite is an announced fake that really evaluates the where/select the lib sends and throws on an operator it does not implement, so a wrong WHERE clause cannot pass. Schema facts (the columns, the guard as a real database predicate, the claim serialising a send) are pinned on real Postgres.

Deliberately not here

  • No third notify_decision column: a supported row with an email and a NULL notified_at IS the pending decision.
  • No extra index. The existing domain index serves the "already told?" lookup, and adding one would have meant editing a migration already applied on developer machines.
  • TODO(#225): the danger-zone "Delete my data" route still does not scrub requester_email from these rows — carried over, not addressed here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant