diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 642198f..1fe7479 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -17,6 +17,9 @@ import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport import CreateFlowFooter from "../../components/navigation/CreateFlowFooter"; import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav"; import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, getNextStep, getStepIndex, parseReviewReturnSearchParam, @@ -158,7 +161,17 @@ function CreateFlowLayoutContent({ resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState, + markCreateFlowInteraction, } = useCreateFlow(); + const manageStakeholdersIntent = + searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; + const editingPublishedRuleIdTrimmed = + state.editingPublishedRuleId?.trim() ?? ""; + const isConfirmStakeholdersManagePublished = + currentStep === "confirm-stakeholders" && + manageStakeholdersIntent && + editingPublishedRuleIdTrimmed.length > 0; const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = @@ -411,6 +424,7 @@ function CreateFlowLayoutContent({ const isRightRailStep = currentStep === "decision-approaches"; const isFinalReviewLike = currentStep === "final-review" || currentStep === "edit-rule"; + const isEditRuleStep = currentStep === "edit-rule"; const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep); /** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */ const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( @@ -581,6 +595,7 @@ function CreateFlowLayoutContent({ hasShare={isCompletedStep} hasExport={isCompletedStep} hasEdit={isCompletedStep} + hasManageStakeholders={isEditRuleStep} saveDraftOnExit={saveDraftOnExit} onShare={ isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined @@ -601,6 +616,20 @@ function CreateFlowLayoutContent({ } : undefined } + onManageStakeholders={ + isEditRuleStep + ? () => { + markCreateFlowInteraction(); + router.push( + createFlowStepPath("confirm-stakeholders", { + [CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "edit-rule", + [CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]: + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, + }), + ); + } + : undefined + } onExit={(opts) => void handleExit(opts)} buttonPalette={isCompletedStep ? "inverse" : undefined} className={`shrink-0 ${ @@ -762,6 +791,27 @@ function CreateFlowLayoutContent({ > {footer[customRuleConfirmFooter.footerMessageKey]} + ) : isConfirmStakeholdersManagePublished ? ( + { + router.push( + createFlowStepPathAfterStrippingReviewReturn( + "edit-rule", + searchParams, + ), + ); + }} + > + { + create.reviewAndComplete.confirmStakeholders.managePublished + .footerDone + } + ) : nextStep || isFinalReviewLike ? ( typeof e === "string" && e.trim() !== "", + ); const publishResult = await publishRule({ title, summary, document: ruleDocument, + ...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}), }); setIsPublishing(false); if (publishResult.ok === true) { diff --git a/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx b/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx index 560735d..d585167 100644 --- a/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx +++ b/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx @@ -1,25 +1,82 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import MultiSelect from "../../../../components/controls/MultiSelect"; import Alert from "../../../../components/modals/Alert"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; import { useTranslation } from "../../../../contexts/MessagesContext"; +import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; +import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, +} from "../../utils/flowSteps"; +import { createFlowStepPath } from "../../utils/createFlowPaths"; +import { PublishedStakeholdersManagePanel } from "./PublishedStakeholdersManagePanel"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function emailsToChipOptions(emails: string[]): ChipOption[] { + return emails.map((email) => ({ + id: email, + label: email, + state: "selected" as const, + })); +} export function ConfirmStakeholdersScreen() { - const { markCreateFlowInteraction } = useCreateFlow(); + const router = useRouter(); + const searchParams = useSearchParams(); + const { state, updateState, markCreateFlowInteraction } = useCreateFlow(); const t = useTranslation("create.reviewAndComplete.confirmStakeholders"); + + const manageStakeholdersIntent = + searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; + const editingPublishedRuleId = state.editingPublishedRuleId?.trim() ?? ""; + const managePublishedMode = + manageStakeholdersIntent && editingPublishedRuleId.length > 0; + + useEffect(() => { + if (!manageStakeholdersIntent) return; + if (editingPublishedRuleId.length > 0) return; + router.replace(createFlowStepPath("edit-rule")); + }, [ + manageStakeholdersIntent, + editingPublishedRuleId.length, + router, + ]); + + const persistedKey = (state.stakeholderEmails ?? []).join("\0"); const [toastDismissed, setToastDismissed] = useState(false); + const [chipError, setChipError] = useState(null); const [stakeholderOptions, setStakeholderOptions] = useState( - [], + () => emailsToChipOptions(state.stakeholderEmails ?? []), ); + useEffect(() => { + setStakeholderOptions((prev) => { + const inFlight = prev.filter((c) => c.state === "custom"); + const nextPersisted = emailsToChipOptions(state.stakeholderEmails ?? []); + return [...nextPersisted, ...inFlight]; + }); + }, [persistedKey]); + const handleAddStakeholder = () => { markCreateFlowInteraction(); + setChipError(null); + const confirmed = state.stakeholderEmails ?? []; + const customCount = stakeholderOptions.filter( + (o) => o.state === "custom", + ).length; + if (confirmed.length + customCount >= MAX_STAKEHOLDER_EMAILS) { + setChipError(t("maxStakeholders")); + return; + } setStakeholderOptions((prev) => [ ...prev, { id: crypto.randomUUID(), label: "", state: "custom" }, @@ -28,23 +85,72 @@ export function ConfirmStakeholdersScreen() { const handleCustomChipConfirm = (chipId: string, value: string) => { markCreateFlowInteraction(); + setChipError(null); + const trimmed = value.trim().toLowerCase(); + if (!EMAIL_PATTERN.test(trimmed)) { + setChipError(t("invalidEmail")); + return; + } + const current = state.stakeholderEmails ?? []; + if (current.includes(trimmed)) { + setChipError(t("duplicateEmail")); + setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); + return; + } + if (current.length >= MAX_STAKEHOLDER_EMAILS) { + setChipError(t("maxStakeholders")); + setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); + return; + } setStakeholderOptions((prev) => prev.map((opt) => - opt.id === chipId ? { ...opt, label: value, state: "selected" } : opt, + opt.id === chipId + ? { id: trimmed, label: trimmed, state: "selected" as const } + : opt, ), ); + updateState({ stakeholderEmails: [...current, trimmed] }); }; const handleCustomChipClose = (chipId: string) => { markCreateFlowInteraction(); + setChipError(null); setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); }; const handleChipClick = (chipId: string) => { markCreateFlowInteraction(); + setChipError(null); setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId)); + updateState({ + stakeholderEmails: (state.stakeholderEmails ?? []).filter( + (e) => e !== chipId, + ), + }); }; + if (managePublishedMode) { + return ( + + + + + + + + + ); + } + return ( <> + {chipError ? ( + + {chipError} + + ) : null} (null); + const [loadError, setLoadError] = useState(false); + const [email, setEmail] = useState(""); + const [fieldError, setFieldError] = useState(""); + const [bannerError, setBannerError] = useState(""); + const [addBusy, setAddBusy] = useState(false); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(async () => { + setLoadError(false); + const list = await fetchRuleStakeholders(ruleId); + if (list === null) { + setLoadError(true); + setItems([]); + return; + } + setItems(list); + }, [ruleId]); + + useEffect(() => { + void load(); + }, [load]); + + const handleAdd = async () => { + setBannerError(""); + setFieldError(""); + const trimmed = email.trim().toLowerCase(); + if (!EMAIL_PATTERN.test(trimmed)) { + setFieldError(t("managePublished.invalidEmail")); + return; + } + setAddBusy(true); + const res = await addRuleStakeholder(ruleId, trimmed); + setAddBusy(false); + if (res.ok === true) { + setEmail(""); + void load(); + return; + } + if (res.retryAfterMs != null && res.retryAfterMs > 0) { + const seconds = Math.ceil(res.retryAfterMs / 1000); + setBannerError( + t("managePublished.rateLimited").replace("{seconds}", String(seconds)), + ); + return; + } + setBannerError( + res.error.trim() !== "" ? res.error : t("managePublished.actionFailed"), + ); + }; + + const handleRemove = async (id: string) => { + setBannerError(""); + setBusyId(id); + const res = await deleteRuleStakeholder(ruleId, id); + setBusyId(null); + if (res.ok === true) { + void load(); + return; + } + setBannerError( + res.error.trim() !== "" ? res.error : t("managePublished.actionFailed"), + ); + }; + + const handleResend = async (id: string) => { + setBannerError(""); + setBusyId(id); + const res = await resendRuleStakeholderInvite(ruleId, id); + setBusyId(null); + if (res.ok === true) { + return; + } + if (res.retryAfterMs != null && res.retryAfterMs > 0) { + const seconds = Math.ceil(res.retryAfterMs / 1000); + setBannerError( + t("managePublished.rateLimited").replace("{seconds}", String(seconds)), + ); + return; + } + setBannerError( + res.error.trim() !== "" ? res.error : t("managePublished.actionFailed"), + ); + }; + + return ( + + {bannerError ? ( + + {bannerError} + + ) : null} + + {loadError ? ( + + {t("managePublished.loadFailed")} + + ) : items === null ? ( + + {t("managePublished.loading")} + + ) : items.length === 0 ? ( + + {t("managePublished.empty")} + + ) : ( + + {items.map((row) => ( + + + + {row.email} + + + {row.status === "pending" + ? t("managePublished.pending") + : t("managePublished.accepted")} + + + + {row.status === "pending" ? ( + void handleResend(row.id)} + ariaLabel={t("managePublished.resendAria").replace( + "{email}", + row.email, + )} + > + {t("managePublished.resend")} + + ) : null} + void handleRemove(row.id)} + ariaLabel={t("managePublished.removeAria").replace( + "{email}", + row.email, + )} + > + {t("managePublished.remove")} + + + + ))} + + )} + + + + { + setEmail(e.target.value); + setFieldError(""); + }} + error={Boolean(fieldError)} + textHint={fieldError || false} + autoComplete="email" + /> + + void handleAdd()} + > + {t("managePublished.addInvite")} + + + + ); +} diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index daaf124..e2a3a14 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -218,8 +218,11 @@ export interface CreateFlowState { currentStep?: CreateFlowStep; /** Section drafts; structure will tighten as steps persist real shapes. */ sections?: Record[]; - /** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */ - stakeholders?: Record[]; + /** + * Stakeholder invite emails (confirm-stakeholders step). Normalized on the server; + * invites are sent at first publish (`POST /api/rules`). + */ + stakeholderEmails?: string[]; /** Extra step-specific fields (must be JSON-serializable for server draft sync). */ [key: string]: unknown; } diff --git a/app/(app)/create/utils/createFlowPaths.ts b/app/(app)/create/utils/createFlowPaths.ts index 8d5ba0f..9e2e0e6 100644 --- a/app/(app)/create/utils/createFlowPaths.ts +++ b/app/(app)/create/utils/createFlowPaths.ts @@ -4,7 +4,10 @@ */ import type { CreateFlowStep } from "../types"; -import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps"; +import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, +} from "./flowSteps"; export const CREATE_ROUTES = { root: "/", @@ -59,7 +62,7 @@ export function createCompletedPath(query?: CreateFlowPathQuery): string { /** * Navigate back from a facet step to final-review / edit-rule, dropping - * `reviewReturn` from the current query while preserving other params. + * `reviewReturn` and `manageStakeholders` from the current query while preserving other params. */ export function createFlowStepPathAfterStrippingReviewReturn( step: CreateFlowStep, @@ -67,6 +70,7 @@ export function createFlowStepPathAfterStrippingReviewReturn( ): string { const params = new URLSearchParams(searchParams?.toString() ?? ""); params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); + params.delete(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY); const query: CreateFlowPathQuery = {}; params.forEach((value, key) => { query[key] = value; diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index cbe40aa..10cbab9 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -188,6 +188,13 @@ export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const; /** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */ export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const; +/** + * `/create/confirm-stakeholders?manageStakeholders=1` — edit published rule invites (requires `state.editingPublishedRuleId`). + * Typically paired with `reviewReturn=edit-rule`. + */ +export const CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY = "manageStakeholders" as const; +export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const; + export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule"; export function parseReviewReturnSearchParam( diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index b485643..49e75e8 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -340,28 +340,38 @@ export function ProfilePageView({ expanded size={ruleCardSize} hasBottomLinks - bottomLinks={[ - { - id: "view", - label: t("viewPublic"), - href: `/rules/${encodeURIComponent(rule.id)}`, - }, - { - id: "manage", - label: t("manageRule"), - href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, - }, - { - id: "dup", - label: t("duplicate"), - onClick: () => onDuplicateRule(rule.id), - }, - { - id: "del", - label: t("deleteRule"), - onClick: () => onDeleteRule(rule.id), - }, - ]} + bottomLinks={ + rule.role === "stakeholder" + ? [ + { + id: "view", + label: t("viewPublic"), + href: `/rules/${encodeURIComponent(rule.id)}`, + }, + ] + : [ + { + id: "view", + label: t("viewPublic"), + href: `/rules/${encodeURIComponent(rule.id)}`, + }, + { + id: "manage", + label: t("manageRule"), + href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, + }, + { + id: "dup", + label: t("duplicate"), + onClick: () => onDuplicateRule(rule.id), + }, + { + id: "del", + label: t("deleteRule"), + onClick: () => onDeleteRule(rule.id), + }, + ] + } communityInitials={ rule.title.trim().charAt(0).toUpperCase() || "·" } diff --git a/app/api/invites/rule-stakeholder/verify/route.ts b/app/api/invites/rule-stakeholder/verify/route.ts new file mode 100644 index 0000000..ed63b04 --- /dev/null +++ b/app/api/invites/rule-stakeholder/verify/route.ts @@ -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; +} diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts new file mode 100644 index 0000000..87fcc3e --- /dev/null +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts @@ -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( + "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 }); + }, +); diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts new file mode 100644 index 0000000..51adcdd --- /dev/null +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts @@ -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( + "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 }); + }, +); diff --git a/app/api/rules/[id]/stakeholders/route.ts b/app/api/rules/[id]/stakeholders/route.ts new file mode 100644 index 0000000..f169cb2 --- /dev/null +++ b/app/api/rules/[id]/stakeholders/route.ts @@ -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( + "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( + "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 }, + ); + }, +); diff --git a/app/api/rules/me/route.ts b/app/api/rules/me/route.ts index 69d5e8f..06a90d5 100644 --- a/app/api/rules/me/route.ts +++ b/app/api/rules/me/route.ts @@ -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, + })), + }); }); diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 35ad450..2b0983b 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -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, + }, + }); + }, +); diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 02abeb4..e99f9b3 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -125,11 +125,13 @@ export default function LoginForm({ const urlErrorMessage = errorParam === "expired_link" ? t("errors.expiredLink") - : errorParam === "invalid_link" || errorParam === "server" - ? errorParam === "server" - ? t("errors.serverError") - : t("errors.invalidLink") - : ""; + : errorParam === "stakeholder_wrong_account" + ? t("errors.stakeholderWrongAccount") + : errorParam === "invalid_link" || errorParam === "server" + ? errorParam === "server" + ? t("errors.serverError") + : t("errors.invalidLink") + : ""; const titleId = "login-modal-heading"; diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx index f09e5d5..74d4f16 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx @@ -16,10 +16,12 @@ const CreateFlowTopNavContainer = memo( hasShare = false, hasExport = false, hasEdit = false, + hasManageStakeholders = false, saveDraftOnExit = false, onShare, onSelectExportFormat, onEdit, + onManageStakeholders, onExit, buttonPalette, className = "", @@ -41,10 +43,12 @@ const CreateFlowTopNavContainer = memo( hasShare={hasShare} hasExport={hasExport} hasEdit={hasEdit} + hasManageStakeholders={hasManageStakeholders} saveDraftOnExit={saveDraftOnExit} onShare={onShare} onSelectExportFormat={onSelectExportFormat} onEdit={onEdit} + onManageStakeholders={onManageStakeholders} onExit={handleExit} buttonPalette={buttonPalette} className={className} diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts index 6589855..0bf91b0 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts @@ -21,6 +21,12 @@ export interface CreateFlowTopNavProps { * @default false */ hasEdit?: boolean; + /** + * Whether to show **Manage Stakeholders** (published-rule invite management). + * Used on `/create/edit-rule` only. + * @default false + */ + hasManageStakeholders?: boolean; /** * When true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`. * When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss). @@ -39,6 +45,10 @@ export interface CreateFlowTopNavProps { * Callback when Edit button is clicked */ onEdit?: () => void; + /** + * Callback when Manage Stakeholders is clicked + */ + onManageStakeholders?: () => void; /** * Callback when Exit/Save & Exit button is clicked. * When `saveDraftOnExit` is true, called with `{ saveDraft: true }`. diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx index 1498450..a78071f 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -15,10 +15,12 @@ export function CreateFlowTopNavView({ hasShare = false, hasExport = false, hasEdit = false, + hasManageStakeholders = false, saveDraftOnExit = false, onShare, onSelectExportFormat, onEdit, + onManageStakeholders, onExit, buttonPalette = "default", className = "", @@ -165,6 +167,20 @@ export function CreateFlowTopNavView({ )} + {hasManageStakeholders && onManageStakeholders ? ( + + {t("manageStakeholders")} + + ) : null} + ` and only wires `onChipClick`. `category.onAddClick` is **not provided**, so the `+` button on each MultiSelect category renders by default (`addButton={!hideCategoryAddButton}` in [`Rule.view.tsx`](../../app/components/cards/Rule/Rule.view.tsx)) but **does nothing** when clicked. Dead control we are shipping today. diff --git a/lib/create/api.ts b/lib/create/api.ts index b0e88cb..2fa21c9 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -199,6 +199,7 @@ export async function publishRule(input: { title: string; summary?: string; document: Record; + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/lib/create/stakeholderLimits.ts b/lib/create/stakeholderLimits.ts new file mode 100644 index 0000000..3447172 --- /dev/null +++ b/lib/create/stakeholderLimits.ts @@ -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; diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 2af3f32..83ef508 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -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 { + 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, diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts index f91e765..6f1ecf0 100644 --- a/lib/server/publishedRules.ts +++ b/lib/server/publishedRules.ts @@ -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 { + 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 { + 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); +} diff --git a/lib/server/responses.ts b/lib/server/responses.ts index 23cf49b..1dcc0e9 100644 --- a/lib/server/responses.ts +++ b/lib/server/responses.ts @@ -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, { diff --git a/lib/server/ruleStakeholderInviteOps.ts b/lib/server/ruleStakeholderInviteOps.ts new file mode 100644 index 0000000..91516da --- /dev/null +++ b/lib/server/ruleStakeholderInviteOps.ts @@ -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 }; + } +} diff --git a/lib/server/ruleStakeholders.ts b/lib/server/ruleStakeholders.ts new file mode 100644 index 0000000..f1eee6b --- /dev/null +++ b/lib/server/ruleStakeholders.ts @@ -0,0 +1,2 @@ +/** Parity with magic-link request TTL (15 minutes). */ +export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000; diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index b3e6fdd..41adccd 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -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; +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(); + 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, }); diff --git a/messages/en/create/reviewAndComplete/confirmStakeholders.json b/messages/en/create/reviewAndComplete/confirmStakeholders.json index c7bdb2e..27324bb 100644 --- a/messages/en/create/reviewAndComplete/confirmStakeholders.json +++ b/messages/en/create/reviewAndComplete/confirmStakeholders.json @@ -1,6 +1,29 @@ { "title": "Do other stakeholders need to be involved in creating your community?", - "description": "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.", + "description": "Add their email addresses. When you publish, we'll send each person a one-time link to join Community Rule and view this rule in their profile (they won't be able to manage it unless they created it).", "addStakeholder": "Add stakeholder", - "draftToastTitle": "Congratulations! You've drafted your CommunityRule!" + "draftToastTitle": "Congratulations! You've drafted your CommunityRule!", + "invalidEmail": "Enter a valid email address.", + "duplicateEmail": "That email is already on the list.", + "maxStakeholders": "You can add up to 30 stakeholder emails.", + "managePublished": { + "lockupTitle": "Stakeholders", + "lockupDescription": "Invite people by email. They get a one-time link to view this rule in their profile.", + "emailLabel": "Email address", + "emailPlaceholder": "colleague@example.com", + "addInvite": "Send invite", + "loading": "Loading…", + "loadFailed": "Could not load stakeholders. Refresh and try again.", + "pending": "Pending", + "accepted": "Accepted", + "remove": "Remove", + "resend": "Resend invite", + "empty": "No stakeholders yet.", + "invalidEmail": "Enter a valid email address.", + "actionFailed": "Something went wrong. Try again.", + "rateLimited": "Too many requests. Try again in {seconds} seconds.", + "removeAria": "Remove {email}", + "resendAria": "Resend invite to {email}", + "footerDone": "Done" + } } diff --git a/messages/en/create/topNav.json b/messages/en/create/topNav.json index 103fa92..a0f40f7 100644 --- a/messages/en/create/topNav.json +++ b/messages/en/create/topNav.json @@ -6,9 +6,11 @@ "share": "Share", "export": "Export", "edit": "Edit", + "manageStakeholders": "Manage Stakeholders", "shareAriaLabel": "Share", "exportAriaLabel": "Export", "editAriaLabel": "Edit", + "manageStakeholdersAriaLabel": "Manage Stakeholders", "leaveConfirmLoss": "Leave create flow? Your progress will be lost.", "draftSaveBannerTitle": "Couldn't save draft", "postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}" diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index 694c911..d7819ad 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -20,6 +20,7 @@ "generic": "Something went wrong. Try again.", "invalidLink": "That sign-in link is not valid. Request a new one from the login page.", "expiredLink": "That sign-in link has expired. Request a new one from the login page.", - "serverError": "Something went wrong on our end. Try again later." + "serverError": "Something went wrong on our end. Try again later.", + "stakeholderWrongAccount": "Sign out and open the stakeholder invite link again, or use the same email you were invited with." } } diff --git a/prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql b/prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql new file mode 100644 index 0000000..b5781f9 --- /dev/null +++ b/prisma/migrations/20260509035322_add_rule_stakeholder/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "RuleStakeholder" ( + "id" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "invitedByUserId" TEXT, + "userId" TEXT, + "inviteTokenHash" TEXT, + "inviteExpiresAt" TIMESTAMP(3), + "invitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "acceptedAt" TIMESTAMP(3), + + CONSTRAINT "RuleStakeholder_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "RuleStakeholder_inviteTokenHash_key" ON "RuleStakeholder"("inviteTokenHash"); + +-- CreateIndex +CREATE INDEX "RuleStakeholder_userId_idx" ON "RuleStakeholder"("userId"); + +-- CreateIndex +CREATE INDEX "RuleStakeholder_email_idx" ON "RuleStakeholder"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "RuleStakeholder_ruleId_email_key" ON "RuleStakeholder"("ruleId", "email"); + +-- AddForeignKey +ALTER TABLE "RuleStakeholder" ADD CONSTRAINT "RuleStakeholder_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "PublishedRule"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RuleStakeholder" ADD CONSTRAINT "RuleStakeholder_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RuleStakeholder" ADD CONSTRAINT "RuleStakeholder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1ef543a..a24a997 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,10 @@ model User { rules PublishedRule[] /// At most one pending verified email change (CR-103). emailChangeToken EmailChangeToken? + /// Rules this user was invited to as a stakeholder (after accepting invite). + ruleStakeholders RuleStakeholder[] @relation("RuleStakeholderUser") + /// Stakeholder rows where this user sent the invite. + stakeholderInvitesSent RuleStakeholder[] @relation("RuleStakeholderInvitedBy") } /// Pending email change: user must open verify link sent to `newEmail` (CR-103). @@ -74,9 +78,35 @@ model PublishedRule { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + stakeholders RuleStakeholder[] + @@index([userId]) } +/// Invite + access for a published rule: email invite at publish; userId set after magic-link-style accept. +model RuleStakeholder { + id String @id @default(cuid()) + ruleId String + rule PublishedRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + /// Normalized lowercase email (invite target). + email String + /// Publisher at invite time; null if that account was removed. + invitedByUserId String? + invitedBy User? @relation("RuleStakeholderInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull) + /// Set when the invitee completes the verify link (same account as `email`). + userId String? + user User? @relation("RuleStakeholderUser", fields: [userId], references: [id], onDelete: SetNull) + /// One-time invite token (hashed); null after accept or revoke path (consume on verify). + inviteTokenHash String? @unique + inviteExpiresAt DateTime? + invitedAt DateTime @default(now()) + acceptedAt DateTime? + + @@unique([ruleId, email]) + @@index([userId]) + @@index([email]) +} + model RuleTemplate { id String @id @default(cuid()) slug String @unique diff --git a/stories/navigation/CreateFlowTopNav.stories.js b/stories/navigation/CreateFlowTopNav.stories.js index ed4d1c4..be092bc 100644 --- a/stories/navigation/CreateFlowTopNav.stories.js +++ b/stories/navigation/CreateFlowTopNav.stories.js @@ -8,7 +8,7 @@ export default { docs: { description: { component: - "Top navigation bar for the create rule flow. Includes logo and action buttons (Share, Export, Edit, Exit/Save & Exit).", + "Top navigation bar for the create rule flow. Includes logo and action buttons (Share, Export, Edit, Manage Stakeholders, Exit/Save & Exit).", }, }, }, @@ -25,6 +25,11 @@ export default { control: "boolean", description: "Whether to show the Edit button", }, + hasManageStakeholders: { + control: "boolean", + description: + "Whether to show Manage Stakeholders (edit published rule invites)", + }, saveDraftOnExit: { control: "boolean", description: @@ -33,6 +38,7 @@ export default { onShare: { action: "share clicked" }, onSelectExportFormat: { action: "export format" }, onEdit: { action: "edit clicked" }, + onManageStakeholders: { action: "manage stakeholders clicked" }, onExit: { action: "exit clicked" }, }, tags: ["autodocs"], @@ -64,3 +70,13 @@ export const SaveDraftOnExit = { saveDraftOnExit: true, }, }; + +export const EditRuleHeader = { + args: { + hasShare: false, + hasExport: false, + hasEdit: false, + hasManageStakeholders: true, + saveDraftOnExit: true, + }, +}; diff --git a/tests/components/ConfirmStakeholdersPage.test.tsx b/tests/components/ConfirmStakeholdersPage.test.tsx index 8f85373..38c2d98 100644 --- a/tests/components/ConfirmStakeholdersPage.test.tsx +++ b/tests/components/ConfirmStakeholdersPage.test.tsx @@ -14,7 +14,7 @@ describe("ConfirmStakeholdersScreen", () => { ).toBeInTheDocument(); expect( screen.getByText( - /Adding people at this step will invite them to see your proposed CommunityRule/i, + /Add their email addresses\. When you publish, we'll send each person a one-time link/i, ), ).toBeInTheDocument(); }); diff --git a/tests/components/CreateFlowTopNav.test.tsx b/tests/components/CreateFlowTopNav.test.tsx index 041c432..58e6ca1 100644 --- a/tests/components/CreateFlowTopNav.test.tsx +++ b/tests/components/CreateFlowTopNav.test.tsx @@ -38,6 +38,8 @@ const config: ComponentTestSuiteConfig = { onShare: vi.fn(), onSelectExportFormat: vi.fn(), onEdit: vi.fn(), + hasManageStakeholders: true, + onManageStakeholders: vi.fn(), onExit: vi.fn(), className: "test-class", }, @@ -121,6 +123,33 @@ describe("CreateFlowTopNav (behavioral tests)", () => { expect(editButton).toBeInTheDocument(); }); + it("renders Manage Stakeholders when hasManageStakeholders is true", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "Manage Stakeholders" }), + ).toBeInTheDocument(); + }); + + it("calls onManageStakeholders when Manage Stakeholders is clicked", async () => { + const user = userEvent.setup(); + const handler = vi.fn(); + render( + , + ); + await user.click( + screen.getByRole("button", { name: "Manage Stakeholders" }), + ); + expect(handler).toHaveBeenCalledTimes(1); + }); + it("calls onExit when Exit button is clicked", async () => { const user = userEvent.setup(); const handleExit = vi.fn(); diff --git a/tests/unit/createFlowPaths.test.ts b/tests/unit/createFlowPaths.test.ts index 60bece8..ebc5139 100644 --- a/tests/unit/createFlowPaths.test.ts +++ b/tests/unit/createFlowPaths.test.ts @@ -7,7 +7,10 @@ import { createFlowStepPathAfterStrippingReviewReturn, createFlowStepPathWithSyncDraft, } from "../../app/(app)/create/utils/createFlowPaths"; -import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../app/(app)/create/utils/flowSteps"; +import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, +} from "../../app/(app)/create/utils/flowSteps"; describe("createFlowPaths (CR-92 §2)", () => { it("createFlowStepPath builds segment path", () => { @@ -26,9 +29,9 @@ describe("createFlowPaths (CR-92 §2)", () => { ); }); - it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn only", () => { + it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn and manageStakeholders", () => { const sp = new URLSearchParams( - `a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&b=2`, + `a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&${CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY}=1&b=2`, ); expect(createFlowStepPathAfterStrippingReviewReturn("final-review", sp)).toBe( "/create/final-review?a=1&b=2", diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts index 6f1e99e..31fbe22 100644 --- a/tests/unit/createFlowValidation.test.ts +++ b/tests/unit/createFlowValidation.test.ts @@ -7,6 +7,7 @@ import { createFlowStateSchema, publishRuleBodySchema, putDraftBodySchema, + uniqueStakeholderEmailsForPublish, } from "../../lib/server/validation/createFlowSchemas"; describe("assertPlainJsonValue", () => { @@ -175,6 +176,16 @@ describe("createFlowStateSchema", () => { }); expect(r.success).toBe(false); }); + + it("accepts stakeholderEmails on draft payload", () => { + const r = createFlowStateSchema.safeParse({ + stakeholderEmails: [" one@example.com "], + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.stakeholderEmails).toEqual(["one@example.com"]); + } + }); }); describe("putDraftBodySchema", () => { @@ -224,4 +235,27 @@ describe("publishRuleBodySchema", () => { }); expect(r.success).toBe(false); }); + + it("normalizes stakeholderEmails", () => { + const r = publishRuleBodySchema.safeParse({ + title: "Ok", + document: {}, + stakeholderEmails: [" A@Example.COM ", "b@example.com"], + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.stakeholderEmails).toEqual(["a@example.com", "b@example.com"]); + } + }); +}); + +describe("uniqueStakeholderEmailsForPublish", () => { + it("dedupes and drops publisher email", () => { + expect( + uniqueStakeholderEmailsForPublish( + ["a@b.c", "A@B.C", "x@y.z"], + "a@b.c", + ), + ).toEqual(["x@y.z"]); + }); }); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx index 9a317a7..d2cdfff 100644 --- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -80,6 +80,37 @@ describe("useCreateFlowFinalize", () => { }); }); + it("passes stakeholderEmails to publishRule on initial publish", async () => { + vi.mocked(publishRule).mockResolvedValue({ + ok: true, + id: "new-rule-id", + title: "Published title", + }); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: { + ...emptyState, + stakeholderEmails: ["invitee@example.com"], + }, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review", + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(publishRule).toHaveBeenCalledWith( + expect.objectContaining({ + stakeholderEmails: ["invitee@example.com"], + }), + ); + }); + it("routes to /create/completed without celebrate after PATCH update", async () => { vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true }); diff --git a/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts new file mode 100644 index 0000000..7f1b1cc --- /dev/null +++ b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts @@ -0,0 +1,100 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUniqueMock = vi.fn(); +const updateMock = vi.fn(); +const upsertMock = vi.fn(); +const getSessionUserMock = vi.fn(); +const createSessionMock = vi.fn(); +const setCookieMock = vi.fn(); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + ruleStakeholder: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + update: (...args: unknown[]) => updateMock(...args), + }, + user: { + upsert: (...args: unknown[]) => upsertMock(...args), + }, + }, +})); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, + getSessionPepper: () => "pepper", +})); + +vi.mock("../../lib/server/hash", () => ({ + hashSessionToken: (t: string) => `h-${t}`, +})); + +vi.mock("../../lib/server/session", () => ({ + getSessionUser: () => getSessionUserMock(), + createSessionForUser: (...args: unknown[]) => createSessionMock(...args), + setSessionCookie: (...args: unknown[]) => setCookieMock(...args), +})); + +import { GET } from "../../app/api/invites/rule-stakeholder/verify/route"; + +beforeEach(() => { + findUniqueMock.mockReset(); + updateMock.mockReset(); + upsertMock.mockReset(); + getSessionUserMock.mockReset(); + createSessionMock.mockReset(); + setCookieMock.mockReset(); +}); + +describe("GET /api/invites/rule-stakeholder/verify", () => { + it("redirects to the rule after a valid token", async () => { + getSessionUserMock.mockResolvedValue(null); + findUniqueMock.mockResolvedValue({ + id: "st1", + email: "inv@example.com", + ruleId: "rule-1", + inviteExpiresAt: new Date(Date.now() + 60_000), + }); + upsertMock.mockResolvedValue({ id: "u1", email: "inv@example.com" }); + updateMock.mockResolvedValue({}); + createSessionMock.mockResolvedValue({ + token: "sess", + expiresAt: new Date(), + }); + + const res = await GET( + new NextRequest( + `https://x.test/api/invites/rule-stakeholder/verify?token=${"x".repeat(12)}`, + ), + ); + + expect(res.status).toBeGreaterThanOrEqual(300); + expect(res.status).toBeLessThan(400); + expect(res.headers.get("location")).toContain("/rules/rule-1"); + expect(setCookieMock).toHaveBeenCalled(); + }); + + it("redirects to login when another user is already signed in", async () => { + getSessionUserMock.mockResolvedValue({ + id: "u-other", + email: "other@example.com", + }); + findUniqueMock.mockResolvedValue({ + id: "st1", + email: "inv@example.com", + ruleId: "rule-1", + inviteExpiresAt: new Date(Date.now() + 60_000), + }); + + const res = await GET( + new NextRequest( + `https://x.test/api/invites/rule-stakeholder/verify?token=${"y".repeat(12)}`, + ), + ); + + expect(res.headers.get("location")).toContain( + "error=stakeholder_wrong_account", + ); + expect(upsertMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/rulesMeRoute.test.ts b/tests/unit/rulesMeRoute.test.ts index 41392a1..13f56b5 100644 --- a/tests/unit/rulesMeRoute.test.ts +++ b/tests/unit/rulesMeRoute.test.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const isDatabaseConfiguredMock = vi.fn(); -const listForUserMock = vi.fn(); +const listProfileMock = vi.fn(); const getSessionUserMock = vi.fn(); vi.mock("../../lib/server/env", () => ({ @@ -10,7 +10,7 @@ vi.mock("../../lib/server/env", () => ({ })); vi.mock("../../lib/server/publishedRules", () => ({ - listPublishedRulesForUser: (...args: unknown[]) => listForUserMock(...args), + listProfileRulesForUser: (...args: unknown[]) => listProfileMock(...args), })); vi.mock("../../lib/server/session", () => ({ @@ -21,7 +21,7 @@ import { GET } from "../../app/api/rules/me/route"; beforeEach(() => { isDatabaseConfiguredMock.mockReset(); - listForUserMock.mockReset(); + listProfileMock.mockReset(); getSessionUserMock.mockReset(); }); @@ -44,7 +44,7 @@ describe("GET /api/rules/me", () => { undefined, ); expect(res.status).toBe(401); - expect(listForUserMock).not.toHaveBeenCalled(); + expect(listProfileMock).not.toHaveBeenCalled(); }); it("returns 200 with { rules } for the session user", async () => { @@ -59,17 +59,20 @@ describe("GET /api/rules/me", () => { updatedAt: new Date("2026-01-02T00:00:00Z"), }, ]; - listForUserMock.mockResolvedValueOnce(rows); + listProfileMock.mockResolvedValueOnce( + rows.map((r) => ({ ...r, role: "owner" as const })), + ); const res = await GET( new NextRequest("https://x.test/api/rules/me?limit=10"), undefined, ); expect(res.status).toBe(200); - expect(listForUserMock).toHaveBeenCalledWith("user-1", 10); + expect(listProfileMock).toHaveBeenCalledWith("user-1", 10); const body = (await res.json()) as { - rules: Array<{ id: string; title: string }>; + rules: Array<{ id: string; title: string; role: string }>; }; expect(body.rules).toHaveLength(1); expect(body.rules[0].id).toBe("r1"); + expect(body.rules[0].role).toBe("owner"); }); }); diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts new file mode 100644 index 0000000..eadd849 --- /dev/null +++ b/tests/unit/rulesPublishPostRoute.test.ts @@ -0,0 +1,174 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionUserMock = vi.fn(); +const transactionMock = vi.fn(); +const publishedRuleCreateMock = vi.fn(); +const publishedRuleDeleteMock = vi.fn(); +const sendInviteMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, + getSessionPepper: () => "test-pepper", +})); + +vi.mock("../../lib/server/session", () => ({ + getSessionUser: () => getSessionUserMock(), +})); + +vi.mock("../../lib/server/rateLimit", () => ({ + rateLimitKey: () => ({ ok: true as const }), +})); + +vi.mock("../../lib/server/mail", () => ({ + sendRuleStakeholderInviteEmail: (...args: unknown[]) => + sendInviteMock(...args), +})); + +vi.mock("../../lib/server/hash", () => ({ + newSessionToken: () => "x".repeat(32), + hashSessionToken: (t: string) => `hashed-${t}`, +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + $transaction: (fn: (tx: unknown) => Promise) => + transactionMock(fn), + publishedRule: { + create: (...args: unknown[]) => publishedRuleCreateMock(...args), + delete: (...args: unknown[]) => publishedRuleDeleteMock(...args), + }, + ruleStakeholder: { + create: vi.fn().mockResolvedValue({}), + }, + }, +})); + +import { POST } from "../../app/api/rules/route"; + +beforeEach(() => { + getSessionUserMock.mockReset(); + transactionMock.mockReset(); + publishedRuleCreateMock.mockReset(); + publishedRuleDeleteMock.mockReset(); + sendInviteMock.mockReset(); + getSessionUserMock.mockResolvedValue({ + id: "user-1", + email: "owner@example.com", + }); +}); + +describe("POST /api/rules", () => { + it("creates rule without transaction when there are no stakeholder emails", async () => { + publishedRuleCreateMock.mockResolvedValueOnce({ + id: "rule-solo", + title: "T", + summary: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + const res = await POST( + new NextRequest("https://x.test/api/rules", { + method: "POST", + body: JSON.stringify({ + title: "T", + summary: null, + document: {}, + }), + }), + undefined, + ); + + expect(res.status).toBe(200); + expect(transactionMock).not.toHaveBeenCalled(); + expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1); + expect(sendInviteMock).not.toHaveBeenCalled(); + }); + + it("uses a transaction and sends stakeholder invites", async () => { + const created = { + id: "rule-new", + title: "Published title", + summary: null, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + transactionMock.mockImplementation( + async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise) => { + const tx = { + publishedRule: { + create: vi.fn().mockResolvedValue(created), + }, + ruleStakeholder: { + create: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }, + ); + sendInviteMock.mockResolvedValue(undefined); + + const res = await POST( + new NextRequest("https://x.test/api/rules", { + method: "POST", + body: JSON.stringify({ + title: "Published title", + summary: null, + document: {}, + stakeholderEmails: ["stakeholder@example.com"], + }), + }), + undefined, + ); + + expect(res.status).toBe(200); + expect(transactionMock).toHaveBeenCalledTimes(1); + expect(sendInviteMock).toHaveBeenCalledTimes(1); + expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com"); + expect(String(sendInviteMock.mock.calls[0][1])).toContain( + "/api/invites/rule-stakeholder/verify?token=", + ); + expect(publishedRuleCreateMock).not.toHaveBeenCalled(); + }); + + it("rolls back publish when mail fails", async () => { + const created = { + id: "rule-new", + title: "Published title", + summary: null, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + transactionMock.mockImplementation( + async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise) => { + const tx = { + publishedRule: { + create: vi.fn().mockResolvedValue(created), + }, + ruleStakeholder: { + create: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }, + ); + sendInviteMock.mockRejectedValueOnce(new Error("smtp down")); + publishedRuleDeleteMock.mockResolvedValueOnce({}); + + const res = await POST( + new NextRequest("https://x.test/api/rules", { + method: "POST", + body: JSON.stringify({ + title: "Published title", + summary: null, + document: {}, + stakeholderEmails: ["stakeholder@example.com"], + }), + }), + undefined, + ); + + expect(res.status).toBe(502); + expect(publishedRuleDeleteMock).toHaveBeenCalledWith({ + where: { id: "rule-new" }, + }); + }); +}); diff --git a/tests/unit/rulesStakeholdersRoutes.test.ts b/tests/unit/rulesStakeholdersRoutes.test.ts new file mode 100644 index 0000000..29722b7 --- /dev/null +++ b/tests/unit/rulesStakeholdersRoutes.test.ts @@ -0,0 +1,79 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findManyStakeholdersMock = vi.fn(); +const findFirstRuleMock = vi.fn(); +const getSessionUserMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + publishedRule: { + findFirst: (...args: unknown[]) => findFirstRuleMock(...args), + }, + ruleStakeholder: { + findMany: (...args: unknown[]) => findManyStakeholdersMock(...args), + }, + }, +})); + +vi.mock("../../lib/server/session", () => ({ + getSessionUser: () => getSessionUserMock(), +})); + +import { GET } from "../../app/api/rules/[id]/stakeholders/route"; + +beforeEach(() => { + findManyStakeholdersMock.mockReset(); + findFirstRuleMock.mockReset(); + getSessionUserMock.mockReset(); + getSessionUserMock.mockResolvedValue({ id: "owner-1", email: "o@example.com" }); +}); + +describe("GET /api/rules/[id]/stakeholders", () => { + it("returns 401 when unauthenticated", async () => { + getSessionUserMock.mockResolvedValueOnce(null); + const res = await GET( + new NextRequest("https://x.test/api/rules/r1/stakeholders"), + { params: Promise.resolve({ id: "r1" }) }, + ); + expect(res.status).toBe(401); + }); + + it("returns stakeholders for the rule owner", async () => { + findFirstRuleMock.mockResolvedValueOnce({ + id: "r1", + title: "My rule", + }); + findManyStakeholdersMock.mockResolvedValueOnce([ + { + id: "s1", + email: "a@b.c", + invitedAt: new Date("2026-01-01T00:00:00Z"), + acceptedAt: null, + inviteTokenHash: "hash", + }, + { + id: "s2", + email: "x@y.z", + invitedAt: new Date("2026-01-02T00:00:00Z"), + acceptedAt: new Date("2026-01-03T00:00:00Z"), + inviteTokenHash: null, + }, + ]); + const res = await GET( + new NextRequest("https://x.test/api/rules/r1/stakeholders"), + { params: Promise.resolve({ id: "r1" }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + stakeholders: Array<{ status: string; email: string }>; + }; + expect(body.stakeholders).toHaveLength(2); + expect(body.stakeholders[0].status).toBe("pending"); + expect(body.stakeholders[1].status).toBe("accepted"); + }); +});
+ {chipError} +
+ {bannerError} +
+ {t("managePublished.loadFailed")} +
+ {t("managePublished.loading")} +
+ {t("managePublished.empty")} +