Files
community-rule/app/api/rules/[id]/stakeholders/route.ts
T
2026-05-23 18:19:45 -06:00

194 lines
5.2 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { logRouteError } from "../../../../../lib/server/requestId";
import { createRuleStakeholderInviteAndSendMail } from "../../../../../lib/server/ruleStakeholderInviteOps";
import {
conflict,
dbUnavailable,
errorJson,
notFound,
rateLimited,
serverMisconfigured,
unauthorized,
} from "../../../../../lib/server/responses";
import { getSessionUser } from "../../../../../lib/server/session";
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
import {
MAX_STAKEHOLDER_EMAILS,
postRuleStakeholderBodySchema,
} from "../../../../../lib/server/validation/createFlowSchemas";
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
type RouteContext = { params: Promise<{ id: string }> };
async function ownedRuleMeta(ruleId: string, userId: string) {
return prisma.publishedRule.findFirst({
where: { id: ruleId, userId },
select: { id: true, title: true },
});
}
export const GET = apiRoute<RouteContext>(
"rules.stakeholders.list",
async (_request, context) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { id: ruleId } = await context.params;
const rule = await ownedRuleMeta(ruleId, user.id);
if (!rule) {
return notFound();
}
const rows = await prisma.ruleStakeholder.findMany({
where: { ruleId: rule.id },
orderBy: [{ invitedAt: "asc" }, { id: "asc" }],
select: {
id: true,
email: true,
invitedAt: true,
acceptedAt: true,
inviteTokenHash: true,
},
});
return NextResponse.json({
stakeholders: rows.map((r) => ({
id: r.id,
email: r.email,
invitedAt: r.invitedAt.toISOString(),
acceptedAt: r.acceptedAt?.toISOString() ?? null,
status:
r.inviteTokenHash !== null ? ("pending" as const) : ("accepted" as const),
})),
});
},
);
export const POST = apiRoute<RouteContext>(
"rules.stakeholders.add",
async (request: NextRequest, context, { requestId }) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { id: ruleId } = await context.params;
const rule = await ownedRuleMeta(ruleId, user.id);
if (!rule) {
return notFound();
}
const parsedBody = await readLimitedJson(request);
if (parsedBody.ok === false) {
return parsedBody.response;
}
const validated = postRuleStakeholderBodySchema.safeParse(parsedBody.value);
if (!validated.success) {
return jsonFromZodError(validated.error);
}
const email = validated.data.email;
if (email === user.email.trim().toLowerCase()) {
return errorJson(
"validation_error",
"You cannot invite your own account email",
400,
);
}
const existing = await prisma.ruleStakeholder.findFirst({
where: { ruleId: rule.id, email },
});
if (existing) {
return conflict("That email is already invited for this rule");
}
const count = await prisma.ruleStakeholder.count({
where: { ruleId: rule.id },
});
if (count >= MAX_STAKEHOLDER_EMAILS) {
return errorJson(
"validation_error",
`You can invite at most ${MAX_STAKEHOLDER_EMAILS} stakeholders per rule`,
400,
);
}
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown";
const rl = rateLimitKey(`rule-stakeholders-add-ip:${ip}`, 60_000);
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch (err) {
logRouteError("rules.stakeholders.add", requestId, err, {
phase: "getSessionPepper",
});
return serverMisconfigured();
}
const origin = getPublicOrigin(request);
const sent = await createRuleStakeholderInviteAndSendMail({
scope: "rules.stakeholders.add",
requestId,
origin,
ruleId: rule.id,
ruleTitle: rule.title,
email,
invitedByUserId: user.id,
pepper,
});
if (!sent.ok) {
return errorJson(
"mail_failed",
"Could not send stakeholder invite",
502,
);
}
const created = await prisma.ruleStakeholder.findFirst({
where: { ruleId: rule.id, email },
select: { id: true, email: true, invitedAt: true },
});
return NextResponse.json(
{
stakeholder: created && {
id: created.id,
email: created.email,
invitedAt: created.invitedAt.toISOString(),
acceptedAt: null,
status: "pending" as const,
},
},
{ status: 201 },
);
},
);