Manage stakeholders implemented
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashSessionToken } from "../../../../../lib/server/hash";
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
getOrCreateRequestId,
|
||||
logRouteError,
|
||||
} from "../../../../../lib/server/requestId";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import {
|
||||
createSessionForUser,
|
||||
getSessionUser,
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
|
||||
const SCOPE = "invites.ruleStakeholder.verify";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = getOrCreateRequestId(request);
|
||||
|
||||
if (!isDatabaseConfigured()) {
|
||||
const res = dbUnavailable();
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token || token.length < 10) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=invalid_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" });
|
||||
return redirectWithRequestId(request, "/login?error=server", requestId);
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
|
||||
const row = await prisma.ruleStakeholder.findUnique({
|
||||
where: { inviteTokenHash: tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
ruleId: true,
|
||||
inviteExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!row ||
|
||||
!row.inviteExpiresAt ||
|
||||
row.inviteExpiresAt < new Date()
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=expired_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const existingSession = await getSessionUser();
|
||||
if (
|
||||
existingSession &&
|
||||
existingSession.email.trim().toLowerCase() !== row.email
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=stakeholder_wrong_account",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: row.email },
|
||||
create: { email: row.email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.ruleStakeholder.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
userId: user.id,
|
||||
acceptedAt: new Date(),
|
||||
inviteTokenHash: null,
|
||||
inviteExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||
user.id,
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
|
||||
const dest = `/rules/${encodeURIComponent(row.ruleId)}`;
|
||||
return redirectWithRequestId(request, dest, requestId);
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err);
|
||||
return redirectWithRequestId(request, "/login?error=server", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithRequestId(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
requestId: string,
|
||||
): NextResponse {
|
||||
const res = NextResponse.redirect(new URL(path, request.url));
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../../../lib/server/ruleStakeholders";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
forbidden,
|
||||
notFound,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../../../lib/server/session";
|
||||
import { rateLimitKey } from "../../../../../../../lib/server/rateLimit";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> };
|
||||
|
||||
export const POST = apiRoute<RouteContext>(
|
||||
"rules.stakeholders.resend",
|
||||
async (request: NextRequest, context, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id: ruleId, stakeholderId } = await context.params;
|
||||
|
||||
const row = await prisma.ruleStakeholder.findFirst({
|
||||
where: { id: stakeholderId, ruleId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
inviteTokenHash: true,
|
||||
inviteExpiresAt: true,
|
||||
rule: { select: { userId: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return notFound();
|
||||
}
|
||||
if (row.rule.userId !== user.id) {
|
||||
return forbidden();
|
||||
}
|
||||
if (row.inviteTokenHash === null) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"This stakeholder has already accepted the invite",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const rl = rateLimitKey(`rule-stakeholders-resend:${row.id}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("rules.stakeholders.resend", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const prevHash = row.inviteTokenHash;
|
||||
const prevExp = row.inviteExpiresAt;
|
||||
const token = newSessionToken();
|
||||
const newHash = hashSessionToken(token, 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 verifyUrl = stakeholderInviteVerifyUrl(request.nextUrl.origin, token);
|
||||
try {
|
||||
await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title);
|
||||
} catch (err) {
|
||||
logRouteError("rules.stakeholders.resend", 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 });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../../../../lib/server/env";
|
||||
import { apiRoute } from "../../../../../../lib/server/apiRoute";
|
||||
import {
|
||||
dbUnavailable,
|
||||
forbidden,
|
||||
notFound,
|
||||
unauthorized,
|
||||
} from "../../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../../lib/server/session";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> };
|
||||
|
||||
export const DELETE = apiRoute<RouteContext>(
|
||||
"rules.stakeholders.delete",
|
||||
async (_request: NextRequest, context) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id: ruleId, stakeholderId } = await context.params;
|
||||
|
||||
const row = await prisma.ruleStakeholder.findFirst({
|
||||
where: { id: stakeholderId, ruleId },
|
||||
select: {
|
||||
id: true,
|
||||
rule: { select: { userId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return notFound();
|
||||
}
|
||||
if (row.rule.userId !== user.id) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
await prisma.ruleStakeholder.delete({ where: { id: row.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,192 @@
|
||||
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 {
|
||||
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 = request.nextUrl.origin;
|
||||
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 },
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules";
|
||||
import { listProfileRulesForUser } from "../../../../lib/server/publishedRules";
|
||||
import {
|
||||
dbUnavailable,
|
||||
internalError,
|
||||
@@ -22,10 +22,19 @@ export const GET = apiRoute("rules.me.list", async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||
|
||||
const rules = await listPublishedRulesForUser(user.id, take);
|
||||
const rules = await listProfileRulesForUser(user.id, take);
|
||||
if (rules === null) {
|
||||
return internalError("Failed to list rules");
|
||||
}
|
||||
|
||||
return NextResponse.json({ rules });
|
||||
return NextResponse.json({
|
||||
rules: rules.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
role: r.role,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
+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