186 lines
5.4 KiB
TypeScript
186 lines
5.4 KiB
TypeScript
import type { Prisma } from "@prisma/client";
|
|
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 { 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 { getPublicOrigin } from "../../../lib/server/publicOrigin";
|
|
import {
|
|
publishRuleBodySchema,
|
|
uniqueStakeholderEmailsForPublish,
|
|
} from "../../../lib/server/validation/createFlowSchemas";
|
|
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
|
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
|
|
|
export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
|
if (!isDatabaseConfigured()) {
|
|
return dbUnavailable();
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url);
|
|
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
|
|
|
/** Public catalog: mirror profile “my rules” recency semantics (last touched first). */
|
|
const rules = await prisma.publishedRule.findMany({
|
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
|
take,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
summary: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({ rules });
|
|
});
|
|
|
|
export const POST = apiRoute(
|
|
"rules.publish",
|
|
async (request: NextRequest, _ctx, { requestId }) => {
|
|
if (!isDatabaseConfigured()) {
|
|
return dbUnavailable();
|
|
}
|
|
|
|
const user = await getSessionUser();
|
|
if (!user) {
|
|
return unauthorized();
|
|
}
|
|
|
|
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 { title, summary, document, stakeholderEmails } = validated.data;
|
|
const inviteEmails = uniqueStakeholderEmailsForPublish(
|
|
stakeholderEmails,
|
|
user.email,
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 = getPublicOrigin(request);
|
|
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,
|
|
},
|
|
});
|
|
},
|
|
);
|