Manage stakeholders implemented
This commit is contained in:
+142
-36
@@ -1,14 +1,29 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import {
|
||||
hashSessionToken,
|
||||
newSessionToken,
|
||||
} from "../../../lib/server/hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../lib/server/rateLimit";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../lib/server/responses";
|
||||
import { logRouteError } from "../../../lib/server/requestId";
|
||||
import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
||||
import {
|
||||
publishRuleBodySchema,
|
||||
uniqueStakeholderEmailsForPublish,
|
||||
} from "../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||
|
||||
@@ -36,43 +51,134 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||
return NextResponse.json({ rules });
|
||||
});
|
||||
|
||||
export const POST = apiRoute("rules.publish", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
export const POST = apiRoute(
|
||||
"rules.publish",
|
||||
async (request: NextRequest, _ctx, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
|
||||
const { title, summary, document } = validated.data;
|
||||
const { title, summary, document, stakeholderEmails } = validated.data;
|
||||
const inviteEmails = uniqueStakeholderEmailsForPublish(
|
||||
stakeholderEmails,
|
||||
user.email,
|
||||
);
|
||||
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (inviteEmails.length > 0) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (inviteEmails.length === 0) {
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("rules.publish", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
const { rule, invites } = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const toSend: { email: string; token: string }[] = [];
|
||||
for (const email of inviteEmails) {
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
await tx.ruleStakeholder.create({
|
||||
data: {
|
||||
ruleId: created.id,
|
||||
email,
|
||||
invitedByUserId: user.id,
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
toSend.push({ email, token });
|
||||
}
|
||||
return { rule: created, invites: toSend };
|
||||
});
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
try {
|
||||
for (const inv of invites) {
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
|
||||
await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title);
|
||||
}
|
||||
} catch (err) {
|
||||
logRouteError("rules.publish", requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
});
|
||||
try {
|
||||
await prisma.publishedRule.delete({ where: { id: rule.id } });
|
||||
} catch (delErr) {
|
||||
logRouteError("rules.publish", requestId, delErr, {
|
||||
phase: "rollbackPublishAfterMailFailure",
|
||||
});
|
||||
}
|
||||
return errorJson(
|
||||
"mail_failed",
|
||||
"Could not send stakeholder invites",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user