From ebea6125d0f7de86c954f0948580178e5fb28d52 Mon Sep 17 00:00:00 2001 From: Chester Date: Thu, 30 Jul 2026 13:54:34 -0700 Subject: [PATCH 1/2] feat(#402): workspace pool-cap alerting + system email module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pool-level usage alerts for Team workspaces (the only alert before this was per-user against a personal cap). - email.ts (new): one place for product-sent email. sendEmail() via Resend (console fallback when RESEND_API_KEY unset) + templates whose chrome mirrors the Clerk emails (black logo PNG, white card, #111827 text, near-black button, #B7B8C2 divider + © footer) so it reads as the same sender. renderPoolAlert (lead client-facing + internal ops, 80%/100%). - public/email/ladder-logo.png: black logo rasterized from LadderLogo.svg (Gmail strips SVG, so the email uses a hosted PNG). - usage.ts: POOL_ALERT_THRESHOLDS (80%/100% as fractions of TEAM_MONTHLY_POOL); maybeAlertPoolCrossed (team-tier only; resolves the workspace, sums members, fires once per workspace/threshold/month, emails Team Lead + ops); maybeAlertCapCrossed now suppresses the team tier (pool alert replaces it). - scores.ts: persistScoreEntry fires maybeAlertPoolCrossed fire-and-forget. - admin email-preview + email-test routes for the see/refine/test loop. /hq: features row + architecture "System email + usage alerting" note (#421). Templates are interim — to be revisited at the paid Clerk tier. Co-Authored-By: Claude Opus 4.8 --- public/email/ladder-logo.png | Bin 0 -> 5470 bytes src/app/api/admin/email-preview/route.ts | 35 +++++ src/app/api/admin/email-test/route.ts | 38 +++++ src/content/hq/architecture.mdx | 4 +- src/content/hq/features.mdx | 5 +- src/lib/email.ts | 180 +++++++++++++++++++++++ src/lib/scores.ts | 10 +- src/lib/usage.ts | 95 +++++++++++- 8 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 public/email/ladder-logo.png create mode 100644 src/app/api/admin/email-preview/route.ts create mode 100644 src/app/api/admin/email-test/route.ts create mode 100644 src/lib/email.ts diff --git a/public/email/ladder-logo.png b/public/email/ladder-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..27b45dbf8a59406a7d74cd0b31fe6a762a3d8980 GIT binary patch literal 5470 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 literal 0 HcmV?d00001 diff --git a/src/app/api/admin/email-preview/route.ts b/src/app/api/admin/email-preview/route.ts new file mode 100644 index 0000000..a22d2c2 --- /dev/null +++ b/src/app/api/admin/email-preview/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminEmail } from "@/lib/admin"; +import { emailSample, EMAIL_SAMPLE_TYPES } from "@/lib/email"; + +/** + * Admin email preview (#402) — render any system email in the browser to + * iterate on the look with no sending. + * + * GET /api/admin/email-preview?type=pool-lead-80 + * + * With no (or an unknown) type, returns a small index of the available types. + * Gated by getAdminEmail(). + */ +export async function GET(req: NextRequest) { + if (!(await getAdminEmail())) { + return NextResponse.json({ error: "Admin access required" }, { status: 403 }); + } + + const type = req.nextUrl.searchParams.get("type") ?? ""; + const sample = emailSample(type); + + if (!sample) { + const links = EMAIL_SAMPLE_TYPES.map( + (t) => `
  • ${t}
  • `, + ).join(""); + return new NextResponse( + `Email preview

    Email preview

    Add ?type=:

      ${links}
    `, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ); + } + + return new NextResponse(sample.html, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} diff --git a/src/app/api/admin/email-test/route.ts b/src/app/api/admin/email-test/route.ts new file mode 100644 index 0000000..99ad545 --- /dev/null +++ b/src/app/api/admin/email-test/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminEmail } from "@/lib/admin"; +import { emailSample, sendEmail, EMAIL_SAMPLE_TYPES } from "@/lib/email"; + +/** + * Admin test-send (#402) — fire a sample system email to a chosen inbox so we + * can see how it renders in a real client (Gmail mangles HTML differently than + * a browser). + * + * POST /api/admin/email-test body { type, to } + * + * Requires RESEND_API_KEY to actually deliver; otherwise sendEmail logs and + * this still returns ok (the log is visible in Vercel runtime logs). Gated by + * getAdminEmail(). + */ +export async function POST(req: NextRequest) { + const adminEmail = await getAdminEmail(); + if (!adminEmail) { + return NextResponse.json({ error: "Admin access required" }, { status: 403 }); + } + + const body = (await req.json().catch(() => ({}))) as { type?: string; to?: string }; + const type = typeof body.type === "string" ? body.type : ""; + // Default to the acting admin's own email so a mistyped recipient can't send + // a client-looking email to an outsider. + const to = typeof body.to === "string" && body.to.includes("@") ? body.to : adminEmail; + + const sample = emailSample(type); + if (!sample) { + return NextResponse.json( + { error: `Unknown type. Expected one of: ${EMAIL_SAMPLE_TYPES.join(", ")}` }, + { status: 400 }, + ); + } + + await sendEmail({ to, subject: `[TEST] ${sample.subject}`, html: sample.html }); + return NextResponse.json({ ok: true, sent: { type, to } }); +} diff --git a/src/content/hq/architecture.mdx b/src/content/hq/architecture.mdx index 025ed7c..817c947 100644 --- a/src/content/hq/architecture.mdx +++ b/src/content/hq/architecture.mdx @@ -2,7 +2,7 @@ title: Architecture updatedAt: 2026-07-30 updatedBy: Sara -lastPr: 420 +lastPr: 421 --- ## Deployment environments @@ -70,6 +70,8 @@ This diagram shows the future-target architecture (one engine, thin clients). To **Data.** Upstash Redis in `runladder` for scoring history and tier counters. Pulse uses Prisma + Postgres on Fly.io in `ladder-beta`. Postgres in `runladder` is planned but not scheduled. +**System email + usage alerting ([#402](https://github.com/drawbackwards/runladder/issues/402)).** Product-sent email (distinct from Clerk auth email) goes through one module, `src/lib/email.ts` — `sendEmail()` (Resend via `RESEND_API_KEY`, console-log fallback when unset) plus templates whose chrome mirrors the Clerk emails (centered-left black logo `PNG` at `/email/ladder-logo.png`, white card, `#111827` text, near-black buttons, `#B7B8C2` divider + `© year Ladder` footer) so it reads as the same sender. Alerting: `maybeAlertCapCrossed` emails ops when a **Pro** user crosses their personal cap (suppressed for the team tier); `maybeAlertPoolCrossed` fires when a **Team** workspace's combined usage crosses `POOL_ALERT_THRESHOLDS` (80%, 100% — fractions of `TEAM_MONTHLY_POOL`, so a pool change needs no code edit), once per (workspace, threshold, month) via a Redis `SET NX` flag, emailing the Team Lead (client-facing) + ops (internal, names the client + links to admin). Both fire fire-and-forget from `persistScoreEntry`. Admin tooling: `GET /api/admin/email-preview?type=…` renders any template in-browser; `POST /api/admin/email-test` sends a sample to a chosen inbox. Templates are interim — to be revisited when we move to the paid Clerk tier. + **Per-surface usage attribution ([#401](https://github.com/drawbackwards/runladder/issues/401)).** The combined monthly pool meter (`user:{id}:scans:{yyyymm}`, the 25K-cap counter) is unchanged. `persistScoreEntry` additionally increments a sibling hash `user:{id}:scans:{yyyymm}:by-surface` (fields: `web`/`figma`/`skill`, same 40-day TTL) via `surfaceFromSource(source)` — the cheap no-scan rollup primitive. The admin Team Detail surface popover reconstructs the split from each entry's durable `source` (so it has full history and always sums to the month's total); the counter starts clean at first real usage and is not UI-consumed yet. **Token-cost accounting (COGS, internal only).** Every Anthropic call in this repo routes its response `usage` through the single chokepoint `recordTokenCost(...)` in `src/lib/token-cost.ts`, which prices tokens per model and accumulates cost per **user, per month, per category** in the Redis hash `usage:cost:{userId}:{yyyymm}` (micro-USD, no TTL). Categories: `score`, `overhead` (moderation + transcription), `copy`, `a11y`, `style-guide`, `annotation`, `chat`, `feedback`. The admin Team Detail page ([#397](https://github.com/drawbackwards/runladder/issues/397)) sums current members' costs to show what a client costs us to run, with a per-category popover ([#406](https://github.com/drawbackwards/runladder/issues/406)). Months before this shipped have no token data, so they're shown as an **estimate** (score count × a per-score rate; score category only) and tagged "Estimated" in the UI. This number is **internal COGS only — never client-facing**, and it is intentionally decoupled from the user-facing "scores this month" count (bonus plugin features cost money but are not scores). Pricing rates live in `RATES` in `token-cost.ts`; update them when Anthropic pricing changes. diff --git a/src/content/hq/features.mdx b/src/content/hq/features.mdx index 4e919e3..52d47f7 100644 --- a/src/content/hq/features.mdx +++ b/src/content/hq/features.mdx @@ -1,8 +1,8 @@ --- title: Feature Inventory -updatedAt: 2026-07-29 +updatedAt: 2026-07-30 updatedBy: Sara -lastPr: 411 +lastPr: 421 --- ## How to read this page @@ -154,6 +154,7 @@ Gated by `ADMIN_EMAILS` env var. Defaults: Ward, Michael, Jordan. | Team Detail: per-org member roster + usage-by-month (reconstructed from durable score history, `Overage` tag at ≥ pool); clients rows link through | Admin | Live (#397) | `src/app/admin/clients/[orgId]/page.tsx`, `src/app/api/admin/clients/[orgId]/route.ts` (GET) | | Team Detail token-cost (COGS) column: per-month Anthropic spend with per-category popover; `Estimated` tag for pre-instrumentation months. Internal only, never client-facing. All LLM calls route through `recordTokenCost` | Admin | Live (#406) | `src/lib/token-cost.ts`, `src/app/admin/clients/[orgId]/`; capture wired in `scoring.ts`, `style-guide.ts`, `copy-audit.ts`, `annotation-analysis.ts`, `screenshot` | | Team Detail per-surface usage: `Scores used` cell has a hover popover splitting the monthly total by surface (Web / Figma / Claude Skill), reconstructed from each score's `source`. A sibling `scans:{yyyymm}:by-surface` counter is written at persist time as the no-scan rollup primitive; the combined 25K pool meter is unchanged | Admin | Live (#401) | `src/app/admin/clients/[orgId]/`, `src/lib/surface.ts` (`surfaceFromSource`), `src/lib/scores.ts`, `src/lib/redis.ts` (`surfaceScansKey`) | +| Workspace pool cap alerting: when a Team workspace's combined monthly usage crosses 80% then 100% of the pool, a branded HTML email fires to the Team Lead (client-facing) + internal ops, once per (workspace, threshold, month). Percentage thresholds, so a pool change needs no code edit. Per-user cap alerts are suppressed for the team tier. Admin email preview + test-send tooling | Team Lead / Admin | Live (#402) | `src/lib/usage.ts` (`maybeAlertPoolCrossed`, `POOL_ALERT_THRESHOLDS`), `src/lib/email.ts`, `src/app/api/admin/email-preview/`, `src/app/api/admin/email-test/` | | Internal-org protection (Drawbackwards never suspended/deleted) | Admin | Live (v0.5.5) | `src/lib/orgs.ts` `isInternalOrg` | | Admin-only "Admin" entry in Account menu (single row; lands on Clients tab) | Admin | Live | `src/components/UserMenu.tsx`, `src/app/api/admin/status/route.ts` | | Unified Admin shell with tabs (Clients / Evaluations / Feedback / Comps / Beta Codes) | Admin | Live (#231) | `src/app/admin/layout.tsx`, `src/components/Tabs.tsx` | diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..d13a0b3 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,180 @@ +/** + * System email — shared sender + templates (#402). + * + * All product-sent email (not Clerk auth email) goes through here so there's + * one place to style. The chrome deliberately mirrors the Clerk email templates + * (centered-left black Ladder logo, white card, #111827 headings/body, near- + * black buttons, #B7B8C2 divider + "© year Ladder" footer) so to a recipient + * it reads as coming from the same place as their sign-in emails. Templates are + * interim — they'll be revisited when we upgrade to the paid Clerk tier. + * + * Sending uses Resend (RESEND_API_KEY). With no key it falls back to a console + * log so the event is still visible in Vercel runtime logs (dev/preview). + */ + +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://runladder.com"; +const LOGO_URL = `${APP_URL}/email/ladder-logo.png`; +const FROM = "Ladder "; +/** Internal ops inbox — matches the existing cap-alert recipient. */ +export const OPS_EMAIL = "hello@drawbackwards.com"; + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + +/** + * Wrap body HTML in the Clerk-matching shell (logo header + card + footer). + * `body` is trusted, pre-built HTML from a template function below. + */ +function shell(body: string): string { + const year = new Date().getFullYear(); + return `
    +
    + + + + +
    + Ladder +
    ${body}
    +
     
    +

    © ${year} Ladder

    +
    +
    +
    `; +} + +/** Reusable pieces. */ +const h1 = (text: string) => + `

    ${esc(text)}

    `; +const p = (html: string, mt = 24) => + `

    ${html}

    `; + +/** App-style pool meter — black fill on a gray track, Outlook-safe table. */ +function meter(used: number, total: number, daysToReset: number): string { + const fill = Math.min(100, Math.round((used / total) * 100)); + const rest = 100 - fill; + return ` + + + + +
    ${used.toLocaleString()} of ${total.toLocaleString()} scores this monthResets in ${daysToReset} day${daysToReset === 1 ? "" : "s"}
    + + + +
      
    `; +} + +export type PoolAlert = { + audience: "lead" | "internal"; + /** Crossed threshold as an integer percent (80 or 100). */ + threshold: number; + teamName: string; + leadFirstName?: string | null; + used: number; + total: number; + daysToReset: number; + /** For the internal admin-dashboard link. */ + orgId?: string | null; +}; + +/** Build { subject, html } for a pool-usage alert, per audience + threshold. */ +export function renderPoolAlert(a: PoolAlert): { subject: string; html: string } { + const reached = a.threshold >= 100; + const team = esc(a.teamName); + + if (a.audience === "lead") { + const hi = a.leadFirstName ? `Hi ${esc(a.leadFirstName)}, ` : ""; + const usage = reached + ? `${hi}${team} has used 100% of its monthly pool.` + : `${hi}${team} has used about ${a.threshold}% of its monthly pool.`; + const body = + h1(reached ? "Your team has reached its monthly pool" : "Your team is approaching its monthly pool") + + p(usage) + + meter(a.used, a.total, a.daysToReset) + + p("Don't worry, you can keep scoring past it during a short grace period. We just wanted to give you a heads-up.") + + p("Need more capacity? Just reply to this email and we'll talk about it."); + return { + subject: reached + ? "Your team has reached its monthly Ladder pool" + : "Your team is approaching its monthly Ladder pool", + html: shell(body), + }; + } + + // Internal (ops) — names the client in the heading, links to admin. + const adminLink = a.orgId ? `${APP_URL}/admin/clients/${a.orgId}` : `${APP_URL}/admin/clients`; + const body = + h1(reached ? `${a.teamName} has reached its monthly pool` : `${a.teamName} is approaching its monthly pool`) + + p(reached ? `${team} has used 100% of its monthly pool.` : `${team} has used about ${a.threshold}% of its monthly pool.`) + + meter(a.used, a.total, a.daysToReset) + + p(`The team lead has been emailed. Review usage in the admin dashboard.`); + return { + subject: `[Ladder] ${a.teamName} ${reached ? "reached" : `at ${a.threshold}% of`} its monthly pool`, + html: shell(body), + }; +} + +/** + * Sample renderings for the admin preview + test-send tooling (#402). Maps a + * short type string to a rendered email with representative data. Returns null + * for an unknown type. Keep the type strings in sync with the admin UI. + */ +export function emailSample(type: string): { subject: string; html: string } | null { + const m = /^pool-(lead|internal)-(80|100)$/.exec(type); + if (m) { + const audience = m[1] as "lead" | "internal"; + const threshold = Number(m[2]); + const total = 25000; + return renderPoolAlert({ + audience, + threshold, + teamName: "Lumin Digital", + leadFirstName: "Mike", + used: Math.round((threshold / 100) * total), + total, + daysToReset: 12, + orgId: "org_sample", + }); + } + return null; +} + +/** Type strings the preview/test tooling understands. */ +export const EMAIL_SAMPLE_TYPES = [ + "pool-lead-80", + "pool-lead-100", + "pool-internal-80", + "pool-internal-100", +] as const; + +/** + * Send an HTML email via Resend. Falls back to a console log when + * RESEND_API_KEY is unset (dev/preview) so the event is still observable. + * Best-effort — never throws to the caller. + */ +export async function sendEmail(opts: { + to: string | string[]; + subject: string; + html: string; +}): Promise { + const key = process.env.RESEND_API_KEY; + if (!key) { + console.log("[LADDER:EMAIL]", JSON.stringify({ to: opts.to, subject: opts.subject })); + return; + } + try { + await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: FROM, + to: Array.isArray(opts.to) ? opts.to : [opts.to], + subject: opts.subject, + html: opts.html, + }), + }); + } catch (err) { + console.error("[LADDER:EMAIL] send failed:", err); + } +} diff --git a/src/lib/scores.ts b/src/lib/scores.ts index 622dfe5..2115313 100644 --- a/src/lib/scores.ts +++ b/src/lib/scores.ts @@ -6,7 +6,7 @@ import { currentYearMonth, } from "@/lib/redis"; import { surfaceFromSource } from "@/lib/surface"; -import { maybeAlertCapCrossed, ANY_TIER_CAP_THRESHOLD } from "@/lib/usage"; +import { maybeAlertCapCrossed, maybeAlertPoolCrossed, ANY_TIER_CAP_THRESHOLD } from "@/lib/usage"; /** * Single source of truth for persisting a Ladder score to a user's @@ -208,6 +208,14 @@ export async function persistScoreEntry( }); } + // Workspace pool-crossing alert (#402). Fire-and-forget; self-gates to the + // team tier, so non-team scores cost only one cheap subscription read. Not + // gated on the individual's count — the pool can cross while any one member + // is low. + maybeAlertPoolCrossed(userId).catch((err) => { + console.error("[LADDER:POOL-ALERT] background failure:", err); + }); + // Update bestScore only if the new score beats the current best. const currentBestRaw = await redis.hget( STATS_KEY(userId), diff --git a/src/lib/usage.ts b/src/lib/usage.ts index 83c560c..3354c2c 100644 --- a/src/lib/usage.ts +++ b/src/lib/usage.ts @@ -9,9 +9,11 @@ * same number — no per-call-site arithmetic drift. */ import { redis, lifetimeScansKey, monthlyScansKey, currentYearMonth } from "@/lib/redis"; -import { monthlyScoreCapForTier, PRO_MONTHLY_LIMIT } from "@/lib/plans"; +import { monthlyScoreCapForTier, PRO_MONTHLY_LIMIT, TEAM_MONTHLY_POOL } from "@/lib/plans"; import { getUserSubscription } from "@/lib/tier"; import { clerkClient } from "@clerk/nextjs/server"; +import { isProvisioningUser, orgMeta } from "@/lib/orgs"; +import { renderPoolAlert, sendEmail, OPS_EMAIL } from "@/lib/email"; /** * Days remaining in the current UTC month, inclusive of today. @@ -84,6 +86,12 @@ export async function getTeamMonthlyByUser( */ export const ANY_TIER_CAP_THRESHOLD = PRO_MONTHLY_LIMIT; +/** + * Workspace pool-alert thresholds as FRACTIONS of the team pool (#402). + * Percentages, not hard numbers, so a future pool change needs no code edit. + */ +export const POOL_ALERT_THRESHOLDS = [0.8, 1.0] as const; + /** * Sends a one-time email alert to hello@drawbackwards.com when a paid * user crosses their tier's monthly soft cap for the first time in @@ -116,6 +124,10 @@ export async function maybeAlertCapCrossed( const sub = await getUserSubscription(userId); const tier = sub.tier; + // Team members are covered by the workspace POOL alert (#402), not a + // per-user alert — a single designer's personal count is meaningless when + // the pool is shared. Suppress the individual alert for the team tier. + if (tier === "team") return; const cap = monthlyScoreCapForTier(tier); if (cap === null) return; if (newMonthlyCount <= cap) return; @@ -185,3 +197,84 @@ export async function maybeAlertCapCrossed( // on transient send failures. Next month we'll alert fresh. } } + +/** + * Workspace pool-level cap alert (#402). + * + * Fires once per (workspace, threshold, month) when a Team workspace's COMBINED + * monthly usage crosses a POOL_ALERT_THRESHOLD (80%, then 100%), independent of + * any single member's count. Emails the Team Lead (client-facing) and internal + * ops. Called fire-and-forget from persistScoreEntry on every score; self-gates + * to the team tier so non-team scores cost only one cheap subscription read. + * + * The pool total is the same sum the dashboard computes on read — we just run + * it at write time to catch the crossing in real time. + */ +export async function maybeAlertPoolCrossed(userId: string): Promise { + // Cheap gate: only team-tier users can contribute to a workspace pool. + const sub = await getUserSubscription(userId); + if (sub.tier !== "team") return; + + const clerk = await clerkClient(); + + // Resolve the scorer's workspace. + const userOrgs = await clerk.users.getOrganizationMembershipList({ userId }); + const orgId = userOrgs.data[0]?.organization?.id; + if (!orgId) return; + + const yyyymm = currentYearMonth(); + + // Current members (excluding the hidden provisioning account), then the + // combined pool usage — same math the team dashboard does on read. + const memberships = await clerk.organizations.getOrganizationMembershipList({ + organizationId: orgId, + limit: 100, + }); + const memberIds = memberships.data + .map((m) => m.publicUserData?.userId) + .filter((id): id is string => !!id && !isProvisioningUser(id)); + const used = await getTeamMonthlyTotal(memberIds, yyyymm); + const pool = TEAM_MONTHLY_POOL; + const fraction = used / pool; + + // Highest crossed threshold wins. Claim its per-month flag with SET NX; if we + // get it, this is the first crossing of that level this month → alert. If the + // flag already exists (already alerted at this level or higher), stop. + const descending = [...POOL_ALERT_THRESHOLDS].sort((a, b) => b - a); + const crossed = descending.find((t) => fraction >= t); + if (crossed === undefined) return; + + const pct = Math.round(crossed * 100); + const flagKey = `org:${orgId}:pool_alert:${yyyymm}:${pct}`; + const claimed = await redis.set(flagKey, "1", { nx: true, ex: 60 * 60 * 24 * 40 }); + if (claimed === null) return; // already alerted at this level this month + + // Resolve org name + team lead for the client-facing email. + const org = await clerk.organizations.getOrganization({ organizationId: orgId }); + const teamName = org.name || "Your team"; + const lead = orgMeta(org).teamLead; + const daysToReset = daysUntilMonthEnd(); + + const base = { + threshold: pct, + teamName, + used, + total: pool, + daysToReset, + orgId, + } as const; + + // Internal ops — always. + const internal = renderPoolAlert({ ...base, audience: "internal" }); + await sendEmail({ to: OPS_EMAIL, subject: internal.subject, html: internal.html }); + + // Team Lead — when we have their email on the org metadata. + if (lead?.email) { + const leadMail = renderPoolAlert({ + ...base, + audience: "lead", + leadFirstName: lead.firstName ?? null, + }); + await sendEmail({ to: lead.email, subject: leadMail.subject, html: leadMail.html }); + } +} From 81056cfc65d23a7c1efbbe7310d2fbb4f90a5a54 Mon Sep 17 00:00:00 2001 From: Chester Date: Thu, 30 Jul 2026 13:59:35 -0700 Subject: [PATCH 2/2] test(#402): register email-preview + email-test in the auth-gate manifest The auth-gate guardrail requires every /api/admin route to be in PROTECTED_ROUTES with an anon-rejection test. Added both new routes + their expectAllMethodsRejectAnon() cases (both are getAdminEmail-gated, so they reject anon with 403). Co-Authored-By: Claude Opus 4.8 --- src/test/auth-gate.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/auth-gate.test.ts b/src/test/auth-gate.test.ts index 85e23d9..0b2aa48 100644 --- a/src/test/auth-gate.test.ts +++ b/src/test/auth-gate.test.ts @@ -90,6 +90,8 @@ const PROTECTED_ROUTES = [ "src/app/api/admin/clients/[orgId]/route.ts", "src/app/api/admin/clients/route.ts", "src/app/api/admin/comps/route.ts", + "src/app/api/admin/email-preview/route.ts", + "src/app/api/admin/email-test/route.ts", "src/app/api/admin/debug-log/route.ts", "src/app/api/admin/evaluations/[id]/analyze/route.ts", "src/app/api/admin/evaluations/[id]/route.ts", @@ -185,6 +187,10 @@ describe("auth gate: /api/admin/*", () => { expectAllMethodsRejectAnon( "@/app/api/admin/evaluations/[id]/analyze/route", )); + it("email-preview", () => + expectAllMethodsRejectAnon("@/app/api/admin/email-preview/route")); + it("email-test", () => + expectAllMethodsRejectAnon("@/app/api/admin/email-test/route")); it("feedback", () => expectAllMethodsRejectAnon("@/app/api/admin/feedback/route")); it("invites", () =>