mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(brief): Phase 9 / Todo #223 — share button + referral attribution Adds a Share button to the dashboard Brief panel so PRO users can spread WorldMonitor virally. Built on the existing referral plumbing (registrations.referralCode + referredBy fields; api/register-interest already passes referredBy through) — this PR fills in the last mile: a stable referral code for signed-in Clerk users, a share URL, and a client-side share sheet. Files: server/_shared/referral-code.ts (new) Deterministic 8-char hex code: HMAC(BRIEF_URL_SIGNING_SECRET, 'referral:v1:' + userId). Same Clerk userId always produces the same code. No DB write on login, no schema migration, stable for the life of the account. api/referral/me.ts (new) GET -> { code, shareUrl, invitedCount }. Bearer-auth via Clerk. Reuses BRIEF_URL_SIGNING_SECRET to avoid another Railway env var. Stats fail gracefully to 0 on Convex outage. convex/registerInterest.ts + convex/http.ts New internal query getReferralStatsByCode({referralCode}) counts registrations rows that named this code as their referredBy. Exposed via POST /relay/referral-stats (RELAY_SHARED_SECRET auth). src/services/referral.ts (new) getReferralProfile: 5-min cache, profile is effectively immutable shareReferral: Web Share API primary (mobile native sheet), clipboard fallback on desktop. Returns 'shared'/'copied'/'blocked' /'error'. AbortError is treated as 'blocked', not failure. clearReferralCache for account-switch hygiene. src/components/LatestBriefPanel.ts + src/styles/panels.css New share row below the brief cover card. Button disabled until /api/referral/me resolves; if fetch fails the row removes itself. invitedCount > 0 renders as 'N invited' next to the button. Referral cache invalidated alongside Clerk token cache on account switch (otherwise user B would see user A's share link for 5 min). Tests: 10 new cases in tests/brief-referral-code.test.mjs - getReferralCodeForUser: hex shape, determinism, uniqueness, secret-rotation invalidates, input guards - buildShareUrl: path shape, trailing-slash trim, URL-encoding 153/153 brief + deploy tests pass. Both tsconfigs typecheck clean. Attribution flow (already working end-to-end): 1. Share button -> worldmonitor.app/pro?ref={code} 2. /pro landing page already reads ?ref= and passes to /api/register-interest as referredBy 3. convex registerInterest:register increments the referrer's referralCount and stores referredBy on the new row 4. /api/referral/me reads the count back via the relay query 5. 'N invited' updates on next 5-min cache refresh Scope boundaries (deferred): - Convex conversion tracking (invited -> PRO subscribed). Needs a join from registrations.referredBy to subscriptions.userId via email. Surface 'N converted' in a follow-up. - Referral-credit / reward system: viral loop works today, reward logic is a separate product decision. * fix(brief): address three P2 review findings on #3175 - api/referral/me.ts JSDoc said '503 if REFERRAL_SIGNING_SECRET is not configured' but the handler actually reads BRIEF_URL_SIGNING_SECRET. Updated the docstring so an operator chasing a 503 doesn't look for an env var that doesn't exist. - server/_shared/referral-code.ts carried a RESERVED_CODES Set to avoid collisions with URL-path keywords ('index', 'robots', 'admin'). The guard is dead code: the code alphabet is [0-9a-f] (hex output of the HMAC) so none of those non-hex keywords can ever appear. Removed the Set + the while loop; left a comment explaining why it was unnecessary so nobody re-adds it. - src/components/LatestBriefPanel.ts passed disabled: 'true' (string) to the h() helper. DOM-utils' h() calls setAttribute for unknown props, which does disable the button — but it's inconsistent with the later .disabled = false property write. Fixed to the boolean disabled: true so the attribute and the IDL property agree. 10/10 referral-code tests pass. Both tsconfigs typecheck clean. * fix(brief): address two review findings on #3175 — drop misleading count + fix user-agnostic cache P1: invitedCount wired to the wrong attribution store. The share URL is /pro?ref=<code>. On /pro the 'ref' feeds Dodopayments checkout metadata (affonso_referral), NOT registrations.referredBy. /api/referral/me counted only the waitlist path, so the panel would show 0 invited for anyone who converted direct-to-checkout — misleading. Rather than ship a count that measures only one of two attribution paths (and the less-common one at that), the count is removed entirely. The share button itself still works. A proper metric requires unifying the waitlist + Dodo-metadata paths into a single attribution store, which is a follow-up. Changes: - api/referral/me.ts: response shape is { code, shareUrl } — no invitedCount / convertedCount - convex/registerInterest.ts: removed getReferralStatsByCode internal query - convex/http.ts: removed /relay/referral-stats route - src/services/referral.ts: ReferralProfile interface no longer has invitedCount; fetch call unchanged in behaviour - src/components/LatestBriefPanel.ts: dropped the 'N invited' render branch P2: referral cache was user-agnostic. Module-global _cached had no userId key, so a stale cache primed by user A would hand user B user A's share link for up to 5 min after an account switch — if no panel is mounted at the transition moment to call clearReferralCache(). Per the reviewer's point, this is a real race. Fix: two-part. (a) Cache entry carries the userId it was computed for; reads check the current Clerk userId and only accept hits when they match. Mismatch → drop + re-fetch. (b) src/services/referral.ts self-subscribes to auth-state at module load (ensureAuthSubscription). On any id transition _cached is dropped. Module-level subscription means the invalidation works even when no panel is currently mounted. (c) Belt-and-suspenders: post-fetch, re-check the current user before caching. Protects against account switches that happen mid-flight between 'read cache → ask network → write cache'. Panel's local clearReferralCache() call removed — module now self-invalidates. 10/10 referral-code tests pass. Both tsconfigs typecheck clean. * fix(referral): address P1 review finding — share codes now actually credit the sharer The earlier head generated 8-char Clerk-derived HMAC codes for the share button, but the waitlist register mutation only looked up registrations.by_referral_code (6-char email-generated codes). Codes issued by the share button NEVER resolved to a sharer — the 'referral attribution' half of the feature was non-functional. Fix (schema-level, honest attribution path): convex/schema.ts - userReferralCodes { userId, code, createdAt } + by_code, by_user - userReferralCredits { referrerUserId, refereeEmail, createdAt } + by_referrer, by_referrer_email convex/registerInterest.ts - register mutation: after the existing registrations.by_referral_code lookup, falls through to userReferralCodes.by_code. On match, inserts a userReferralCredits row (the Clerk user has no registrations row to increment, so credit needs its own table). Dedupes by (referrer, refereeEmail) so returning visitors can't double-credit. - new internalMutation registerUserReferralCode({userId, code}) idempotent binding of a code to a userId. Collisions logged and ignored (keeps first writer). convex/http.ts - new POST /relay/register-referral-code (RELAY_SHARED_SECRET auth) that runs the mutation above. api/referral/me.ts - signature gains a ctx.waitUntil handle - after generating the user's code, fire-and-forget POSTs to /relay/register-referral-code so the binding is live by the time anyone clicks a shared link. Idempotent — a failure just means the NEXT call re-registers. Still deferred: display of 'N credited' / 'N converted' in the LatestBriefPanel. The waitlist side now resolves correctly, but the Dodopayments checkout path (/pro?ref=<code> → affonso_referral) is tracked in Dodo, not Convex. Surfacing a unified count requires a separate follow-up to pull Dodo metadata into Convex. Regression tests (3 new cases in tests/brief-referral-code.test.mjs): - register mutation extends to userReferralCodes + inserts credits - schema declares both tables with the right indexes - /api/referral/me registers the binding via waitUntil 13/13 referral tests pass. Both tsconfigs typecheck clean. * fix(referral): address two P1 review findings — checkout attribution + dead-link prevention P1: share URL didn't credit on the /pro?ref= checkout path. The earlier PR wired Clerk codes into the waitlist path (/api/register-interest -> userReferralCodes -> userReferralCredits) but a visitor landing on /pro?ref=<code> and going straight to Dodo checkout forwarded the code only into Dodo metadata (affonso_referral). Nothing on our side credited the sharer. Fix: convex/payments/subscriptionHelpers.ts handleSubscriptionActive now reads data.metadata.affonso_referral when inserting a NEW subscription row. If the code resolves in userReferralCodes, a userReferralCredits row crediting the sharer is inserted (deduped by (referrer, refereeEmail) so webhook replays don't double-credit). The credit only lands on first-activation — the else-branch of the existing/new split guards against replays. P1: /api/referral/me returned 200 + share link even when the (code, userId) binding failed. ctx.waitUntil(registerReferralCodeInConvex(...)) ran the binding asynchronously, swallowing missing env + non-2xx + network errors. Users got a share URL that the waitlist lookup could never resolve — dead link. Fix: registerReferralCodeInConvex is now BLOCKING (throws on any failure) and the handler awaits it before returning. On failure the endpoint responds 503 service_unavailable rather than a 200 with a non-functional URL. Mutation is idempotent so client retries are safe. Regression tests (2 updated/new in tests/brief-referral-code.test.mjs): - asserts the binding is awaited, not ctx.waitUntil'd; asserts the failure path returns 503 - asserts subscriptionHelpers reads affonso_referral, resolves via userReferralCodes.by_code, inserts a userReferralCredits row, and dedupes by (referrer, refereeEmail) 14/14 referral tests pass. Both tsconfigs typecheck clean. Net effect: /pro?ref=<code> visitors who convert (direct checkout) now credit the sharer on webhook receipt, same as waitlist signups. The share button is no longer a dead-end UI.
1022 lines
36 KiB
TypeScript
1022 lines
36 KiB
TypeScript
import { anyApi, httpRouter } from "convex/server";
|
|
import { httpAction } from "./_generated/server";
|
|
import { internal } from "./_generated/api";
|
|
import { webhookHandler } from "./payments/webhookHandlers";
|
|
import { resendWebhookHandler } from "./resendWebhookHandler";
|
|
|
|
const TRUSTED = [
|
|
"https://worldmonitor.app",
|
|
"*.worldmonitor.app",
|
|
"http://localhost:3000",
|
|
];
|
|
|
|
function matchOrigin(origin: string, pattern: string): boolean {
|
|
if (pattern.startsWith("*.")) {
|
|
return origin.endsWith(pattern.slice(1));
|
|
}
|
|
return origin === pattern;
|
|
}
|
|
|
|
function allowedOrigin(origin: string | null, trusted: string[]): string | null {
|
|
if (!origin) return null;
|
|
return trusted.some((p) => matchOrigin(origin, p)) ? origin : null;
|
|
}
|
|
|
|
function corsHeaders(origin: string | null): Headers {
|
|
const headers = new Headers();
|
|
const allowed = allowedOrigin(origin, TRUSTED);
|
|
if (allowed) {
|
|
headers.set("Access-Control-Allow-Origin", allowed);
|
|
headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
headers.set("Access-Control-Max-Age", "86400");
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function timingSafeEqualStrings(a: string, b: string): Promise<boolean> {
|
|
const enc = new TextEncoder();
|
|
const keyMaterial = await crypto.subtle.generateKey(
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const [sigA, sigB] = await Promise.all([
|
|
crypto.subtle.sign("HMAC", keyMaterial, enc.encode(a)),
|
|
crypto.subtle.sign("HMAC", keyMaterial, enc.encode(b)),
|
|
]);
|
|
const aArr = new Uint8Array(sigA);
|
|
const bArr = new Uint8Array(sigB);
|
|
let diff = 0;
|
|
for (let i = 0; i < aArr.length; i++) diff |= aArr[i]! ^ bArr[i]!;
|
|
return diff === 0;
|
|
}
|
|
|
|
const http = httpRouter();
|
|
|
|
http.route({
|
|
path: "/api/internal-entitlements",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const providedSecret = request.headers.get("x-convex-shared-secret") ?? "";
|
|
const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? "";
|
|
if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: { userId?: unknown };
|
|
try {
|
|
body = await request.json() as { userId?: unknown };
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (typeof body.userId !== "string" || body.userId.length === 0) {
|
|
return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const result = await ctx.runQuery(
|
|
internal.entitlements.getEntitlementsByUserId,
|
|
{ userId: body.userId },
|
|
);
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/api/user-prefs",
|
|
method: "OPTIONS",
|
|
handler: httpAction(async (_ctx, request) => {
|
|
const headers = corsHeaders(request.headers.get("Origin"));
|
|
return new Response(null, { status: 204, headers });
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/api/user-prefs",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const headers = corsHeaders(request.headers.get("Origin"));
|
|
headers.set("Content-Type", "application/json");
|
|
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHENTICATED" }), {
|
|
status: 401,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
let body: {
|
|
variant?: string;
|
|
data?: unknown;
|
|
expectedSyncVersion?: number;
|
|
schemaVersion?: number;
|
|
};
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
if (
|
|
typeof body.variant !== "string" ||
|
|
body.data === undefined ||
|
|
typeof body.expectedSyncVersion !== "number"
|
|
) {
|
|
return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), {
|
|
status: 400,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await ctx.runMutation(
|
|
anyApi.userPreferences!.setPreferences as any,
|
|
{
|
|
variant: body.variant,
|
|
data: body.data,
|
|
expectedSyncVersion: body.expectedSyncVersion,
|
|
schemaVersion: body.schemaVersion,
|
|
},
|
|
);
|
|
return new Response(JSON.stringify(result), { status: 200, headers });
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("CONFLICT")) {
|
|
return new Response(JSON.stringify({ error: "CONFLICT" }), {
|
|
status: 409,
|
|
headers,
|
|
});
|
|
}
|
|
if (msg.includes("BLOB_TOO_LARGE")) {
|
|
return new Response(JSON.stringify({ error: "BLOB_TOO_LARGE" }), {
|
|
status: 400,
|
|
headers,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/api/telegram-pair-callback",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
// Always return 200 — non-200 triggers Telegram retry storm
|
|
const secret = process.env.TELEGRAM_WEBHOOK_SECRET ?? "";
|
|
const provided =
|
|
request.headers.get("X-Telegram-Bot-Api-Secret-Token") ?? "";
|
|
|
|
// Drop only when a secret header IS provided but doesn't match (spoofing).
|
|
// If the header is absent, Telegram's secret_token registration may have
|
|
// silently failed — the pairing token (43-char, single-use, 15-min TTL)
|
|
// provides sufficient defence against token guessing.
|
|
if (provided && secret && !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response("OK", { status: 200 });
|
|
}
|
|
if (!provided) console.warn("[telegram-webhook] secret header absent — relying on pairing token auth");
|
|
|
|
let update: {
|
|
message?: {
|
|
chat?: { type?: string; id?: number };
|
|
text?: string;
|
|
date?: number;
|
|
};
|
|
};
|
|
try {
|
|
update = await request.json() as typeof update;
|
|
} catch {
|
|
return new Response("OK", { status: 200 });
|
|
}
|
|
|
|
const msg = update.message;
|
|
if (!msg) return new Response("OK", { status: 200 });
|
|
|
|
if (msg.chat?.type !== "private") return new Response("OK", { status: 200 });
|
|
|
|
if (!msg.date || Math.abs(Date.now() / 1000 - msg.date) > 900) {
|
|
return new Response("OK", { status: 200 });
|
|
}
|
|
|
|
const text = msg.text?.trim() ?? "";
|
|
const chatId = String(msg.chat.id);
|
|
|
|
const match = text.match(/^\/start\s+([A-Za-z0-9_-]{40,50})$/);
|
|
if (!match) return new Response("OK", { status: 200 });
|
|
|
|
const claimed = await ctx.runMutation(anyApi.notificationChannels!.claimPairingToken as any, {
|
|
token: match[1],
|
|
chatId,
|
|
});
|
|
|
|
// Send welcome on successful first/re-pair — must be awaited in HTTP actions
|
|
const botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
|
|
if (claimed.ok && botToken) {
|
|
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "User-Agent": "worldmonitor-convex/1.0" },
|
|
body: JSON.stringify({
|
|
chat_id: chatId,
|
|
text: "✅ WorldMonitor connected! You'll receive breaking news alerts here.",
|
|
}),
|
|
signal: AbortSignal.timeout(8000),
|
|
}).catch((err: unknown) => {
|
|
console.error("[telegram-webhook] sendMessage failed:", err);
|
|
});
|
|
}
|
|
|
|
return new Response("OK", { status: 200 });
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/relay/deactivate",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: { userId?: string; channelType?: string };
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (
|
|
typeof body.userId !== "string" || !body.userId ||
|
|
(body.channelType !== "telegram" && body.channelType !== "slack" && body.channelType !== "email" && body.channelType !== "discord" && body.channelType !== "web_push")
|
|
) {
|
|
return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
await ctx.runMutation((internal as any).notificationChannels.deactivateChannelForUser, {
|
|
userId: body.userId,
|
|
channelType: body.channelType,
|
|
});
|
|
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/relay/channels",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: { userId?: string };
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (typeof body.userId !== "string" || !body.userId) {
|
|
return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const channels = await ctx.runQuery((internal as any).notificationChannels.getChannelsByUserId, {
|
|
userId: body.userId,
|
|
});
|
|
|
|
return new Response(JSON.stringify(channels ?? []), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
// Service-to-service notification channel management (no user JWT required).
|
|
// Authenticated via RELAY_SHARED_SECRET; caller supplies the validated userId.
|
|
http.route({
|
|
path: "/relay/notification-channels",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: {
|
|
action?: string;
|
|
userId?: string;
|
|
channelType?: string;
|
|
chatId?: string;
|
|
webhookEnvelope?: string;
|
|
webhookLabel?: string;
|
|
email?: string;
|
|
variant?: string;
|
|
enabled?: boolean;
|
|
eventTypes?: string[];
|
|
sensitivity?: string;
|
|
channels?: string[];
|
|
slackChannelName?: string;
|
|
slackTeamName?: string;
|
|
slackConfigurationUrl?: string;
|
|
discordGuildId?: string;
|
|
discordChannelId?: string;
|
|
endpoint?: string;
|
|
p256dh?: string;
|
|
auth?: string;
|
|
userAgent?: string;
|
|
quietHoursEnabled?: boolean;
|
|
quietHoursStart?: number;
|
|
quietHoursEnd?: number;
|
|
quietHoursTimezone?: string;
|
|
quietHoursOverride?: string;
|
|
digestMode?: string;
|
|
digestHour?: number;
|
|
digestTimezone?: string;
|
|
aiDigestEnabled?: boolean;
|
|
};
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const { action = "get", userId } = body;
|
|
if (typeof userId !== "string" || !userId) {
|
|
return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
if (action === "get") {
|
|
const [channels, alertRules] = await Promise.all([
|
|
ctx.runQuery((internal as any).notificationChannels.getChannelsByUserId, { userId }),
|
|
ctx.runQuery((internal as any).alertRules.getAlertRulesByUserId, { userId }),
|
|
]);
|
|
return new Response(JSON.stringify({ channels: channels ?? [], alertRules: alertRules ?? [] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (action === "create-pairing-token") {
|
|
const result = await ctx.runMutation((internal as any).notificationChannels.createPairingTokenForUser, {
|
|
userId,
|
|
variant: body.variant,
|
|
});
|
|
return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-channel") {
|
|
if (!body.channelType) {
|
|
return new Response(JSON.stringify({ error: "channelType required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
const setResult = await ctx.runMutation((internal as any).notificationChannels.setChannelForUser, {
|
|
userId,
|
|
channelType: body.channelType as "telegram" | "slack" | "email" | "webhook",
|
|
chatId: body.chatId,
|
|
webhookEnvelope: body.webhookEnvelope,
|
|
email: body.email,
|
|
webhookLabel: body.webhookLabel,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true, isNew: setResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-slack-oauth") {
|
|
if (!body.webhookEnvelope) {
|
|
return new Response(JSON.stringify({ error: "webhookEnvelope required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
const oauthResult = await ctx.runMutation((internal as any).notificationChannels.setSlackOAuthChannelForUser, {
|
|
userId,
|
|
webhookEnvelope: body.webhookEnvelope,
|
|
slackChannelName: body.slackChannelName,
|
|
slackTeamName: body.slackTeamName,
|
|
slackConfigurationUrl: body.slackConfigurationUrl,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true, isNew: oauthResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-discord-oauth") {
|
|
if (!body.webhookEnvelope) {
|
|
return new Response(JSON.stringify({ error: "webhookEnvelope required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
const discordResult = await ctx.runMutation((internal as any).notificationChannels.setDiscordOAuthChannelForUser, {
|
|
userId,
|
|
webhookEnvelope: body.webhookEnvelope,
|
|
discordGuildId: body.discordGuildId,
|
|
discordChannelId: body.discordChannelId,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true, isNew: discordResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-web-push") {
|
|
if (!body.endpoint || !body.p256dh || !body.auth) {
|
|
return new Response(JSON.stringify({ error: "endpoint, p256dh, auth required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
const webPushResult = await ctx.runMutation((internal as any).notificationChannels.setWebPushChannelForUser, {
|
|
userId,
|
|
endpoint: body.endpoint,
|
|
p256dh: body.p256dh,
|
|
auth: body.auth,
|
|
userAgent: body.userAgent,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true, isNew: webPushResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "delete-channel") {
|
|
if (!body.channelType) {
|
|
return new Response(JSON.stringify({ error: "channelType required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
await ctx.runMutation((internal as any).notificationChannels.deleteChannelForUser, {
|
|
userId,
|
|
channelType: body.channelType as "telegram" | "slack" | "email" | "discord",
|
|
});
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-alert-rules") {
|
|
const VALID_SENSITIVITY = new Set(["all", "high", "critical"]);
|
|
if (
|
|
typeof body.variant !== "string" || !body.variant ||
|
|
typeof body.enabled !== "boolean" ||
|
|
!Array.isArray(body.eventTypes) ||
|
|
!Array.isArray(body.channels) ||
|
|
(body.sensitivity !== undefined && !VALID_SENSITIVITY.has(body.sensitivity as string))
|
|
) {
|
|
return new Response(JSON.stringify({ error: "MISSING_REQUIRED_FIELDS" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
await ctx.runMutation((internal as any).alertRules.setAlertRulesForUser, {
|
|
userId,
|
|
variant: body.variant,
|
|
enabled: body.enabled,
|
|
eventTypes: body.eventTypes as string[],
|
|
sensitivity: (body.sensitivity ?? "all") as "all" | "high" | "critical",
|
|
channels: body.channels as Array<"telegram" | "slack" | "email">,
|
|
aiDigestEnabled: typeof body.aiDigestEnabled === "boolean" ? body.aiDigestEnabled : undefined,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-quiet-hours") {
|
|
const VALID_OVERRIDE = new Set(["critical_only", "silence_all", "batch_on_wake"]);
|
|
if (typeof body.variant !== "string" || !body.variant || typeof body.quietHoursEnabled !== "boolean") {
|
|
return new Response(JSON.stringify({ error: "variant and quietHoursEnabled required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (body.quietHoursOverride !== undefined && !VALID_OVERRIDE.has(body.quietHoursOverride)) {
|
|
return new Response(JSON.stringify({ error: "invalid quietHoursOverride" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
await ctx.runMutation((internal as any).alertRules.setQuietHoursForUser, {
|
|
userId,
|
|
variant: body.variant,
|
|
quietHoursEnabled: body.quietHoursEnabled,
|
|
quietHoursStart: body.quietHoursStart,
|
|
quietHoursEnd: body.quietHoursEnd,
|
|
quietHoursTimezone: body.quietHoursTimezone,
|
|
quietHoursOverride: body.quietHoursOverride as "critical_only" | "silence_all" | "batch_on_wake" | undefined,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
if (action === "set-digest-settings") {
|
|
const VALID_DIGEST_MODE = new Set(["realtime", "daily", "twice_daily", "weekly"]);
|
|
if (
|
|
typeof body.variant !== "string" || !body.variant ||
|
|
!VALID_DIGEST_MODE.has(body.digestMode as string)
|
|
) {
|
|
return new Response(JSON.stringify({ error: "MISSING_REQUIRED_FIELDS" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
await ctx.runMutation((internal as any).alertRules.setDigestSettingsForUser, {
|
|
userId,
|
|
variant: body.variant,
|
|
digestMode: body.digestMode as "realtime" | "daily" | "twice_daily" | "weekly",
|
|
digestHour: typeof body.digestHour === "number" ? body.digestHour : undefined,
|
|
digestTimezone: typeof body.digestTimezone === "string" ? body.digestTimezone : undefined,
|
|
});
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
}),
|
|
});
|
|
|
|
// Service-to-service: Railway digest cron fetches due rules (no user JWT required).
|
|
http.route({
|
|
path: "/relay/digest-rules",
|
|
method: "GET",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
const rules = await ctx.runQuery((internal as any).alertRules.getDigestRules);
|
|
return new Response(JSON.stringify(rules), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/relay/user-preferences",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
let body: { userId?: string; variant?: string };
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_BODY" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (!body.userId || !body.variant) {
|
|
return new Response(JSON.stringify({ error: "userId and variant required" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
const prefs = await ctx.runQuery(
|
|
(internal as any).userPreferences.getPreferencesByUserId,
|
|
{ userId: body.userId, variant: body.variant },
|
|
);
|
|
return new Response(JSON.stringify(prefs?.data ?? null), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/relay/entitlement",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
let body: { userId?: string };
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_BODY" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (!body.userId) {
|
|
return new Response(JSON.stringify({ error: "userId required" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
const ent = await ctx.runQuery(
|
|
internal.entitlements.getEntitlementsByUserId,
|
|
{ userId: body.userId },
|
|
);
|
|
const tier = ent?.features?.tier ?? 0;
|
|
return new Response(JSON.stringify({ tier }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Referral code registration (Phase 9 / Todo #223)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Edge-route companion for /api/referral/me. Binds a Clerk-derived
|
|
// 8-char share code to the signed-in user's Clerk userId so future
|
|
// /pro?ref=<code> signups can credit the sharer via the
|
|
// userReferralCredits path in registerInterest:register. Auth is
|
|
// server-to-server via RELAY_SHARED_SECRET — the edge route already
|
|
// validated the caller's Clerk bearer before hitting this.
|
|
http.route({
|
|
path: "/relay/register-referral-code",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
let body: { userId?: string; code?: string };
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_BODY" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
const userId = typeof body.userId === "string" ? body.userId.trim() : "";
|
|
const code = typeof body.code === "string" ? body.code.trim() : "";
|
|
if (!userId || !code || code.length < 4 || code.length > 32) {
|
|
return new Response(JSON.stringify({ error: "userId + code required" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
const result = await ctx.runMutation(
|
|
(internal as any).registerInterest.registerUserReferralCode,
|
|
{ userId, code },
|
|
);
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User API key validation (service-to-service only)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Service-to-service: validate a user API key by its SHA-256 hash.
|
|
// Called by the Vercel edge gateway to look up user-owned keys.
|
|
http.route({
|
|
path: "/api/internal-validate-api-key",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const providedSecret = request.headers.get("x-convex-shared-secret") ?? "";
|
|
const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? "";
|
|
if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: { keyHash?: unknown };
|
|
try {
|
|
body = await request.json() as { keyHash?: unknown };
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (typeof body.keyHash !== "string" || body.keyHash.length === 0) {
|
|
return new Response(JSON.stringify({ error: "MISSING_KEY_HASH" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const result = await ctx.runQuery(
|
|
(internal as any).apiKeys.validateKeyByHash,
|
|
{ keyHash: body.keyHash },
|
|
);
|
|
|
|
if (result) {
|
|
// Fire-and-forget: update lastUsedAt (don't await, don't block response)
|
|
void ctx.runMutation((internal as any).apiKeys.touchKeyLastUsed, { keyId: result.id });
|
|
}
|
|
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
// Service-to-service: look up the owner of a key by hash (regardless of revoked status).
|
|
// Used by the cache-invalidation endpoint to verify tenancy boundaries.
|
|
http.route({
|
|
path: "/api/internal-get-key-owner",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const providedSecret = request.headers.get("x-convex-shared-secret") ?? "";
|
|
const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? "";
|
|
if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: { keyHash?: unknown };
|
|
try {
|
|
body = await request.json() as { keyHash?: unknown };
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (typeof body.keyHash !== "string" || !/^[a-f0-9]{64}$/.test(body.keyHash)) {
|
|
return new Response(JSON.stringify({ error: "INVALID_KEY_HASH" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const result = await ctx.runQuery(
|
|
(internal as any).apiKeys.getKeyOwner,
|
|
{ keyHash: body.keyHash },
|
|
);
|
|
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/dodopayments-webhook",
|
|
method: "POST",
|
|
handler: webhookHandler,
|
|
});
|
|
|
|
// Service-to-service: Vercel edge gateway creates Dodo checkout sessions.
|
|
// Authenticated via RELAY_SHARED_SECRET; edge endpoint validates Clerk JWT
|
|
// and forwards the verified userId.
|
|
http.route({
|
|
path: "/relay/create-checkout",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(
|
|
/^Bearer\s+/,
|
|
"",
|
|
);
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: {
|
|
userId?: string;
|
|
email?: string;
|
|
name?: string;
|
|
productId?: string;
|
|
returnUrl?: string;
|
|
discountCode?: string;
|
|
referralCode?: string;
|
|
};
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (!body.userId || !body.productId) {
|
|
return new Response(
|
|
JSON.stringify({ error: "MISSING_FIELDS", required: ["userId", "productId"] }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
try {
|
|
const result = await ctx.runAction(
|
|
internal.payments.checkout.internalCreateCheckout,
|
|
{
|
|
userId: body.userId,
|
|
email: body.email,
|
|
name: body.name,
|
|
productId: body.productId,
|
|
returnUrl: body.returnUrl,
|
|
discountCode: body.discountCode,
|
|
referralCode: body.referralCode,
|
|
},
|
|
);
|
|
if (
|
|
result &&
|
|
typeof result === "object" &&
|
|
"blocked" in result &&
|
|
result.blocked === true
|
|
) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: result.code,
|
|
message: result.message,
|
|
subscription: result.subscription,
|
|
}),
|
|
{
|
|
status: 409,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
}
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Checkout creation failed";
|
|
return new Response(JSON.stringify({ error: msg }), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
}),
|
|
});
|
|
|
|
// Service-to-service: Vercel edge gateway creates Dodo customer portal sessions.
|
|
// Authenticated via RELAY_SHARED_SECRET; edge endpoint validates Clerk JWT
|
|
// and forwards the verified userId.
|
|
http.route({
|
|
path: "/relay/customer-portal",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(
|
|
/^Bearer\s+/,
|
|
"",
|
|
);
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: { userId?: string };
|
|
try {
|
|
body = await request.json() as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (!body.userId) {
|
|
return new Response(
|
|
JSON.stringify({ error: "MISSING_FIELDS", required: ["userId"] }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
try {
|
|
const result = await ctx.runAction(
|
|
internal.payments.billing.internalGetCustomerPortalUrl,
|
|
{ userId: body.userId },
|
|
);
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Customer portal creation failed";
|
|
const status = msg === "No Dodo customer found for this user" ? 404 : 500;
|
|
return new Response(JSON.stringify({ error: msg }), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
}),
|
|
});
|
|
|
|
// Resend webhook: captures bounce/complaint events and suppresses emails.
|
|
// Signature verification + internal mutation, same pattern as Dodo webhook.
|
|
http.route({
|
|
path: "/resend-webhook",
|
|
method: "POST",
|
|
handler: resendWebhookHandler,
|
|
});
|
|
|
|
// Bulk email suppression: service-to-service, authenticated via RELAY_SHARED_SECRET.
|
|
// Used by the one-time import script (scripts/import-bounced-emails.mjs).
|
|
http.route({
|
|
path: "/relay/bulk-suppress-emails",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const secret = process.env.RELAY_SHARED_SECRET ?? "";
|
|
const provided = (request.headers.get("Authorization") ?? "").replace(
|
|
/^Bearer\s+/,
|
|
"",
|
|
);
|
|
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
|
|
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
let body: {
|
|
emails: Array<{
|
|
email: string;
|
|
reason: "bounce" | "complaint" | "manual";
|
|
source?: string;
|
|
}>;
|
|
};
|
|
try {
|
|
body = (await request.json()) as typeof body;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
if (!Array.isArray(body.emails) || body.emails.length === 0) {
|
|
return new Response(
|
|
JSON.stringify({ error: "MISSING_FIELDS", required: ["emails"] }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
try {
|
|
const result = await ctx.runMutation(
|
|
internal.emailSuppressions.bulkSuppress,
|
|
{ emails: body.emails },
|
|
);
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Bulk suppress failed";
|
|
return new Response(JSON.stringify({ error: msg }), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
}),
|
|
});
|
|
|
|
export default http;
|