diff --git a/public/email/ladder-logo.png b/public/email/ladder-logo.png
new file mode 100644
index 0000000..27b45db
Binary files /dev/null and b/public/email/ladder-logo.png differ
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 previewEmail preview
Add ?type=:
`,
+ { 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 `
+
+
+
+
+ |
+ | ${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 month |
+ Resets 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 });
+ }
+}
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", () =>