+ {domain} is now supported
+
+ You asked us to support {domain}. It is live
+ now, so Caramel will find and test coupon codes for you the next
+ time you check out there.
+
+
+ See {domain} codes
+
+
+
+ Thanks for the suggestion.
+
+ )
+}
diff --git a/apps/caramel-app/prisma/migrations/20260908140000_site_suggestions_status_and_notice/migration.sql b/apps/caramel-app/prisma/migrations/20260908140000_site_suggestions_status_and_notice/migration.sql
new file mode 100644
index 00000000..59018739
--- /dev/null
+++ b/apps/caramel-app/prisma/migrations/20260908140000_site_suggestions_status_and_notice/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "public"."site_suggestions" ADD COLUMN "notified_at" TIMESTAMP(3),
+ADD COLUMN "status_changed_at" TIMESTAMP(3);
diff --git a/apps/caramel-app/prisma/schema.prisma b/apps/caramel-app/prisma/schema.prisma
index f5942e61..ba0cf1ff 100644
--- a/apps/caramel-app/prisma/schema.prisma
+++ b/apps/caramel-app/prisma/schema.prisma
@@ -358,25 +358,47 @@ model SavingsEvent {
/// (api/account/data/delete) does not yet scrub `requester_email` from these
/// rows — a later PR adds it to that transaction.
///
-/// `status` is an OPEN string, `new` by default; the ingest routes' zod schemas
-/// are the single place the `new` | `imported` vocabulary is declared.
+/// `status` is an OPEN string, `new` by default; src/lib/siteSuggestions.ts is
+/// the single place the vocabulary (`new` | `imported` | `rejected` |
+/// `unsupported` | `supported`) AND the allowed transitions are declared. It is
+/// a string, not a Prisma enum, so a later status is a one-line change there
+/// and never a migration. The ack endpoint stamps `statusChangedAt` on every
+/// transition; `supported` is the one that matters to the requester, and
+/// `notifiedAt` records that the "your store is now supported" notice went out
+/// (once per requester email per domain — a duplicate suggestion is stamped
+/// alongside the one that was actually mailed).
+///
+/// A `supported` row WITH a `requesterEmail` and a NULL `notifiedAt` is a
+/// notice AWAITING THE OWNER'S DECISION. Nothing mails a requester on its own
+/// unless SITE_SUGGESTIONS_AUTO_NOTIFY is on; otherwise the notice goes out
+/// only when POST /api/ingest/site-suggestions/notify names the row.
model SiteSuggestion {
- id String @id @default(cuid())
- domain String
- rawUrl String @map("raw_url")
- userId String? @map("user_id")
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
- requesterEmail String? @map("requester_email")
+ id String @id @default(cuid())
+ domain String
+ rawUrl String @map("raw_url")
+ userId String? @map("user_id")
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+ requesterEmail String? @map("requester_email")
// Which surface asked: 'web' (the /supported-stores form) or 'extension'
// (reserved — the extension has no suggest form today).
- source String
- userAgent String? @map("user_agent")
- status String @default("new")
- createdAt DateTime @default(now()) @map("created_at")
+ source String
+ userAgent String? @map("user_agent")
+ status String @default("new")
+ createdAt DateTime @default(now()) @map("created_at")
// Stamped by the ack endpoint when the pipeline confirms it imported the row.
- importedAt DateTime? @map("imported_at")
+ importedAt DateTime? @map("imported_at")
+ // Stamped on EVERY status transition (imported, rejected, unsupported,
+ // supported); importedAt is the legacy stamp for the first one only.
+ statusChangedAt DateTime? @map("status_changed_at")
+ // When the requester was told this store is supported. NULL on a `supported`
+ // row that carries an email = the notice is still pending a decision.
+ notifiedAt DateTime? @map("notified_at")
@@index([status, createdAt])
+ // Also the "has this person already been told about this store?" lookup the
+ // notifier runs before every send (`domain IN (...)`, then the requester
+ // email is folded and matched in memory over the handful of rows a store
+ // has) — one mail per requester email per domain.
@@index([domain])
@@map("site_suggestions")
}
diff --git a/apps/caramel-app/src/app/api/ingest/site-suggestions/ack/route.ts b/apps/caramel-app/src/app/api/ingest/site-suggestions/ack/route.ts
index 46691739..0b61ed6f 100644
--- a/apps/caramel-app/src/app/api/ingest/site-suggestions/ack/route.ts
+++ b/apps/caramel-app/src/app/api/ingest/site-suggestions/ack/route.ts
@@ -1,21 +1,50 @@
import { withRoute } from '@/lib/api/withRoute'
import {
- acknowledgeSiteSuggestions,
SiteSuggestionAckBodySchema,
+ transitionSiteSuggestions,
} from '@/lib/siteSuggestions'
import { NextResponse } from 'next/server'
-// POST /api/ingest/site-suggestions/ack — the pipeline confirms it imported the
-// suggestions it read from GET /api/ingest/site-suggestions, which flips them
-// `new` -> `imported` (and stamps imported_at) so the next drain does not hand
-// them over again. Same bearer + server-to-server posture as the GET.
+// POST /api/ingest/site-suggestions/ack — how a suggestion is ANSWERED.
+//
+// Originally (PR #225) the one answer was "imported": the pipeline confirming
+// it had taken the rows it read from GET /api/ingest/site-suggestions. That is
+// still the default and still behaves identically, but a hand-over is not an
+// answer to the person who asked — three more are:
+//
+// rejected the URL does not name a store; there is nothing to support
+// unsupported we tried and cannot support it
+// supported the store is live — the one the requester cares about
+//
+// Same bearer + server-to-server posture as the GET.
//
// Cross-repo contract (consumed verbatim by caramel-coupons):
-// body { ids: string[] } (1..1000 ids from the GET)
-// 200 { ok: true, acknowledged: n } n = rows that were `new` and flipped
-// Idempotent: an id already past `new` is untouched and not counted, so a
-// retried ack cannot drag a row backwards. 401 without the bearer; 422 on a
-// bad body.
+// body { ids: string[], status?: 'imported'|'rejected'|'unsupported'|'supported' }
+// `status` absent = 'imported', so the existing `{ids}`-only caller is
+// untouched.
+// 200 {
+// ok: true,
+// status, the status that was applied
+// acknowledged: n, = changed.length (the legacy key, kept)
+// changed: string[], ids that really moved on THIS call
+// refused: [{ id, from, reason }],
+// reason: not_found | already | backwards | raced
+// notified: { sent, pending, failed },
+// opsNotified: boolean|null
+// }
+// 401 without the bearer; 422 on a bad body.
+//
+// The rule behind `refused`/`backwards`: a CLOSED row (rejected, unsupported,
+// supported) can never be dragged back to `imported`. A re-drain of stale ids
+// would otherwise re-open every store we had already decided about, and would
+// send `supported` rows round the loop again to re-notify their requesters.
+// Terminal-to-terminal IS allowed — `unsupported` becoming `supported` later is
+// the ordinary happy path.
+//
+// NOTHING here mails a requester unless SITE_SUGGESTIONS_AUTO_NOTIFY is on
+// (default OFF). With it off a `supported` ack only MARKS the rows and reports
+// how many people are waiting; the send is an explicit act — POST
+// /api/ingest/site-suggestions/notify.
export const POST = withRoute(
{
method: 'POST',
@@ -24,7 +53,22 @@ export const POST = withRoute(
body: SiteSuggestionAckBodySchema,
},
async ({ body }) => {
- const result = await acknowledgeSiteSuggestions(body.ids)
- return NextResponse.json({ ok: true, ...result })
+ const result = await transitionSiteSuggestions(body.ids, body.status)
+ return NextResponse.json({
+ ok: true,
+ status: result.status,
+ // The pre-#226 key, preserved: the count of rows that actually
+ // moved. Dropping or redefining it would break the shipped
+ // caramel-coupons caller for no gain.
+ acknowledged: result.changed.length,
+ changed: result.changed,
+ refused: result.refused,
+ notified: {
+ sent: result.notifiedSent,
+ pending: result.notifiedPending,
+ failed: result.notifiedFailed,
+ },
+ opsNotified: result.opsNotified,
+ })
},
)
diff --git a/apps/caramel-app/src/app/api/ingest/site-suggestions/notify/route.ts b/apps/caramel-app/src/app/api/ingest/site-suggestions/notify/route.ts
new file mode 100644
index 00000000..fc78b878
--- /dev/null
+++ b/apps/caramel-app/src/app/api/ingest/site-suggestions/notify/route.ts
@@ -0,0 +1,52 @@
+import { withRoute } from '@/lib/api/withRoute'
+import {
+ notifySupportedRequesters,
+ SiteSuggestionNotifyBodySchema,
+} from '@/lib/siteSuggestions'
+import { NextResponse } from 'next/server'
+
+// POST /api/ingest/site-suggestions/notify — the OWNER'S SEND.
+//
+// The product rule this route exists for: nothing emails a person who asked for
+// a store until somebody decides to. The ack endpoint only MARKS a row
+// `supported`; this is the deliberate act that turns a mark into mail, and it
+// works whether or not SITE_SUGGESTIONS_AUTO_NOTIFY is on — the switch decides
+// whether the ACK may send on its own, never whether the owner may.
+//
+// Same `ingest` bearer as its siblings: the operator drives it with the same
+// credential (the exact curl is printed in the ops notice every `supported` ack
+// sends, with the pending ids already filled in).
+//
+// Cross-repo contract:
+// body { ids: string[] }
+// 200 {
+// ok: true,
+// sent: n, alreadyNotified: n, notEligible: n, failed: n,
+// results: [{ id, outcome, reason? }]
+// outcome: sent | already_notified | not_eligible | failed
+// }
+// 401 without the bearer; 422 on a bad body.
+//
+// Eligible = the row is `supported`, carries a requester email, and has never
+// been notified. Everything else is REPORTED with a reason rather than quietly
+// skipped, so "nothing happened" can always be told apart from "nothing needed
+// to happen".
+//
+// IDEMPOTENT by claim, not by check: `notified_at` is stamped before the mail
+// goes out, so a second call — or two concurrent ones — cannot re-send. One
+// mail per requester email per domain, however many times that person suggested
+// the store; the duplicate rows are stamped alongside the one that was mailed.
+// A send that FAILS releases its claim and is reported `failed`, so the notice
+// stays pending and this same command retries it.
+export const POST = withRoute(
+ {
+ method: 'POST',
+ routeName: 'ingest/site-suggestions/notify',
+ apiKey: 'ingest',
+ body: SiteSuggestionNotifyBodySchema,
+ },
+ async ({ body }) => {
+ const summary = await notifySupportedRequesters(body.ids)
+ return NextResponse.json({ ok: true, ...summary })
+ },
+)
diff --git a/apps/caramel-app/src/app/api/sites/suggest/route.ts b/apps/caramel-app/src/app/api/sites/suggest/route.ts
index 826869d8..ab68b79f 100644
--- a/apps/caramel-app/src/app/api/sites/suggest/route.ts
+++ b/apps/caramel-app/src/app/api/sites/suggest/route.ts
@@ -1,5 +1,6 @@
import { withRoute } from '@/lib/api/withRoute'
import { sendEmail } from '@/lib/email'
+import { SITE_SUGGESTION_OPS_EMAIL } from '@/lib/siteSuggestionNotices'
import {
normalizeSuggestedDomain,
recordSiteSuggestion,
@@ -77,14 +78,13 @@ export const POST = withRoute(
try {
// First line kept VERBATIM ("A user suggested a new site: