Manage stakeholders implemented
This commit is contained in:
@@ -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]}
|
||||
</Button>
|
||||
) : isConfirmStakeholdersManagePublished ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
"edit-rule",
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
create.reviewAndComplete.confirmStakeholders.managePublished
|
||||
.footerDone
|
||||
}
|
||||
</Button>
|
||||
) : nextStep || isFinalReviewLike ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
|
||||
@@ -100,10 +100,14 @@ export function useCreateFlowFinalize({
|
||||
return;
|
||||
}
|
||||
|
||||
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||
(e) => typeof e === "string" && e.trim() !== "",
|
||||
);
|
||||
const publishResult = await publishRule({
|
||||
title,
|
||||
summary,
|
||||
document: ruleDocument,
|
||||
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
|
||||
});
|
||||
setIsPublishing(false);
|
||||
if (publishResult.ok === true) {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
[],
|
||||
() => 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 (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrowBottomPad"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("managePublished.lockupTitle")}
|
||||
description={t("managePublished.lockupDescription")}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowStepShell
|
||||
@@ -61,6 +167,14 @@ export function ConfirmStakeholdersScreen() {
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
{chipError ? (
|
||||
<p
|
||||
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||
role="alert"
|
||||
>
|
||||
{chipError}
|
||||
</p>
|
||||
) : null}
|
||||
<MultiSelect
|
||||
formHeader={false}
|
||||
showHelpIcon={false}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Button from "../../../../components/buttons/Button";
|
||||
import TextInput from "../../../../components/controls/TextInput";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import {
|
||||
addRuleStakeholder,
|
||||
deleteRuleStakeholder,
|
||||
fetchRuleStakeholders,
|
||||
resendRuleStakeholderInvite,
|
||||
type RuleStakeholderListItem,
|
||||
} from "../../../../../lib/create/api";
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function PublishedStakeholdersManagePanel({
|
||||
ruleId,
|
||||
}: {
|
||||
ruleId: string;
|
||||
}) {
|
||||
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
||||
const [items, setItems] = useState<RuleStakeholderListItem[] | 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<string | null>(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 (
|
||||
<section className="flex w-full flex-col gap-4 pt-1 pb-2">
|
||||
{bannerError ? (
|
||||
<p
|
||||
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||
role="alert"
|
||||
>
|
||||
{bannerError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<p className="font-inter text-sm text-[var(--color-border-default-utility-negative)]">
|
||||
{t("managePublished.loadFailed")}
|
||||
</p>
|
||||
) : items === null ? (
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]">
|
||||
{t("managePublished.loading")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
||||
{t("managePublished.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{items.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="truncate font-inter text-sm font-medium text-[var(--color-content-default-primary)] md:text-base">
|
||||
{row.email}
|
||||
</span>
|
||||
<span className="font-inter text-xs text-[var(--color-content-default-tertiary)] md:text-sm">
|
||||
{row.status === "pending"
|
||||
? t("managePublished.pending")
|
||||
: t("managePublished.accepted")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.status === "pending" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => void handleResend(row.id)}
|
||||
ariaLabel={t("managePublished.resendAria").replace(
|
||||
"{email}",
|
||||
row.email,
|
||||
)}
|
||||
>
|
||||
{t("managePublished.resend")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => void handleRemove(row.id)}
|
||||
ariaLabel={t("managePublished.removeAria").replace(
|
||||
"{email}",
|
||||
row.email,
|
||||
)}
|
||||
>
|
||||
{t("managePublished.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<TextInput
|
||||
id="published-stakeholder-email"
|
||||
type="email"
|
||||
inputSize="small"
|
||||
showHelpIcon={false}
|
||||
label={t("managePublished.emailLabel")}
|
||||
placeholder={t("managePublished.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setFieldError("");
|
||||
}}
|
||||
error={Boolean(fieldError)}
|
||||
textHint={fieldError || false}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
className="md:mb-[2px]"
|
||||
disabled={addBusy || items === null}
|
||||
onClick={() => void handleAdd()}
|
||||
>
|
||||
{t("managePublished.addInvite")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -218,8 +218,11 @@ export interface CreateFlowState {
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
|
||||
stakeholders?: Record<string, unknown>[];
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() || "·"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashSessionToken } from "../../../../../lib/server/hash";
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
getOrCreateRequestId,
|
||||
logRouteError,
|
||||
} from "../../../../../lib/server/requestId";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import {
|
||||
createSessionForUser,
|
||||
getSessionUser,
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
|
||||
const SCOPE = "invites.ruleStakeholder.verify";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = getOrCreateRequestId(request);
|
||||
|
||||
if (!isDatabaseConfigured()) {
|
||||
const res = dbUnavailable();
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token || token.length < 10) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=invalid_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" });
|
||||
return redirectWithRequestId(request, "/login?error=server", requestId);
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
|
||||
const row = await prisma.ruleStakeholder.findUnique({
|
||||
where: { inviteTokenHash: tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
ruleId: true,
|
||||
inviteExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!row ||
|
||||
!row.inviteExpiresAt ||
|
||||
row.inviteExpiresAt < new Date()
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=expired_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const existingSession = await getSessionUser();
|
||||
if (
|
||||
existingSession &&
|
||||
existingSession.email.trim().toLowerCase() !== row.email
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=stakeholder_wrong_account",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: row.email },
|
||||
create: { email: row.email },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.ruleStakeholder.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
userId: user.id,
|
||||
acceptedAt: new Date(),
|
||||
inviteTokenHash: null,
|
||||
inviteExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||
user.id,
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
|
||||
const dest = `/rules/${encodeURIComponent(row.ruleId)}`;
|
||||
return redirectWithRequestId(request, dest, requestId);
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err);
|
||||
return redirectWithRequestId(request, "/login?error=server", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithRequestId(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
requestId: string,
|
||||
): NextResponse {
|
||||
const res = NextResponse.redirect(new URL(path, request.url));
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../../../lib/server/env";
|
||||
import { hashSessionToken, newSessionToken } from "../../../../../../../lib/server/hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "../../../../../../../lib/server/mail";
|
||||
import { apiRoute } from "../../../../../../../lib/server/apiRoute";
|
||||
import { logRouteError } from "../../../../../../../lib/server/requestId";
|
||||
import { stakeholderInviteVerifyUrl } from "../../../../../../../lib/server/ruleStakeholderInviteOps";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../../../lib/server/ruleStakeholders";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
forbidden,
|
||||
notFound,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../../../lib/server/session";
|
||||
import { rateLimitKey } from "../../../../../../../lib/server/rateLimit";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> };
|
||||
|
||||
export const POST = apiRoute<RouteContext>(
|
||||
"rules.stakeholders.resend",
|
||||
async (request: NextRequest, context, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id: ruleId, stakeholderId } = await context.params;
|
||||
|
||||
const row = await prisma.ruleStakeholder.findFirst({
|
||||
where: { id: stakeholderId, ruleId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
inviteTokenHash: true,
|
||||
inviteExpiresAt: true,
|
||||
rule: { select: { userId: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return notFound();
|
||||
}
|
||||
if (row.rule.userId !== user.id) {
|
||||
return forbidden();
|
||||
}
|
||||
if (row.inviteTokenHash === null) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"This stakeholder has already accepted the invite",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const rl = rateLimitKey(`rule-stakeholders-resend:${row.id}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("rules.stakeholders.resend", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const prevHash = row.inviteTokenHash;
|
||||
const prevExp = row.inviteExpiresAt;
|
||||
const token = newSessionToken();
|
||||
const newHash = hashSessionToken(token, pepper);
|
||||
const newExp = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
|
||||
await prisma.ruleStakeholder.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
inviteTokenHash: newHash,
|
||||
inviteExpiresAt: newExp,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(request.nextUrl.origin, token);
|
||||
try {
|
||||
await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title);
|
||||
} catch (err) {
|
||||
logRouteError("rules.stakeholders.resend", requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
});
|
||||
await prisma.ruleStakeholder
|
||||
.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
inviteTokenHash: prevHash,
|
||||
inviteExpiresAt: prevExp,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return errorJson(
|
||||
"mail_failed",
|
||||
"Could not resend stakeholder invite",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../../../../lib/server/env";
|
||||
import { apiRoute } from "../../../../../../lib/server/apiRoute";
|
||||
import {
|
||||
dbUnavailable,
|
||||
forbidden,
|
||||
notFound,
|
||||
unauthorized,
|
||||
} from "../../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../../lib/server/session";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> };
|
||||
|
||||
export const DELETE = apiRoute<RouteContext>(
|
||||
"rules.stakeholders.delete",
|
||||
async (_request: NextRequest, context) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id: ruleId, stakeholderId } = await context.params;
|
||||
|
||||
const row = await prisma.ruleStakeholder.findFirst({
|
||||
where: { id: stakeholderId, ruleId },
|
||||
select: {
|
||||
id: true,
|
||||
rule: { select: { userId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return notFound();
|
||||
}
|
||||
if (row.rule.userId !== user.id) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
await prisma.ruleStakeholder.delete({ where: { id: row.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,192 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||
import { createRuleStakeholderInviteAndSendMail } from "../../../../../lib/server/ruleStakeholderInviteOps";
|
||||
import {
|
||||
conflict,
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
notFound,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../lib/server/session";
|
||||
import {
|
||||
MAX_STAKEHOLDER_EMAILS,
|
||||
postRuleStakeholderBodySchema,
|
||||
} from "../../../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
async function ownedRuleMeta(ruleId: string, userId: string) {
|
||||
return prisma.publishedRule.findFirst({
|
||||
where: { id: ruleId, userId },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = apiRoute<RouteContext>(
|
||||
"rules.stakeholders.list",
|
||||
async (_request, context) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id: ruleId } = await context.params;
|
||||
const rule = await ownedRuleMeta(ruleId, user.id);
|
||||
if (!rule) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const rows = await prisma.ruleStakeholder.findMany({
|
||||
where: { ruleId: rule.id },
|
||||
orderBy: [{ invitedAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
invitedAt: true,
|
||||
acceptedAt: true,
|
||||
inviteTokenHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
stakeholders: rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
invitedAt: r.invitedAt.toISOString(),
|
||||
acceptedAt: r.acceptedAt?.toISOString() ?? null,
|
||||
status:
|
||||
r.inviteTokenHash !== null ? ("pending" as const) : ("accepted" as const),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiRoute<RouteContext>(
|
||||
"rules.stakeholders.add",
|
||||
async (request: NextRequest, context, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id: ruleId } = await context.params;
|
||||
const rule = await ownedRuleMeta(ruleId, user.id);
|
||||
if (!rule) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
|
||||
const validated = postRuleStakeholderBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
|
||||
const email = validated.data.email;
|
||||
if (email === user.email.trim().toLowerCase()) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"You cannot invite your own account email",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.ruleStakeholder.findFirst({
|
||||
where: { ruleId: rule.id, email },
|
||||
});
|
||||
if (existing) {
|
||||
return conflict("That email is already invited for this rule");
|
||||
}
|
||||
|
||||
const count = await prisma.ruleStakeholder.count({
|
||||
where: { ruleId: rule.id },
|
||||
});
|
||||
if (count >= MAX_STAKEHOLDER_EMAILS) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
`You can invite at most ${MAX_STAKEHOLDER_EMAILS} stakeholders per rule`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
const rl = rateLimitKey(`rule-stakeholders-add-ip:${ip}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("rules.stakeholders.add", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
const sent = await createRuleStakeholderInviteAndSendMail({
|
||||
scope: "rules.stakeholders.add",
|
||||
requestId,
|
||||
origin,
|
||||
ruleId: rule.id,
|
||||
ruleTitle: rule.title,
|
||||
email,
|
||||
invitedByUserId: user.id,
|
||||
pepper,
|
||||
});
|
||||
|
||||
if (!sent.ok) {
|
||||
return errorJson(
|
||||
"mail_failed",
|
||||
"Could not send stakeholder invite",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.ruleStakeholder.findFirst({
|
||||
where: { ruleId: rule.id, email },
|
||||
select: { id: true, email: true, invitedAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
stakeholder: created && {
|
||||
id: created.id,
|
||||
email: created.email,
|
||||
invitedAt: created.invitedAt.toISOString(),
|
||||
acceptedAt: null,
|
||||
status: "pending" as const,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules";
|
||||
import { listProfileRulesForUser } from "../../../../lib/server/publishedRules";
|
||||
import {
|
||||
dbUnavailable,
|
||||
internalError,
|
||||
@@ -22,10 +22,19 @@ export const GET = apiRoute("rules.me.list", async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||
|
||||
const rules = await listPublishedRulesForUser(user.id, take);
|
||||
const rules = await listProfileRulesForUser(user.id, take);
|
||||
if (rules === null) {
|
||||
return internalError("Failed to list rules");
|
||||
}
|
||||
|
||||
return NextResponse.json({ rules });
|
||||
return NextResponse.json({
|
||||
rules: rules.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
role: r.role,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
+142
-36
@@ -1,14 +1,29 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import {
|
||||
hashSessionToken,
|
||||
newSessionToken,
|
||||
} from "../../../lib/server/hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../lib/server/rateLimit";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../lib/server/responses";
|
||||
import { logRouteError } from "../../../lib/server/requestId";
|
||||
import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
||||
import {
|
||||
publishRuleBodySchema,
|
||||
uniqueStakeholderEmailsForPublish,
|
||||
} from "../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||
|
||||
@@ -36,43 +51,134 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||
return NextResponse.json({ rules });
|
||||
});
|
||||
|
||||
export const POST = apiRoute("rules.publish", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
export const POST = apiRoute(
|
||||
"rules.publish",
|
||||
async (request: NextRequest, _ctx, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
|
||||
const { title, summary, document } = validated.data;
|
||||
const { title, summary, document, stakeholderEmails } = validated.data;
|
||||
const inviteEmails = uniqueStakeholderEmailsForPublish(
|
||||
stakeholderEmails,
|
||||
user.email,
|
||||
);
|
||||
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (inviteEmails.length > 0) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (inviteEmails.length === 0) {
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("rules.publish", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
const { rule, invites } = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const toSend: { email: string; token: string }[] = [];
|
||||
for (const email of inviteEmails) {
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
await tx.ruleStakeholder.create({
|
||||
data: {
|
||||
ruleId: created.id,
|
||||
email,
|
||||
invitedByUserId: user.id,
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
toSend.push({ email, token });
|
||||
}
|
||||
return { rule: created, invites: toSend };
|
||||
});
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
try {
|
||||
for (const inv of invites) {
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
|
||||
await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title);
|
||||
}
|
||||
} catch (err) {
|
||||
logRouteError("rules.publish", requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
});
|
||||
try {
|
||||
await prisma.publishedRule.delete({ where: { id: rule.id } });
|
||||
} catch (delErr) {
|
||||
logRouteError("rules.publish", requestId, delErr, {
|
||||
phase: "rollbackPublishAfterMailFailure",
|
||||
});
|
||||
}
|
||||
return errorJson(
|
||||
"mail_failed",
|
||||
"Could not send stakeholder invites",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
hasManageStakeholders = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onSelectExportFormat,
|
||||
onEdit,
|
||||
onManageStakeholders,
|
||||
onExit,
|
||||
buttonPalette,
|
||||
className = "",
|
||||
@@ -41,10 +43,12 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare={hasShare}
|
||||
hasExport={hasExport}
|
||||
hasEdit={hasEdit}
|
||||
hasManageStakeholders={hasManageStakeholders}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={onShare}
|
||||
onSelectExportFormat={onSelectExportFormat}
|
||||
onEdit={onEdit}
|
||||
onManageStakeholders={onManageStakeholders}
|
||||
onExit={handleExit}
|
||||
buttonPalette={buttonPalette}
|
||||
className={className}
|
||||
|
||||
@@ -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 }`.
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasManageStakeholders && onManageStakeholders ? (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
type="button"
|
||||
onClick={onManageStakeholders}
|
||||
ariaLabel={t("manageStakeholdersAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
{t("manageStakeholders")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
|
||||
Reference in New Issue
Block a user