Manage stakeholders implemented
This commit is contained in:
@@ -27,6 +27,35 @@ export async function sendMagicLinkEmail(
|
||||
}
|
||||
|
||||
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
|
||||
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
|
||||
export async function sendRuleStakeholderInviteEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
ruleTitle: string,
|
||||
): Promise<void> {
|
||||
const url = process.env.SMTP_URL;
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(
|
||||
`[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: `You're invited to view a Community Rule: ${ruleTitle}`,
|
||||
text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmailChangeEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
|
||||
@@ -88,3 +88,68 @@ export async function listPublishedRulesForUser(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProfileRuleListItem = OwnerPublishedRuleListItem & {
|
||||
role: "owner" | "stakeholder";
|
||||
};
|
||||
|
||||
/**
|
||||
* Published rules the user can access as an **accepted** stakeholder (`userId` set).
|
||||
* Same metadata shape as {@link listPublishedRulesForUser}; no `document`.
|
||||
*/
|
||||
export async function listStakeholderRulesForUser(
|
||||
userId: string,
|
||||
take: number,
|
||||
): Promise<OwnerPublishedRuleListItem[] | null> {
|
||||
if (!isDatabaseConfigured()) return null;
|
||||
if (typeof userId !== "string" || userId.trim() === "") return null;
|
||||
const clamped = Math.min(Math.max(0, take), 100);
|
||||
if (clamped === 0) return [];
|
||||
try {
|
||||
const rows = await prisma.ruleStakeholder.findMany({
|
||||
where: { userId },
|
||||
take: clamped,
|
||||
orderBy: [{ rule: { updatedAt: "desc" } }, { id: "asc" }],
|
||||
select: {
|
||||
rule: { select: PUBLISHED_RULE_OWNER_LIST_SELECT },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => r.rule);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile list: owned rules plus stakeholder access, **owner wins** if both,
|
||||
* sorted by `updatedAt` desc (then `id`).
|
||||
*/
|
||||
export async function listProfileRulesForUser(
|
||||
userId: string,
|
||||
take: number,
|
||||
): Promise<ProfileRuleListItem[] | null> {
|
||||
const cap = Math.min(Math.max(0, take), 100);
|
||||
if (cap === 0) return [];
|
||||
/** Merge then slice so ordering is global by `updatedAt`. */
|
||||
const fetchCap = 100;
|
||||
const [owned, stakeholderRules] = await Promise.all([
|
||||
listPublishedRulesForUser(userId, fetchCap),
|
||||
listStakeholderRulesForUser(userId, fetchCap),
|
||||
]);
|
||||
if (owned === null || stakeholderRules === null) return null;
|
||||
const ownerIds = new Set(owned.map((r) => r.id));
|
||||
const stakeholderOnly = stakeholderRules.filter((r) => !ownerIds.has(r.id));
|
||||
const combined: ProfileRuleListItem[] = [
|
||||
...owned.map((r) => ({ ...r, role: "owner" as const })),
|
||||
...stakeholderOnly.map((r) => ({
|
||||
...r,
|
||||
role: "stakeholder" as const,
|
||||
})),
|
||||
];
|
||||
combined.sort((a, b) => {
|
||||
const t = b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
if (t !== 0) return t;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return combined.slice(0, cap);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export type ApiErrorCode =
|
||||
| "rate_limited"
|
||||
| "server_misconfigured"
|
||||
| "mail_failed"
|
||||
| "internal_error";
|
||||
| "internal_error"
|
||||
| "conflict";
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error: { code: ApiErrorCode; message: string };
|
||||
@@ -66,6 +67,10 @@ export function forbidden(message = "Forbidden"): NextResponse {
|
||||
return errorJson("forbidden", message, 403);
|
||||
}
|
||||
|
||||
export function conflict(message = "Conflict"): NextResponse {
|
||||
return errorJson("conflict", message, 409);
|
||||
}
|
||||
|
||||
export function rateLimited(retryAfterMs: number): NextResponse {
|
||||
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
|
||||
return errorJson("rate_limited", "Too many requests", 429, {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from "./db";
|
||||
import { hashSessionToken, newSessionToken } from "./hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "./mail";
|
||||
import { logRouteError } from "./requestId";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "./ruleStakeholders";
|
||||
|
||||
export function stakeholderInviteVerifyUrl(origin: string, token: string): string {
|
||||
return `${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pending {@link RuleStakeholder} row and sends the invite email.
|
||||
* On mail failure, deletes the row and returns `ok: false`.
|
||||
*/
|
||||
export async function createRuleStakeholderInviteAndSendMail(opts: {
|
||||
scope: string;
|
||||
requestId: string;
|
||||
origin: string;
|
||||
ruleId: string;
|
||||
ruleTitle: string;
|
||||
email: string;
|
||||
invitedByUserId: string;
|
||||
pepper: string;
|
||||
}): Promise<{ ok: true } | { ok: false }> {
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, opts.pepper);
|
||||
const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
|
||||
const row = await prisma.ruleStakeholder.create({
|
||||
data: {
|
||||
ruleId: opts.ruleId,
|
||||
email: opts.email,
|
||||
invitedByUserId: opts.invitedByUserId,
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token);
|
||||
try {
|
||||
await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
logRouteError(opts.scope, opts.requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
email: opts.email,
|
||||
});
|
||||
try {
|
||||
await prisma.ruleStakeholder.delete({ where: { id: row.id } });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Parity with magic-link request TTL (15 minutes). */
|
||||
export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
||||
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||
|
||||
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
|
||||
@@ -64,6 +65,15 @@ const customMethodCardMetaEntrySchema = z.object({
|
||||
supportText: z.string().max(48),
|
||||
});
|
||||
|
||||
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
|
||||
const stakeholderEmailSchema = z
|
||||
.string()
|
||||
.max(320)
|
||||
.transform((s) => s.trim().toLowerCase())
|
||||
.pipe(z.string().email());
|
||||
|
||||
export { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||
|
||||
/**
|
||||
* Published rule `document` column: arbitrary JSON object with safety bounds.
|
||||
*/
|
||||
@@ -144,7 +154,10 @@ export const createFlowStateSchema = z
|
||||
editingPublishedRuleId: z.string().max(200).optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
stakeholderEmails: z
|
||||
.array(stakeholderEmailSchema)
|
||||
.max(MAX_STAKEHOLDER_EMAILS)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -171,10 +184,40 @@ export const publishRuleBodySchema = z.object({
|
||||
return t.length > 0 ? t : null;
|
||||
}),
|
||||
document: publishedRuleDocumentSchema,
|
||||
stakeholderEmails: z
|
||||
.array(stakeholderEmailSchema)
|
||||
.max(MAX_STAKEHOLDER_EMAILS)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
|
||||
|
||||
export const postRuleStakeholderBodySchema = z.object({
|
||||
email: stakeholderEmailSchema,
|
||||
});
|
||||
|
||||
export type PostRuleStakeholderBody = z.infer<
|
||||
typeof postRuleStakeholderBodySchema
|
||||
>;
|
||||
|
||||
/** Dedupe and drop the publisher’s own email (`emails` need not be pre-normalized). */
|
||||
export function uniqueStakeholderEmailsForPublish(
|
||||
emails: string[] | undefined,
|
||||
publisherEmailNormalized: string,
|
||||
): string[] {
|
||||
if (!emails?.length) return [];
|
||||
const pub = publisherEmailNormalized.trim().toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of emails) {
|
||||
const e = raw.trim().toLowerCase();
|
||||
if (e === pub || seen.has(e)) continue;
|
||||
seen.add(e);
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const putDraftBodySchema = z.object({
|
||||
payload: createFlowStateSchema,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user