diff --git a/apps/caramel-app/.env.example b/apps/caramel-app/.env.example index b67c8f8d..50d611fa 100644 --- a/apps/caramel-app/.env.example +++ b/apps/caramel-app/.env.example @@ -67,6 +67,12 @@ USESEND_FROM_NAME=Caramel SUPPORT_EMAIL_TO=support-team@example.com,second-operator@example.com # OpenRouter (extension cart classifier) +# Does a `supported` site-suggestion ack mail the requester automatically? +# 'false' (the default) = the app only MARKS the row; the notice is sent by an +# explicit POST /api/ingest/site-suggestions/notify. Only 'true' or 'false' — +# anything else fails boot rather than being guessed at. +SITE_SUGGESTIONS_AUTO_NOTIFY=false + OPENROUTER_API_KEY= OPENROUTER_MODEL=openai/gpt-5-mini diff --git a/apps/caramel-app/emails/StoreSupportedTemplate.tsx b/apps/caramel-app/emails/StoreSupportedTemplate.tsx new file mode 100644 index 00000000..e4822a17 --- /dev/null +++ b/apps/caramel-app/emails/StoreSupportedTemplate.tsx @@ -0,0 +1,40 @@ +import EmailLayout, { EmailButton, text } from './EmailLayout' + +interface StoreSupportedEmailProps { + /** Bare host of the store the person asked for, e.g. `worldofbooks.com`. */ + domain: string + /** Absolute URL of that store's page on the site. Built by the caller from + * BASE_URL — never assembled here, so the text part and this HTML part can + * never point at different places. */ + storeUrl: string +} + +/** + * "The store you asked for is now supported." + * + * The ONLY mail this product sends to someone because of a site suggestion, and + * it is sent at most once per requester email per domain. Short and plain on + * purpose: they asked a question months ago and this is the answer, not a + * campaign — no offers, no cross-sell, no other stores. + */ +export default function StoreSupportedTemplate({ + domain, + storeUrl, +}: StoreSupportedEmailProps) { + return ( + +

{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: ") — // the coupons repo's manual import mines this mail by that phrase. - // aladdin@devino.ca, NOT support@unotes.net: the old recipient was - // a copy-paste from another project whose mailbox bounces, so every - // visitor suggestion was silently lost (a real one bounced - // 2026-08-08 06:33 UTC and had to be recovered from the UseSend - // event log). Deliberately NOT SUPPORT_EMAIL_TO: the pipeline's - // manual import mines THIS inbox for the subject line. + // ONE constant for the ops inbox (siteSuggestionNotices.ts), which + // also carries the reasons it is neither support@unotes.net nor + // SUPPORT_EMAIL_TO. Shared with the "store went live" notice on + // purpose: an operator who reads one of the two and not the other + // cannot close the loop on a request. await sendEmail({ - to: 'aladdin@devino.ca', + to: SITE_SUGGESTION_OPS_EMAIL, subject: 'Caramel Site Suggestion', text: [ `A user suggested a new site: ${body.url}`, diff --git a/apps/caramel-app/src/lib/env.ts b/apps/caramel-app/src/lib/env.ts index f82299f6..88dca9d2 100644 --- a/apps/caramel-app/src/lib/env.ts +++ b/apps/caramel-app/src/lib/env.ts @@ -87,6 +87,19 @@ const serverObjectSchema = z.object({ // requests become MCP-readable with per-app permission delegation, instead // of landing in personal mailboxes. SUPPORT_EMAIL_TO: z.string().default('aladdin@devino.ca'), + // Does a `supported` ack MAIL the requester on its own? + // + // OFF by default, and the default is the whole point: the ack endpoint is + // driven by an automated pipeline, so leaving this on would let a machine + // decide that a stranger gets mail. With it off the app only MARKS the row + // (`status='supported'`, `notified_at` left NULL) and reports the pending + // count in the ops notice; the send is an explicit act — POST + // /api/ingest/site-suggestions/notify. + // + // A strict two-value enum, not a truthiness check: `SITE_SUGGESTIONS_AUTO_NOTIFY=yes` + // must fail at boot with a named variable, never be read as "off" (a silent + // downgrade) or as "on" (mail nobody asked for). + SITE_SUGGESTIONS_AUTO_NOTIFY: z.enum(['true', 'false']).default('false'), OPENROUTER_API_KEY: z.string().optional(), OPENROUTER_MODEL: z.string().default('openai/gpt-5-mini'), API_ENCRYPTION_ENABLED: z.string().optional(), diff --git a/apps/caramel-app/src/lib/siteSuggestionNotices.ts b/apps/caramel-app/src/lib/siteSuggestionNotices.ts new file mode 100644 index 00000000..49666ba6 --- /dev/null +++ b/apps/caramel-app/src/lib/siteSuggestionNotices.ts @@ -0,0 +1,154 @@ +// src/lib/siteSuggestionNotices.ts +// +// The two emails a site suggestion can produce once the store becomes +// supported: the REQUESTER notice ("the store you asked for is live") and the +// OPS notice ("these domains flipped; N people are waiting to be told"). +// +// Composition and sending only — this module never touches prisma. The table +// and its lifecycle stay in src/lib/siteSuggestions.ts (the one home), which +// calls in here; keeping the split means a mail change can never quietly change +// what a row says, and a row change can never quietly change what is mailed. +import StoreSupportedTemplate from '@/emails/StoreSupportedTemplate' +import { sendEmail } from '@/lib/email' +import { BASE_URL } from '@/lib/env.client' +import { render } from '@react-email/render' + +/** + * Where operator mail about site suggestions goes. + * + * aladdin@devino.ca, NOT support@unotes.net: the old recipient was a + * copy-paste from another project whose mailbox bounces, so every visitor + * suggestion was silently lost (a real one bounced 2026-08-08 06:33 UTC and had + * to be recovered from the UseSend event log). Deliberately NOT + * env.SUPPORT_EMAIL_TO either — the manual import flow mines THIS inbox for the + * suggestion subject line, so the two must not drift apart on a deploy-env edit. + * + * One constant, because the "a store went live" notice must land in the same + * inbox as the "someone asked for a store" notice: an operator reading one and + * not the other cannot close the loop. + */ +export const SITE_SUGGESTION_OPS_EMAIL = 'aladdin@devino.ca' + +/** The store's own page — the one link the requester notice carries. It renders + * for any host (only an EMPTY slug 404s), so this can never be a dead link. */ +export function storePageUrl(domain: string): string { + return `${BASE_URL}/coupons/${encodeURIComponent(domain)}` +} + +/** Subject line of the requester notice. Exported so the tests and the ops mail + * can quote the real thing rather than a copy that drifts. */ +export function storeSupportedSubject(domain: string): string { + return `${domain} is now supported in Caramel` +} + +/** + * Mail ONE requester that the store they asked for is supported. + * + * Throws on a send failure — deliberately. The caller stamps `notified_at` + * BEFORE calling (so two concurrent notifiers cannot both send), and it needs + * the throw to know it must un-stamp: a swallowed failure here would leave a + * row that says the person was told when nobody told them, and nothing would + * ever try again. + */ +export async function sendStoreSupportedNotice(input: { + to: string + domain: string +}): Promise { + const { to, domain } = input + const storeUrl = storePageUrl(domain) + // Both body parts, always. A mail client that drops the HTML part must + // still receive a readable message, and the URL is built once so the two + // parts can never point at different places. + const html = await render(StoreSupportedTemplate({ domain, storeUrl })) + const lines = [ + `${domain} is now supported in Caramel.`, + '', + `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: ${storeUrl}`, + '', + 'Thanks for the suggestion.', + ] + await sendEmail({ + to, + subject: storeSupportedSubject(domain), + html, + text: lines.join('\n'), + }) +} + +export interface SupportedOpsNoticeInput { + /** Domains this ack flipped to `supported`, in the order they were named. */ + domains: string[] + /** Rows that flipped and carry a requester email. */ + requesters: number + /** Requesters actually mailed by this ack (0 when the switch is off). */ + autoNotified: number + /** Requesters left waiting for the owner's decision. */ + pending: number + /** Suggestion ids of exactly those pending rows — pasted into the command + * below so the operator never has to look one up. */ + pendingIds: string[] + /** Sends this ack attempted and FAILED (the row was un-stamped and stays + * pending). Reported so a failure is never invisible to the operator. */ + failed: number +} + +/** Plain-text ops notice. `sendEmail` turns it into escaped,
-preserving + * HTML, so both body parts carry the same words. */ +export function buildSupportedOpsNoticeText( + input: SupportedOpsNoticeInput, +): string { + const { domains, requesters, autoNotified, pending, pendingIds, failed } = + input + const lines: string[] = [ + `Marked supported: ${domains.join(', ')}`, + '', + `Requesters with an email: ${requesters}`, + `Auto-notified now: ${autoNotified}`, + `Awaiting your decision: ${pending}`, + ] + if (failed > 0) { + lines.push( + `Send FAILED (still pending, safe to retry): ${failed}`, + 'Those rows were left un-notified on purpose — the command below retries them.', + ) + } + if (pendingIds.length > 0) { + lines.push( + '', + 'To send the notice to everyone still waiting, run:', + '', + ` curl -X POST ${BASE_URL}/api/ingest/site-suggestions/notify \\`, + ' -H "Authorization: Bearer $INGEST_API_KEY" \\', + " -H 'Content-Type: application/json' \\", + ` -d '${JSON.stringify({ ids: pendingIds })}'`, + '', + 'It is idempotent: a requester already told about a domain is never', + 'mailed twice, however many times the command runs.', + ) + } else { + lines.push('', 'Nothing is waiting — no notice to send.') + } + return lines.join('\n') +} + +/** + * Tell the operator what an ack just did. Throws on a send failure; the caller + * reports it rather than failing the ack, because the status transitions are + * the system of record and are already committed by the time this runs. + */ +export async function sendSupportedOpsNotice( + input: SupportedOpsNoticeInput, +): Promise { + const subject = + input.domains.length === 1 + ? `Caramel: ${input.domains[0]} marked supported` + : `Caramel: ${input.domains.length} stores marked supported` + await sendEmail({ + to: SITE_SUGGESTION_OPS_EMAIL, + subject, + text: buildSupportedOpsNoticeText(input), + }) +} diff --git a/apps/caramel-app/src/lib/siteSuggestions.ts b/apps/caramel-app/src/lib/siteSuggestions.ts index ccc9c13f..eb20b169 100644 --- a/apps/caramel-app/src/lib/siteSuggestions.ts +++ b/apps/caramel-app/src/lib/siteSuggestions.ts @@ -8,16 +8,89 @@ // // This is app-owned user/ops state, like coupon_reports and favorite_stores — // NOT the coupon catalog (couponsRepo.ts owns that, with its own write rules). +import { env } from '@/lib/env' import prisma from '@/lib/prisma' +import { + sendStoreSupportedNotice, + sendSupportedOpsNotice, +} from '@/lib/siteSuggestionNotices' import { resolveStoreDomain } from '@/lib/storeDomain' import type { Prisma } from '@prisma/client' +import * as Sentry from '@sentry/nextjs' import { z } from 'zod' -// The lifecycle the app and the pipeline agree on. `new` = captured and not yet -// handed over; `imported` = the pipeline acknowledged it (POST .../ack). Kept as -// a const tuple (not a Prisma enum) so a later status ("supported", "rejected") -// is a one-line change here and a data change in the DB, never a migration. -const SITE_SUGGESTION_STATUSES = ['new', 'imported'] as const +// The lifecycle the app and the pipeline agree on. Kept as a const tuple (not a +// Prisma enum) so a later status is a one-line change here and a data change in +// the DB, never a migration. +// +// new captured, not yet handed to the pipeline +// imported the pipeline acknowledged it (POST .../ack) and is working on it +// rejected not a store host — nothing to support, closed +// unsupported we tried and cannot support it, closed +// supported the store is live in the catalog — the one the requester cares +// about, and the only one that can produce a notice +const SITE_SUGGESTION_STATUSES = [ + 'new', + 'imported', + 'rejected', + 'unsupported', + 'supported', +] as const + +export type SiteSuggestionStatus = (typeof SITE_SUGGESTION_STATUSES)[number] + +/** + * Statuses that CLOSE a suggestion: an operator (or the pipeline) has answered + * it, so it must never fall back into the work queue. A transition into + * `imported`/`new` from one of these is the "backwards" move the ack refuses — + * without that rule a re-drain of stale ids would silently re-open every store + * we had already decided about, and `supported` rows would go round again and + * re-notify their requesters. + * + * Terminal-to-terminal IS allowed and is a real operator path: a store we + * marked `unsupported` can later become `supported`, and a `supported` store + * whose config dies can be marked `unsupported` again. + */ +const TERMINAL_STATUSES = new Set([ + 'rejected', + 'unsupported', + 'supported', +]) + +/** The statuses an ack may WRITE. `new` is absent on purpose: it is the state a + * row is born in, never one anything transitions back to. */ +const ACK_TARGET_STATUSES = [ + 'imported', + 'rejected', + 'unsupported', + 'supported', +] as const + +export type SiteSuggestionAckStatus = (typeof ACK_TARGET_STATUSES)[number] + +/** + * Which current statuses may move to `target`. + * + * `imported` accepts ONLY `new` — byte-for-byte the rule the pipeline has had + * since PR #225 (an already-imported id counts 0, a closed row is refused), so + * the existing `{ids}`-only caller behaves exactly as before. + */ +function allowedSourcesFor( + target: SiteSuggestionAckStatus, +): SiteSuggestionStatus[] { + if (target === 'imported') { + // Only a status that is NOT terminal may be handed over. Derived from + // TERMINAL_STATUSES rather than spelled `['new']`, so a fifth closing + // status inherits the no-re-opening rule the day it is added instead of + // silently becoming re-importable. Evaluates to ['new'] today. + return SITE_SUGGESTION_STATUSES.filter( + status => status !== 'imported' && !TERMINAL_STATUSES.has(status), + ) + } + // Any other current status may be answered, including another terminal one + // — except the target itself, which is a no-op rather than a transition. + return SITE_SUGGESTION_STATUSES.filter(status => status !== target) +} /** Where a suggestion was submitted from. The extension has no suggest form * today; `extension` is reserved so a future caller needs no schema change. */ @@ -97,8 +170,20 @@ export const SiteSuggestionListQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(1000).default(500), }) +const SiteSuggestionIdsSchema = z + .array(z.string().min(1).max(64)) + .min(1) + .max(1000) + export const SiteSuggestionAckBodySchema = z.object({ - ids: z.array(z.string().min(1).max(64)).min(1).max(1000), + ids: SiteSuggestionIdsSchema, + // Absent means `imported` — the pipeline's original one-key body + // (`{ids}`, PR #225 / caramel-coupons PR #126) keeps working untouched. + status: z.enum(ACK_TARGET_STATUSES).default('imported'), +}) + +export const SiteSuggestionNotifyBodySchema = z.object({ + ids: SiteSuggestionIdsSchema, }) /** The wire projection the pipeline receives — a subset of the row, camelCase, @@ -150,18 +235,391 @@ export async function listSiteSuggestions( })) } +// --------------------------------------------------------------------------- +// Status transitions (POST /api/ingest/site-suggestions/ack). +// --------------------------------------------------------------------------- + +/** Why an id did NOT move. Reported per id so the caller can tell "I sent a + * stale list" (`already`/`backwards`) from "I sent a wrong id" (`not_found`). */ +export type SiteSuggestionRefusalReason = + | 'not_found' + | 'already' + | 'backwards' + | 'raced' + +export interface SiteSuggestionRefusal { + id: string + /** The status the row is actually in; null when there is no such row. */ + from: string | null + reason: SiteSuggestionRefusalReason +} + +export interface SiteSuggestionTransitionResult { + status: SiteSuggestionAckStatus + /** Ids that really moved into `status` on THIS call. */ + changed: string[] + refused: SiteSuggestionRefusal[] + /** Requester notices this call sent (auto-notify only; 0 when it is off). */ + notifiedSent: number + /** `supported` rows with an email still awaiting the owner's decision. */ + notifiedPending: number + /** Notices this call tried to send and could not. Those rows stay pending. */ + notifiedFailed: number + /** Did the ops notice go out? Null when this transition sends none. */ + opsNotified: boolean | null +} + +/** Is the machine allowed to mail a requester by itself? OFF by default - see + * SITE_SUGGESTIONS_AUTO_NOTIFY in src/lib/env.ts. */ +export function siteSuggestionAutoNotifyEnabled(): boolean { + return env.SITE_SUGGESTIONS_AUTO_NOTIFY === 'true' +} + +/** + * Move the named suggestions into `status`. + * + * The write is ONE guarded `updateManyAndReturn`: the allowed-source list sits + * in the WHERE clause, so the rule is enforced by the database rather than by + * the read that preceded it, and a row someone else moved in between is + * reported as `raced` instead of being 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. + * + * `supported` is the only transition that can produce mail; every other target + * just stamps the row. Neither the requester notice nor the ops notice can fail + * this call: the transitions are the system of record and are committed before + * any mail is attempted, so a send failure is REPORTED (and the row left + * pending) rather than turned into a 500 that would make the pipeline retry a + * transition it has already completed. + */ +export async function transitionSiteSuggestions( + ids: string[], + status: SiteSuggestionAckStatus, +): Promise { + const allowedSources = allowedSourcesFor(status) + const existing = await prisma.siteSuggestion.findMany({ + where: { id: { in: ids } }, + select: { id: true, status: true }, + }) + const currentById = new Map(existing.map(row => [row.id, row.status])) + + const refused: SiteSuggestionRefusal[] = [] + const eligible: string[] = [] + for (const id of ids) { + const current = currentById.get(id) + if (current === undefined) { + refused.push({ id, from: null, reason: 'not_found' }) + } else if (current === status) { + refused.push({ id, from: current, reason: 'already' }) + } else if (!allowedSources.includes(current as SiteSuggestionStatus)) { + // The only way to reach here is a CLOSED row aimed back at + // `imported` - the backwards move the whole rule exists to stop. + refused.push({ id, from: current, reason: 'backwards' }) + } else { + eligible.push(id) + } + } + + const now = new Date() + const moved = + eligible.length === 0 + ? [] + : await prisma.siteSuggestion.updateManyAndReturn({ + where: { + id: { in: eligible }, + status: { in: allowedSources }, + }, + data: { + status, + statusChangedAt: now, + // importedAt is the LEGACY stamp for the first hand-over + // only; every other transition is dated by + // statusChangedAt, so a later ack cannot rewrite the day + // the pipeline first took the row. + ...(status === 'imported' ? { importedAt: now } : {}), + }, + select: { id: true, domain: true, requesterEmail: true }, + }) + const movedIds = new Set(moved.map(row => row.id)) + for (const id of eligible) { + if (!movedIds.has(id)) { + refused.push({ + id, + from: currentById.get(id) ?? null, + reason: 'raced', + }) + } + } + + const result: SiteSuggestionTransitionResult = { + status, + changed: moved.map(row => row.id), + refused, + notifiedSent: 0, + notifiedPending: 0, + notifiedFailed: 0, + opsNotified: null, + } + if (status !== 'supported' || moved.length === 0) return result + + const withEmail = moved.filter(row => Boolean(row.requesterEmail)) + if (siteSuggestionAutoNotifyEnabled()) { + const notice = await notifySupportedRequesters( + withEmail.map(row => row.id), + ) + result.notifiedSent = notice.sent + result.notifiedFailed = notice.failed + } + + // Read the pending set back from the ROWS rather than deriving it from the + // counters above: a failed send releases its claim, and a row someone else + // notified in between is not waiting on us. This is the number the operator + // acts on, so it is measured, not inferred. + const pendingIds = await pendingNoticeIds(moved.map(row => row.id)) + result.notifiedPending = pendingIds.length + + const domains: string[] = [] + for (const row of moved) { + if (!domains.includes(row.domain)) domains.push(row.domain) + } + try { + await sendSupportedOpsNotice({ + domains, + requesters: withEmail.length, + autoNotified: result.notifiedSent, + pending: pendingIds.length, + pendingIds, + failed: result.notifiedFailed, + }) + result.opsNotified = true + } catch (error) { + // The rows ARE marked; only the operator's notification failed. Loud + // (Sentry, with the ids) but never a 500. + result.opsNotified = false + Sentry.captureException(error, { + tags: { + operation: 'site_suggestion_ops_notice', + route: 'ingest/site-suggestions/ack', + }, + extra: { domains, changed: result.changed }, + }) + } + return result +} + +/** Of these rows, which are `supported`, carry an email, and have NOT been + * notified - i.e. exactly the notices a human still has to authorise. */ +async function pendingNoticeIds(ids: string[]): Promise { + if (ids.length === 0) return [] + const rows = await prisma.siteSuggestion.findMany({ + where: { + id: { in: ids }, + status: 'supported', + notifiedAt: null, + NOT: { requesterEmail: null }, + }, + select: { id: true }, + }) + return rows.map(row => row.id) +} + +// --------------------------------------------------------------------------- +// The requester notice (POST /api/ingest/site-suggestions/notify). +// --------------------------------------------------------------------------- + +export type SiteSuggestionNotifyOutcome = + | 'sent' + | 'already_notified' + | 'not_eligible' + | 'failed' + +export interface SiteSuggestionNotifyResult { + id: string + outcome: SiteSuggestionNotifyOutcome + /** Present on `not_eligible` and `failed`; names WHY, so an operator is not + * left guessing which of several refusals they hit. */ + reason?: string +} + +export interface SiteSuggestionNotifySummary { + results: SiteSuggestionNotifyResult[] + sent: number + alreadyNotified: number + notEligible: number + failed: number +} + +/** One mail per requester email per domain, so the key folds case: a person who + * typed `Shopper@x.com` once and `shopper@x.com` the next time is one person, + * and must not be mailed twice about one store. */ +function noticeKey(domain: string, email: string): string { + return `${domain} ${email.trim().toLowerCase()}` +} + /** - * Flip `new` rows to `imported`. ONLY `new` rows move — an id already past - * `new` is left alone, so a pipeline retry (or a stale id list) is idempotent - * and can never drag a row backwards. Returns how many rows actually flipped; - * the caller compares that against what it sent if it cares. + * Send the "your store is supported" notice for exactly these ids. + * + * Eligible = the row is `supported`, carries a requester email, and has never + * been notified. Everything else is REPORTED, never silently skipped. + * + * 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. If the send then fails the claim is RELEASED (back to NULL) + * and the id is reported `failed` - a row that says the person was told when + * nobody told them would bury the notice forever, because nothing ever re-reads + * a stamped row. */ -export async function acknowledgeSiteSuggestions( +export async function notifySupportedRequesters( ids: string[], -): Promise<{ acknowledged: number }> { - const result = await prisma.siteSuggestion.updateMany({ - where: { id: { in: ids }, status: 'new' }, - data: { status: 'imported', importedAt: new Date() }, +): Promise { + const results: SiteSuggestionNotifyResult[] = [] + if (ids.length === 0) return summariseNotifyResults(results) + + const rows = await prisma.siteSuggestion.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + domain: true, + requesterEmail: true, + status: true, + notifiedAt: true, + }, }) - return { acknowledged: result.count } + const byId = new Map(rows.map(row => [row.id, row])) + + // Group the eligible rows by (domain, folded email): several suggestions of + // the same store by the same person are ONE notice, and the duplicates are + // stamped alongside the row that was actually mailed. + const groups = new Map< + string, + { domain: string; email: string; ids: string[] } + >() + // Keys in the order they were first seen. The Map is the lookup; this is + // what the loop below walks, so the output order follows the caller's ids + // and the module never has to iterate a Map (the tsconfig target predates + // downlevel iteration). + const groupKeys: string[] = [] + const domains: string[] = [] + for (const id of ids) { + const row = byId.get(id) + if (!row) { + results.push({ id, outcome: 'not_eligible', reason: 'not_found' }) + continue + } + if (row.status !== 'supported') { + results.push({ + id, + outcome: 'not_eligible', + reason: `status_is_${row.status}`, + }) + continue + } + if (!row.requesterEmail) { + results.push({ id, outcome: 'not_eligible', reason: 'no_email' }) + continue + } + if (row.notifiedAt) { + results.push({ id, outcome: 'already_notified' }) + continue + } + const key = noticeKey(row.domain, row.requesterEmail) + const group = groups.get(key) + if (group) { + group.ids.push(id) + } else { + groups.set(key, { + domain: row.domain, + email: row.requesterEmail, + ids: [id], + }) + groupKeys.push(key) + if (!domains.includes(row.domain)) domains.push(row.domain) + } + } + if (groups.size === 0) return summariseNotifyResults(results) + + // ONE query for "who has already been told about these domains?", matched + // case-insensitively in JS rather than with a per-row insensitive query. + // Without it, a person who suggested the same store twice months apart and + // was mailed the first time would be mailed again for the second row. + const alreadyTold = await prisma.siteSuggestion.findMany({ + where: { domain: { in: domains }, NOT: { notifiedAt: null } }, + select: { domain: true, requesterEmail: true, notifiedAt: true }, + }) + const toldAt = new Map() + for (const row of alreadyTold) { + if (!row.requesterEmail || !row.notifiedAt) continue + const key = noticeKey(row.domain, row.requesterEmail) + const seen = toldAt.get(key) + // Keep the EARLIEST stamp: a duplicate row inherits the instant the + // person was actually told, not the moment we noticed they had been. + if (!seen || row.notifiedAt < seen) toldAt.set(key, row.notifiedAt) + } + + for (const key of groupKeys) { + const group = groups.get(key)! + const previous = toldAt.get(key) + if (previous) { + await prisma.siteSuggestion.updateMany({ + where: { id: { in: group.ids }, notifiedAt: null }, + data: { notifiedAt: previous }, + }) + for (const id of group.ids) { + results.push({ id, outcome: 'already_notified' }) + } + continue + } + + const claimedAt = new Date() + const claim = await prisma.siteSuggestion.updateMany({ + where: { id: { in: group.ids }, notifiedAt: null }, + data: { notifiedAt: claimedAt }, + }) + if (claim.count === 0) { + // Another notifier claimed every row between the read and here. + for (const id of group.ids) { + results.push({ id, outcome: 'already_notified' }) + } + continue + } + + try { + await sendStoreSupportedNotice({ + to: group.email, + domain: group.domain, + }) + for (const id of group.ids) results.push({ id, outcome: 'sent' }) + } catch (error) { + // Release the claim so the notice stays visibly PENDING and the + // same command retries it. Reported to Sentry AND to the caller. + await prisma.siteSuggestion.updateMany({ + where: { id: { in: group.ids }, notifiedAt: claimedAt }, + data: { notifiedAt: null }, + }) + Sentry.captureException(error, { + tags: { + operation: 'site_suggestion_requester_notice', + route: 'ingest/site-suggestions/notify', + }, + extra: { domain: group.domain, ids: group.ids }, + }) + for (const id of group.ids) { + results.push({ id, outcome: 'failed', reason: 'send_failed' }) + } + } + } + return summariseNotifyResults(results) +} + +function summariseNotifyResults( + results: SiteSuggestionNotifyResult[], +): SiteSuggestionNotifySummary { + return { + results, + sent: results.filter(r => r.outcome === 'sent').length, + alreadyNotified: results.filter(r => r.outcome === 'already_notified') + .length, + notEligible: results.filter(r => r.outcome === 'not_eligible').length, + failed: results.filter(r => r.outcome === 'failed').length, + } } diff --git a/apps/caramel-app/tests/integration/site-suggestions.itest.ts b/apps/caramel-app/tests/integration/site-suggestions.itest.ts index 462bb969..b0a885e8 100644 --- a/apps/caramel-app/tests/integration/site-suggestions.itest.ts +++ b/apps/caramel-app/tests/integration/site-suggestions.itest.ts @@ -1,8 +1,10 @@ import { POST } from '@/app/api/sites/suggest/route' import prisma from '@/lib/prisma' import { - acknowledgeSiteSuggestions, listSiteSuggestions, + notifySupportedRequesters, + siteSuggestionAutoNotifyEnabled, + transitionSiteSuggestions, } from '@/lib/siteSuggestions' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -173,12 +175,14 @@ describe('site_suggestions — real rows, real FK, real queries', () => { expect(listed[0]!.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/) const firstTwo = listed.slice(0, 2).map(s => s.id) - expect(await acknowledgeSiteSuggestions(firstTwo)).toEqual({ - acknowledged: 2, - }) - expect(await acknowledgeSiteSuggestions(firstTwo)).toEqual({ - acknowledged: 0, - }) + const acked = await transitionSiteSuggestions(firstTwo, 'imported') + expect(new Set(acked.changed)).toEqual(new Set(firstTwo)) + const reAcked = await transitionSiteSuggestions(firstTwo, 'imported') + expect(reAcked.changed).toEqual([]) + expect(reAcked.refused.map(r => r.reason)).toEqual([ + 'already', + 'already', + ]) const remaining = ( await listSiteSuggestions({ @@ -224,3 +228,167 @@ describe('site_suggestions — real rows, real FK, real queries', () => { ]) }) }) + +// The status/notice half (#226). These are the facts the in-memory fake in +// tests/unit cannot judge, because they are properties of the SCHEMA and of +// Postgres, not of our TypeScript: that the `status_changed_at`/`notified_at` +// columns really landed, that the allowed-source guard in the transition's +// WHERE clause is a real predicate the database enforces, and that the +// `notified_at IS NULL` claim really serialises a send. +async function createRow( + domain: string, + overrides: { + status?: string + requesterEmail?: string | null + notifiedAt?: Date | null + } = {}, +): Promise { + const created = await prisma.siteSuggestion.create({ + data: { + domain, + rawUrl: `https://${domain}/`, + source: 'web', + status: overrides.status ?? 'new', + requesterEmail: overrides.requesterEmail ?? null, + notifiedAt: overrides.notifiedAt ?? null, + }, + select: { id: true }, + }) + return created.id +} + +describe('site suggestions — answering a request (real Postgres)', () => { + it('the migration really added status_changed_at and notified_at, and a transition stamps the first', async () => { + const id = await createRow(`marks.${ITEST_DOMAIN_SUFFIX}`) + const result = await transitionSiteSuggestions([id], 'unsupported') + expect(result.changed).toEqual([id]) + + const row = await prisma.siteSuggestion.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + statusChangedAt: true, + importedAt: true, + notifiedAt: true, + }, + }) + expect(row.status).toBe('unsupported') + expect(row.statusChangedAt).toBeInstanceOf(Date) + // importedAt dates the pipeline's first hand-over only — a `unsupported` + // answer must not invent one. + expect(row.importedAt).toBeNull() + expect(row.notifiedAt).toBeNull() + }) + + it('THE PAIR: the closed-row guard is enforced by the DATABASE, not by the read before it', async () => { + const id = await createRow(`closed.${ITEST_DOMAIN_SUFFIX}`, { + status: 'supported', + }) + + // The guard lives in the UPDATE's own WHERE clause, so even a caller + // that reached it with a stale reading of the row cannot move it. + const direct = await prisma.siteSuggestion.updateMany({ + where: { id, status: { in: ['new'] } }, + data: { status: 'imported' }, + }) + expect(direct.count).toBe(0) + + const refused = await transitionSiteSuggestions([id], 'imported') + expect(refused.changed).toEqual([]) + expect(refused.refused).toEqual([ + { id, from: 'supported', reason: 'backwards' }, + ]) + await expect( + prisma.siteSuggestion.findUniqueOrThrow({ + where: { id }, + select: { status: true }, + }), + ).resolves.toEqual({ status: 'supported' }) + + // ...while a terminal-to-terminal answer really does move it. + const moved = await transitionSiteSuggestions([id], 'unsupported') + expect(moved.changed).toEqual([id]) + }) + + it('a `supported` ack with the switch OFF marks the row and mails NOBODY', async () => { + // Read from the REAL env module (this config loads the package .env): + // if a machine has turned the switch on, say so plainly instead of + // failing later on a count that looks unexplained. + expect( + siteSuggestionAutoNotifyEnabled(), + 'SITE_SUGGESTIONS_AUTO_NOTIFY must be off (its default) for this test', + ).toBe(false) + const id = await createRow(`quiet.${ITEST_DOMAIN_SUFFIX}`, { + requesterEmail: USER_EMAIL, + }) + const result = await transitionSiteSuggestions([id], 'supported') + expect(result.notifiedSent).toBe(0) + expect(result.notifiedPending).toBe(1) + expect(sendEmailMock).not.toHaveBeenCalledWith( + expect.objectContaining({ to: USER_EMAIL }), + ) + await expect( + prisma.siteSuggestion.findUniqueOrThrow({ + where: { id }, + select: { notifiedAt: true }, + }), + ).resolves.toEqual({ notifiedAt: null }) + }) + + it('the notifier claims notified_at BEFORE sending, so a real second call cannot re-send', async () => { + const id = await createRow(`told.${ITEST_DOMAIN_SUFFIX}`, { + status: 'supported', + requesterEmail: USER_EMAIL, + }) + const first = await notifySupportedRequesters([id]) + expect(first.sent).toBe(1) + const stamped = await prisma.siteSuggestion.findUniqueOrThrow({ + where: { id }, + select: { notifiedAt: true }, + }) + expect(stamped.notifiedAt).toBeInstanceOf(Date) + + const second = await notifySupportedRequesters([id]) + expect(second.sent).toBe(0) + expect(second.alreadyNotified).toBe(1) + await expect( + prisma.siteSuggestion.findUniqueOrThrow({ + where: { id }, + select: { notifiedAt: true }, + }), + ).resolves.toEqual(stamped) + }) + + it('one mail per requester email per domain — a duplicate row is stamped, never mailed again', async () => { + const domain = `dupe.${ITEST_DOMAIN_SUFFIX}` + const first = await createRow(domain, { + status: 'supported', + requesterEmail: USER_EMAIL, + }) + await notifySupportedRequesters([first]) + const toldAt = ( + await prisma.siteSuggestion.findUniqueOrThrow({ + where: { id: first }, + select: { notifiedAt: true }, + }) + ).notifiedAt + sendEmailMock.mockClear() + + // The same person asking for the same store again, later. + const again = await createRow(domain, { + status: 'supported', + requesterEmail: USER_EMAIL.toUpperCase(), + }) + const result = await notifySupportedRequesters([again]) + expect(result.sent).toBe(0) + expect(result.alreadyNotified).toBe(1) + expect(sendEmailMock).not.toHaveBeenCalled() + // Stamped with the instant they were ACTUALLY told. + await expect( + prisma.siteSuggestion.findUniqueOrThrow({ + where: { id: again }, + select: { notifiedAt: true }, + }), + ).resolves.toEqual({ notifiedAt: toldAt }) + }) +}) diff --git a/apps/caramel-app/tests/unit/ingest-site-suggestions.test.ts b/apps/caramel-app/tests/unit/ingest-site-suggestions.test.ts index 3367a0c9..42bbd5d0 100644 --- a/apps/caramel-app/tests/unit/ingest-site-suggestions.test.ts +++ b/apps/caramel-app/tests/unit/ingest-site-suggestions.test.ts @@ -2,6 +2,12 @@ import { POST as ackPOST } from '@/app/api/ingest/site-suggestions/ack/route' import { GET as listGET } from '@/app/api/ingest/site-suggestions/route' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + resetTable, + seedRow, + siteSuggestionFake, + table, +} from './support/siteSuggestionsPrismaFake' // Unit pins for the coupons pipeline's read/ack door onto site_suggestions — // the HTTP contract on top of src/lib/siteSuggestions.ts: the apiKey:'ingest' @@ -10,106 +16,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // the new -> imported flip. The SQL half (a real findMany/updateMany against // Postgres) is covered by tests/integration/site-suggestions.itest.ts. // -// ANNOUNCED FAKE: prisma's siteSuggestion.findMany / updateMany are backed by a -// tiny in-memory table that honours the where/orderBy/take the lib sends, so -// the status filter, the `since` cut and the only-`new`-flips rule are -// genuinely exercised rather than asserted from a canned return value. +// ANNOUNCED FAKE: prisma's siteSuggestion queries are backed by a tiny +// in-memory table (support/siteSuggestionsPrismaFake.ts) that honours the +// where/orderBy/take the lib sends, so the status filter, the `since` cut and +// the only-`new`-flips rule are genuinely exercised rather than asserted from a +// canned return value. The status MACHINE (the closed-row rule, the requester +// notice) is pinned next door in site-suggestion-lifecycle.test.ts. const { envMock } = vi.hoisted(() => ({ envMock: { INGEST_API_KEY: undefined as string | undefined }, })) vi.mock('@/lib/env', () => ({ env: envMock })) -const { prismaMock, table } = vi.hoisted(() => { - interface Row { - id: string - domain: string - rawUrl: string - userId: string | null - requesterEmail: string | null - source: string - userAgent: string | null - status: string - createdAt: Date - importedAt: Date | null - } - const rows: Row[] = [] - return { - table: rows, - prismaMock: { - siteSuggestion: { - findMany: vi.fn( - async (args: { - where: { status: string; createdAt?: { gte: Date } } - orderBy: { createdAt: 'asc' } - take: number - select: Record - }) => { - const since = args.where.createdAt?.gte - // Oldest-first, honouring `orderBy` — an insertion - // sort rather than Array#sort (the tsconfig lib - // predates toSorted, and sort() mutates the table). - const ordered: Row[] = [] - for (const row of rows) { - if (row.status !== args.where.status) continue - if (since && row.createdAt < since) continue - const after = ordered.findIndex( - r => r.createdAt > row.createdAt, - ) - if (after === -1) ordered.push(row) - else ordered.splice(after, 0, row) - } - return ordered.slice(0, args.take).map(row => { - const picked: Record = {} - for (const key of Object.keys(args.select)) { - picked[key] = row[key as keyof Row] - } - return picked - }) - }, - ), - updateMany: vi.fn( - async (args: { - where: { id: { in: string[] }; status: string } - data: { status: string; importedAt: Date } - }) => { - let count = 0 - for (const row of rows) { - if ( - args.where.id.in.includes(row.id) && - row.status === args.where.status - ) { - row.status = args.data.status - row.importedAt = args.data.importedAt - count += 1 - } - } - return { count } - }, - ), - }, - }, - } +vi.mock('@/lib/prisma', async () => { + const fake = await import('./support/siteSuggestionsPrismaFake') + return { default: fake.prismaFake } }) -vi.mock('@/lib/prisma', () => ({ default: prismaMock })) const INGEST_KEY = 'test-ingest-key-suggestions' function seed( id: string, createdAt: string, - overrides: Partial<(typeof table)[number]> = {}, + overrides: Record = {}, ) { - table.push({ - id, + seedRow(id, { domain: `${id}.example`, rawUrl: `https://www.${id}.example/`, - userId: null, - requesterEmail: null, - source: 'web', - userAgent: 'ua', - status: 'new', createdAt: new Date(createdAt), - importedAt: null, ...overrides, }) } @@ -133,9 +66,7 @@ const bearer = { authorization: `Bearer ${INGEST_KEY}` } beforeEach(() => { envMock.INGEST_API_KEY = INGEST_KEY - table.length = 0 - prismaMock.siteSuggestion.findMany.mockClear() - prismaMock.siteSuggestion.updateMany.mockClear() + resetTable() }) describe('GET /api/ingest/site-suggestions — bearer gate (apiKey:ingest)', () => { @@ -143,7 +74,7 @@ describe('GET /api/ingest/site-suggestions — bearer gate (apiKey:ingest)', () seed('a', '2026-09-01T00:00:00.000Z') const res = await listGET(listRequest()) expect(res.status).toBe(401) - expect(prismaMock.siteSuggestion.findMany).not.toHaveBeenCalled() + expect(siteSuggestionFake.findMany).not.toHaveBeenCalled() }) it('wrong bearer → 401', async () => { @@ -258,6 +189,11 @@ describe('GET /api/ingest/site-suggestions — the cross-repo contract', () => { }) }) +// The ack grew a `status` field (#226). These pin the ORIGINAL contract — an +// ids-only body from caramel-coupons PR #126 — which must keep behaving exactly +// as it did: default `imported`, only `new` rows move, `acknowledged` still the +// count of rows that really flipped. The added keys are asserted with +// toMatchObject so a later additive key never reds this file. describe('POST /api/ingest/site-suggestions/ack — new -> imported', () => { it('no bearer → 401, nothing flipped', async () => { seed('a', '2026-09-01T00:00:00.000Z') @@ -272,7 +208,12 @@ describe('POST /api/ingest/site-suggestions/ack — new -> imported', () => { seed('c', '2026-09-01T00:00:00.000Z') const res = await ackPOST(ackRequest({ ids: ['a', 'b'] }, bearer)) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ ok: true, acknowledged: 2 }) + expect(await res.json()).toMatchObject({ + ok: true, + status: 'imported', + acknowledged: 2, + changed: ['a', 'b'], + }) expect(table.map(r => [r.id, r.status])).toEqual([ ['a', 'imported'], ['b', 'imported'], @@ -288,7 +229,11 @@ describe('POST /api/ingest/site-suggestions/ack — new -> imported', () => { importedAt: new Date('2026-09-02T00:00:00.000Z'), }) const res = await ackPOST(ackRequest({ ids: ['a', 'ghost'] }, bearer)) - expect(await res.json()).toEqual({ ok: true, acknowledged: 0 }) + expect(await res.json()).toMatchObject({ + ok: true, + acknowledged: 0, + changed: [], + }) expect(table[0]!.importedAt).toEqual( new Date('2026-09-02T00:00:00.000Z'), ) @@ -306,6 +251,6 @@ describe('POST /api/ingest/site-suggestions/ack — new -> imported', () => { 422, ) expect((await ackPOST(ackRequest({}, bearer))).status).toBe(422) - expect(prismaMock.siteSuggestion.updateMany).not.toHaveBeenCalled() + expect(siteSuggestionFake.updateManyAndReturn).not.toHaveBeenCalled() }) }) diff --git a/apps/caramel-app/tests/unit/site-suggestion-lifecycle.test.ts b/apps/caramel-app/tests/unit/site-suggestion-lifecycle.test.ts new file mode 100644 index 00000000..8208b7b0 --- /dev/null +++ b/apps/caramel-app/tests/unit/site-suggestion-lifecycle.test.ts @@ -0,0 +1,507 @@ +import { POST as ackPOST } from '@/app/api/ingest/site-suggestions/ack/route' +import { POST as notifyPOST } from '@/app/api/ingest/site-suggestions/notify/route' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + resetTable, + seedRow, + siteSuggestionFake, + table, +} from './support/siteSuggestionsPrismaFake' + +// The answer half of a site suggestion: marking a request `rejected` / +// `unsupported` / `supported`, and — only for `supported` — telling the person +// who asked. +// +// The product rule these pin: NOTHING mails a requester on its own. The ack is +// driven by an automated pipeline, so with SITE_SUGGESTIONS_AUTO_NOTIFY off (the +// default) a `supported` ack only MARKS the rows and reports how many people are +// waiting; the send is an explicit act (POST .../notify). The switch decides +// whether the ACK may send, never whether the owner may. +// +// The other rule is that a closed suggestion can never be re-opened: a re-drain +// of stale ids would otherwise drag every already-answered store back to +// `imported`, and would send `supported` rows round the loop to re-notify their +// requesters. +// +// The in-memory `site_suggestions` table is an ANNOUNCED FAKE that really +// evaluates the where/select the lib sends — see support/siteSuggestionsPrismaFake.ts. +// Postgres-only facts (the migration, the FK, the indexes) live in +// tests/integration/site-suggestions.itest.ts. + +const { envMock } = vi.hoisted(() => ({ + envMock: { + INGEST_API_KEY: undefined as string | undefined, + SITE_SUGGESTIONS_AUTO_NOTIFY: 'false' as 'true' | 'false', + }, +})) +vi.mock('@/lib/env', () => ({ env: envMock })) + +const { sendEmailMock } = vi.hoisted(() => ({ + sendEmailMock: vi.fn(async (_payload: Record) => {}), +})) +vi.mock('@/lib/email', async importOriginal => ({ + ...(await importOriginal>()), + sendEmail: sendEmailMock, +})) + +vi.mock('@/lib/prisma', async () => { + const fake = await import('./support/siteSuggestionsPrismaFake') + return { default: fake.prismaFake } +}) + +const INGEST_KEY = 'test-ingest-key-lifecycle' +const bearer = { authorization: `Bearer ${INGEST_KEY}` } + +function post(path: string, body: unknown, headers: Record) { + return new NextRequest( + `http://localhost/api/ingest/site-suggestions${path}`, + { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }, + ) +} + +const ack = (body: unknown, headers: Record = bearer) => + ackPOST(post('/ack', body, headers)) +const notify = (body: unknown, headers: Record = bearer) => + notifyPOST(post('/notify', body, headers)) + +/** Mail actually addressed to a requester (the ops notice goes to the operator + * inbox and is asserted separately). */ +function requesterMails() { + return sendEmailMock.mock.calls + .map(call => call[0] as Record) + .filter(payload => payload.to !== 'aladdin@devino.ca') +} + +function opsMails() { + return sendEmailMock.mock.calls + .map(call => call[0] as Record) + .filter(payload => payload.to === 'aladdin@devino.ca') +} + +function row(id: string) { + return table.find(r => r.id === id)! +} + +beforeEach(() => { + envMock.INGEST_API_KEY = INGEST_KEY + envMock.SITE_SUGGESTIONS_AUTO_NOTIFY = 'false' + sendEmailMock.mockClear() + sendEmailMock.mockImplementation(async () => {}) + resetTable() +}) + +describe('ack — the status machine', () => { + it('an ids-only body still means `imported`, and still flips ONLY `new` rows (the shipped caramel-coupons caller)', async () => { + seedRow('a') + seedRow('b', { status: 'imported' }) + const res = await ack({ ids: ['a', 'b'] }) + expect(res.status).toBe(200) + const payload = await res.json() + expect(payload.status).toBe('imported') + expect(payload.acknowledged).toBe(1) + expect(payload.changed).toEqual(['a']) + expect(payload.refused).toEqual([ + { id: 'b', from: 'imported', reason: 'already' }, + ]) + expect(row('a').status).toBe('imported') + expect(row('a').importedAt).toBeInstanceOf(Date) + expect(row('a').statusChangedAt).toBeInstanceOf(Date) + }) + + it('`rejected` and `unsupported` close a request and stamp statusChangedAt, never importedAt', async () => { + seedRow('junk') + seedRow('hopeless', { status: 'imported' }) + await ack({ ids: ['junk'], status: 'rejected' }) + await ack({ ids: ['hopeless'], status: 'unsupported' }) + expect(row('junk').status).toBe('rejected') + expect(row('hopeless').status).toBe('unsupported') + for (const id of ['junk', 'hopeless']) { + expect(row(id).statusChangedAt).toBeInstanceOf(Date) + // importedAt dates the pipeline's FIRST hand-over. A later answer + // must not rewrite it, and must not invent one where there was none. + expect(row(id).importedAt).toBeNull() + } + }) + + it('a closed row can NEVER be dragged back to `imported` — the PAIR: refused backwards, but a terminal-to-terminal answer is allowed', async () => { + seedRow('live', { status: 'supported' }) + seedRow('dead', { status: 'unsupported' }) + seedRow('junk', { status: 'rejected' }) + + const backwards = await ack({ + ids: ['live', 'dead', 'junk'], + status: 'imported', + }) + const refusedPayload = await backwards.json() + expect(refusedPayload.changed).toEqual([]) + expect(refusedPayload.acknowledged).toBe(0) + expect(refusedPayload.refused).toEqual([ + { id: 'live', from: 'supported', reason: 'backwards' }, + { id: 'dead', from: 'unsupported', reason: 'backwards' }, + { id: 'junk', from: 'rejected', reason: 'backwards' }, + ]) + expect(row('live').status).toBe('supported') + + // Same rows, a terminal target: a store we could not support becoming + // supported (and a supported store dying) is the ordinary operator path. + const forwards = await ack({ ids: ['dead'], status: 'supported' }) + expect((await forwards.json()).changed).toEqual(['dead']) + expect(row('dead').status).toBe('supported') + }) + + it('an unknown id is reported `not_found`, never counted as acknowledged', async () => { + const res = await ack({ ids: ['ghost'], status: 'supported' }) + const payload = await res.json() + expect(payload.acknowledged).toBe(0) + expect(payload.refused).toEqual([ + { id: 'ghost', from: null, reason: 'not_found' }, + ]) + }) + + it('an unknown status is 422 and nothing is written', async () => { + seedRow('a') + const res = await ack({ ids: ['a'], status: 'archived' }) + expect(res.status).toBe(422) + expect(siteSuggestionFake.updateManyAndReturn).not.toHaveBeenCalled() + expect(row('a').status).toBe('new') + }) + + it('no bearer → 401, nothing written', async () => { + seedRow('a') + const res = await ack({ ids: ['a'], status: 'supported' }, {}) + expect(res.status).toBe(401) + expect(row('a').status).toBe('new') + }) +}) + +describe('ack — `supported` with SITE_SUGGESTIONS_AUTO_NOTIFY off (the default)', () => { + it('MARKS the rows, mails NO requester, and reports the pending count', async () => { + seedRow('r1', { + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + seedRow('r2', { + domain: 'worldofbooks.com', + requesterEmail: 'other@example.com', + }) + const res = await ack({ ids: ['r1', 'r2'], status: 'supported' }) + const payload = await res.json() + + expect(payload.notified).toEqual({ sent: 0, pending: 2, failed: 0 }) + expect(requesterMails()).toEqual([]) + expect(row('r1').notifiedAt).toBeNull() + expect(row('r2').notifiedAt).toBeNull() + }) + + it('a supported row with NO requester email is never pending — there is nobody to tell', async () => { + seedRow('anon', { domain: 'worldofbooks.com' }) + const payload = await ( + await ack({ ids: ['anon'], status: 'supported' }) + ).json() + expect(payload.notified).toEqual({ sent: 0, pending: 0, failed: 0 }) + }) + + it('sends ONE ops notice naming the domains, the counts, and the exact command with the pending ids in it', async () => { + seedRow('r1', { + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + seedRow('r2', { domain: 'peepers.com', requesterEmail: null }) + const payload = await ( + await ack({ ids: ['r1', 'r2'], status: 'supported' }) + ).json() + expect(payload.opsNotified).toBe(true) + + const ops = opsMails() + expect(ops).toHaveLength(1) + const text = ops[0]!.text as string + expect(text).toContain('worldofbooks.com') + expect(text).toContain('peepers.com') + expect(text).toContain('Requesters with an email: 1') + expect(text).toContain('Auto-notified now: 0') + expect(text).toContain('Awaiting your decision: 1') + // The operator must not have to look an id up to act on this. + expect(text).toContain('/api/ingest/site-suggestions/notify') + expect(text).toContain('{"ids":["r1"]}') + expect(text).not.toContain('"r2"') + }) + + it('an ops-notice failure NEVER fails the ack — the transitions are the system of record', async () => { + seedRow('r1', { requesterEmail: 'shopper@example.com' }) + sendEmailMock.mockRejectedValueOnce(new Error('usesend down')) + const res = await ack({ ids: ['r1'], status: 'supported' }) + expect(res.status).toBe(200) + const payload = await res.json() + expect(payload.changed).toEqual(['r1']) + expect(payload.opsNotified).toBe(false) + expect(row('r1').status).toBe('supported') + }) + + it('a non-supported transition sends no mail at all', async () => { + seedRow('a', { requesterEmail: 'shopper@example.com' }) + const payload = await ( + await ack({ ids: ['a'], status: 'unsupported' }) + ).json() + expect(payload.opsNotified).toBeNull() + expect(sendEmailMock).not.toHaveBeenCalled() + }) +}) + +describe('ack — `supported` with SITE_SUGGESTIONS_AUTO_NOTIFY on', () => { + beforeEach(() => { + envMock.SITE_SUGGESTIONS_AUTO_NOTIFY = 'true' + }) + + it('mails each requester once, stamps notifiedAt, and reports nothing pending', async () => { + seedRow('r1', { + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + seedRow('r2', { + domain: 'peepers.com', + requesterEmail: 'other@example.com', + }) + const payload = await ( + await ack({ ids: ['r1', 'r2'], status: 'supported' }) + ).json() + + expect(payload.notified).toEqual({ sent: 2, pending: 0, failed: 0 }) + expect(new Set(requesterMails().map(m => m.to))).toEqual( + new Set(['shopper@example.com', 'other@example.com']), + ) + expect(row('r1').notifiedAt).toBeInstanceOf(Date) + expect(row('r2').notifiedAt).toBeInstanceOf(Date) + }) + + it('a requester send failure leaves the row PENDING, reports it, and the ops notice still names the id to retry', async () => { + seedRow('r1', { + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + // First call is the requester notice; the ops notice that follows works. + sendEmailMock.mockRejectedValueOnce(new Error('usesend down')) + const payload = await ( + await ack({ ids: ['r1'], status: 'supported' }) + ).json() + + expect(payload.notified).toEqual({ sent: 0, pending: 1, failed: 1 }) + // The claim is RELEASED. A row that says the person was told when + // nobody told them would bury the notice forever. + expect(row('r1').notifiedAt).toBeNull() + const text = opsMails()[0]!.text as string + expect(text).toContain('Send FAILED') + expect(text).toContain('{"ids":["r1"]}') + }) + + it('an accidental `SITE_SUGGESTIONS_AUTO_NOTIFY` value the env schema would reject is not read as ON here either', async () => { + // env.ts fail-fasts at boot on anything but 'true'/'false'; this pins + // that the read is an equality against 'true', never a truthiness test + // that would treat 'no' or '0' as permission to mail a stranger. + envMock.SITE_SUGGESTIONS_AUTO_NOTIFY = 'no' as 'true' | 'false' + seedRow('r1', { requesterEmail: 'shopper@example.com' }) + const payload = await ( + await ack({ ids: ['r1'], status: 'supported' }) + ).json() + expect(payload.notified.sent).toBe(0) + expect(requesterMails()).toEqual([]) + }) +}) + +describe('POST /notify — the owner’s explicit send', () => { + it('no bearer → 401, nothing mailed', async () => { + seedRow('r1', { + status: 'supported', + requesterEmail: 'shopper@example.com', + }) + const res = await notify({ ids: ['r1'] }, {}) + expect(res.status).toBe(401) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('sends for an eligible id with the switch still OFF — the switch gates the ACK, not the owner', async () => { + expect(envMock.SITE_SUGGESTIONS_AUTO_NOTIFY).toBe('false') + seedRow('r1', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + const payload = await (await notify({ ids: ['r1'] })).json() + expect(payload.sent).toBe(1) + expect(payload.results).toEqual([{ id: 'r1', outcome: 'sent' }]) + expect(row('r1').notifiedAt).toBeInstanceOf(Date) + }) + + it('is idempotent: a second call re-sends NOTHING and reports already_notified', async () => { + seedRow('r1', { + status: 'supported', + requesterEmail: 'shopper@example.com', + }) + await notify({ ids: ['r1'] }) + const stampedAt = row('r1').notifiedAt + sendEmailMock.mockClear() + + const payload = await (await notify({ ids: ['r1'] })).json() + expect(payload).toMatchObject({ + sent: 0, + alreadyNotified: 1, + results: [{ id: 'r1', outcome: 'already_notified' }], + }) + expect(sendEmailMock).not.toHaveBeenCalled() + expect(row('r1').notifiedAt).toEqual(stampedAt) + }) + + it('refuses the ineligible with a NAMED reason rather than skipping them silently', async () => { + seedRow('pending-import', { status: 'imported' }) + seedRow('closed', { status: 'unsupported' }) + seedRow('anon', { status: 'supported', requesterEmail: null }) + const payload = await ( + await notify({ ids: ['pending-import', 'closed', 'anon', 'ghost'] }) + ).json() + + expect(payload.sent).toBe(0) + expect(payload.notEligible).toBe(4) + expect(payload.results).toEqual([ + { + id: 'pending-import', + outcome: 'not_eligible', + reason: 'status_is_imported', + }, + { + id: 'closed', + outcome: 'not_eligible', + reason: 'status_is_unsupported', + }, + { id: 'anon', outcome: 'not_eligible', reason: 'no_email' }, + { id: 'ghost', outcome: 'not_eligible', reason: 'not_found' }, + ]) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('ONE mail per requester email per domain — duplicate suggestions are stamped alongside the row that was mailed', async () => { + seedRow('first', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + seedRow('again', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + const payload = await (await notify({ ids: ['first', 'again'] })).json() + + expect(requesterMails()).toHaveLength(1) + expect(payload.sent).toBe(2) + expect(row('first').notifiedAt).toBeInstanceOf(Date) + expect(row('again').notifiedAt).toBeInstanceOf(Date) + }) + + it('the one-mail rule folds email case and survives ACROSS calls', async () => { + seedRow('first', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'Shopper@Example.com', + }) + await notify({ ids: ['first'] }) + const toldAt = row('first').notifiedAt + sendEmailMock.mockClear() + + // The same person, same store, months later, lower-cased. + seedRow('later', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + const payload = await (await notify({ ids: ['later'] })).json() + + expect(sendEmailMock).not.toHaveBeenCalled() + expect(payload.results).toEqual([ + { id: 'later', outcome: 'already_notified' }, + ]) + // Stamped with the instant they were ACTUALLY told, not with now. + expect(row('later').notifiedAt).toEqual(toldAt) + }) + + it('a different person, or the same person about a different store, still gets their mail', async () => { + seedRow('mine', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + notifiedAt: new Date('2026-09-02T00:00:00.000Z'), + }) + seedRow('theirs', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'someone-else@example.com', + }) + seedRow('other-store', { + status: 'supported', + domain: 'peepers.com', + requesterEmail: 'shopper@example.com', + }) + const payload = await ( + await notify({ ids: ['theirs', 'other-store'] }) + ).json() + + expect(payload.sent).toBe(2) + expect(new Set(requesterMails().map(m => m.to))).toEqual( + new Set(['shopper@example.com', 'someone-else@example.com']), + ) + }) + + it('a failed send releases the claim so the SAME command can retry it', async () => { + seedRow('r1', { + status: 'supported', + requesterEmail: 'shopper@example.com', + }) + sendEmailMock.mockRejectedValueOnce(new Error('usesend down')) + const failed = await (await notify({ ids: ['r1'] })).json() + expect(failed).toMatchObject({ + sent: 0, + failed: 1, + results: [{ id: 'r1', outcome: 'failed', reason: 'send_failed' }], + }) + expect(row('r1').notifiedAt).toBeNull() + + const retried = await (await notify({ ids: ['r1'] })).json() + expect(retried.sent).toBe(1) + expect(row('r1').notifiedAt).toBeInstanceOf(Date) + }) + + it('an empty or missing ids list → 422', async () => { + expect((await notify({ ids: [] })).status).toBe(422) + expect((await notify({})).status).toBe(422) + expect(sendEmailMock).not.toHaveBeenCalled() + }) +}) + +describe('the notice a requester actually reads', () => { + it('names the store in the subject and carries the SAME store link in both body parts', async () => { + seedRow('r1', { + status: 'supported', + domain: 'worldofbooks.com', + requesterEmail: 'shopper@example.com', + }) + await notify({ ids: ['r1'] }) + + const mail = requesterMails()[0]! + expect(mail.subject).toBe( + 'worldofbooks.com is now supported in Caramel', + ) + const text = mail.text as string + const html = mail.html as string + // Both parts always: a client that drops the HTML must still read it. + expect(text.length).toBeGreaterThan(0) + expect(html).toContain('<') + const link = 'https://grabcaramel.com/coupons/worldofbooks.com' + expect(text).toContain('worldofbooks.com') + expect(text).toContain(link) + expect(html).toContain('worldofbooks.com') + expect(html).toContain(link) + }) +}) diff --git a/apps/caramel-app/tests/unit/support/siteSuggestionsPrismaFake.ts b/apps/caramel-app/tests/unit/support/siteSuggestionsPrismaFake.ts new file mode 100644 index 00000000..6f5c2cdf --- /dev/null +++ b/apps/caramel-app/tests/unit/support/siteSuggestionsPrismaFake.ts @@ -0,0 +1,204 @@ +// tests/unit/support/siteSuggestionsPrismaFake.ts +// +// ANNOUNCED FAKE — not a mock that returns canned values. +// +// A tiny in-memory `site_suggestions` table that really honours the +// where/orderBy/take/select the lib sends, so the rules under test (the +// allowed-source guard in the transition's WHERE clause, the `notifiedAt: null` +// claim, the only-`new`-flips-to-imported contract, the `since` cut) are +// genuinely EXERCISED rather than asserted from a stubbed return value. A +// recording-only mock would let a wrong WHERE clause pass every assertion, +// which is exactly the class of bug these suites exist to catch. +// +// It is NOT a Postgres: a window function, a real index or an FK is not +// something this can judge. Those live in tests/integration/site-suggestions.itest.ts. +import { vi } from 'vitest' + +export interface FakeSuggestionRow { + id: string + domain: string + rawUrl: string + userId: string | null + requesterEmail: string | null + source: string + userAgent: string | null + status: string + createdAt: Date + importedAt: Date | null + statusChangedAt: Date | null + notifiedAt: Date | null +} + +/** The shared table. `vi.mock('@/lib/prisma')` and the test file import the + * SAME module instance, so both see these rows. */ +export const table: FakeSuggestionRow[] = [] + +type Condition = unknown + +/** The subset of Prisma's filter grammar this lib actually uses. Anything the + * lib starts sending that is not handled here THROWS, so the fake can never + * silently ignore a clause and report a pass it did not earn. */ +function matchesField(value: unknown, cond: Condition): boolean { + if (cond === null) return value === null + if (cond instanceof Date) { + return value instanceof Date && value.getTime() === cond.getTime() + } + if (typeof cond === 'object') { + const record = cond as Record + for (const key of Object.keys(record)) { + if (key === 'in') { + if (!(record.in as unknown[]).includes(value)) return false + } else if (key === 'gte') { + if (!(value instanceof Date)) return false + if (value < (record.gte as Date)) return false + } else if (key === 'not') { + if (matchesField(value, record.not)) return false + } else { + throw new Error(`fake prisma: unsupported operator "${key}"`) + } + } + return true + } + return value === cond +} + +export function matchesWhere( + row: FakeSuggestionRow, + where: Record | undefined, +): boolean { + if (!where) return true + for (const [key, cond] of Object.entries(where)) { + if (key === 'NOT') { + if (matchesWhere(row, cond as Record)) return false + continue + } + if (!(key in row)) { + throw new Error(`fake prisma: unknown column "${key}"`) + } + if (!matchesField(row[key as keyof FakeSuggestionRow], cond)) { + return false + } + } + return true +} + +function project( + row: FakeSuggestionRow, + select: Record | undefined, +): Record { + if (!select) return { ...row } + const picked: Record = {} + for (const key of Object.keys(select)) { + picked[key] = row[key as keyof FakeSuggestionRow] + } + return picked +} + +/** Oldest-first insertion sort — the tsconfig lib predates toSorted, and + * Array#sort would mutate the table itself. */ +function orderedByCreatedAt(rows: FakeSuggestionRow[]): FakeSuggestionRow[] { + const ordered: FakeSuggestionRow[] = [] + for (const row of rows) { + const after = ordered.findIndex(r => r.createdAt > row.createdAt) + if (after === -1) ordered.push(row) + else ordered.splice(after, 0, row) + } + return ordered +} + +export const siteSuggestionFake = { + findMany: vi.fn( + async (args: { + where?: Record + orderBy?: { createdAt: 'asc' } + take?: number + select?: Record + }) => { + let rows = table.filter(row => matchesWhere(row, args.where)) + if (args.orderBy) rows = orderedByCreatedAt(rows) + if (typeof args.take === 'number') rows = rows.slice(0, args.take) + return rows.map(row => project(row, args.select)) + }, + ), + updateMany: vi.fn( + async (args: { + where?: Record + data: Partial + }) => { + let count = 0 + for (const row of table) { + if (!matchesWhere(row, args.where)) continue + Object.assign(row, args.data) + count += 1 + } + return { count } + }, + ), + updateManyAndReturn: vi.fn( + async (args: { + where?: Record + data: Partial + select?: Record + }) => { + const updated: Record[] = [] + for (const row of table) { + if (!matchesWhere(row, args.where)) continue + Object.assign(row, args.data) + updated.push(project(row, args.select)) + } + return updated + }, + ), + create: vi.fn( + async (args: { + data: Partial + select?: Record + }) => { + const row = makeRow(`created-${table.length}`, args.data) + table.push(row) + return project(row, args.select) + }, + ), +} + +export const prismaFake = { siteSuggestion: siteSuggestionFake } + +/** A row with every column present — a partial row would let a test pass + * against a filter that reads a column the real table always has. */ +export function makeRow( + id: string, + overrides: Partial = {}, +): FakeSuggestionRow { + return { + id, + domain: `${id}.example.com`, + rawUrl: `https://www.${id}.example.com/`, + userId: null, + requesterEmail: null, + source: 'web', + userAgent: 'ua', + status: 'new', + createdAt: new Date('2026-09-01T00:00:00.000Z'), + importedAt: null, + statusChangedAt: null, + notifiedAt: null, + ...overrides, + } +} + +export function seedRow( + id: string, + overrides: Partial = {}, +): FakeSuggestionRow { + const row = makeRow(id, overrides) + table.push(row) + return row +} + +export function resetTable(): void { + table.length = 0 + siteSuggestionFake.findMany.mockClear() + siteSuggestionFake.updateMany.mockClear() + siteSuggestionFake.updateManyAndReturn.mockClear() + siteSuggestionFake.create.mockClear() +}