Stakeholder invites + Ask an organizer (modal, API, email) #51
@@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min"
|
|||||||
SMTP_URL=
|
SMTP_URL=
|
||||||
SMTP_FROM="Community Rule <noreply@localhost>"
|
SMTP_FROM="Community Rule <noreply@localhost>"
|
||||||
|
|
||||||
|
# CR-107: inbox for Ask an organizer form submissions (requires SMTP_URL in production).
|
||||||
|
ORGANIZER_INQUIRY_TO=
|
||||||
|
|
||||||
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport
|
|||||||
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||||
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||||
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getStepIndex,
|
getStepIndex,
|
||||||
parseReviewReturnSearchParam,
|
parseReviewReturnSearchParam,
|
||||||
@@ -158,7 +161,17 @@ function CreateFlowLayoutContent({
|
|||||||
resetCustomRuleSelections,
|
resetCustomRuleSelections,
|
||||||
setMethodSectionsPinCommitted,
|
setMethodSectionsPinCommitted,
|
||||||
replaceState,
|
replaceState,
|
||||||
|
markCreateFlowInteraction,
|
||||||
} = useCreateFlow();
|
} = 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 } =
|
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||||
useCreateFlowDraftSaveBanner();
|
useCreateFlowDraftSaveBanner();
|
||||||
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||||
@@ -411,6 +424,7 @@ function CreateFlowLayoutContent({
|
|||||||
const isRightRailStep = currentStep === "decision-approaches";
|
const isRightRailStep = currentStep === "decision-approaches";
|
||||||
const isFinalReviewLike =
|
const isFinalReviewLike =
|
||||||
currentStep === "final-review" || currentStep === "edit-rule";
|
currentStep === "final-review" || currentStep === "edit-rule";
|
||||||
|
const isEditRuleStep = currentStep === "edit-rule";
|
||||||
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||||
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||||
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
||||||
@@ -581,6 +595,7 @@ function CreateFlowLayoutContent({
|
|||||||
hasShare={isCompletedStep}
|
hasShare={isCompletedStep}
|
||||||
hasExport={isCompletedStep}
|
hasExport={isCompletedStep}
|
||||||
hasEdit={isCompletedStep}
|
hasEdit={isCompletedStep}
|
||||||
|
hasManageStakeholders={isEditRuleStep}
|
||||||
saveDraftOnExit={saveDraftOnExit}
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
onShare={
|
onShare={
|
||||||
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
|
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
|
||||||
@@ -601,6 +616,20 @@ function CreateFlowLayoutContent({
|
|||||||
}
|
}
|
||||||
: undefined
|
: 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)}
|
onExit={(opts) => void handleExit(opts)}
|
||||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||||
className={`shrink-0 ${
|
className={`shrink-0 ${
|
||||||
@@ -615,7 +644,11 @@ function CreateFlowLayoutContent({
|
|||||||
{!isCompletedStep && (
|
{!isCompletedStep && (
|
||||||
<CreateFlowFooter
|
<CreateFlowFooter
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
progressBar={!isTemplateReviewRoute && !isFinalReviewLike}
|
progressBar={
|
||||||
|
!isTemplateReviewRoute &&
|
||||||
|
!isFinalReviewLike &&
|
||||||
|
reviewReturnTarget !== "edit-rule"
|
||||||
|
}
|
||||||
proportionBarProgress={proportionBarProgress}
|
proportionBarProgress={proportionBarProgress}
|
||||||
proportionBarVariant="segmented"
|
proportionBarVariant="segmented"
|
||||||
secondButton={
|
secondButton={
|
||||||
@@ -762,6 +795,27 @@ function CreateFlowLayoutContent({
|
|||||||
>
|
>
|
||||||
{footer[customRuleConfirmFooter.footerMessageKey]}
|
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||||
</Button>
|
</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 ? (
|
) : nextStep || isFinalReviewLike ? (
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
|
|||||||
@@ -100,10 +100,14 @@ export function useCreateFlowFinalize({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||||
|
(e) => typeof e === "string" && e.trim() !== "",
|
||||||
|
);
|
||||||
const publishResult = await publishRule({
|
const publishResult = await publishRule({
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
document: ruleDocument,
|
document: ruleDocument,
|
||||||
|
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
|
||||||
});
|
});
|
||||||
setIsPublishing(false);
|
setIsPublishing(false);
|
||||||
if (publishResult.ok === true) {
|
if (publishResult.ok === true) {
|
||||||
|
|||||||
@@ -1,25 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import Alert from "../../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
|
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
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() {
|
export function ConfirmStakeholdersScreen() {
|
||||||
const { markCreateFlowInteraction } = useCreateFlow();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
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 [toastDismissed, setToastDismissed] = useState(false);
|
||||||
|
const [chipError, setChipError] = useState<string | null>(null);
|
||||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
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 = () => {
|
const handleAddStakeholder = () => {
|
||||||
markCreateFlowInteraction();
|
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) => [
|
setStakeholderOptions((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
@@ -28,23 +85,72 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
|
|
||||||
const handleCustomChipConfirm = (chipId: string, value: string) => {
|
const handleCustomChipConfirm = (chipId: string, value: string) => {
|
||||||
markCreateFlowInteraction();
|
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) =>
|
setStakeholderOptions((prev) =>
|
||||||
prev.map((opt) =>
|
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) => {
|
const handleCustomChipClose = (chipId: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
setChipError(null);
|
||||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
setChipError(null);
|
||||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
@@ -61,6 +167,14 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{chipError ? (
|
||||||
|
<p
|
||||||
|
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{chipError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
showHelpIcon={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;
|
currentStep?: CreateFlowStep;
|
||||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||||
sections?: Record<string, unknown>[];
|
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). */
|
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CreateFlowStep } from "../types";
|
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 = {
|
export const CREATE_ROUTES = {
|
||||||
root: "/",
|
root: "/",
|
||||||
@@ -59,7 +62,7 @@ export function createCompletedPath(query?: CreateFlowPathQuery): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate back from a facet step to final-review / edit-rule, dropping
|
* 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(
|
export function createFlowStepPathAfterStrippingReviewReturn(
|
||||||
step: CreateFlowStep,
|
step: CreateFlowStep,
|
||||||
@@ -67,6 +70,7 @@ export function createFlowStepPathAfterStrippingReviewReturn(
|
|||||||
): string {
|
): string {
|
||||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||||
|
params.delete(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY);
|
||||||
const query: CreateFlowPathQuery = {};
|
const query: CreateFlowPathQuery = {};
|
||||||
params.forEach((value, key) => {
|
params.forEach((value, key) => {
|
||||||
query[key] = value;
|
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 + */
|
/** `/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;
|
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 type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
|
||||||
|
|
||||||
export function parseReviewReturnSearchParam(
|
export function parseReviewReturnSearchParam(
|
||||||
|
|||||||
@@ -340,28 +340,38 @@ export function ProfilePageView({
|
|||||||
expanded
|
expanded
|
||||||
size={ruleCardSize}
|
size={ruleCardSize}
|
||||||
hasBottomLinks
|
hasBottomLinks
|
||||||
bottomLinks={[
|
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: "view",
|
||||||
id: "dup",
|
label: t("viewPublic"),
|
||||||
label: t("duplicate"),
|
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||||
onClick: () => onDuplicateRule(rule.id),
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "manage",
|
||||||
id: "del",
|
label: t("manageRule"),
|
||||||
label: t("deleteRule"),
|
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||||
onClick: () => onDeleteRule(rule.id),
|
},
|
||||||
},
|
{
|
||||||
]}
|
id: "dup",
|
||||||
|
label: t("duplicate"),
|
||||||
|
onClick: () => onDuplicateRule(rule.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "del",
|
||||||
|
label: t("deleteRule"),
|
||||||
|
onClick: () => onDeleteRule(rule.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
communityInitials={
|
communityInitials={
|
||||||
rule.title.trim().charAt(0).toUpperCase() || "·"
|
rule.title.trim().charAt(0).toUpperCase() || "·"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const askOrganizerData = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export default function LearnPage() {
|
|||||||
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
||||||
description: t("pages.learn.askOrganizer.description"),
|
description: t("pages.learn.askOrganizer.description"),
|
||||||
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
||||||
buttonHref: t("pages.learn.askOrganizer.buttonHref"),
|
|
||||||
variant: "centered" as const,
|
variant: "centered" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export default function Page() {
|
|||||||
title: t("pages.home.askOrganizer.title"),
|
title: t("pages.home.askOrganizer.title"),
|
||||||
subtitle: t("pages.home.askOrganizer.subtitle"),
|
subtitle: t("pages.home.askOrganizer.subtitle"),
|
||||||
buttonText: t("pages.home.askOrganizer.buttonText"),
|
buttonText: t("pages.home.askOrganizer.buttonText"),
|
||||||
buttonHref: t("pages.home.askOrganizer.buttonHref"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,89 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { sendOrganizerInquiryNotification } from "../../../lib/server/mail";
|
||||||
|
import { rateLimitKey } from "../../../lib/server/rateLimit";
|
||||||
|
import {
|
||||||
|
errorJson,
|
||||||
|
rateLimited,
|
||||||
|
serverMisconfigured,
|
||||||
|
} from "../../../lib/server/responses";
|
||||||
|
import { logRouteError } from "../../../lib/server/requestId";
|
||||||
|
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||||
|
import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../lib/organizerInquiryConstants";
|
||||||
|
import { organizerInquiryBodySchema } from "../../../lib/server/validation/organizerInquirySchemas";
|
||||||
|
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||||
|
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
|
const SCOPE = "organizer-inquiry.submit";
|
||||||
|
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||||
|
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||||
|
|
||||||
|
function clientIp(request: NextRequest): string {
|
||||||
|
return (
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
request.headers.get("x-real-ip") ??
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerInquiryTo(): string | null {
|
||||||
|
const raw = process.env.ORGANIZER_INQUIRY_TO?.trim();
|
||||||
|
return raw && raw.length > 0 ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
||||||
|
const parsedBody = await readLimitedJson(request);
|
||||||
|
if (parsedBody.ok === false) {
|
||||||
|
return parsedBody.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = organizerInquiryBodySchema.safeParse(parsedBody.value);
|
||||||
|
if (!validated.success) {
|
||||||
|
return jsonFromZodError(validated.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, message } = validated.data;
|
||||||
|
const honeypot = validated.data[ORGANIZER_INQUIRY_HONEYPOT_FIELD];
|
||||||
|
|
||||||
|
if (honeypot.length > 0) {
|
||||||
|
// Silent success for bots — do not send mail or reveal rejection.
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = clientIp(request);
|
||||||
|
|
||||||
|
const rlEmail = rateLimitKey(`organizer-inquiry-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||||
|
if (rlEmail.ok === false) {
|
||||||
|
return rateLimited(rlEmail.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rlIp = rateLimitKey(`organizer-inquiry-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||||
|
if (rlIp.ok === false) {
|
||||||
|
return rateLimited(rlIp.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = organizerInquiryTo();
|
||||||
|
if (!to) {
|
||||||
|
return serverMisconfigured("ORGANIZER_INQUIRY_TO is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOrganizerInquiryNotification({
|
||||||
|
to,
|
||||||
|
fromEmail: from,
|
||||||
|
visitorEmail: email,
|
||||||
|
message,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError(SCOPE, requestId, err, { phase: "sendOrganizerInquiryNotification" });
|
||||||
|
return errorJson(
|
||||||
|
"mail_failed",
|
||||||
|
"We could not send your message. Please try again later.",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules";
|
import { listProfileRulesForUser } from "../../../../lib/server/publishedRules";
|
||||||
import {
|
import {
|
||||||
dbUnavailable,
|
dbUnavailable,
|
||||||
internalError,
|
internalError,
|
||||||
@@ -22,10 +22,19 @@ export const GET = apiRoute("rules.me.list", async (request: NextRequest) => {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
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) {
|
if (rules === null) {
|
||||||
return internalError("Failed to list rules");
|
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 type { Prisma } from "@prisma/client";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/server/db";
|
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 {
|
import {
|
||||||
dbUnavailable,
|
dbUnavailable,
|
||||||
|
errorJson,
|
||||||
|
rateLimited,
|
||||||
|
serverMisconfigured,
|
||||||
unauthorized,
|
unauthorized,
|
||||||
} from "../../../lib/server/responses";
|
} 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 { getSessionUser } from "../../../lib/server/session";
|
||||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
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 { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
@@ -36,43 +51,134 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
|||||||
return NextResponse.json({ rules });
|
return NextResponse.json({ rules });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const POST = apiRoute("rules.publish", async (request: NextRequest) => {
|
export const POST = apiRoute(
|
||||||
if (!isDatabaseConfigured()) {
|
"rules.publish",
|
||||||
return dbUnavailable();
|
async (request: NextRequest, _ctx, { requestId }) => {
|
||||||
}
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedBody = await readLimitedJson(request);
|
const parsedBody = await readLimitedJson(request);
|
||||||
if (parsedBody.ok === false) {
|
if (parsedBody.ok === false) {
|
||||||
return parsedBody.response;
|
return parsedBody.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
return jsonFromZodError(validated.error);
|
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({
|
if (inviteEmails.length > 0) {
|
||||||
data: {
|
const ip =
|
||||||
userId: user.id,
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
title,
|
request.headers.get("x-real-ip") ??
|
||||||
summary,
|
"unknown";
|
||||||
document: document as Prisma.InputJsonValue,
|
const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000);
|
||||||
},
|
if (rl.ok === false) {
|
||||||
});
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
if (inviteEmails.length === 0) {
|
||||||
rule: {
|
const rule = await prisma.publishedRule.create({
|
||||||
id: rule.id,
|
data: {
|
||||||
title: rule.title,
|
userId: user.id,
|
||||||
summary: rule.summary,
|
title,
|
||||||
createdAt: rule.createdAt,
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: Community Rule System — Modal / Ask an Organizer (22078-587823)
|
||||||
|
* File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view";
|
||||||
|
import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
|
||||||
|
import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
|
||||||
|
({ isOpen, onClose }) => {
|
||||||
|
const t = useTranslation("modals.askOrganizerInquiry");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [honeypot, setHoneypot] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [emailError, setEmailError] = useState(false);
|
||||||
|
const [questionError, setQuestionError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setEmail("");
|
||||||
|
setMessage("");
|
||||||
|
setHoneypot("");
|
||||||
|
setSubmitting(false);
|
||||||
|
setSuccess(false);
|
||||||
|
setFormError(null);
|
||||||
|
setEmailError(false);
|
||||||
|
setQuestionError(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
setEmailError(false);
|
||||||
|
setQuestionError(false);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
[ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypot,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: unknown = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
setFormError(t("rateLimitedError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
"error" in data &&
|
||||||
|
data.error &&
|
||||||
|
typeof data.error === "object" &&
|
||||||
|
"message" in data.error &&
|
||||||
|
typeof (data.error as { message: unknown }).message === "string"
|
||||||
|
) {
|
||||||
|
const msg = (data.error as { message: string }).message;
|
||||||
|
const lower = msg.toLowerCase();
|
||||||
|
if (lower.includes("email")) {
|
||||||
|
setEmailError(true);
|
||||||
|
}
|
||||||
|
if (lower.includes("character") || lower.includes("question")) {
|
||||||
|
setQuestionError(true);
|
||||||
|
}
|
||||||
|
setFormError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormError(t("genericError"));
|
||||||
|
} catch {
|
||||||
|
setFormError(t("genericError"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[email, message, honeypot, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AskOrganizerInquiryModalView
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
email={email}
|
||||||
|
message={message}
|
||||||
|
honeypot={honeypot}
|
||||||
|
submitting={submitting}
|
||||||
|
success={success}
|
||||||
|
formError={formError}
|
||||||
|
emailError={emailError}
|
||||||
|
questionError={questionError}
|
||||||
|
onEmailChange={setEmail}
|
||||||
|
onMessageChange={setMessage}
|
||||||
|
onHoneypotChange={setHoneypot}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AskOrganizerInquiryModalContainer.displayName = "AskOrganizerInquiryModal";
|
||||||
|
|
||||||
|
export default AskOrganizerInquiryModalContainer;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface AskOrganizerInquiryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import Create from "../Create";
|
||||||
|
import TextInput from "../../controls/TextInput";
|
||||||
|
import TextArea from "../../controls/TextArea";
|
||||||
|
import Button from "../../buttons/Button";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import {
|
||||||
|
ASK_ORGANIZER_INQUIRY_FORM_ID,
|
||||||
|
ORGANIZER_INQUIRY_HONEYPOT_FIELD,
|
||||||
|
} from "../../../../lib/organizerInquiryConstants";
|
||||||
|
import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
|
||||||
|
|
||||||
|
export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & {
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
honeypot: string;
|
||||||
|
submitting: boolean;
|
||||||
|
success: boolean;
|
||||||
|
formError: string | null;
|
||||||
|
emailError: boolean;
|
||||||
|
questionError: boolean;
|
||||||
|
onEmailChange: (_v: string) => void;
|
||||||
|
onMessageChange: (_v: string) => void;
|
||||||
|
onHoneypotChange: (_v: string) => void;
|
||||||
|
onSubmit: (_e: FormEvent<HTMLFormElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: Community Rule System — Modal / Ask an Organizer (22078-587823)
|
||||||
|
*/
|
||||||
|
export function AskOrganizerInquiryModalView({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
honeypot,
|
||||||
|
submitting,
|
||||||
|
success,
|
||||||
|
formError,
|
||||||
|
emailError,
|
||||||
|
questionError,
|
||||||
|
onEmailChange,
|
||||||
|
onMessageChange,
|
||||||
|
onHoneypotChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AskOrganizerInquiryModalViewProps) {
|
||||||
|
const t = useTranslation("modals.askOrganizerInquiry");
|
||||||
|
|
||||||
|
const footer = success ? (
|
||||||
|
<div className="w-full px-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="large"
|
||||||
|
className="w-full !justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{t("closeAfterSuccess")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full px-1">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={ASK_ORGANIZER_INQUIRY_FORM_ID}
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="large"
|
||||||
|
className="w-full !justify-center"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{t("submitButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("title")}
|
||||||
|
description={t("description")}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton={false}
|
||||||
|
stepper={false}
|
||||||
|
ariaLabel={t("ariaDialog")}
|
||||||
|
footerContent={footer}
|
||||||
|
footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4"
|
||||||
|
>
|
||||||
|
{success ? (
|
||||||
|
<div className="flex flex-col gap-3 py-2">
|
||||||
|
<p className="font-inter text-[18px] font-semibold leading-[24px] text-[var(--color-content-default-primary)]">
|
||||||
|
{t("successTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]">
|
||||||
|
{t("successDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
id={ASK_ORGANIZER_INQUIRY_FORM_ID}
|
||||||
|
className="relative flex flex-col gap-6 pb-2"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{formError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-negative-primary)]"
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
label={t("emailLabel")}
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
|
error={emailError}
|
||||||
|
inputSize="medium"
|
||||||
|
showHelpIcon={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
name="message"
|
||||||
|
label={t("questionLabel")}
|
||||||
|
placeholder={t("questionPlaceholder")}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => onMessageChange(e.target.value)}
|
||||||
|
error={questionError}
|
||||||
|
size="medium"
|
||||||
|
appearance="embedded"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-0 top-0 h-px w-px overflow-hidden opacity-0"
|
||||||
|
>
|
||||||
|
<label htmlFor={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}>
|
||||||
|
{t("honeypotLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}
|
||||||
|
type="text"
|
||||||
|
name={ORGANIZER_INQUIRY_HONEYPOT_FIELD}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={honeypot}
|
||||||
|
onChange={(e) => onHoneypotChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./AskOrganizerInquiryModal.container";
|
||||||
|
export * from "./AskOrganizerInquiryModal.types";
|
||||||
@@ -14,6 +14,7 @@ const CreateContainer = memo<CreateProps>(
|
|||||||
headerContent,
|
headerContent,
|
||||||
children,
|
children,
|
||||||
footerContent,
|
footerContent,
|
||||||
|
footerClassName,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
showNextButton = true,
|
showNextButton = true,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -47,6 +48,7 @@ const CreateContainer = memo<CreateProps>(
|
|||||||
// eslint-disable-next-line react/no-children-prop
|
// eslint-disable-next-line react/no-children-prop
|
||||||
children={children}
|
children={children}
|
||||||
footerContent={footerContent}
|
footerContent={footerContent}
|
||||||
|
footerClassName={footerClassName}
|
||||||
showBackButton={showBackButton}
|
showBackButton={showBackButton}
|
||||||
showNextButton={showNextButton}
|
showNextButton={showNextButton}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface CreateProps {
|
|||||||
headerContent?: React.ReactNode;
|
headerContent?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
footerContent?: React.ReactNode;
|
footerContent?: React.ReactNode;
|
||||||
|
/** Optional class on {@link ModalFooter} shell (e.g. taller custom footer). */
|
||||||
|
footerClassName?: string;
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
showNextButton?: boolean;
|
showNextButton?: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -51,6 +53,7 @@ export interface CreateViewProps {
|
|||||||
headerContent?: React.ReactNode;
|
headerContent?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
footerContent?: React.ReactNode;
|
footerContent?: React.ReactNode;
|
||||||
|
footerClassName?: string;
|
||||||
showBackButton: boolean;
|
showBackButton: boolean;
|
||||||
showNextButton: boolean;
|
showNextButton: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function CreateView({
|
|||||||
headerContent,
|
headerContent,
|
||||||
children,
|
children,
|
||||||
footerContent,
|
footerContent,
|
||||||
|
footerClassName,
|
||||||
showBackButton,
|
showBackButton,
|
||||||
showNextButton,
|
showNextButton,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -82,6 +83,7 @@ export function CreateView({
|
|||||||
totalSteps={totalSteps}
|
totalSteps={totalSteps}
|
||||||
stepper={stepper}
|
stepper={stepper}
|
||||||
footerContent={footerContent}
|
footerContent={footerContent}
|
||||||
|
className={footerClassName}
|
||||||
/>
|
/>
|
||||||
</CreateModalFrameView>
|
</CreateModalFrameView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -125,11 +125,13 @@ export default function LoginForm({
|
|||||||
const urlErrorMessage =
|
const urlErrorMessage =
|
||||||
errorParam === "expired_link"
|
errorParam === "expired_link"
|
||||||
? t("errors.expiredLink")
|
? t("errors.expiredLink")
|
||||||
: errorParam === "invalid_link" || errorParam === "server"
|
: errorParam === "stakeholder_wrong_account"
|
||||||
? errorParam === "server"
|
? t("errors.stakeholderWrongAccount")
|
||||||
? t("errors.serverError")
|
: errorParam === "invalid_link" || errorParam === "server"
|
||||||
: t("errors.invalidLink")
|
? errorParam === "server"
|
||||||
: "";
|
? t("errors.serverError")
|
||||||
|
: t("errors.invalidLink")
|
||||||
|
: "";
|
||||||
|
|
||||||
const titleId = "login-modal-heading";
|
const titleId = "login-modal-heading";
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
|||||||
hasShare = false,
|
hasShare = false,
|
||||||
hasExport = false,
|
hasExport = false,
|
||||||
hasEdit = false,
|
hasEdit = false,
|
||||||
|
hasManageStakeholders = false,
|
||||||
saveDraftOnExit = false,
|
saveDraftOnExit = false,
|
||||||
onShare,
|
onShare,
|
||||||
onSelectExportFormat,
|
onSelectExportFormat,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onManageStakeholders,
|
||||||
onExit,
|
onExit,
|
||||||
buttonPalette,
|
buttonPalette,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -41,10 +43,12 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
|||||||
hasShare={hasShare}
|
hasShare={hasShare}
|
||||||
hasExport={hasExport}
|
hasExport={hasExport}
|
||||||
hasEdit={hasEdit}
|
hasEdit={hasEdit}
|
||||||
|
hasManageStakeholders={hasManageStakeholders}
|
||||||
saveDraftOnExit={saveDraftOnExit}
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
onShare={onShare}
|
onShare={onShare}
|
||||||
onSelectExportFormat={onSelectExportFormat}
|
onSelectExportFormat={onSelectExportFormat}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
onManageStakeholders={onManageStakeholders}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
buttonPalette={buttonPalette}
|
buttonPalette={buttonPalette}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface CreateFlowTopNavProps {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
hasEdit?: boolean;
|
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 true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`.
|
||||||
* When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss).
|
* 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
|
* Callback when Edit button is clicked
|
||||||
*/
|
*/
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
/**
|
||||||
|
* Callback when Manage Stakeholders is clicked
|
||||||
|
*/
|
||||||
|
onManageStakeholders?: () => void;
|
||||||
/**
|
/**
|
||||||
* Callback when Exit/Save & Exit button is clicked.
|
* Callback when Exit/Save & Exit button is clicked.
|
||||||
* When `saveDraftOnExit` is true, called with `{ saveDraft: true }`.
|
* When `saveDraftOnExit` is true, called with `{ saveDraft: true }`.
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ export function CreateFlowTopNavView({
|
|||||||
hasShare = false,
|
hasShare = false,
|
||||||
hasExport = false,
|
hasExport = false,
|
||||||
hasEdit = false,
|
hasEdit = false,
|
||||||
|
hasManageStakeholders = false,
|
||||||
saveDraftOnExit = false,
|
saveDraftOnExit = false,
|
||||||
onShare,
|
onShare,
|
||||||
onSelectExportFormat,
|
onSelectExportFormat,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onManageStakeholders,
|
||||||
onExit,
|
onExit,
|
||||||
buttonPalette = "default",
|
buttonPalette = "default",
|
||||||
className = "",
|
className = "",
|
||||||
@@ -165,6 +167,20 @@ export function CreateFlowTopNavView({
|
|||||||
</Button>
|
</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
|
<Button
|
||||||
buttonType="outline"
|
buttonType="outline"
|
||||||
palette={buttonPalette}
|
palette={buttonPalette}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useCallback, useState } from "react";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import { useAnalytics } from "../../../hooks";
|
import { useAnalytics } from "../../../hooks";
|
||||||
|
import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
|
||||||
import AskOrganizerView from "./AskOrganizer.view";
|
import AskOrganizerView from "./AskOrganizer.view";
|
||||||
import type {
|
import type {
|
||||||
AskOrganizerProps,
|
AskOrganizerProps,
|
||||||
@@ -45,8 +46,9 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
const variant = variantProp;
|
const variant = variantProp;
|
||||||
const t = useTranslation();
|
const t = useTranslation();
|
||||||
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
||||||
const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
|
const analyticsHref = buttonHref ?? "modal";
|
||||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||||
|
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||||
|
|
||||||
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
||||||
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
|
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
|
||||||
@@ -66,6 +68,31 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
const handleContactClick = (
|
const handleContactClick = (
|
||||||
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||||
) => {
|
) => {
|
||||||
|
if (buttonHref) {
|
||||||
|
// Legacy link CTA: do not intercept navigation.
|
||||||
|
trackEvent({
|
||||||
|
event: "contact_button_click",
|
||||||
|
category: "engagement",
|
||||||
|
label: "ask_organizer",
|
||||||
|
component: "AskOrganizer",
|
||||||
|
variant: resolvedVariant,
|
||||||
|
});
|
||||||
|
trackCustomEvent(
|
||||||
|
"contact_button_click",
|
||||||
|
{
|
||||||
|
component: "AskOrganizer",
|
||||||
|
variant: resolvedVariant,
|
||||||
|
buttonText: defaultButtonText,
|
||||||
|
buttonHref: analyticsHref,
|
||||||
|
},
|
||||||
|
onContactClick as
|
||||||
|
| ((_data: Record<string, unknown>) => void)
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
trackEvent({
|
trackEvent({
|
||||||
event: "contact_button_click",
|
event: "contact_button_click",
|
||||||
category: "engagement",
|
category: "engagement",
|
||||||
@@ -80,33 +107,39 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
component: "AskOrganizer",
|
component: "AskOrganizer",
|
||||||
variant: resolvedVariant,
|
variant: resolvedVariant,
|
||||||
buttonText: defaultButtonText,
|
buttonText: defaultButtonText,
|
||||||
buttonHref: defaultButtonHref,
|
buttonHref: analyticsHref,
|
||||||
},
|
},
|
||||||
onContactClick as
|
onContactClick as
|
||||||
| ((_data: Record<string, unknown>) => void)
|
| ((_data: Record<string, unknown>) => void)
|
||||||
| undefined,
|
| undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preserve existing button behavior (no preventDefault here)
|
setInquiryOpen(true);
|
||||||
// while still tracking analytics.
|
|
||||||
return event;
|
return event;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeInquiry = useCallback(() => {
|
||||||
|
setInquiryOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AskOrganizerView
|
<>
|
||||||
title={title}
|
<AskOrganizerView
|
||||||
subtitle={subtitle}
|
title={title}
|
||||||
description={description}
|
subtitle={subtitle}
|
||||||
buttonText={defaultButtonText}
|
description={description}
|
||||||
buttonHref={defaultButtonHref}
|
buttonText={defaultButtonText}
|
||||||
className={className}
|
buttonHref={buttonHref}
|
||||||
sectionPadding={sectionPadding}
|
className={className}
|
||||||
contentGap={`${contentGap} ${styles.container}`}
|
sectionPadding={sectionPadding}
|
||||||
buttonContainerClass={styles.buttonContainer}
|
contentGap={`${contentGap} ${styles.container}`}
|
||||||
variant={resolvedVariant}
|
buttonContainerClass={styles.buttonContainer}
|
||||||
labelledBy={labelledBy}
|
variant={resolvedVariant}
|
||||||
onContactClick={handleContactClick}
|
labelledBy={labelledBy}
|
||||||
/>
|
onContactClick={handleContactClick}
|
||||||
|
/>
|
||||||
|
<AskOrganizerInquiryModal isOpen={inquiryOpen} onClose={closeInquiry} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface AskOrganizerProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
/**
|
||||||
|
* @deprecated Modal-only flow (CR-107). Omit; kept optional for Storybook overrides.
|
||||||
|
*/
|
||||||
buttonHref?: string;
|
buttonHref?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +25,7 @@ export interface AskOrganizerProps {
|
|||||||
component: string;
|
component: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
buttonHref: string;
|
buttonHref?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,7 @@ export interface AskOrganizerViewProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
buttonHref: string;
|
buttonHref?: string;
|
||||||
className: string;
|
className: string;
|
||||||
sectionPadding: string;
|
sectionPadding: string;
|
||||||
contentGap: string;
|
contentGap: string;
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ function AskOrganizerView({
|
|||||||
{/* Button */}
|
{/* Button */}
|
||||||
<div className={buttonContainerClass}>
|
<div className={buttonContainerClass}>
|
||||||
<Button
|
<Button
|
||||||
href={buttonHref}
|
{...(buttonHref ? { href: buttonHref } : {})}
|
||||||
size="large"
|
size="large"
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette={variant === "inverse" ? "inverse" : "default"}
|
palette={variant === "inverse" ? "inverse" : "default"}
|
||||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||||
onClick={onContactClick}
|
onClick={onContactClick}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
|
data-testid="ask-organizer-cta"
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
|
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
|
||||||
export interface CommunityRuleLabeledBlock {
|
export interface CommunityRuleLabeledBlock {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** With {@link imageUrl}, optional caption paragraphs only (not the uploaded file name). */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Image URL (e.g. custom method upload). Rendered as `<img>` when set. */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Non-image attachment URL. Rendered as a link when set and {@link imageUrl} is absent. */
|
||||||
|
fileUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommunityRuleEntry {
|
export interface CommunityRuleEntry {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import type { TextBlockProps } from "./TextBlock.types";
|
import type { TextBlockProps } from "./TextBlock.types";
|
||||||
|
|
||||||
|
type TextRow = NonNullable<TextBlockProps["rows"]>[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma: Utility / **Community Rule / Text Block** (22001:29793).
|
* Figma: Utility / **Community Rule / Text Block** (22001:29793).
|
||||||
* Title + body paragraphs and/or labeled rows (12px between stacks, 8px label→body).
|
* Title + body paragraphs and/or labeled rows (12px between stacks, 8px label→body).
|
||||||
@@ -38,6 +40,45 @@ function ParagraphGroup({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LabeledRowView({ row }: { row: TextRow }) {
|
||||||
|
const imageSrc = row.imageUrl?.trim();
|
||||||
|
const fileHref = row.fileUrl?.trim();
|
||||||
|
const caption = row.body.trim();
|
||||||
|
const hasCaption = caption.length > 0;
|
||||||
|
const alt = hasCaption ? caption : row.label;
|
||||||
|
const linkText = hasCaption ? caption : fileHref;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<p className={ROW_LABEL_CLASS}>{row.label}</p>
|
||||||
|
{imageSrc ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt}
|
||||||
|
className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||||
|
/>
|
||||||
|
{hasCaption ? <ParagraphGroup text={row.body} /> : null}
|
||||||
|
</>
|
||||||
|
) : fileHref ? (
|
||||||
|
<p className={`${PARAGRAPH_CLASS} whitespace-pre-wrap`}>
|
||||||
|
<a
|
||||||
|
href={fileHref}
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{linkText}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ParagraphGroup text={row.body} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TextBlockView({
|
function TextBlockView({
|
||||||
title,
|
title,
|
||||||
body = "",
|
body = "",
|
||||||
@@ -54,12 +95,7 @@ function TextBlockView({
|
|||||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||||
<div className="flex min-w-0 flex-col gap-3">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
{hasRows
|
{hasRows
|
||||||
? rows!.map((row, i) => (
|
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
||||||
<div key={i} className="flex min-w-0 flex-col gap-2">
|
|
||||||
<p className={ROW_LABEL_CLASS}>{row.label}</p>
|
|
||||||
<ParagraphGroup text={row.body} />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ Active step for chrome and navigation is resolved from the pathname via [`parseC
|
|||||||
|
|
||||||
Wizard step → React screen rendering lives in [`createFlowScreenComponents.tsx`](../app/(app)/create/screens/createFlowScreenComponents.tsx) (`renderCreateFlowScreen`), paired with [`CREATE_FLOW_SCREEN_REGISTRY`](../app/(app)/create/utils/createFlowScreenRegistry.ts) for Figma/layout metadata.
|
Wizard step → React screen rendering lives in [`createFlowScreenComponents.tsx`](../app/(app)/create/screens/createFlowScreenComponents.tsx) (`renderCreateFlowScreen`), paired with [`CREATE_FLOW_SCREEN_REGISTRY`](../app/(app)/create/utils/createFlowScreenRegistry.ts) for Figma/layout metadata.
|
||||||
|
|
||||||
|
### Stakeholder emails (`confirm-stakeholders`)
|
||||||
|
|
||||||
|
The step persists **`stakeholderEmails`** on `CreateFlowState` (validated on `PUT /api/drafts/me`). On **first publish** (`POST /api/rules`), the server records [`RuleStakeholder`](../prisma/schema.prisma) rows and emails each address a one-time link to [`GET /api/invites/rule-stakeholder/verify`](../app/api/invites/rule-stakeholder/verify/route.ts). Opening the link creates or signs in the account for that email and redirects to the public rule; the rule also appears on the invitee’s **profile** with **view** access (not manage). **After publish**, owners manage invites from **`/create/edit-rule`** via **Manage Stakeholders**, which opens **`/create/confirm-stakeholders?reviewReturn=edit-rule&manageStakeholders=1`** (same screen layout as the pre-publish step, backed by [`GET` / `POST` `DELETE` / resend](../app/api/rules/[id]/stakeholders/route.ts)). **`PATCH /api/rules/[id]`** still does not read stakeholder emails from the wizard draft.
|
||||||
|
|
||||||
### Fresh start vs continue draft (signed-in + sync)
|
### Fresh start vs continue draft (signed-in + sync)
|
||||||
|
|
||||||
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Signed-in users additionally get **Save & Exit** and **publish**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Signed-in users additionally get **Save & Exit** and **publish**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
|||||||
|
|
||||||
- **Core values** ([`CoreValuesSelectScreen.tsx`](../../app/(app)/create/screens/select/CoreValuesSelectScreen.tsx)) — full custom-chip flow: `Add value` → empty chip with input → check → opens an editable `meaning` / `signals` modal. Dismissing the modal now drops the brand-new chip (`customPending` session). Add Value confirms it as a selected chip.
|
- **Core values** ([`CoreValuesSelectScreen.tsx`](../../app/(app)/create/screens/select/CoreValuesSelectScreen.tsx)) — full custom-chip flow: `Add value` → empty chip with input → check → opens an editable `meaning` / `signals` modal. Dismissing the modal now drops the brand-new chip (`customPending` session). Add Value confirms it as a selected chip.
|
||||||
- **Communication / Membership / Conflict management / Decision approaches** (card-style screens, e.g. [`CommunicationMethodsScreen.tsx`](../../app/(app)/create/screens/card/CommunicationMethodsScreen.tsx)) — there is **no `Add custom method` affordance**. The inline `add` link in the page description (`messages/en/create/customRule/*.json`, `compactDescriptionLinkLabel: "add"`) only toggles `setExpanded(true)` on the card stack — it shows more preset cards, it does **not** open a creation modal.
|
- **Communication / Membership / Conflict management / Decision approaches** (card-style screens, e.g. [`CommunicationMethodsScreen.tsx`](../../app/(app)/create/screens/card/CommunicationMethodsScreen.tsx)) — there is **no `Add custom method` affordance**. The inline `add` link in the page description (`messages/en/create/customRule/*.json`, `compactDescriptionLinkLabel: "add"`) only toggles `setExpanded(true)` on the card stack — it shows more preset cards, it does **not** open a creation modal.
|
||||||
- **Confirm stakeholders** — multiselect-style add (free-text chip), pending real invite work in **Ticket 18 / CR-90**.
|
- **Confirm stakeholders** — email chips persisted as `stakeholderEmails`; invites sent at first publish; see [docs/create-flow.md](../../create-flow.md) § *Stakeholder emails* and **Ticket 18 / CR-90**.
|
||||||
- **Community structure** — multiselect-style add (free-text chip).
|
- **Community structure** — multiselect-style add (free-text chip).
|
||||||
- **Final Review** ([`FinalReviewScreen.tsx`](../../app/(app)/create/screens/review/FinalReviewScreen.tsx)) — renders `<Rule categories=…>` 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.
|
- **Final Review** ([`FinalReviewScreen.tsx`](../../app/(app)/create/screens/review/FinalReviewScreen.tsx)) — renders `<Rule categories=…>` 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.
|
||||||
|
|
||||||
|
|||||||
+183
-1
@@ -199,6 +199,7 @@ export async function publishRule(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
document: Record<string, unknown>;
|
document: Record<string, unknown>;
|
||||||
|
stakeholderEmails?: string[];
|
||||||
}): Promise<
|
}): Promise<
|
||||||
| { ok: true; id: string; title: string }
|
| { ok: true; id: string; title: string }
|
||||||
| { ok: false; error: string; status?: number }
|
| { ok: false; error: string; status?: number }
|
||||||
@@ -212,6 +213,9 @@ export async function publishRule(input: {
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
summary: input.summary,
|
summary: input.summary,
|
||||||
document: input.document,
|
document: input.document,
|
||||||
|
...(input.stakeholderEmails?.length
|
||||||
|
? { stakeholderEmails: input.stakeholderEmails }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = (await safeParseJsonResponse(res)) as {
|
const data = (await safeParseJsonResponse(res)) as {
|
||||||
@@ -289,6 +293,8 @@ export type MyPublishedRule = {
|
|||||||
summary: string | null;
|
summary: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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[];
|
rules?: MyPublishedRule[];
|
||||||
} | null;
|
} | null;
|
||||||
if (!data || !Array.isArray(data.rules)) return 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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -355,6 +370,173 @@ export async function fetchPublishedRuleDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RuleStakeholderListItem = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
invitedAt: string;
|
||||||
|
acceptedAt: string | null;
|
||||||
|
status: "pending" | "accepted";
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseStakeholdersPayload(data: unknown): RuleStakeholderListItem[] | null {
|
||||||
|
if (!data || typeof data !== "object" || !("stakeholders" in data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = (data as { stakeholders: unknown }).stakeholders;
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const out: RuleStakeholderListItem[] = [];
|
||||||
|
for (const x of raw) {
|
||||||
|
if (
|
||||||
|
!x ||
|
||||||
|
typeof x !== "object" ||
|
||||||
|
typeof (x as { id?: unknown }).id !== "string" ||
|
||||||
|
typeof (x as { email?: unknown }).email !== "string" ||
|
||||||
|
typeof (x as { invitedAt?: unknown }).invitedAt !== "string" ||
|
||||||
|
((x as { status?: unknown }).status !== "pending" &&
|
||||||
|
(x as { status?: unknown }).status !== "accepted")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const acceptedRaw = (x as { acceptedAt?: unknown }).acceptedAt;
|
||||||
|
const acceptedAt =
|
||||||
|
acceptedRaw === null
|
||||||
|
? null
|
||||||
|
: typeof acceptedRaw === "string"
|
||||||
|
? acceptedRaw
|
||||||
|
: null;
|
||||||
|
out.push({
|
||||||
|
id: (x as { id: string }).id,
|
||||||
|
email: (x as { email: string }).email,
|
||||||
|
invitedAt: (x as { invitedAt: string }).invitedAt,
|
||||||
|
acceptedAt,
|
||||||
|
status: (x as { status: "pending" | "accepted" }).status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRuleStakeholders(
|
||||||
|
ruleId: string,
|
||||||
|
): Promise<RuleStakeholderListItem[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await safeParseJsonResponse(res);
|
||||||
|
return parseStakeholdersPayload(data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleStakeholderMutationResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; error: string; status: number; retryAfterMs?: number };
|
||||||
|
|
||||||
|
function retryAfterFromResponse(
|
||||||
|
res: Response,
|
||||||
|
data: unknown,
|
||||||
|
): number | undefined {
|
||||||
|
if (res.status !== 429) return undefined;
|
||||||
|
if (data && typeof data === "object" && "details" in data) {
|
||||||
|
const d = (data as { details?: unknown }).details;
|
||||||
|
if (d && typeof d === "object" && "retryAfterMs" in d) {
|
||||||
|
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
|
||||||
|
if (typeof ms === "number" && ms > 0) return ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h = res.headers.get("retry-after");
|
||||||
|
if (h) {
|
||||||
|
const sec = Number.parseInt(h, 10);
|
||||||
|
if (!Number.isNaN(sec)) return sec * 1000;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRuleStakeholder(
|
||||||
|
ruleId: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<RuleStakeholderMutationResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.ok) return { ok: true };
|
||||||
|
const data = await safeParseJsonResponse(res);
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: readApiErrorMessage(data),
|
||||||
|
status: res.status,
|
||||||
|
retryAfterMs: retryAfterFromResponse(res, data),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||||
|
status: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRuleStakeholder(
|
||||||
|
ruleId: string,
|
||||||
|
stakeholderId: string,
|
||||||
|
): Promise<RuleStakeholderMutationResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}`,
|
||||||
|
{ method: "DELETE", credentials: "include" },
|
||||||
|
);
|
||||||
|
if (res.ok) return { ok: true };
|
||||||
|
const data = await safeParseJsonResponse(res);
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: readApiErrorMessage(data),
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||||
|
status: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendRuleStakeholderInvite(
|
||||||
|
ruleId: string,
|
||||||
|
stakeholderId: string,
|
||||||
|
): Promise<RuleStakeholderMutationResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}/resend`,
|
||||||
|
{ method: "POST", credentials: "include" },
|
||||||
|
);
|
||||||
|
if (res.ok) return { ok: true };
|
||||||
|
const data = await safeParseJsonResponse(res);
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: readApiErrorMessage(data),
|
||||||
|
status: res.status,
|
||||||
|
retryAfterMs: retryAfterFromResponse(res, data),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||||
|
status: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type DeleteRuleResult =
|
export type DeleteRuleResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; error: string; status: number };
|
| { ok: false; error: string; status: number };
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
|
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
|
||||||
if (!x || typeof x !== "object") return false;
|
if (!x || typeof x !== "object") return false;
|
||||||
const o = x as Record<string, unknown>;
|
const o = x as Record<string, unknown>;
|
||||||
return typeof o.label === "string" && typeof o.body === "string";
|
if (typeof o.label !== "string" || typeof o.body !== "string") return false;
|
||||||
|
if (o.imageUrl !== undefined && typeof o.imageUrl !== "string") return false;
|
||||||
|
if (o.fileUrl !== undefined && typeof o.fileUrl !== "string") return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared by publish payload parsing and template body parsing — keep in sync. */
|
/** Shared by publish payload parsing and template body parsing — keep in sync. */
|
||||||
|
|||||||
+78
-13
@@ -2,6 +2,7 @@ import { jsPDF } from "jspdf";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CommunityRuleEntry,
|
CommunityRuleEntry,
|
||||||
|
CommunityRuleLabeledBlock,
|
||||||
CommunityRuleSection,
|
CommunityRuleSection,
|
||||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||||
@@ -23,11 +24,33 @@ export function exportFilenameBase(rule: StoredLastPublishedRule): string {
|
|||||||
return fromTitle.length > 0 ? fromTitle : `rule-${rule.id.slice(0, 8)}`;
|
return fromTitle.length > 0 ? fromTitle : `rule-${rule.id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labeledBlockMedia(b: CommunityRuleLabeledBlock): {
|
||||||
|
img?: string;
|
||||||
|
file?: string;
|
||||||
|
bodyTrim: string;
|
||||||
|
} {
|
||||||
|
const imgRaw = b.imageUrl?.trim();
|
||||||
|
const fileRaw = b.fileUrl?.trim();
|
||||||
|
return {
|
||||||
|
img: imgRaw && imgRaw.length > 0 ? imgRaw : undefined,
|
||||||
|
file: fileRaw && fileRaw.length > 0 ? fileRaw : undefined,
|
||||||
|
bodyTrim: b.body.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function entryToMarkdown(entry: CommunityRuleEntry): string {
|
function entryToMarkdown(entry: CommunityRuleEntry): string {
|
||||||
const lines: string[] = [`### ${entry.title}`, ""];
|
const lines: string[] = [`### ${entry.title}`, ""];
|
||||||
if (entry.blocks && entry.blocks.length > 0) {
|
if (entry.blocks && entry.blocks.length > 0) {
|
||||||
for (const b of entry.blocks) {
|
for (const b of entry.blocks) {
|
||||||
lines.push(`#### ${b.label}`, "", b.body, "");
|
lines.push(`#### ${b.label}`, "");
|
||||||
|
const { img, file, bodyTrim } = labeledBlockMedia(b);
|
||||||
|
if (img) {
|
||||||
|
lines.push(``, "");
|
||||||
|
} else if (file) {
|
||||||
|
lines.push(`[${bodyTrim || "file"}](${file})`, "");
|
||||||
|
} else {
|
||||||
|
lines.push(b.body, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const body = (entry.body ?? "").trim();
|
const body = (entry.body ?? "").trim();
|
||||||
@@ -86,7 +109,17 @@ export function sectionsToCsv(
|
|||||||
for (const ent of sec.entries) {
|
for (const ent of sec.entries) {
|
||||||
if (ent.blocks && ent.blocks.length > 0) {
|
if (ent.blocks && ent.blocks.length > 0) {
|
||||||
for (const b of ent.blocks) {
|
for (const b of ent.blocks) {
|
||||||
rows.push([sec.categoryName, ent.title, b.label, b.body]);
|
const { img, file, bodyTrim } = labeledBlockMedia(b);
|
||||||
|
const content = img
|
||||||
|
? bodyTrim.length > 0
|
||||||
|
? `${bodyTrim}\n${img}`
|
||||||
|
: img
|
||||||
|
: file
|
||||||
|
? bodyTrim.length > 0
|
||||||
|
? `${bodyTrim}\n${file}`
|
||||||
|
: file
|
||||||
|
: b.body;
|
||||||
|
rows.push([sec.categoryName, ent.title, b.label, content]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
|
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
|
||||||
@@ -136,7 +169,17 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
|
|||||||
if (entry.blocks && entry.blocks.length > 0) {
|
if (entry.blocks && entry.blocks.length > 0) {
|
||||||
for (const b of entry.blocks) {
|
for (const b of entry.blocks) {
|
||||||
inner += `<h4 class="block-label">${escapeHtml(b.label)}</h4>`;
|
inner += `<h4 class="block-label">${escapeHtml(b.label)}</h4>`;
|
||||||
inner += paragraphsHtml(b.body);
|
const { img, file, bodyTrim } = labeledBlockMedia(b);
|
||||||
|
if (img) {
|
||||||
|
inner += `<p><img src="${escapeHtml(img)}" alt="${escapeHtml(bodyTrim || b.label)}" /></p>`;
|
||||||
|
if (bodyTrim.length > 0) {
|
||||||
|
inner += paragraphsHtml(b.body);
|
||||||
|
}
|
||||||
|
} else if (file) {
|
||||||
|
inner += `<p><a href="${escapeHtml(file)}">${escapeHtml(bodyTrim || file)}</a></p>`;
|
||||||
|
} else {
|
||||||
|
inner += paragraphsHtml(b.body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
inner += paragraphsHtml(entry.body ?? "");
|
inner += paragraphsHtml(entry.body ?? "");
|
||||||
@@ -194,6 +237,29 @@ export function sectionsToPdfBlob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeBlockBodyParagraphs(body: string): void {
|
||||||
|
for (const p of splitDisplayParagraphs(body)) {
|
||||||
|
doc.setFont("helvetica", "normal");
|
||||||
|
doc.setFontSize(11);
|
||||||
|
const lines = doc.splitTextToSize(p, maxW);
|
||||||
|
const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
|
||||||
|
ensureSpace(dim.h + 1);
|
||||||
|
doc.text(lines, margin, y);
|
||||||
|
y += dim.h + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain URL line(s); italic for images so captions vs URL read distinctly in print. */
|
||||||
|
function writeMediaUrlLines(url: string, fontStyle: "normal" | "italic"): void {
|
||||||
|
doc.setFont("helvetica", fontStyle);
|
||||||
|
doc.setFontSize(10);
|
||||||
|
const urlLines = doc.splitTextToSize(url, maxW);
|
||||||
|
const urlDim = doc.getTextDimensions(urlLines.join("\n"), { maxWidth: maxW });
|
||||||
|
ensureSpace(urlDim.h + 1);
|
||||||
|
doc.text(urlLines, margin, y);
|
||||||
|
y += urlDim.h + 2;
|
||||||
|
}
|
||||||
|
|
||||||
doc.setFont("helvetica", "bold");
|
doc.setFont("helvetica", "bold");
|
||||||
doc.setFontSize(16);
|
doc.setFontSize(16);
|
||||||
{
|
{
|
||||||
@@ -253,16 +319,15 @@ export function sectionsToPdfBlob(
|
|||||||
doc.text(lines, margin, y);
|
doc.text(lines, margin, y);
|
||||||
y += dim.h + 2;
|
y += dim.h + 2;
|
||||||
}
|
}
|
||||||
for (const p of splitDisplayParagraphs(b.body)) {
|
const { img, file } = labeledBlockMedia(b);
|
||||||
doc.setFont("helvetica", "normal");
|
if (img) {
|
||||||
doc.setFontSize(11);
|
writeBlockBodyParagraphs(b.body);
|
||||||
const lines = doc.splitTextToSize(p, maxW);
|
writeMediaUrlLines(img, "italic");
|
||||||
const dim = doc.getTextDimensions(lines.join("\n"), {
|
} else if (file) {
|
||||||
maxWidth: maxW,
|
writeBlockBodyParagraphs(b.body);
|
||||||
});
|
writeMediaUrlLines(file, "normal");
|
||||||
ensureSpace(dim.h + 1);
|
} else {
|
||||||
doc.text(lines, margin, y);
|
writeBlockBodyParagraphs(b.body);
|
||||||
y += dim.h + 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import type { PublishedMethodSelections } from "./buildPublishPayload";
|
|||||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||||
|
|
||||||
|
/** Uses filename extension and/or URL path so uploads render as `<img>` vs file link on read-only surfaces. */
|
||||||
|
export function wizardUploadDisplaysAsImage(
|
||||||
|
fileName: string | null,
|
||||||
|
assetUrl: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (fileName && /\.(jpe?g|png|gif|webp)$/i.test(fileName)) return true;
|
||||||
|
if (assetUrl && /\.(jpe?g|png|gif|webp)(\?|#|$)/i.test(assetUrl)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize wizard-authored field blocks into Community Rule labeled rows for
|
* Serialize wizard-authored field blocks into Community Rule labeled rows for
|
||||||
* read-only surfaces (completed step, exported views). Matches how those blocks
|
* read-only surfaces (completed step, exported views). Matches how those blocks
|
||||||
@@ -32,8 +42,23 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
|||||||
case "upload": {
|
case "upload": {
|
||||||
const name = nonEmptyTrimmed(b.fileName);
|
const name = nonEmptyTrimmed(b.fileName);
|
||||||
const url = nonEmptyTrimmed(b.assetUrl);
|
const url = nonEmptyTrimmed(b.assetUrl);
|
||||||
const body = name ?? url;
|
if (url) {
|
||||||
if (body) out.push({ label: b.blockTitle, body });
|
if (wizardUploadDisplaysAsImage(name, url)) {
|
||||||
|
out.push({
|
||||||
|
label: b.blockTitle,
|
||||||
|
body: "",
|
||||||
|
imageUrl: url,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
label: b.blockTitle,
|
||||||
|
body: name ?? url,
|
||||||
|
fileUrl: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (name) {
|
||||||
|
out.push({ label: b.blockTitle, body: name });
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "proportion":
|
case "proportion":
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Shared between client (form JSON) and server (Zod + honeypot check).
|
||||||
|
* CR-107 Ask an organizer.
|
||||||
|
*/
|
||||||
|
export const ORGANIZER_INQUIRY_HONEYPOT_FIELD = "company" as const;
|
||||||
|
|
||||||
|
export const ASK_ORGANIZER_INQUIRY_FORM_ID = "ask-organizer-inquiry-form" as const;
|
||||||
@@ -27,6 +27,68 @@ export async function sendMagicLinkEmail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
|
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
|
||||||
|
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
|
||||||
|
export async function sendRuleStakeholderInviteEmail(
|
||||||
|
to: string,
|
||||||
|
verifyUrl: string,
|
||||||
|
ruleTitle: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const url = process.env.SMTP_URL;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
logger.info(
|
||||||
|
`[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("SMTP_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(url);
|
||||||
|
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: `You're invited to view a Community Rule: ${ruleTitle}`,
|
||||||
|
text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */
|
||||||
|
export async function sendOrganizerInquiryNotification(params: {
|
||||||
|
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
|
||||||
|
to: string;
|
||||||
|
fromEmail: string;
|
||||||
|
visitorEmail: string;
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { to, fromEmail, visitorEmail, message, requestId } = params;
|
||||||
|
const url = process.env.SMTP_URL;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
logger.info(
|
||||||
|
`[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("SMTP_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(url);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: fromEmail,
|
||||||
|
to,
|
||||||
|
replyTo: visitorEmail,
|
||||||
|
subject: `Ask an organizer inquiry from ${visitorEmail}`,
|
||||||
|
text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmailChangeEmail(
|
export async function sendEmailChangeEmail(
|
||||||
to: string,
|
to: string,
|
||||||
verifyUrl: string,
|
verifyUrl: string,
|
||||||
|
|||||||
@@ -88,3 +88,68 @@ export async function listPublishedRulesForUser(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProfileRuleListItem = OwnerPublishedRuleListItem & {
|
||||||
|
role: "owner" | "stakeholder";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published rules the user can access as an **accepted** stakeholder (`userId` set).
|
||||||
|
* Same metadata shape as {@link listPublishedRulesForUser}; no `document`.
|
||||||
|
*/
|
||||||
|
export async function listStakeholderRulesForUser(
|
||||||
|
userId: string,
|
||||||
|
take: number,
|
||||||
|
): Promise<OwnerPublishedRuleListItem[] | null> {
|
||||||
|
if (!isDatabaseConfigured()) return null;
|
||||||
|
if (typeof userId !== "string" || userId.trim() === "") return null;
|
||||||
|
const clamped = Math.min(Math.max(0, take), 100);
|
||||||
|
if (clamped === 0) return [];
|
||||||
|
try {
|
||||||
|
const rows = await prisma.ruleStakeholder.findMany({
|
||||||
|
where: { userId },
|
||||||
|
take: clamped,
|
||||||
|
orderBy: [{ rule: { updatedAt: "desc" } }, { id: "asc" }],
|
||||||
|
select: {
|
||||||
|
rule: { select: PUBLISHED_RULE_OWNER_LIST_SELECT },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.rule);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile list: owned rules plus stakeholder access, **owner wins** if both,
|
||||||
|
* sorted by `updatedAt` desc (then `id`).
|
||||||
|
*/
|
||||||
|
export async function listProfileRulesForUser(
|
||||||
|
userId: string,
|
||||||
|
take: number,
|
||||||
|
): Promise<ProfileRuleListItem[] | null> {
|
||||||
|
const cap = Math.min(Math.max(0, take), 100);
|
||||||
|
if (cap === 0) return [];
|
||||||
|
/** Merge then slice so ordering is global by `updatedAt`. */
|
||||||
|
const fetchCap = 100;
|
||||||
|
const [owned, stakeholderRules] = await Promise.all([
|
||||||
|
listPublishedRulesForUser(userId, fetchCap),
|
||||||
|
listStakeholderRulesForUser(userId, fetchCap),
|
||||||
|
]);
|
||||||
|
if (owned === null || stakeholderRules === null) return null;
|
||||||
|
const ownerIds = new Set(owned.map((r) => r.id));
|
||||||
|
const stakeholderOnly = stakeholderRules.filter((r) => !ownerIds.has(r.id));
|
||||||
|
const combined: ProfileRuleListItem[] = [
|
||||||
|
...owned.map((r) => ({ ...r, role: "owner" as const })),
|
||||||
|
...stakeholderOnly.map((r) => ({
|
||||||
|
...r,
|
||||||
|
role: "stakeholder" as const,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
combined.sort((a, b) => {
|
||||||
|
const t = b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||||
|
if (t !== 0) return t;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
return combined.slice(0, cap);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export type ApiErrorCode =
|
|||||||
| "rate_limited"
|
| "rate_limited"
|
||||||
| "server_misconfigured"
|
| "server_misconfigured"
|
||||||
| "mail_failed"
|
| "mail_failed"
|
||||||
| "internal_error";
|
| "internal_error"
|
||||||
|
| "conflict";
|
||||||
|
|
||||||
export interface ApiErrorBody {
|
export interface ApiErrorBody {
|
||||||
error: { code: ApiErrorCode; message: string };
|
error: { code: ApiErrorCode; message: string };
|
||||||
@@ -66,6 +67,10 @@ export function forbidden(message = "Forbidden"): NextResponse {
|
|||||||
return errorJson("forbidden", message, 403);
|
return errorJson("forbidden", message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function conflict(message = "Conflict"): NextResponse {
|
||||||
|
return errorJson("conflict", message, 409);
|
||||||
|
}
|
||||||
|
|
||||||
export function rateLimited(retryAfterMs: number): NextResponse {
|
export function rateLimited(retryAfterMs: number): NextResponse {
|
||||||
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
|
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
|
||||||
return errorJson("rate_limited", "Too many requests", 429, {
|
return errorJson("rate_limited", "Too many requests", 429, {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { prisma } from "./db";
|
||||||
|
import { hashSessionToken, newSessionToken } from "./hash";
|
||||||
|
import { sendRuleStakeholderInviteEmail } from "./mail";
|
||||||
|
import { logRouteError } from "./requestId";
|
||||||
|
import { STAKEHOLDER_INVITE_TTL_MS } from "./ruleStakeholders";
|
||||||
|
|
||||||
|
export function stakeholderInviteVerifyUrl(origin: string, token: string): string {
|
||||||
|
return `${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a pending {@link RuleStakeholder} row and sends the invite email.
|
||||||
|
* On mail failure, deletes the row and returns `ok: false`.
|
||||||
|
*/
|
||||||
|
export async function createRuleStakeholderInviteAndSendMail(opts: {
|
||||||
|
scope: string;
|
||||||
|
requestId: string;
|
||||||
|
origin: string;
|
||||||
|
ruleId: string;
|
||||||
|
ruleTitle: string;
|
||||||
|
email: string;
|
||||||
|
invitedByUserId: string;
|
||||||
|
pepper: string;
|
||||||
|
}): Promise<{ ok: true } | { ok: false }> {
|
||||||
|
const token = newSessionToken();
|
||||||
|
const tokenHash = hashSessionToken(token, opts.pepper);
|
||||||
|
const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||||
|
|
||||||
|
const row = await prisma.ruleStakeholder.create({
|
||||||
|
data: {
|
||||||
|
ruleId: opts.ruleId,
|
||||||
|
email: opts.email,
|
||||||
|
invitedByUserId: opts.invitedByUserId,
|
||||||
|
inviteTokenHash: tokenHash,
|
||||||
|
inviteExpiresAt: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token);
|
||||||
|
try {
|
||||||
|
await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError(opts.scope, opts.requestId, err, {
|
||||||
|
phase: "sendRuleStakeholderInviteEmail",
|
||||||
|
email: opts.email,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await prisma.ruleStakeholder.delete({ where: { id: row.id } });
|
||||||
|
} catch {
|
||||||
|
/* best-effort cleanup */
|
||||||
|
}
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Parity with magic-link request TTL (15 minutes). */
|
||||||
|
export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
||||||
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||||
|
|
||||||
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
|
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
|
||||||
@@ -64,6 +65,15 @@ const customMethodCardMetaEntrySchema = z.object({
|
|||||||
supportText: z.string().max(48),
|
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.
|
* Published rule `document` column: arbitrary JSON object with safety bounds.
|
||||||
*/
|
*/
|
||||||
@@ -144,7 +154,10 @@ export const createFlowStateSchema = z
|
|||||||
editingPublishedRuleId: z.string().max(200).optional(),
|
editingPublishedRuleId: z.string().max(200).optional(),
|
||||||
currentStep: createFlowStepSchema.optional(),
|
currentStep: createFlowStepSchema.optional(),
|
||||||
sections: z.array(z.unknown()).optional(),
|
sections: z.array(z.unknown()).optional(),
|
||||||
stakeholders: z.array(z.unknown()).optional(),
|
stakeholderEmails: z
|
||||||
|
.array(stakeholderEmailSchema)
|
||||||
|
.max(MAX_STAKEHOLDER_EMAILS)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
@@ -171,10 +184,40 @@ export const publishRuleBodySchema = z.object({
|
|||||||
return t.length > 0 ? t : null;
|
return t.length > 0 ? t : null;
|
||||||
}),
|
}),
|
||||||
document: publishedRuleDocumentSchema,
|
document: publishedRuleDocumentSchema,
|
||||||
|
stakeholderEmails: z
|
||||||
|
.array(stakeholderEmailSchema)
|
||||||
|
.max(MAX_STAKEHOLDER_EMAILS)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
|
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
|
||||||
|
|
||||||
|
export const postRuleStakeholderBodySchema = z.object({
|
||||||
|
email: stakeholderEmailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PostRuleStakeholderBody = z.infer<
|
||||||
|
typeof postRuleStakeholderBodySchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Dedupe and drop the publisher’s own email (`emails` need not be pre-normalized). */
|
||||||
|
export function uniqueStakeholderEmailsForPublish(
|
||||||
|
emails: string[] | undefined,
|
||||||
|
publisherEmailNormalized: string,
|
||||||
|
): string[] {
|
||||||
|
if (!emails?.length) return [];
|
||||||
|
const pub = publisherEmailNormalized.trim().toLowerCase();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of emails) {
|
||||||
|
const e = raw.trim().toLowerCase();
|
||||||
|
if (e === pub || seen.has(e)) continue;
|
||||||
|
seen.add(e);
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export const putDraftBodySchema = z.object({
|
export const putDraftBodySchema = z.object({
|
||||||
payload: createFlowStateSchema,
|
payload: createFlowStateSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../organizerInquiryConstants";
|
||||||
|
|
||||||
|
const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.max(254)
|
||||||
|
.transform((s) => s.toLowerCase())
|
||||||
|
.pipe(z.string().email("Enter a valid email address"));
|
||||||
|
|
||||||
|
const messageSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(10, "Please enter at least 10 characters")
|
||||||
|
.max(10_000, "Message is too long");
|
||||||
|
|
||||||
|
/** Optional honeypot; non-empty after trim indicates a bot. */
|
||||||
|
const honeypotSchema = z
|
||||||
|
.union([z.string(), z.undefined()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (typeof v === "string" ? v.trim() : ""));
|
||||||
|
|
||||||
|
export const organizerInquiryBodySchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
message: messageSchema,
|
||||||
|
[ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypotSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrganizerInquiryBody = z.infer<typeof organizerInquiryBodySchema>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "AskOrganizer component defaults (shared across pages)",
|
"_comment": "AskOrganizer component defaults (shared across pages)",
|
||||||
"ariaLabel": "Ask an organizer - Contact an organizer for help"
|
"ariaLabel": "Ask an organizer - Contact an organizer for help",
|
||||||
|
"buttonText": "Ask an organizer"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"title": "Do other stakeholders need to be involved in creating your community?",
|
"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",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
"share": "Share",
|
"share": "Share",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"manageStakeholders": "Manage Stakeholders",
|
||||||
"shareAriaLabel": "Share",
|
"shareAriaLabel": "Share",
|
||||||
"exportAriaLabel": "Export",
|
"exportAriaLabel": "Export",
|
||||||
"editAriaLabel": "Edit",
|
"editAriaLabel": "Edit",
|
||||||
|
"manageStakeholdersAriaLabel": "Manage Stakeholders",
|
||||||
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
|
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
|
||||||
"draftSaveBannerTitle": "Couldn't save draft",
|
"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}"
|
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import navigation from "./navigation.json";
|
|||||||
import metadata from "./metadata.json";
|
import metadata from "./metadata.json";
|
||||||
import modalsShare from "./modals/share.json";
|
import modalsShare from "./modals/share.json";
|
||||||
import modalsPopoverExport from "./modals/popoverExport.json";
|
import modalsPopoverExport from "./modals/popoverExport.json";
|
||||||
|
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
|
||||||
|
|
||||||
// create – stage 1: community
|
// create – stage 1: community
|
||||||
import createInformational from "./create/community/informational.json";
|
import createInformational from "./create/community/informational.json";
|
||||||
@@ -117,5 +118,6 @@ export default {
|
|||||||
modals: {
|
modals: {
|
||||||
share: modalsShare,
|
share: modalsShare,
|
||||||
popoverExport: modalsPopoverExport,
|
popoverExport: modalsPopoverExport,
|
||||||
|
askOrganizerInquiry: modalsAskOrganizerInquiry,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"_comment": "CR-107 Ask an organizer modal (Figma 22078-587823)",
|
||||||
|
"title": "Ask an Organizer",
|
||||||
|
"description": "Have a question about organizing? Send it over and an experienced organizer will get back to you.",
|
||||||
|
"emailLabel": "Email address",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"questionLabel": "Your question",
|
||||||
|
"questionPlaceholder": "What would you like to know?",
|
||||||
|
"submitButton": "Confirm Question",
|
||||||
|
"closeAfterSuccess": "Close",
|
||||||
|
"successTitle": "Thanks, we received your question",
|
||||||
|
"successDescription": "Check your inbox and an organizer will reply when they can.",
|
||||||
|
"genericError": "Something went wrong. Please try again.",
|
||||||
|
"rateLimitedError": "Too many attempts. Please wait a bit and try again.",
|
||||||
|
"ariaDialog": "Ask an organizer",
|
||||||
|
"honeypotLabel": "Company"
|
||||||
|
}
|
||||||
@@ -53,8 +53,7 @@
|
|||||||
"askOrganizer": {
|
"askOrganizer": {
|
||||||
"title": "Still have questions?",
|
"title": "Still have questions?",
|
||||||
"subtitle": "Get answers from an experienced organizer",
|
"subtitle": "Get answers from an experienced organizer",
|
||||||
"buttonText": "Ask an organizer",
|
"buttonText": "Ask an organizer"
|
||||||
"buttonHref": "#contact"
|
|
||||||
},
|
},
|
||||||
"ruleStack": {
|
"ruleStack": {
|
||||||
"title": "Popular templates",
|
"title": "Popular templates",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"title": "Still have questions?",
|
"title": "Still have questions?",
|
||||||
"subtitle": "Get answers from an experienced organizer",
|
"subtitle": "Get answers from an experienced organizer",
|
||||||
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
|
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
|
||||||
"buttonText": "Ask an organizer",
|
"buttonText": "Ask an organizer"
|
||||||
"buttonHref": "/contact"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"generic": "Something went wrong. Try again.",
|
"generic": "Something went wrong. Try again.",
|
||||||
"invalidLink": "That sign-in link is not valid. Request a new one from the login page.",
|
"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.",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -18,6 +18,10 @@ model User {
|
|||||||
rules PublishedRule[]
|
rules PublishedRule[]
|
||||||
/// At most one pending verified email change (CR-103).
|
/// At most one pending verified email change (CR-103).
|
||||||
emailChangeToken EmailChangeToken?
|
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).
|
/// Pending email change: user must open verify link sent to `newEmail` (CR-103).
|
||||||
@@ -74,9 +78,35 @@ model PublishedRule {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
stakeholders RuleStakeholder[]
|
||||||
|
|
||||||
@@index([userId])
|
@@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 {
|
model RuleTemplate {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
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",
|
control: "boolean",
|
||||||
description: "Whether to show the Edit button",
|
description: "Whether to show the Edit button",
|
||||||
},
|
},
|
||||||
|
hasManageStakeholders: {
|
||||||
|
control: "boolean",
|
||||||
|
description:
|
||||||
|
"Whether to show Manage Stakeholders (edit published rule invites)",
|
||||||
|
},
|
||||||
saveDraftOnExit: {
|
saveDraftOnExit: {
|
||||||
control: "boolean",
|
control: "boolean",
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +38,7 @@ export default {
|
|||||||
onShare: { action: "share clicked" },
|
onShare: { action: "share clicked" },
|
||||||
onSelectExportFormat: { action: "export format" },
|
onSelectExportFormat: { action: "export format" },
|
||||||
onEdit: { action: "edit clicked" },
|
onEdit: { action: "edit clicked" },
|
||||||
|
onManageStakeholders: { action: "manage stakeholders clicked" },
|
||||||
onExit: { action: "exit clicked" },
|
onExit: { action: "exit clicked" },
|
||||||
},
|
},
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
@@ -64,3 +70,13 @@ export const SaveDraftOnExit = {
|
|||||||
saveDraftOnExit: true,
|
saveDraftOnExit: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EditRuleHeader = {
|
||||||
|
args: {
|
||||||
|
hasShare: false,
|
||||||
|
hasExport: false,
|
||||||
|
hasEdit: false,
|
||||||
|
hasManageStakeholders: true,
|
||||||
|
saveDraftOnExit: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const Default = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "centered",
|
variant: "centered",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
@@ -60,7 +59,6 @@ export const LeftAligned = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "left-aligned",
|
variant: "left-aligned",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
@@ -71,7 +69,6 @@ export const Compact = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "compact",
|
variant: "compact",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
@@ -82,8 +79,19 @@ export const Inverse = {
|
|||||||
title: "Still have questions?",
|
title: "Still have questions?",
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: "Get answers from an experienced organizer",
|
||||||
buttonText: "Ask an organizer",
|
buttonText: "Ask an organizer",
|
||||||
buttonHref: "#contact",
|
|
||||||
variant: "inverse",
|
variant: "inverse",
|
||||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Legacy: CTA is a link (no inquiry modal). */
|
||||||
|
export const LinkCta = {
|
||||||
|
args: {
|
||||||
|
title: "Still have questions?",
|
||||||
|
subtitle: "Get answers from an experienced organizer",
|
||||||
|
buttonText: "Ask an organizer",
|
||||||
|
buttonHref: "/contact",
|
||||||
|
variant: "centered",
|
||||||
|
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import AskOrganizer from "../../app/components/sections/AskOrganizer";
|
import AskOrganizer from "../../app/components/sections/AskOrganizer";
|
||||||
@@ -52,15 +53,24 @@ describe("AskOrganizer (behavioral tests)", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders button with default text", () => {
|
it("renders CTA button with default label", () => {
|
||||||
render(<AskOrganizer title="Test" />);
|
render(<AskOrganizer title="Test" />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", {
|
screen.getByRole("button", {
|
||||||
name: /ask an organizer/i,
|
name: /ask an organizer/i,
|
||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens inquiry modal when CTA is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AskOrganizer title="Test" />);
|
||||||
|
await user.click(screen.getByTestId("ask-organizer-cta"));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("dialog", { name: /ask an organizer/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders button with custom text", () => {
|
it("renders button with custom text", () => {
|
||||||
render(
|
render(
|
||||||
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe("ConfirmStakeholdersScreen", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
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();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const config: ComponentTestSuiteConfig<CreateFlowTopNavProps> = {
|
|||||||
onShare: vi.fn(),
|
onShare: vi.fn(),
|
||||||
onSelectExportFormat: vi.fn(),
|
onSelectExportFormat: vi.fn(),
|
||||||
onEdit: vi.fn(),
|
onEdit: vi.fn(),
|
||||||
|
hasManageStakeholders: true,
|
||||||
|
onManageStakeholders: vi.fn(),
|
||||||
onExit: vi.fn(),
|
onExit: vi.fn(),
|
||||||
className: "test-class",
|
className: "test-class",
|
||||||
},
|
},
|
||||||
@@ -121,6 +123,33 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
|||||||
expect(editButton).toBeInTheDocument();
|
expect(editButton).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders Manage Stakeholders when hasManageStakeholders is true", () => {
|
||||||
|
render(
|
||||||
|
<CreateFlowTopNav
|
||||||
|
hasManageStakeholders={true}
|
||||||
|
onManageStakeholders={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<CreateFlowTopNav
|
||||||
|
hasManageStakeholders={true}
|
||||||
|
onManageStakeholders={handler}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Manage Stakeholders" }),
|
||||||
|
);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onExit when Exit button is clicked", async () => {
|
it("calls onExit when Exit button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const handleExit = vi.fn();
|
const handleExit = vi.fn();
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
componentTestSuite,
|
componentTestSuite,
|
||||||
type ComponentTestSuiteConfig,
|
type ComponentTestSuiteConfig,
|
||||||
} from "../utils/componentTestSuite";
|
} from "../utils/componentTestSuite";
|
||||||
import TextBlock from "../../app/components/type/TextBlock";
|
import TextBlock from "../../app/components/type/TextBlock";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { renderWithProviders as render } from "../utils/test-utils";
|
||||||
|
|
||||||
type Props = React.ComponentProps<typeof TextBlock>;
|
type Props = React.ComponentProps<typeof TextBlock>;
|
||||||
|
|
||||||
@@ -23,4 +25,24 @@ const config: ComponentTestSuiteConfig<Props> = {
|
|||||||
|
|
||||||
describe("TextBlock", () => {
|
describe("TextBlock", () => {
|
||||||
componentTestSuite<Props>(config);
|
componentTestSuite<Props>(config);
|
||||||
|
|
||||||
|
it("renders labeled row imageUrl as img", () => {
|
||||||
|
render(
|
||||||
|
<TextBlock
|
||||||
|
title="Entry"
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: "Photo",
|
||||||
|
body: "",
|
||||||
|
imageUrl: "/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const img = screen.getByRole("img", { name: "Photo" });
|
||||||
|
expect(img).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,15 +69,13 @@ test.describe("Critical User Journeys", () => {
|
|||||||
// 8. User reads testimonial
|
// 8. User reads testimonial
|
||||||
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
||||||
|
|
||||||
// 9. User decides to contact organizer
|
// 9. User decides to contact organizer (opens modal)
|
||||||
const askButton = page.locator(
|
const askButton = page.getByTestId("ask-organizer-cta").first();
|
||||||
'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
|
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
|
||||||
);
|
await askButton.click();
|
||||||
if (
|
await expect(
|
||||||
(await askButton.count()) > 0 &&
|
page.getByRole("dialog", { name: /ask an organizer/i }),
|
||||||
(await askButton.first().isVisible())
|
).toBeVisible();
|
||||||
) {
|
|
||||||
await askButton.first().click();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ describe("Page Flow Integration", () => {
|
|||||||
screen.getByText("Get answers from an experienced organizer"),
|
screen.getByText("Get answers from an experienced organizer"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /Ask an organizer/i }),
|
screen.getByRole("button", { name: /ask an organizer/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,9 +198,9 @@ describe("Page Flow Integration", () => {
|
|||||||
test("ask organizer section has proper call-to-action", () => {
|
test("ask organizer section has proper call-to-action", () => {
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
|
const askCta = screen.getByRole("button", { name: /ask an organizer/i });
|
||||||
expect(askLink).toBeInTheDocument();
|
expect(askCta).toBeInTheDocument();
|
||||||
expect(askLink).toHaveAttribute("href", "#contact");
|
expect(askCta).not.toHaveAttribute("href");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("page maintains proper semantic structure", async () => {
|
test("page maintains proper semantic structure", async () => {
|
||||||
@@ -223,16 +223,22 @@ describe("Page Flow Integration", () => {
|
|||||||
expect(mainContent).toBeInTheDocument();
|
expect(mainContent).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all interactive elements are accessible", () => {
|
test("all interactive elements are accessible", async () => {
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
// Check all buttons have proper roles
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
buttons.forEach((button) => {
|
buttons.forEach((button) => {
|
||||||
expect(button).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check all links have proper roles
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByRole("link").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
const links = screen.getAllByRole("link");
|
const links = screen.getAllByRole("link");
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
|
|||||||
@@ -121,10 +121,11 @@ describe("User Journey Integration", () => {
|
|||||||
screen.getByText("Get answers from an experienced organizer"),
|
screen.getByText("Get answers from an experienced organizer"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// User clicks the ask organizer button (it's actually a link, not a button)
|
const askCta = screen.getByTestId("ask-organizer-cta");
|
||||||
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
|
await user.click(askCta);
|
||||||
await user.click(askLink);
|
expect(
|
||||||
expect(askLink).toHaveAttribute("href", "#contact");
|
await screen.findByRole("dialog", { name: /ask an organizer/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user explores the process through CardSteps", async () => {
|
test("user explores the process through CardSteps", async () => {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
createFlowStepPathAfterStrippingReviewReturn,
|
createFlowStepPathAfterStrippingReviewReturn,
|
||||||
createFlowStepPathWithSyncDraft,
|
createFlowStepPathWithSyncDraft,
|
||||||
} from "../../app/(app)/create/utils/createFlowPaths";
|
} 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)", () => {
|
describe("createFlowPaths (CR-92 §2)", () => {
|
||||||
it("createFlowStepPath builds segment path", () => {
|
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(
|
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(
|
expect(createFlowStepPathAfterStrippingReviewReturn("final-review", sp)).toBe(
|
||||||
"/create/final-review?a=1&b=2",
|
"/create/final-review?a=1&b=2",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createFlowStateSchema,
|
createFlowStateSchema,
|
||||||
publishRuleBodySchema,
|
publishRuleBodySchema,
|
||||||
putDraftBodySchema,
|
putDraftBodySchema,
|
||||||
|
uniqueStakeholderEmailsForPublish,
|
||||||
} from "../../lib/server/validation/createFlowSchemas";
|
} from "../../lib/server/validation/createFlowSchemas";
|
||||||
|
|
||||||
describe("assertPlainJsonValue", () => {
|
describe("assertPlainJsonValue", () => {
|
||||||
@@ -175,6 +176,16 @@ describe("createFlowStateSchema", () => {
|
|||||||
});
|
});
|
||||||
expect(r.success).toBe(false);
|
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", () => {
|
describe("putDraftBodySchema", () => {
|
||||||
@@ -224,4 +235,27 @@ describe("publishRuleBodySchema", () => {
|
|||||||
});
|
});
|
||||||
expect(r.success).toBe(false);
|
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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
it("routes to /create/completed without celebrate after PATCH update", async () => {
|
||||||
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
exportFilenameBase,
|
exportFilenameBase,
|
||||||
sectionsToCsv,
|
sectionsToCsv,
|
||||||
sectionsToMarkdown,
|
sectionsToMarkdown,
|
||||||
|
sectionsToPdfBlob,
|
||||||
} from "../../../lib/create/ruleExport";
|
} from "../../../lib/create/ruleExport";
|
||||||
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
|
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
|
||||||
|
|
||||||
@@ -95,6 +96,76 @@ describe("ruleExport", () => {
|
|||||||
expect(csv).toContain(",Title,,My Rule");
|
expect(csv).toContain(",Title,,My Rule");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sectionsToMarkdown, sectionsToCsv, and printable HTML include imageUrl and fileUrl blocks", () => {
|
||||||
|
const sections: CommunityRuleSection[] = [
|
||||||
|
{
|
||||||
|
categoryName: "Values",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
title: "Entry",
|
||||||
|
body: "",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
label: "Photo",
|
||||||
|
body: "Caption",
|
||||||
|
imageUrl: "https://cdn.example.com/pic.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Handbook",
|
||||||
|
body: "Download",
|
||||||
|
fileUrl: "https://cdn.example.com/guidance.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const md = sectionsToMarkdown("Rule", null, sections);
|
||||||
|
expect(md).toContain("");
|
||||||
|
expect(md).toContain("[Download](https://cdn.example.com/guidance.pdf)");
|
||||||
|
|
||||||
|
const csv = sectionsToCsv("Rule", null, sections);
|
||||||
|
expect(csv).toContain("Caption\nhttps://cdn.example.com/pic.jpg");
|
||||||
|
expect(csv).toContain("Download\nhttps://cdn.example.com/guidance.pdf");
|
||||||
|
|
||||||
|
const html = buildPrintableRuleHtmlDocument("Rule", null, sections);
|
||||||
|
expect(html).toContain('src="https://cdn.example.com/pic.jpg"');
|
||||||
|
expect(html).toContain("Caption");
|
||||||
|
expect(html).toContain('href="https://cdn.example.com/guidance.pdf"');
|
||||||
|
expect(html).toContain("Download");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sectionsToPdfBlob embeds image and file URLs as text for labeled blocks", async () => {
|
||||||
|
const sections: CommunityRuleSection[] = [
|
||||||
|
{
|
||||||
|
categoryName: "Values",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
title: "Entry",
|
||||||
|
body: "",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
label: "Photo",
|
||||||
|
body: "Caption",
|
||||||
|
imageUrl: "https://cdn.example.com/pic.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Handbook",
|
||||||
|
body: "Download",
|
||||||
|
fileUrl: "https://cdn.example.com/guidance.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const blob = sectionsToPdfBlob("Rule", null, sections);
|
||||||
|
const buf = new Uint8Array(await readBlobAsArrayBuffer(blob));
|
||||||
|
const raw = new TextDecoder("latin1").decode(buf);
|
||||||
|
expect(raw).toContain("https://cdn.example.com/pic.jpg");
|
||||||
|
expect(raw).toContain("https://cdn.example.com/guidance.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
|
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
|
||||||
const sections: CommunityRuleSection[] = [
|
const sections: CommunityRuleSection[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const sendOrganizerInquiryNotificationMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/mail", () => ({
|
||||||
|
sendOrganizerInquiryNotification: (...args: unknown[]) =>
|
||||||
|
sendOrganizerInquiryNotificationMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rateLimitKeyMock = vi.hoisted(() =>
|
||||||
|
vi.fn(() => ({ ok: true as const })),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/rateLimit", () => ({
|
||||||
|
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "../../app/api/organizer-inquiry/route";
|
||||||
|
|
||||||
|
describe("POST /api/organizer-inquiry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sendOrganizerInquiryNotificationMock.mockReset();
|
||||||
|
sendOrganizerInquiryNotificationMock.mockResolvedValue(undefined);
|
||||||
|
rateLimitKeyMock.mockReset();
|
||||||
|
rateLimitKeyMock.mockImplementation(() => ({ ok: true as const }));
|
||||||
|
process.env.ORGANIZER_INQUIRY_TO = "organizers@example.com";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ORGANIZER_INQUIRY_TO;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 and sends mail for a valid payload", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "Visitor@Example.com",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ ok: true });
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = sendOrganizerInquiryNotificationMock.mock.calls[0][0];
|
||||||
|
expect(arg.visitorEmail).toBe("visitor@example.com");
|
||||||
|
expect(arg.message).toBe("How do we run consensus meetings?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 without sending mail when honeypot is filled", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "spam@example.com",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "Evil Corp",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid email", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "not-an-email",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 when rate limited", async () => {
|
||||||
|
rateLimitKeyMock.mockReturnValue({
|
||||||
|
ok: false as const,
|
||||||
|
retryAfterMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "a@b.co",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when ORGANIZER_INQUIRY_TO is unset", async () => {
|
||||||
|
delete process.env.ORGANIZER_INQUIRY_TO;
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/organizer-inquiry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "a@b.co",
|
||||||
|
message: "How do we run consensus meetings?",
|
||||||
|
company: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -208,4 +208,44 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
|
|||||||
{ label: "Expectations", body: "Answer stored only on field blocks." },
|
{ label: "Expectations", body: "Answer stored only on field blocks." },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes custom upload image blocks with imageUrl for CommunityRule display", () => {
|
||||||
|
const customId = "b7c0a9f3-0000-4000-8000-000000000002";
|
||||||
|
const doc = {
|
||||||
|
sections: [],
|
||||||
|
methodSelections: {
|
||||||
|
communication: [
|
||||||
|
{
|
||||||
|
id: customId,
|
||||||
|
label: "Policy with photo",
|
||||||
|
sections: {
|
||||||
|
corePrinciple: "",
|
||||||
|
logisticsAdmin: "",
|
||||||
|
codeOfConduct: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
[customId]: [
|
||||||
|
{
|
||||||
|
kind: "upload" as const,
|
||||||
|
id: "u1",
|
||||||
|
blockTitle: "Site photo",
|
||||||
|
fileName: "garden.jpg",
|
||||||
|
assetUrl: "/api/uploads/11111111-1111-4111-8111-111111111111",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||||
|
const comm = out.find((s) => s.categoryName === "Communication");
|
||||||
|
expect(comm?.entries[0]?.blocks).toEqual([
|
||||||
|
{
|
||||||
|
label: "Site photo",
|
||||||
|
body: "",
|
||||||
|
imageUrl: "/api/uploads/11111111-1111-4111-8111-111111111111",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const isDatabaseConfiguredMock = vi.fn();
|
const isDatabaseConfiguredMock = vi.fn();
|
||||||
const listForUserMock = vi.fn();
|
const listProfileMock = vi.fn();
|
||||||
const getSessionUserMock = vi.fn();
|
const getSessionUserMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../lib/server/env", () => ({
|
vi.mock("../../lib/server/env", () => ({
|
||||||
@@ -10,7 +10,7 @@ vi.mock("../../lib/server/env", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/server/publishedRules", () => ({
|
vi.mock("../../lib/server/publishedRules", () => ({
|
||||||
listPublishedRulesForUser: (...args: unknown[]) => listForUserMock(...args),
|
listProfileRulesForUser: (...args: unknown[]) => listProfileMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/server/session", () => ({
|
vi.mock("../../lib/server/session", () => ({
|
||||||
@@ -21,7 +21,7 @@ import { GET } from "../../app/api/rules/me/route";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
isDatabaseConfiguredMock.mockReset();
|
isDatabaseConfiguredMock.mockReset();
|
||||||
listForUserMock.mockReset();
|
listProfileMock.mockReset();
|
||||||
getSessionUserMock.mockReset();
|
getSessionUserMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ describe("GET /api/rules/me", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
expect(listForUserMock).not.toHaveBeenCalled();
|
expect(listProfileMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 with { rules } for the session user", async () => {
|
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"),
|
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(
|
const res = await GET(
|
||||||
new NextRequest("https://x.test/api/rules/me?limit=10"),
|
new NextRequest("https://x.test/api/rules/me?limit=10"),
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(listForUserMock).toHaveBeenCalledWith("user-1", 10);
|
expect(listProfileMock).toHaveBeenCalledWith("user-1", 10);
|
||||||
const body = (await res.json()) as {
|
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).toHaveLength(1);
|
||||||
expect(body.rules[0].id).toBe("r1");
|
expect(body.rules[0].id).toBe("r1");
|
||||||
|
expect(body.rules[0].role).toBe("owner");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<unknown>) =>
|
||||||
|
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<unknown>) => {
|
||||||
|
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<unknown>) => {
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user