"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getDb, signupAllowlist, eq, inArray } from "@fetcha/db"; import { getEmailService } from "@fetcha/email"; import { requireAdmin, requestMeta } from "@/lib/session"; import { writeAudit } from "@/lib/audit"; import { SITE_URL } from "@/lib/utils"; import type { AuthUser } from "@/lib/auth"; import type { AdminActionResult } from "@/actions/admin"; /** * Signup allowlist management (invitation-only platform). Every mutation is audited as * `admin.action` with metadata.type in { access.allow, access.invite, access.revoke }. */ async function audit(admin: AuthUser, type: "access.allow" | "access.invite" | "access.revoke", target: string, metadata: Record = {}) { const meta = await requestMeta(); await writeAudit({ userId: admin.id, action: "admin.action", target, metadata: { type, ...metadata }, ipAddress: meta.ip, userAgent: meta.userAgent }); } function fail(e: unknown): { ok: false; error: string } { if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: (e as Error)?.message ?? "Unexpected error" }; } function revalidate() { revalidatePath("/admin/access"); } const emailSchema = z.string().trim().toLowerCase().email("Invalid email address").max(254); /** Split a comma / newline / whitespace separated list into unique, lower-cased, valid emails. */ function parseEmailList(raw: string): { emails: string[]; invalid: string[] } { const seen = new Set(); const emails: string[] = []; const invalid: string[] = []; for (const token of raw.split(/[\s,;]+/)) { const t = token.trim(); if (!t) continue; const r = emailSchema.safeParse(t); if (!r.success) { invalid.push(t); continue; } if (seen.has(r.data)) continue; seen.add(r.data); emails.push(r.data); } return { emails, invalid }; } function signupUrlFor(email: string): string { return `${SITE_URL}/signup?email=${encodeURIComponent(email)}`; } const allowSchema = z.object({ emails: z.string().trim().min(3, "Enter at least one email address").max(20_000), note: z.string().trim().max(280, "Note is too long (280 characters max)").optional().or(z.literal("")), sendInvite: z.boolean().default(false), }); export type AllowEmailsInput = z.input; export type AllowEmailsData = { added: number; existing: number; invited: number; invalid: string[]; failed: string[] }; export async function allowEmails(input: AllowEmailsInput): Promise> { const admin = await requireAdmin(); try { const d = allowSchema.parse(input); const { emails, invalid } = parseEmailList(d.emails); if (emails.length === 0) return { ok: false, error: invalid.length ? `No valid email address found (rejected: ${invalid.slice(0, 3).join(", ")}${invalid.length > 3 ? "…" : ""}).` : "Enter at least one email address." }; if (emails.length > 200) return { ok: false, error: "Add at most 200 addresses at a time." }; const db = getDb(); const note = d.note?.trim() || null; let invited = 0; const failed: string[] = []; const already = new Set((await db.select({ email: signupAllowlist.email }).from(signupAllowlist).where(inArray(signupAllowlist.email, emails))).map((r) => r.email)); const fresh = emails.filter((e) => !already.has(e)); if (fresh.length) await db.insert(signupAllowlist).values(fresh.map((email) => ({ email, note, invitedByUserId: admin.id }))).onConflictDoNothing(); // Existing entries keep their inviter; only refresh the note when one was provided. if (note && already.size) await db.update(signupAllowlist).set({ note }).where(inArray(signupAllowlist.email, Array.from(already))); const added = fresh.length; const existing = already.size; await audit(admin, "access.allow", emails.length === 1 ? emails[0]! : `${emails.length} emails`, { emails, note, added, existing }); if (d.sendInvite) { const inviterName = admin.name?.trim() || admin.email; for (const email of emails) { try { await getEmailService().sendInvite(email, { inviterName, signupUrl: signupUrlFor(email) }); await db.update(signupAllowlist).set({ invitedAt: new Date() }).where(eq(signupAllowlist.email, email)); invited++; } catch (e) { failed.push(email); console.error("[access] invite failed", email, (e as Error).message); } } await audit(admin, "access.invite", emails.length === 1 ? emails[0]! : `${emails.length} emails`, { emails, invited, failed }); } revalidate(); const warnings: string[] = []; if (invalid.length) warnings.push(`${invalid.length} entr${invalid.length === 1 ? "y was" : "ies were"} not a valid email and ${invalid.length === 1 ? "was" : "were"} skipped: ${invalid.slice(0, 5).join(", ")}${invalid.length > 5 ? "…" : ""}`); if (failed.length) warnings.push(`Invitation email could not be sent to ${failed.join(", ")}. Use “Resend invite” later.`); return { ok: true, data: { added, existing, invited, invalid, failed }, warning: warnings.length ? warnings.join(" ") : undefined }; } catch (e) { return fail(e); } } export async function resendInvite(email: string): Promise { const admin = await requireAdmin(); try { const target = emailSchema.parse(email); const db = getDb(); const [row] = await db.select().from(signupAllowlist).where(eq(signupAllowlist.email, target)).limit(1); if (!row) return { ok: false, error: "This address is not on the access list." }; if (row.userId) return { ok: false, error: "This address already has an account; there is nothing to invite." }; const inviterName = admin.name?.trim() || admin.email; await getEmailService().sendInvite(target, { inviterName, signupUrl: signupUrlFor(target) }); await db.update(signupAllowlist).set({ invitedAt: new Date() }).where(eq(signupAllowlist.email, target)); await audit(admin, "access.invite", target, { emails: [target], invited: 1, resend: true }); revalidate(); return { ok: true }; } catch (e) { return fail(e); } } export async function revokeAllow(email: string): Promise { const admin = await requireAdmin(); try { const target = emailSchema.parse(email); const db = getDb(); const [row] = await db.select({ email: signupAllowlist.email, userId: signupAllowlist.userId, note: signupAllowlist.note }).from(signupAllowlist).where(eq(signupAllowlist.email, target)).limit(1); if (!row) return { ok: false, error: "This address is not on the access list." }; if (row.userId) return { ok: false, error: "This address already has an account; ban the user instead." }; await db.delete(signupAllowlist).where(eq(signupAllowlist.email, target)); await audit(admin, "access.revoke", target, { note: row.note }); revalidate(); return { ok: true }; } catch (e) { return fail(e); } }