Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/caramel-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions apps/caramel-app/emails/StoreSupportedTemplate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmailLayout previewText={`${domain} is now supported in Caramel`}>
<h1 style={text.heading}>{domain} is now supported</h1>
<p style={text.body}>
You asked us to support <strong>{domain}</strong>. It is live
now, so Caramel will find and test coupon codes for you the next
time you check out there.
</p>

<EmailButton href={storeUrl}>See {domain} codes</EmailButton>

<div style={text.divider} />

<p style={text.small}>Thanks for the suggestion.</p>
</EmailLayout>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."site_suggestions" ADD COLUMN "notified_at" TIMESTAMP(3),
ADD COLUMN "status_changed_at" TIMESTAMP(3);
48 changes: 35 additions & 13 deletions apps/caramel-app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
68 changes: 56 additions & 12 deletions apps/caramel-app/src/app/api/ingest/site-suggestions/ack/route.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
})
},
)
Original file line number Diff line number Diff line change
@@ -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 })
},
)
14 changes: 7 additions & 7 deletions apps/caramel-app/src/app/api/sites/suggest/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -77,14 +78,13 @@ export const POST = withRoute(
try {
// First line kept VERBATIM ("A user suggested a new site: <url>") —
// 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}`,
Expand Down
13 changes: 13 additions & 0 deletions apps/caramel-app/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading