import { NextRequest, NextResponse } from "next/server"; import { prisma } from "../../../../../lib/server/db"; import { getSessionPepper, isDatabaseConfigured, } from "../../../../../lib/server/env"; import { hashSessionToken, newSessionToken } from "../../../../../lib/server/hash"; import { sendRuleStakeholderInviteEmail } from "../../../../../lib/server/mail"; import { apiRoute } from "../../../../../lib/server/apiRoute"; import { logRouteError } from "../../../../../lib/server/requestId"; import { stakeholderInviteVerifyUrl } from "../../../../../lib/server/ruleStakeholderInviteOps"; import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../../../../lib/server/ruleStakeholderInviteMail"; import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../lib/server/ruleStakeholders"; import { dbUnavailable, errorJson, rateLimited, serverMisconfigured, } from "../../../../../lib/server/responses"; import { rateLimitKey } from "../../../../../lib/server/rateLimit"; import { getPublicOrigin } from "../../../../../lib/server/publicOrigin"; import { readLimitedJson } from "../../../../../lib/server/validation/requestBody"; import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; import { resendExpiredStakeholderInviteBodySchema } from "../../../../../lib/server/validation/createFlowSchemas"; function clientIp(request: NextRequest): string { return ( request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "unknown" ); } export const POST = apiRoute( "invites.ruleStakeholder.resendExpired", async (request: NextRequest, _ctx, { requestId }) => { if (!isDatabaseConfigured()) { return dbUnavailable(); } const body = await readLimitedJson(request); if (body.ok === false) return body.response; const parsed = resendExpiredStakeholderInviteBodySchema.safeParse(body.value); if (!parsed.success) return jsonFromZodError(parsed.error); const { token, email } = parsed.data; const rl = rateLimitKey( `invite-resend-expired:${clientIp(request)}:${email}`, 60_000, ); if (rl.ok === false) { return rateLimited(rl.retryAfterMs); } let pepper: string; try { pepper = getSessionPepper(); } catch (err) { logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, { phase: "getSessionPepper", }); return serverMisconfigured(); } const tokenHash = hashSessionToken(token, pepper); const row = await prisma.ruleStakeholder.findUnique({ where: { inviteTokenHash: tokenHash }, select: { id: true, email: true, inviteTokenHash: true, inviteExpiresAt: true, invitedBy: { select: { email: true } }, rule: { select: { title: true, summary: true, document: true, user: { select: { email: true } }, }, }, }, }); if (!row || row.email !== email) { return NextResponse.json({ ok: true }); } const prevHash = row.inviteTokenHash; const prevExp = row.inviteExpiresAt; const nextToken = newSessionToken(); const newHash = hashSessionToken(nextToken, pepper); const newExp = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS); await prisma.ruleStakeholder.update({ where: { id: row.id }, data: { inviteTokenHash: newHash, inviteExpiresAt: newExp, }, }); const origin = getPublicOrigin(request); const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken); const inviterEmail = row.invitedBy?.email ?? row.rule.user?.email ?? RULE_STAKEHOLDER_INVITER_FALLBACK; try { await sendRuleStakeholderInviteEmail({ to: row.email, verifyUrl, origin, communityName: row.rule.title, inviterEmail, summary: row.rule.summary, document: row.rule.document, }); } catch (err) { logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, { phase: "sendRuleStakeholderInviteEmail", }); await prisma.ruleStakeholder .update({ where: { id: row.id }, data: { inviteTokenHash: prevHash, inviteExpiresAt: prevExp, }, }) .catch(() => {}); return errorJson( "mail_failed", "Could not resend stakeholder invite", 502, ); } return NextResponse.json({ ok: true }); }, );