142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
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 });
|
|
},
|
|
);
|