Skip to content
Merged
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
Binary file added public/email/ladder-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions src/app/api/admin/email-preview/route.ts
Original file line number Diff line number Diff line change
@@ -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) => `<li><a href="/api/admin/email-preview?type=${t}">${t}</a></li>`,
).join("");
return new NextResponse(
`<!doctype html><meta charset="utf-8"><title>Email preview</title><body style="font-family:sans-serif;padding:24px;"><h1>Email preview</h1><p>Add <code>?type=</code>:</p><ul>${links}</ul></body>`,
{ headers: { "Content-Type": "text/html; charset=utf-8" } },
);
}

return new NextResponse(sample.html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
38 changes: 38 additions & 0 deletions src/app/api/admin/email-test/route.ts
Original file line number Diff line number Diff line change
@@ -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 } });
}
4 changes: 3 additions & 1 deletion src/content/hq/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Architecture
updatedAt: 2026-07-30
updatedBy: Sara
lastPr: 420
lastPr: 421
---

## Deployment environments
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/content/hq/features.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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` |
Expand Down
180 changes: 180 additions & 0 deletions src/lib/email.ts
Original file line number Diff line number Diff line change
@@ -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 <alerts@runladder.com>";
/** Internal ops inbox — matches the existing cap-alert recipient. */
export const OPS_EMAIL = "hello@drawbackwards.com";

const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

/**
* 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 `<div style="margin:0;padding:40px 0;background:#f4f4f5;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;"><tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;">
<tr><td align="left" style="padding:32px 32px 8px;">
<img src="${LOGO_URL}" width="103" height="20" alt="Ladder" style="display:block;border:0;outline:none;text-decoration:none;height:20px;width:103px;">
</td></tr>
<tr><td align="left" style="padding:24px 32px 48px;">${body}</td></tr>
<tr><td style="padding:24px 32px 48px;">
<div style="height:1px;background:#B7B8C2;font-size:0;line-height:0;">&nbsp;</div>
<p style="margin:16px 0 0;font-size:13px;color:#747686;">&copy; ${year} Ladder</p>
</td></tr>
</table>
</td></tr></table>
</div>`;
}

/** Reusable pieces. */
const h1 = (text: string) =>
`<h1 style="margin:0;font-size:24px;line-height:32px;font-weight:700;color:#111827;">${esc(text)}</h1>`;
const p = (html: string, mt = 24) =>
`<p style="margin:${mt}px 0 0;font-size:14px;line-height:22px;color:#111827;">${html}</p>`;

/** 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 `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:20px 0 0;">
<tr>
<td align="left" style="font-size:13px;line-height:18px;color:#111827;">${used.toLocaleString()} of ${total.toLocaleString()} scores this month</td>
<td align="right" style="font-size:13px;line-height:18px;color:#111827;">Resets in ${daysToReset} day${daysToReset === 1 ? "" : "s"}</td>
</tr>
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 0;background:#e6e6e6;"><tr>
<td width="${fill}%" style="height:8px;line-height:8px;font-size:0;background:#111827;">&nbsp;</td>
<td width="${rest}%" style="height:8px;line-height:8px;font-size:0;">&nbsp;</td>
</tr></table>`;
}

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. <a href="${adminLink}" style="color:#131316;text-decoration:underline;">Review usage in the admin dashboard</a>.`);
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<void> {
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);
}
}
10 changes: 9 additions & 1 deletion src/lib/scores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number | string>(
STATS_KEY(userId),
Expand Down
Loading
Loading