Manage stakeholders implemented

This commit is contained in:
adilallo
2026-05-09 23:07:59 -06:00
parent 534c6c7c0e
commit 9f2141a62d
43 changed files with 2082 additions and 93 deletions
+183 -1
View File
@@ -199,6 +199,7 @@ export async function publishRule(input: {
title: string;
summary?: string;
document: Record<string, unknown>;
stakeholderEmails?: string[];
}): Promise<
| { ok: true; id: string; title: string }
| { ok: false; error: string; status?: number }
@@ -212,6 +213,9 @@ export async function publishRule(input: {
title: input.title,
summary: input.summary,
document: input.document,
...(input.stakeholderEmails?.length
? { stakeholderEmails: input.stakeholderEmails }
: {}),
}),
});
const data = (await safeParseJsonResponse(res)) as {
@@ -289,6 +293,8 @@ export type MyPublishedRule = {
summary: string | null;
createdAt: string;
updatedAt: string;
/** `owner` = authored rule; `stakeholder` = accepted invite (view only). */
role: "owner" | "stakeholder";
};
/**
@@ -306,7 +312,16 @@ export async function fetchMyPublishedRules(): Promise<
rules?: MyPublishedRule[];
} | null;
if (!data || !Array.isArray(data.rules)) return null;
return data.rules;
const rules = data.rules.filter(
(r): r is MyPublishedRule =>
r != null &&
typeof r === "object" &&
typeof (r as MyPublishedRule).id === "string" &&
typeof (r as MyPublishedRule).title === "string" &&
((r as MyPublishedRule).role === "owner" ||
(r as MyPublishedRule).role === "stakeholder"),
);
return rules;
} catch {
return null;
}
@@ -355,6 +370,173 @@ export async function fetchPublishedRuleDetail(
}
}
export type RuleStakeholderListItem = {
id: string;
email: string;
invitedAt: string;
acceptedAt: string | null;
status: "pending" | "accepted";
};
function parseStakeholdersPayload(data: unknown): RuleStakeholderListItem[] | null {
if (!data || typeof data !== "object" || !("stakeholders" in data)) {
return null;
}
const raw = (data as { stakeholders: unknown }).stakeholders;
if (!Array.isArray(raw)) return null;
const out: RuleStakeholderListItem[] = [];
for (const x of raw) {
if (
!x ||
typeof x !== "object" ||
typeof (x as { id?: unknown }).id !== "string" ||
typeof (x as { email?: unknown }).email !== "string" ||
typeof (x as { invitedAt?: unknown }).invitedAt !== "string" ||
((x as { status?: unknown }).status !== "pending" &&
(x as { status?: unknown }).status !== "accepted")
) {
continue;
}
const acceptedRaw = (x as { acceptedAt?: unknown }).acceptedAt;
const acceptedAt =
acceptedRaw === null
? null
: typeof acceptedRaw === "string"
? acceptedRaw
: null;
out.push({
id: (x as { id: string }).id,
email: (x as { email: string }).email,
invitedAt: (x as { invitedAt: string }).invitedAt,
acceptedAt,
status: (x as { status: "pending" | "accepted" }).status,
});
}
return out;
}
export async function fetchRuleStakeholders(
ruleId: string,
): Promise<RuleStakeholderListItem[] | null> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
{ credentials: "include" },
);
if (!res.ok) return null;
const data = await safeParseJsonResponse(res);
return parseStakeholdersPayload(data);
} catch {
return null;
}
}
export type RuleStakeholderMutationResult =
| { ok: true }
| { ok: false; error: string; status: number; retryAfterMs?: number };
function retryAfterFromResponse(
res: Response,
data: unknown,
): number | undefined {
if (res.status !== 429) return undefined;
if (data && typeof data === "object" && "details" in data) {
const d = (data as { details?: unknown }).details;
if (d && typeof d === "object" && "retryAfterMs" in d) {
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
if (typeof ms === "number" && ms > 0) return ms;
}
}
const h = res.headers.get("retry-after");
if (h) {
const sec = Number.parseInt(h, 10);
if (!Number.isNaN(sec)) return sec * 1000;
}
return undefined;
}
export async function addRuleStakeholder(
ruleId: string,
email: string,
): Promise<RuleStakeholderMutationResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
{
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ email }),
},
);
if (res.ok) return { ok: true };
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
retryAfterMs: retryAfterFromResponse(res, data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export async function deleteRuleStakeholder(
ruleId: string,
stakeholderId: string,
): Promise<RuleStakeholderMutationResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}`,
{ method: "DELETE", credentials: "include" },
);
if (res.ok) return { ok: true };
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export async function resendRuleStakeholderInvite(
ruleId: string,
stakeholderId: string,
): Promise<RuleStakeholderMutationResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}/resend`,
{ method: "POST", credentials: "include" },
);
if (res.ok) return { ok: true };
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
retryAfterMs: retryAfterFromResponse(res, data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteRuleResult =
| { ok: true }
| { ok: false; error: string; status: number };
+5
View File
@@ -0,0 +1,5 @@
/**
* Max stakeholder emails per draft + publish body.
* Server: {@link MAX_STAKEHOLDER_EMAILS} in `lib/server/validation/createFlowSchemas.ts` must match.
*/
export const MAX_STAKEHOLDER_EMAILS = 30;
+29
View File
@@ -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,
+65
View File
@@ -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);
}
+6 -1
View File
@@ -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, {
+55
View File
@@ -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 };
}
}
+2
View File
@@ -0,0 +1,2 @@
/** Parity with magic-link request TTL (15 minutes). */
export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000;
+44 -1
View File
@@ -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 publishers 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,
});