Stakeholder invites + Ask an organizer (modal, API, email) #51
@@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min"
|
||||
SMTP_URL=
|
||||
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.
|
||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport
|
||||
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
getNextStep,
|
||||
getStepIndex,
|
||||
parseReviewReturnSearchParam,
|
||||
@@ -158,7 +161,17 @@ function CreateFlowLayoutContent({
|
||||
resetCustomRuleSelections,
|
||||
setMethodSectionsPinCommitted,
|
||||
replaceState,
|
||||
markCreateFlowInteraction,
|
||||
} = useCreateFlow();
|
||||
const manageStakeholdersIntent =
|
||||
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
|
||||
const editingPublishedRuleIdTrimmed =
|
||||
state.editingPublishedRuleId?.trim() ?? "";
|
||||
const isConfirmStakeholdersManagePublished =
|
||||
currentStep === "confirm-stakeholders" &&
|
||||
manageStakeholdersIntent &&
|
||||
editingPublishedRuleIdTrimmed.length > 0;
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||
@@ -411,6 +424,7 @@ function CreateFlowLayoutContent({
|
||||
const isRightRailStep = currentStep === "decision-approaches";
|
||||
const isFinalReviewLike =
|
||||
currentStep === "final-review" || currentStep === "edit-rule";
|
||||
const isEditRuleStep = currentStep === "edit-rule";
|
||||
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
||||
@@ -581,6 +595,7 @@ function CreateFlowLayoutContent({
|
||||
hasShare={isCompletedStep}
|
||||
hasExport={isCompletedStep}
|
||||
hasEdit={isCompletedStep}
|
||||
hasManageStakeholders={isEditRuleStep}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={
|
||||
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
|
||||
@@ -601,6 +616,20 @@ function CreateFlowLayoutContent({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onManageStakeholders={
|
||||
isEditRuleStep
|
||||
? () => {
|
||||
markCreateFlowInteraction();
|
||||
router.push(
|
||||
createFlowStepPath("confirm-stakeholders", {
|
||||
[CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "edit-rule",
|
||||
[CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]:
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||
}),
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onExit={(opts) => void handleExit(opts)}
|
||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||
className={`shrink-0 ${
|
||||
@@ -615,7 +644,11 @@ function CreateFlowLayoutContent({
|
||||
{!isCompletedStep && (
|
||||
<CreateFlowFooter
|
||||
className="shrink-0"
|
||||
progressBar={!isTemplateReviewRoute && !isFinalReviewLike}
|
||||
progressBar={
|
||||
!isTemplateReviewRoute &&
|
||||
!isFinalReviewLike &&
|
||||
reviewReturnTarget !== "edit-rule"
|
||||
}
|
||||
proportionBarProgress={proportionBarProgress}
|
||||
proportionBarVariant="segmented"
|
||||
secondButton={
|
||||
@@ -762,6 +795,27 @@ function CreateFlowLayoutContent({
|
||||
>
|
||||
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||
</Button>
|
||||
) : isConfirmStakeholdersManagePublished ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
createFlowStepPathAfterStrippingReviewReturn(
|
||||
"edit-rule",
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
create.reviewAndComplete.confirmStakeholders.managePublished
|
||||
.footerDone
|
||||
}
|
||||
</Button>
|
||||
) : nextStep || isFinalReviewLike ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
|
||||
@@ -100,10 +100,14 @@ export function useCreateFlowFinalize({
|
||||
return;
|
||||
}
|
||||
|
||||
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||
(e) => typeof e === "string" && e.trim() !== "",
|
||||
);
|
||||
const publishResult = await publishRule({
|
||||
title,
|
||||
summary,
|
||||
document: ruleDocument,
|
||||
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
|
||||
});
|
||||
setIsPublishing(false);
|
||||
if (publishResult.ok === true) {
|
||||
|
||||
@@ -1,25 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||
} from "../../utils/flowSteps";
|
||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
import { PublishedStakeholdersManagePanel } from "./PublishedStakeholdersManagePanel";
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function emailsToChipOptions(emails: string[]): ChipOption[] {
|
||||
return emails.map((email) => ({
|
||||
id: email,
|
||||
label: email,
|
||||
state: "selected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ConfirmStakeholdersScreen() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
||||
|
||||
const manageStakeholdersIntent =
|
||||
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
|
||||
const editingPublishedRuleId = state.editingPublishedRuleId?.trim() ?? "";
|
||||
const managePublishedMode =
|
||||
manageStakeholdersIntent && editingPublishedRuleId.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageStakeholdersIntent) return;
|
||||
if (editingPublishedRuleId.length > 0) return;
|
||||
router.replace(createFlowStepPath("edit-rule"));
|
||||
}, [
|
||||
manageStakeholdersIntent,
|
||||
editingPublishedRuleId.length,
|
||||
router,
|
||||
]);
|
||||
|
||||
const persistedKey = (state.stakeholderEmails ?? []).join("\0");
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [chipError, setChipError] = useState<string | null>(null);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
[],
|
||||
() => emailsToChipOptions(state.stakeholderEmails ?? []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStakeholderOptions((prev) => {
|
||||
const inFlight = prev.filter((c) => c.state === "custom");
|
||||
const nextPersisted = emailsToChipOptions(state.stakeholderEmails ?? []);
|
||||
return [...nextPersisted, ...inFlight];
|
||||
});
|
||||
}, [persistedKey]);
|
||||
|
||||
const handleAddStakeholder = () => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
const confirmed = state.stakeholderEmails ?? [];
|
||||
const customCount = stakeholderOptions.filter(
|
||||
(o) => o.state === "custom",
|
||||
).length;
|
||||
if (confirmed.length + customCount >= MAX_STAKEHOLDER_EMAILS) {
|
||||
setChipError(t("maxStakeholders"));
|
||||
return;
|
||||
}
|
||||
setStakeholderOptions((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||
@@ -28,23 +85,72 @@ export function ConfirmStakeholdersScreen() {
|
||||
|
||||
const handleCustomChipConfirm = (chipId: string, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(trimmed)) {
|
||||
setChipError(t("invalidEmail"));
|
||||
return;
|
||||
}
|
||||
const current = state.stakeholderEmails ?? [];
|
||||
if (current.includes(trimmed)) {
|
||||
setChipError(t("duplicateEmail"));
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
return;
|
||||
}
|
||||
if (current.length >= MAX_STAKEHOLDER_EMAILS) {
|
||||
setChipError(t("maxStakeholders"));
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
return;
|
||||
}
|
||||
setStakeholderOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId ? { ...opt, label: value, state: "selected" } : opt,
|
||||
opt.id === chipId
|
||||
? { id: trimmed, label: trimmed, state: "selected" as const }
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
updateState({ stakeholderEmails: [...current, trimmed] });
|
||||
};
|
||||
|
||||
const handleCustomChipClose = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
};
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setChipError(null);
|
||||
setStakeholderOptions((prev) => prev.filter((opt) => opt.id !== chipId));
|
||||
updateState({
|
||||
stakeholderEmails: (state.stakeholderEmails ?? []).filter(
|
||||
(e) => e !== chipId,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (managePublishedMode) {
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrowBottomPad"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("managePublished.lockupTitle")}
|
||||
description={t("managePublished.lockupDescription")}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowStepShell
|
||||
@@ -61,6 +167,14 @@ export function ConfirmStakeholdersScreen() {
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
{chipError ? (
|
||||
<p
|
||||
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||
role="alert"
|
||||
>
|
||||
{chipError}
|
||||
</p>
|
||||
) : null}
|
||||
<MultiSelect
|
||||
formHeader={false}
|
||||
showHelpIcon={false}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Button from "../../../../components/buttons/Button";
|
||||
import TextInput from "../../../../components/controls/TextInput";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import {
|
||||
addRuleStakeholder,
|
||||
deleteRuleStakeholder,
|
||||
fetchRuleStakeholders,
|
||||
resendRuleStakeholderInvite,
|
||||
type RuleStakeholderListItem,
|
||||
} from "../../../../../lib/create/api";
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function PublishedStakeholdersManagePanel({
|
||||
ruleId,
|
||||
}: {
|
||||
ruleId: string;
|
||||
}) {
|
||||
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
||||
const [items, setItems] = useState<RuleStakeholderListItem[] | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [fieldError, setFieldError] = useState("");
|
||||
const [bannerError, setBannerError] = useState("");
|
||||
const [addBusy, setAddBusy] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError(false);
|
||||
const list = await fetchRuleStakeholders(ruleId);
|
||||
if (list === null) {
|
||||
setLoadError(true);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setItems(list);
|
||||
}, [ruleId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setBannerError("");
|
||||
setFieldError("");
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(trimmed)) {
|
||||
setFieldError(t("managePublished.invalidEmail"));
|
||||
return;
|
||||
}
|
||||
setAddBusy(true);
|
||||
const res = await addRuleStakeholder(ruleId, trimmed);
|
||||
setAddBusy(false);
|
||||
if (res.ok === true) {
|
||||
setEmail("");
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
if (res.retryAfterMs != null && res.retryAfterMs > 0) {
|
||||
const seconds = Math.ceil(res.retryAfterMs / 1000);
|
||||
setBannerError(
|
||||
t("managePublished.rateLimited").replace("{seconds}", String(seconds)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setBannerError(
|
||||
res.error.trim() !== "" ? res.error : t("managePublished.actionFailed"),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
setBannerError("");
|
||||
setBusyId(id);
|
||||
const res = await deleteRuleStakeholder(ruleId, id);
|
||||
setBusyId(null);
|
||||
if (res.ok === true) {
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
setBannerError(
|
||||
res.error.trim() !== "" ? res.error : t("managePublished.actionFailed"),
|
||||
);
|
||||
};
|
||||
|
||||
const handleResend = async (id: string) => {
|
||||
setBannerError("");
|
||||
setBusyId(id);
|
||||
const res = await resendRuleStakeholderInvite(ruleId, id);
|
||||
setBusyId(null);
|
||||
if (res.ok === true) {
|
||||
return;
|
||||
}
|
||||
if (res.retryAfterMs != null && res.retryAfterMs > 0) {
|
||||
const seconds = Math.ceil(res.retryAfterMs / 1000);
|
||||
setBannerError(
|
||||
t("managePublished.rateLimited").replace("{seconds}", String(seconds)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setBannerError(
|
||||
res.error.trim() !== "" ? res.error : t("managePublished.actionFailed"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex w-full flex-col gap-4 pt-1 pb-2">
|
||||
{bannerError ? (
|
||||
<p
|
||||
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||
role="alert"
|
||||
>
|
||||
{bannerError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<p className="font-inter text-sm text-[var(--color-border-default-utility-negative)]">
|
||||
{t("managePublished.loadFailed")}
|
||||
</p>
|
||||
) : items === null ? (
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]">
|
||||
{t("managePublished.loading")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
||||
{t("managePublished.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{items.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="truncate font-inter text-sm font-medium text-[var(--color-content-default-primary)] md:text-base">
|
||||
{row.email}
|
||||
</span>
|
||||
<span className="font-inter text-xs text-[var(--color-content-default-tertiary)] md:text-sm">
|
||||
{row.status === "pending"
|
||||
? t("managePublished.pending")
|
||||
: t("managePublished.accepted")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.status === "pending" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => void handleResend(row.id)}
|
||||
ariaLabel={t("managePublished.resendAria").replace(
|
||||
"{email}",
|
||||
row.email,
|
||||
)}
|
||||
>
|
||||
{t("managePublished.resend")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => void handleRemove(row.id)}
|
||||
ariaLabel={t("managePublished.removeAria").replace(
|
||||
"{email}",
|
||||
row.email,
|
||||
)}
|
||||
>
|
||||
{t("managePublished.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<TextInput
|
||||
id="published-stakeholder-email"
|
||||
type="email"
|
||||
inputSize="small"
|
||||
showHelpIcon={false}
|
||||
label={t("managePublished.emailLabel")}
|
||||
placeholder={t("managePublished.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setFieldError("");
|
||||
}}
|
||||
error={Boolean(fieldError)}
|
||||
textHint={fieldError || false}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
className="md:mb-[2px]"
|
||||
disabled={addBusy || items === null}
|
||||
onClick={() => void handleAdd()}
|
||||
>
|
||||
{t("managePublished.addInvite")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -218,8 +218,11 @@ export interface CreateFlowState {
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
|
||||
stakeholders?: Record<string, unknown>[];
|
||||
/**
|
||||
* Stakeholder invite emails (confirm-stakeholders step). Normalized on the server;
|
||||
* invites are sent at first publish (`POST /api/rules`).
|
||||
*/
|
||||
stakeholderEmails?: string[];
|
||||
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
*/
|
||||
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
} from "./flowSteps";
|
||||
|
||||
export const CREATE_ROUTES = {
|
||||
root: "/",
|
||||
@@ -59,7 +62,7 @@ export function createCompletedPath(query?: CreateFlowPathQuery): string {
|
||||
|
||||
/**
|
||||
* Navigate back from a facet step to final-review / edit-rule, dropping
|
||||
* `reviewReturn` from the current query while preserving other params.
|
||||
* `reviewReturn` and `manageStakeholders` from the current query while preserving other params.
|
||||
*/
|
||||
export function createFlowStepPathAfterStrippingReviewReturn(
|
||||
step: CreateFlowStep,
|
||||
@@ -67,6 +70,7 @@ export function createFlowStepPathAfterStrippingReviewReturn(
|
||||
): string {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||
params.delete(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY);
|
||||
const query: CreateFlowPathQuery = {};
|
||||
params.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
|
||||
@@ -188,6 +188,13 @@ export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
|
||||
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
||||
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
||||
|
||||
/**
|
||||
* `/create/confirm-stakeholders?manageStakeholders=1` — edit published rule invites (requires `state.editingPublishedRuleId`).
|
||||
* Typically paired with `reviewReturn=edit-rule`.
|
||||
*/
|
||||
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY = "manageStakeholders" as const;
|
||||
export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const;
|
||||
|
||||
export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
|
||||
|
||||
export function parseReviewReturnSearchParam(
|
||||
|
||||
@@ -340,28 +340,38 @@ export function ProfilePageView({
|
||||
expanded
|
||||
size={ruleCardSize}
|
||||
hasBottomLinks
|
||||
bottomLinks={[
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "manage",
|
||||
label: t("manageRule"),
|
||||
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "dup",
|
||||
label: t("duplicate"),
|
||||
onClick: () => onDuplicateRule(rule.id),
|
||||
},
|
||||
{
|
||||
id: "del",
|
||||
label: t("deleteRule"),
|
||||
onClick: () => onDeleteRule(rule.id),
|
||||
},
|
||||
]}
|
||||
bottomLinks={
|
||||
rule.role === "stakeholder"
|
||||
? [
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "manage",
|
||||
label: t("manageRule"),
|
||||
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "dup",
|
||||
label: t("duplicate"),
|
||||
onClick: () => onDuplicateRule(rule.id),
|
||||
},
|
||||
{
|
||||
id: "del",
|
||||
label: t("deleteRule"),
|
||||
onClick: () => onDeleteRule(rule.id),
|
||||
},
|
||||
]
|
||||
}
|
||||
communityInitials={
|
||||
rule.title.trim().charAt(0).toUpperCase() || "·"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ const askOrganizerData = {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an organizer",
|
||||
buttonHref: "#contact",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
|
||||
@@ -24,7 +24,6 @@ export default function LearnPage() {
|
||||
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
||||
description: t("pages.learn.askOrganizer.description"),
|
||||
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
||||
buttonHref: t("pages.learn.askOrganizer.buttonHref"),
|
||||
variant: "centered" as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ export default function Page() {
|
||||
title: t("pages.home.askOrganizer.title"),
|
||||
subtitle: t("pages.home.askOrganizer.subtitle"),
|
||||
buttonText: t("pages.home.askOrganizer.buttonText"),
|
||||
buttonHref: t("pages.home.askOrganizer.buttonHref"),
|
||||
};
|
||||
|
||||
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 { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules";
|
||||
import { listProfileRulesForUser } from "../../../../lib/server/publishedRules";
|
||||
import {
|
||||
dbUnavailable,
|
||||
internalError,
|
||||
@@ -22,10 +22,19 @@ export const GET = apiRoute("rules.me.list", async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||
|
||||
const rules = await listPublishedRulesForUser(user.id, take);
|
||||
const rules = await listProfileRulesForUser(user.id, take);
|
||||
if (rules === null) {
|
||||
return internalError("Failed to list rules");
|
||||
}
|
||||
|
||||
return NextResponse.json({ rules });
|
||||
return NextResponse.json({
|
||||
rules: rules.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
role: r.role,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
+142
-36
@@ -1,14 +1,29 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import {
|
||||
hashSessionToken,
|
||||
newSessionToken,
|
||||
} from "../../../lib/server/hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../lib/server/rateLimit";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../lib/server/responses";
|
||||
import { logRouteError } from "../../../lib/server/requestId";
|
||||
import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
||||
import {
|
||||
publishRuleBodySchema,
|
||||
uniqueStakeholderEmailsForPublish,
|
||||
} from "../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||
|
||||
@@ -36,43 +51,134 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||
return NextResponse.json({ rules });
|
||||
});
|
||||
|
||||
export const POST = apiRoute("rules.publish", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
export const POST = apiRoute(
|
||||
"rules.publish",
|
||||
async (request: NextRequest, _ctx, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) {
|
||||
return parsedBody.response;
|
||||
}
|
||||
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||
if (!validated.success) {
|
||||
return jsonFromZodError(validated.error);
|
||||
}
|
||||
|
||||
const { title, summary, document } = validated.data;
|
||||
const { title, summary, document, stakeholderEmails } = validated.data;
|
||||
const inviteEmails = uniqueStakeholderEmailsForPublish(
|
||||
stakeholderEmails,
|
||||
user.email,
|
||||
);
|
||||
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (inviteEmails.length > 0) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (inviteEmails.length === 0) {
|
||||
const rule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("rules.publish", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
const { rule, invites } = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
summary,
|
||||
document: document as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const toSend: { email: string; token: string }[] = [];
|
||||
for (const email of inviteEmails) {
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
await tx.ruleStakeholder.create({
|
||||
data: {
|
||||
ruleId: created.id,
|
||||
email,
|
||||
invitedByUserId: user.id,
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
toSend.push({ email, token });
|
||||
}
|
||||
return { rule: created, invites: toSend };
|
||||
});
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
try {
|
||||
for (const inv of invites) {
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
|
||||
await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title);
|
||||
}
|
||||
} catch (err) {
|
||||
logRouteError("rules.publish", requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
});
|
||||
try {
|
||||
await prisma.publishedRule.delete({ where: { id: rule.id } });
|
||||
} catch (delErr) {
|
||||
logRouteError("rules.publish", requestId, delErr, {
|
||||
phase: "rollbackPublishAfterMailFailure",
|
||||
});
|
||||
}
|
||||
return errorJson(
|
||||
"mail_failed",
|
||||
"Could not send stakeholder invites",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
summary: rule.summary,
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
children,
|
||||
footerContent,
|
||||
footerClassName,
|
||||
showBackButton = true,
|
||||
showNextButton = true,
|
||||
onBack,
|
||||
@@ -47,6 +48,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
children={children}
|
||||
footerContent={footerContent}
|
||||
footerClassName={footerClassName}
|
||||
showBackButton={showBackButton}
|
||||
showNextButton={showNextButton}
|
||||
onBack={onBack}
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface CreateProps {
|
||||
headerContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footerContent?: React.ReactNode;
|
||||
/** Optional class on {@link ModalFooter} shell (e.g. taller custom footer). */
|
||||
footerClassName?: string;
|
||||
showBackButton?: boolean;
|
||||
showNextButton?: boolean;
|
||||
onBack?: () => void;
|
||||
@@ -51,6 +53,7 @@ export interface CreateViewProps {
|
||||
headerContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footerContent?: React.ReactNode;
|
||||
footerClassName?: string;
|
||||
showBackButton: boolean;
|
||||
showNextButton: boolean;
|
||||
onBack?: () => void;
|
||||
|
||||
@@ -14,6 +14,7 @@ export function CreateView({
|
||||
headerContent,
|
||||
children,
|
||||
footerContent,
|
||||
footerClassName,
|
||||
showBackButton,
|
||||
showNextButton,
|
||||
onBack,
|
||||
@@ -82,6 +83,7 @@ export function CreateView({
|
||||
totalSteps={totalSteps}
|
||||
stepper={stepper}
|
||||
footerContent={footerContent}
|
||||
className={footerClassName}
|
||||
/>
|
||||
</CreateModalFrameView>
|
||||
);
|
||||
|
||||
@@ -125,11 +125,13 @@ export default function LoginForm({
|
||||
const urlErrorMessage =
|
||||
errorParam === "expired_link"
|
||||
? t("errors.expiredLink")
|
||||
: errorParam === "invalid_link" || errorParam === "server"
|
||||
? errorParam === "server"
|
||||
? t("errors.serverError")
|
||||
: t("errors.invalidLink")
|
||||
: "";
|
||||
: errorParam === "stakeholder_wrong_account"
|
||||
? t("errors.stakeholderWrongAccount")
|
||||
: errorParam === "invalid_link" || errorParam === "server"
|
||||
? errorParam === "server"
|
||||
? t("errors.serverError")
|
||||
: t("errors.invalidLink")
|
||||
: "";
|
||||
|
||||
const titleId = "login-modal-heading";
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
hasManageStakeholders = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onSelectExportFormat,
|
||||
onEdit,
|
||||
onManageStakeholders,
|
||||
onExit,
|
||||
buttonPalette,
|
||||
className = "",
|
||||
@@ -41,10 +43,12 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
hasShare={hasShare}
|
||||
hasExport={hasExport}
|
||||
hasEdit={hasEdit}
|
||||
hasManageStakeholders={hasManageStakeholders}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={onShare}
|
||||
onSelectExportFormat={onSelectExportFormat}
|
||||
onEdit={onEdit}
|
||||
onManageStakeholders={onManageStakeholders}
|
||||
onExit={handleExit}
|
||||
buttonPalette={buttonPalette}
|
||||
className={className}
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface CreateFlowTopNavProps {
|
||||
* @default false
|
||||
*/
|
||||
hasEdit?: boolean;
|
||||
/**
|
||||
* Whether to show **Manage Stakeholders** (published-rule invite management).
|
||||
* Used on `/create/edit-rule` only.
|
||||
* @default false
|
||||
*/
|
||||
hasManageStakeholders?: boolean;
|
||||
/**
|
||||
* When true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`.
|
||||
* When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss).
|
||||
@@ -39,6 +45,10 @@ export interface CreateFlowTopNavProps {
|
||||
* Callback when Edit button is clicked
|
||||
*/
|
||||
onEdit?: () => void;
|
||||
/**
|
||||
* Callback when Manage Stakeholders is clicked
|
||||
*/
|
||||
onManageStakeholders?: () => void;
|
||||
/**
|
||||
* Callback when Exit/Save & Exit button is clicked.
|
||||
* When `saveDraftOnExit` is true, called with `{ saveDraft: true }`.
|
||||
|
||||
@@ -15,10 +15,12 @@ export function CreateFlowTopNavView({
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
hasManageStakeholders = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onSelectExportFormat,
|
||||
onEdit,
|
||||
onManageStakeholders,
|
||||
onExit,
|
||||
buttonPalette = "default",
|
||||
className = "",
|
||||
@@ -165,6 +167,20 @@ export function CreateFlowTopNavView({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasManageStakeholders && onManageStakeholders ? (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
type="button"
|
||||
onClick={onManageStakeholders}
|
||||
ariaLabel={t("manageStakeholdersAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
{t("manageStakeholders")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useAnalytics } from "../../../hooks";
|
||||
import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
|
||||
import AskOrganizerView from "./AskOrganizer.view";
|
||||
import type {
|
||||
AskOrganizerProps,
|
||||
@@ -45,8 +46,9 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
const variant = variantProp;
|
||||
const t = useTranslation();
|
||||
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
||||
const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
|
||||
const analyticsHref = buttonHref ?? "modal";
|
||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||
|
||||
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
||||
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
|
||||
@@ -66,6 +68,31 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
const handleContactClick = (
|
||||
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({
|
||||
event: "contact_button_click",
|
||||
category: "engagement",
|
||||
@@ -80,33 +107,39 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
component: "AskOrganizer",
|
||||
variant: resolvedVariant,
|
||||
buttonText: defaultButtonText,
|
||||
buttonHref: defaultButtonHref,
|
||||
buttonHref: analyticsHref,
|
||||
},
|
||||
onContactClick as
|
||||
| ((_data: Record<string, unknown>) => void)
|
||||
| undefined,
|
||||
);
|
||||
|
||||
// Preserve existing button behavior (no preventDefault here)
|
||||
// while still tracking analytics.
|
||||
setInquiryOpen(true);
|
||||
return event;
|
||||
};
|
||||
|
||||
const closeInquiry = useCallback(() => {
|
||||
setInquiryOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AskOrganizerView
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
buttonText={defaultButtonText}
|
||||
buttonHref={defaultButtonHref}
|
||||
className={className}
|
||||
sectionPadding={sectionPadding}
|
||||
contentGap={`${contentGap} ${styles.container}`}
|
||||
buttonContainerClass={styles.buttonContainer}
|
||||
variant={resolvedVariant}
|
||||
labelledBy={labelledBy}
|
||||
onContactClick={handleContactClick}
|
||||
/>
|
||||
<>
|
||||
<AskOrganizerView
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
buttonText={defaultButtonText}
|
||||
buttonHref={buttonHref}
|
||||
className={className}
|
||||
sectionPadding={sectionPadding}
|
||||
contentGap={`${contentGap} ${styles.container}`}
|
||||
buttonContainerClass={styles.buttonContainer}
|
||||
variant={resolvedVariant}
|
||||
labelledBy={labelledBy}
|
||||
onContactClick={handleContactClick}
|
||||
/>
|
||||
<AskOrganizerInquiryModal isOpen={inquiryOpen} onClose={closeInquiry} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface AskOrganizerProps {
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
/**
|
||||
* @deprecated Modal-only flow (CR-107). Omit; kept optional for Storybook overrides.
|
||||
*/
|
||||
buttonHref?: string;
|
||||
className?: string;
|
||||
/**
|
||||
@@ -22,7 +25,7 @@ export interface AskOrganizerProps {
|
||||
component: string;
|
||||
variant: string;
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
buttonHref?: string;
|
||||
timestamp: string;
|
||||
}) => void;
|
||||
}
|
||||
@@ -32,7 +35,7 @@ export interface AskOrganizerViewProps {
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
buttonHref?: string;
|
||||
className: string;
|
||||
sectionPadding: string;
|
||||
contentGap: string;
|
||||
|
||||
@@ -43,13 +43,14 @@ function AskOrganizerView({
|
||||
{/* Button */}
|
||||
<div className={buttonContainerClass}>
|
||||
<Button
|
||||
href={buttonHref}
|
||||
{...(buttonHref ? { href: buttonHref } : {})}
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette={variant === "inverse" ? "inverse" : "default"}
|
||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||
onClick={onContactClick}
|
||||
ariaLabel={ariaLabel}
|
||||
data-testid="ask-organizer-cta"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
|
||||
export interface CommunityRuleLabeledBlock {
|
||||
label: string;
|
||||
/** With {@link imageUrl}, optional caption paragraphs only (not the uploaded file name). */
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { memo } from "react";
|
||||
import type { TextBlockProps } from "./TextBlock.types";
|
||||
|
||||
type TextRow = NonNullable<TextBlockProps["rows"]>[number];
|
||||
|
||||
/**
|
||||
* Figma: Utility / **Community Rule / Text Block** (22001:29793).
|
||||
* 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({
|
||||
title,
|
||||
body = "",
|
||||
@@ -54,12 +95,7 @@ function TextBlockView({
|
||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{hasRows
|
||||
? rows!.map((row, i) => (
|
||||
<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>
|
||||
))
|
||||
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
||||
</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.
|
||||
|
||||
### 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)
|
||||
|
||||
**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.
|
||||
- **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).
|
||||
- **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;
|
||||
summary?: string;
|
||||
document: Record<string, unknown>;
|
||||
stakeholderEmails?: string[];
|
||||
}): Promise<
|
||||
| { ok: true; id: string; title: string }
|
||||
| { ok: false; error: string; status?: number }
|
||||
@@ -212,6 +213,9 @@ export async function publishRule(input: {
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
document: input.document,
|
||||
...(input.stakeholderEmails?.length
|
||||
? { stakeholderEmails: input.stakeholderEmails }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
const data = (await safeParseJsonResponse(res)) as {
|
||||
@@ -289,6 +293,8 @@ export type MyPublishedRule = {
|
||||
summary: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** `owner` = authored rule; `stakeholder` = accepted invite (view only). */
|
||||
role: "owner" | "stakeholder";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -306,7 +312,16 @@ export async function fetchMyPublishedRules(): Promise<
|
||||
rules?: MyPublishedRule[];
|
||||
} | null;
|
||||
if (!data || !Array.isArray(data.rules)) return null;
|
||||
return data.rules;
|
||||
const rules = data.rules.filter(
|
||||
(r): r is MyPublishedRule =>
|
||||
r != null &&
|
||||
typeof r === "object" &&
|
||||
typeof (r as MyPublishedRule).id === "string" &&
|
||||
typeof (r as MyPublishedRule).title === "string" &&
|
||||
((r as MyPublishedRule).role === "owner" ||
|
||||
(r as MyPublishedRule).role === "stakeholder"),
|
||||
);
|
||||
return rules;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -355,6 +370,173 @@ export async function fetchPublishedRuleDetail(
|
||||
}
|
||||
}
|
||||
|
||||
export type RuleStakeholderListItem = {
|
||||
id: string;
|
||||
email: string;
|
||||
invitedAt: string;
|
||||
acceptedAt: string | null;
|
||||
status: "pending" | "accepted";
|
||||
};
|
||||
|
||||
function parseStakeholdersPayload(data: unknown): RuleStakeholderListItem[] | null {
|
||||
if (!data || typeof data !== "object" || !("stakeholders" in data)) {
|
||||
return null;
|
||||
}
|
||||
const raw = (data as { stakeholders: unknown }).stakeholders;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const out: RuleStakeholderListItem[] = [];
|
||||
for (const x of raw) {
|
||||
if (
|
||||
!x ||
|
||||
typeof x !== "object" ||
|
||||
typeof (x as { id?: unknown }).id !== "string" ||
|
||||
typeof (x as { email?: unknown }).email !== "string" ||
|
||||
typeof (x as { invitedAt?: unknown }).invitedAt !== "string" ||
|
||||
((x as { status?: unknown }).status !== "pending" &&
|
||||
(x as { status?: unknown }).status !== "accepted")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const acceptedRaw = (x as { acceptedAt?: unknown }).acceptedAt;
|
||||
const acceptedAt =
|
||||
acceptedRaw === null
|
||||
? null
|
||||
: typeof acceptedRaw === "string"
|
||||
? acceptedRaw
|
||||
: null;
|
||||
out.push({
|
||||
id: (x as { id: string }).id,
|
||||
email: (x as { email: string }).email,
|
||||
invitedAt: (x as { invitedAt: string }).invitedAt,
|
||||
acceptedAt,
|
||||
status: (x as { status: "pending" | "accepted" }).status,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchRuleStakeholders(
|
||||
ruleId: string,
|
||||
): Promise<RuleStakeholderListItem[] | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await safeParseJsonResponse(res);
|
||||
return parseStakeholdersPayload(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type RuleStakeholderMutationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; status: number; retryAfterMs?: number };
|
||||
|
||||
function retryAfterFromResponse(
|
||||
res: Response,
|
||||
data: unknown,
|
||||
): number | undefined {
|
||||
if (res.status !== 429) return undefined;
|
||||
if (data && typeof data === "object" && "details" in data) {
|
||||
const d = (data as { details?: unknown }).details;
|
||||
if (d && typeof d === "object" && "retryAfterMs" in d) {
|
||||
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
|
||||
if (typeof ms === "number" && ms > 0) return ms;
|
||||
}
|
||||
}
|
||||
const h = res.headers.get("retry-after");
|
||||
if (h) {
|
||||
const sec = Number.parseInt(h, 10);
|
||||
if (!Number.isNaN(sec)) return sec * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function addRuleStakeholder(
|
||||
ruleId: string,
|
||||
email: string,
|
||||
): Promise<RuleStakeholderMutationResult> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ email }),
|
||||
},
|
||||
);
|
||||
if (res.ok) return { ok: true };
|
||||
const data = await safeParseJsonResponse(res);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: readApiErrorMessage(data),
|
||||
status: res.status,
|
||||
retryAfterMs: retryAfterFromResponse(res, data),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRuleStakeholder(
|
||||
ruleId: string,
|
||||
stakeholderId: string,
|
||||
): Promise<RuleStakeholderMutationResult> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
);
|
||||
if (res.ok) return { ok: true };
|
||||
const data = await safeParseJsonResponse(res);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: readApiErrorMessage(data),
|
||||
status: res.status,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function resendRuleStakeholderInvite(
|
||||
ruleId: string,
|
||||
stakeholderId: string,
|
||||
): Promise<RuleStakeholderMutationResult> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}/resend`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (res.ok) return { ok: true };
|
||||
const data = await safeParseJsonResponse(res);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: readApiErrorMessage(data),
|
||||
status: res.status,
|
||||
retryAfterMs: retryAfterFromResponse(res, data),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteRuleResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; status: number };
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
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. */
|
||||
|
||||
+78
-13
@@ -2,6 +2,7 @@ import { jsPDF } from "jspdf";
|
||||
|
||||
import type {
|
||||
CommunityRuleEntry,
|
||||
CommunityRuleLabeledBlock,
|
||||
CommunityRuleSection,
|
||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
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)}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
const lines: string[] = [`### ${entry.title}`, ""];
|
||||
if (entry.blocks && entry.blocks.length > 0) {
|
||||
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 {
|
||||
const body = (entry.body ?? "").trim();
|
||||
@@ -86,7 +109,17 @@ export function sectionsToCsv(
|
||||
for (const ent of sec.entries) {
|
||||
if (ent.blocks && ent.blocks.length > 0) {
|
||||
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 {
|
||||
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
|
||||
@@ -136,7 +169,17 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
|
||||
if (entry.blocks && entry.blocks.length > 0) {
|
||||
for (const b of entry.blocks) {
|
||||
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 {
|
||||
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.setFontSize(16);
|
||||
{
|
||||
@@ -253,16 +319,15 @@ export function sectionsToPdfBlob(
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 2;
|
||||
}
|
||||
for (const p of splitDisplayParagraphs(b.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;
|
||||
const { img, file } = labeledBlockMedia(b);
|
||||
if (img) {
|
||||
writeBlockBodyParagraphs(b.body);
|
||||
writeMediaUrlLines(img, "italic");
|
||||
} else if (file) {
|
||||
writeBlockBodyParagraphs(b.body);
|
||||
writeMediaUrlLines(file, "normal");
|
||||
} else {
|
||||
writeBlockBodyParagraphs(b.body);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,16 @@ import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
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
|
||||
* read-only surfaces (completed step, exported views). Matches how those blocks
|
||||
@@ -32,8 +42,23 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
||||
case "upload": {
|
||||
const name = nonEmptyTrimmed(b.fileName);
|
||||
const url = nonEmptyTrimmed(b.assetUrl);
|
||||
const body = name ?? url;
|
||||
if (body) out.push({ label: b.blockTitle, body });
|
||||
if (url) {
|
||||
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;
|
||||
}
|
||||
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. */
|
||||
/** 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(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
|
||||
@@ -88,3 +88,68 @@ export async function listPublishedRulesForUser(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProfileRuleListItem = OwnerPublishedRuleListItem & {
|
||||
role: "owner" | "stakeholder";
|
||||
};
|
||||
|
||||
/**
|
||||
* Published rules the user can access as an **accepted** stakeholder (`userId` set).
|
||||
* Same metadata shape as {@link listPublishedRulesForUser}; no `document`.
|
||||
*/
|
||||
export async function listStakeholderRulesForUser(
|
||||
userId: string,
|
||||
take: number,
|
||||
): Promise<OwnerPublishedRuleListItem[] | null> {
|
||||
if (!isDatabaseConfigured()) return null;
|
||||
if (typeof userId !== "string" || userId.trim() === "") return null;
|
||||
const clamped = Math.min(Math.max(0, take), 100);
|
||||
if (clamped === 0) return [];
|
||||
try {
|
||||
const rows = await prisma.ruleStakeholder.findMany({
|
||||
where: { userId },
|
||||
take: clamped,
|
||||
orderBy: [{ rule: { updatedAt: "desc" } }, { id: "asc" }],
|
||||
select: {
|
||||
rule: { select: PUBLISHED_RULE_OWNER_LIST_SELECT },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => r.rule);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile list: owned rules plus stakeholder access, **owner wins** if both,
|
||||
* sorted by `updatedAt` desc (then `id`).
|
||||
*/
|
||||
export async function listProfileRulesForUser(
|
||||
userId: string,
|
||||
take: number,
|
||||
): Promise<ProfileRuleListItem[] | null> {
|
||||
const cap = Math.min(Math.max(0, take), 100);
|
||||
if (cap === 0) return [];
|
||||
/** Merge then slice so ordering is global by `updatedAt`. */
|
||||
const fetchCap = 100;
|
||||
const [owned, stakeholderRules] = await Promise.all([
|
||||
listPublishedRulesForUser(userId, fetchCap),
|
||||
listStakeholderRulesForUser(userId, fetchCap),
|
||||
]);
|
||||
if (owned === null || stakeholderRules === null) return null;
|
||||
const ownerIds = new Set(owned.map((r) => r.id));
|
||||
const stakeholderOnly = stakeholderRules.filter((r) => !ownerIds.has(r.id));
|
||||
const combined: ProfileRuleListItem[] = [
|
||||
...owned.map((r) => ({ ...r, role: "owner" as const })),
|
||||
...stakeholderOnly.map((r) => ({
|
||||
...r,
|
||||
role: "stakeholder" as const,
|
||||
})),
|
||||
];
|
||||
combined.sort((a, b) => {
|
||||
const t = b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
if (t !== 0) return t;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return combined.slice(0, cap);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export type ApiErrorCode =
|
||||
| "rate_limited"
|
||||
| "server_misconfigured"
|
||||
| "mail_failed"
|
||||
| "internal_error";
|
||||
| "internal_error"
|
||||
| "conflict";
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error: { code: ApiErrorCode; message: string };
|
||||
@@ -66,6 +67,10 @@ export function forbidden(message = "Forbidden"): NextResponse {
|
||||
return errorJson("forbidden", message, 403);
|
||||
}
|
||||
|
||||
export function conflict(message = "Conflict"): NextResponse {
|
||||
return errorJson("conflict", message, 409);
|
||||
}
|
||||
|
||||
export function rateLimited(retryAfterMs: number): NextResponse {
|
||||
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
|
||||
return errorJson("rate_limited", "Too many requests", 429, {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from "./db";
|
||||
import { hashSessionToken, newSessionToken } from "./hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "./mail";
|
||||
import { logRouteError } from "./requestId";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "./ruleStakeholders";
|
||||
|
||||
export function stakeholderInviteVerifyUrl(origin: string, token: string): string {
|
||||
return `${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pending {@link RuleStakeholder} row and sends the invite email.
|
||||
* On mail failure, deletes the row and returns `ok: false`.
|
||||
*/
|
||||
export async function createRuleStakeholderInviteAndSendMail(opts: {
|
||||
scope: string;
|
||||
requestId: string;
|
||||
origin: string;
|
||||
ruleId: string;
|
||||
ruleTitle: string;
|
||||
email: string;
|
||||
invitedByUserId: string;
|
||||
pepper: string;
|
||||
}): Promise<{ ok: true } | { ok: false }> {
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, opts.pepper);
|
||||
const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
|
||||
const row = await prisma.ruleStakeholder.create({
|
||||
data: {
|
||||
ruleId: opts.ruleId,
|
||||
email: opts.email,
|
||||
invitedByUserId: opts.invitedByUserId,
|
||||
inviteTokenHash: tokenHash,
|
||||
inviteExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token);
|
||||
try {
|
||||
await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
logRouteError(opts.scope, opts.requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
email: opts.email,
|
||||
});
|
||||
try {
|
||||
await prisma.ruleStakeholder.delete({ where: { id: row.id } });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Parity with magic-link request TTL (15 minutes). */
|
||||
export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
||||
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||
|
||||
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
|
||||
@@ -64,6 +65,15 @@ const customMethodCardMetaEntrySchema = z.object({
|
||||
supportText: z.string().max(48),
|
||||
});
|
||||
|
||||
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
|
||||
const stakeholderEmailSchema = z
|
||||
.string()
|
||||
.max(320)
|
||||
.transform((s) => s.trim().toLowerCase())
|
||||
.pipe(z.string().email());
|
||||
|
||||
export { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||
|
||||
/**
|
||||
* Published rule `document` column: arbitrary JSON object with safety bounds.
|
||||
*/
|
||||
@@ -144,7 +154,10 @@ export const createFlowStateSchema = z
|
||||
editingPublishedRuleId: z.string().max(200).optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
stakeholderEmails: z
|
||||
.array(stakeholderEmailSchema)
|
||||
.max(MAX_STAKEHOLDER_EMAILS)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -171,10 +184,40 @@ export const publishRuleBodySchema = z.object({
|
||||
return t.length > 0 ? t : null;
|
||||
}),
|
||||
document: publishedRuleDocumentSchema,
|
||||
stakeholderEmails: z
|
||||
.array(stakeholderEmailSchema)
|
||||
.max(MAX_STAKEHOLDER_EMAILS)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
|
||||
|
||||
export const postRuleStakeholderBodySchema = z.object({
|
||||
email: stakeholderEmailSchema,
|
||||
});
|
||||
|
||||
export type PostRuleStakeholderBody = z.infer<
|
||||
typeof postRuleStakeholderBodySchema
|
||||
>;
|
||||
|
||||
/** Dedupe and drop the publisher’s own email (`emails` need not be pre-normalized). */
|
||||
export function uniqueStakeholderEmailsForPublish(
|
||||
emails: string[] | undefined,
|
||||
publisherEmailNormalized: string,
|
||||
): string[] {
|
||||
if (!emails?.length) return [];
|
||||
const pub = publisherEmailNormalized.trim().toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of emails) {
|
||||
const e = raw.trim().toLowerCase();
|
||||
if (e === pub || seen.has(e)) continue;
|
||||
seen.add(e);
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const putDraftBodySchema = z.object({
|
||||
payload: createFlowStateSchema,
|
||||
});
|
||||
|
||||
@@ -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)",
|
||||
"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?",
|
||||
"description": "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.",
|
||||
"description": "Add their email addresses. When you publish, we'll send each person a one-time link to join Community Rule and view this rule in their profile (they won't be able to manage it unless they created it).",
|
||||
"addStakeholder": "Add stakeholder",
|
||||
"draftToastTitle": "Congratulations! You've drafted your CommunityRule!"
|
||||
"draftToastTitle": "Congratulations! You've drafted your CommunityRule!",
|
||||
"invalidEmail": "Enter a valid email address.",
|
||||
"duplicateEmail": "That email is already on the list.",
|
||||
"maxStakeholders": "You can add up to 30 stakeholder emails.",
|
||||
"managePublished": {
|
||||
"lockupTitle": "Stakeholders",
|
||||
"lockupDescription": "Invite people by email. They get a one-time link to view this rule in their profile.",
|
||||
"emailLabel": "Email address",
|
||||
"emailPlaceholder": "colleague@example.com",
|
||||
"addInvite": "Send invite",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Could not load stakeholders. Refresh and try again.",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"remove": "Remove",
|
||||
"resend": "Resend invite",
|
||||
"empty": "No stakeholders yet.",
|
||||
"invalidEmail": "Enter a valid email address.",
|
||||
"actionFailed": "Something went wrong. Try again.",
|
||||
"rateLimited": "Too many requests. Try again in {seconds} seconds.",
|
||||
"removeAria": "Remove {email}",
|
||||
"resendAria": "Resend invite to {email}",
|
||||
"footerDone": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
"share": "Share",
|
||||
"export": "Export",
|
||||
"edit": "Edit",
|
||||
"manageStakeholders": "Manage Stakeholders",
|
||||
"shareAriaLabel": "Share",
|
||||
"exportAriaLabel": "Export",
|
||||
"editAriaLabel": "Edit",
|
||||
"manageStakeholdersAriaLabel": "Manage Stakeholders",
|
||||
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
|
||||
"draftSaveBannerTitle": "Couldn't save draft",
|
||||
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}"
|
||||
|
||||
@@ -23,6 +23,7 @@ import navigation from "./navigation.json";
|
||||
import metadata from "./metadata.json";
|
||||
import modalsShare from "./modals/share.json";
|
||||
import modalsPopoverExport from "./modals/popoverExport.json";
|
||||
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
|
||||
|
||||
// create – stage 1: community
|
||||
import createInformational from "./create/community/informational.json";
|
||||
@@ -117,5 +118,6 @@ export default {
|
||||
modals: {
|
||||
share: modalsShare,
|
||||
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": {
|
||||
"title": "Still have questions?",
|
||||
"subtitle": "Get answers from an experienced organizer",
|
||||
"buttonText": "Ask an organizer",
|
||||
"buttonHref": "#contact"
|
||||
"buttonText": "Ask an organizer"
|
||||
},
|
||||
"ruleStack": {
|
||||
"title": "Popular templates",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"title": "Still have questions?",
|
||||
"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.",
|
||||
"buttonText": "Ask an organizer",
|
||||
"buttonHref": "/contact"
|
||||
"buttonText": "Ask an organizer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"generic": "Something went wrong. Try again.",
|
||||
"invalidLink": "That sign-in link is not valid. Request a new one from the login page.",
|
||||
"expiredLink": "That sign-in link has expired. Request a new one from the login page.",
|
||||
"serverError": "Something went wrong on our end. Try again later."
|
||||
"serverError": "Something went wrong on our end. Try again later.",
|
||||
"stakeholderWrongAccount": "Sign out and open the stakeholder invite link again, or use the same email you were invited with."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
/// At most one pending verified email change (CR-103).
|
||||
emailChangeToken EmailChangeToken?
|
||||
/// Rules this user was invited to as a stakeholder (after accepting invite).
|
||||
ruleStakeholders RuleStakeholder[] @relation("RuleStakeholderUser")
|
||||
/// Stakeholder rows where this user sent the invite.
|
||||
stakeholderInvitesSent RuleStakeholder[] @relation("RuleStakeholderInvitedBy")
|
||||
}
|
||||
|
||||
/// Pending email change: user must open verify link sent to `newEmail` (CR-103).
|
||||
@@ -74,9 +78,35 @@ model PublishedRule {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
stakeholders RuleStakeholder[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Invite + access for a published rule: email invite at publish; userId set after magic-link-style accept.
|
||||
model RuleStakeholder {
|
||||
id String @id @default(cuid())
|
||||
ruleId String
|
||||
rule PublishedRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
|
||||
/// Normalized lowercase email (invite target).
|
||||
email String
|
||||
/// Publisher at invite time; null if that account was removed.
|
||||
invitedByUserId String?
|
||||
invitedBy User? @relation("RuleStakeholderInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
|
||||
/// Set when the invitee completes the verify link (same account as `email`).
|
||||
userId String?
|
||||
user User? @relation("RuleStakeholderUser", fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// One-time invite token (hashed); null after accept or revoke path (consume on verify).
|
||||
inviteTokenHash String? @unique
|
||||
inviteExpiresAt DateTime?
|
||||
invitedAt DateTime @default(now())
|
||||
acceptedAt DateTime?
|
||||
|
||||
@@unique([ruleId, email])
|
||||
@@index([userId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model RuleTemplate {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
|
||||
@@ -8,7 +8,7 @@ export default {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Top navigation bar for the create rule flow. Includes logo and action buttons (Share, Export, Edit, Exit/Save & Exit).",
|
||||
"Top navigation bar for the create rule flow. Includes logo and action buttons (Share, Export, Edit, Manage Stakeholders, Exit/Save & Exit).",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -25,6 +25,11 @@ export default {
|
||||
control: "boolean",
|
||||
description: "Whether to show the Edit button",
|
||||
},
|
||||
hasManageStakeholders: {
|
||||
control: "boolean",
|
||||
description:
|
||||
"Whether to show Manage Stakeholders (edit published rule invites)",
|
||||
},
|
||||
saveDraftOnExit: {
|
||||
control: "boolean",
|
||||
description:
|
||||
@@ -33,6 +38,7 @@ export default {
|
||||
onShare: { action: "share clicked" },
|
||||
onSelectExportFormat: { action: "export format" },
|
||||
onEdit: { action: "edit clicked" },
|
||||
onManageStakeholders: { action: "manage stakeholders clicked" },
|
||||
onExit: { action: "exit clicked" },
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
@@ -64,3 +70,13 @@ export const SaveDraftOnExit = {
|
||||
saveDraftOnExit: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EditRuleHeader = {
|
||||
args: {
|
||||
hasShare: false,
|
||||
hasExport: false,
|
||||
hasEdit: false,
|
||||
hasManageStakeholders: true,
|
||||
saveDraftOnExit: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,7 +49,6 @@ export const Default = {
|
||||
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),
|
||||
},
|
||||
@@ -60,7 +59,6 @@ export const LeftAligned = {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an organizer",
|
||||
buttonHref: "#contact",
|
||||
variant: "left-aligned",
|
||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||
},
|
||||
@@ -71,7 +69,6 @@ export const Compact = {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an organizer",
|
||||
buttonHref: "#contact",
|
||||
variant: "compact",
|
||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||
},
|
||||
@@ -82,8 +79,19 @@ export const Inverse = {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an organizer",
|
||||
buttonHref: "#contact",
|
||||
variant: "inverse",
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import AskOrganizer from "../../app/components/sections/AskOrganizer";
|
||||
@@ -52,15 +53,24 @@ describe("AskOrganizer (behavioral tests)", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders button with default text", () => {
|
||||
it("renders CTA button with default label", () => {
|
||||
render(<AskOrganizer title="Test" />);
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
screen.getByRole("button", {
|
||||
name: /ask an organizer/i,
|
||||
}),
|
||||
).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", () => {
|
||||
render(
|
||||
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("ConfirmStakeholdersScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Adding people at this step will invite them to see your proposed CommunityRule/i,
|
||||
/Add their email addresses\. When you publish, we'll send each person a one-time link/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ const config: ComponentTestSuiteConfig<CreateFlowTopNavProps> = {
|
||||
onShare: vi.fn(),
|
||||
onSelectExportFormat: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
hasManageStakeholders: true,
|
||||
onManageStakeholders: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
className: "test-class",
|
||||
},
|
||||
@@ -121,6 +123,33 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Manage Stakeholders when hasManageStakeholders is true", () => {
|
||||
render(
|
||||
<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 () => {
|
||||
const user = userEvent.setup();
|
||||
const handleExit = vi.fn();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
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>;
|
||||
|
||||
@@ -23,4 +25,24 @@ const config: ComponentTestSuiteConfig<Props> = {
|
||||
|
||||
describe("TextBlock", () => {
|
||||
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
|
||||
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
||||
|
||||
// 9. User decides to contact organizer
|
||||
const askButton = page.locator(
|
||||
'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
|
||||
);
|
||||
if (
|
||||
(await askButton.count()) > 0 &&
|
||||
(await askButton.first().isVisible())
|
||||
) {
|
||||
await askButton.first().click();
|
||||
// 9. User decides to contact organizer (opens modal)
|
||||
const askButton = page.getByTestId("ask-organizer-cta").first();
|
||||
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
|
||||
await askButton.click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /ask an organizer/i }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("Page Flow Integration", () => {
|
||||
screen.getByText("Get answers from an experienced organizer"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Ask an organizer/i }),
|
||||
screen.getByRole("button", { name: /ask an organizer/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -198,9 +198,9 @@ describe("Page Flow Integration", () => {
|
||||
test("ask organizer section has proper call-to-action", () => {
|
||||
render(<Page />);
|
||||
|
||||
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
|
||||
expect(askLink).toBeInTheDocument();
|
||||
expect(askLink).toHaveAttribute("href", "#contact");
|
||||
const askCta = screen.getByRole("button", { name: /ask an organizer/i });
|
||||
expect(askCta).toBeInTheDocument();
|
||||
expect(askCta).not.toHaveAttribute("href");
|
||||
});
|
||||
|
||||
test("page maintains proper semantic structure", async () => {
|
||||
@@ -223,16 +223,22 @@ describe("Page Flow Integration", () => {
|
||||
expect(mainContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("all interactive elements are accessible", () => {
|
||||
test("all interactive elements are accessible", async () => {
|
||||
render(<Page />);
|
||||
|
||||
// Check all buttons have proper roles
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check all links have proper roles
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("link").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
links.forEach((link) => {
|
||||
expect(link).toBeInTheDocument();
|
||||
|
||||
@@ -121,10 +121,11 @@ describe("User Journey Integration", () => {
|
||||
screen.getByText("Get answers from an experienced organizer"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// User clicks the ask organizer button (it's actually a link, not a button)
|
||||
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
|
||||
await user.click(askLink);
|
||||
expect(askLink).toHaveAttribute("href", "#contact");
|
||||
const askCta = screen.getByTestId("ask-organizer-cta");
|
||||
await user.click(askCta);
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: /ask an organizer/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("user explores the process through CardSteps", async () => {
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
createFlowStepPathAfterStrippingReviewReturn,
|
||||
createFlowStepPathWithSyncDraft,
|
||||
} from "../../app/(app)/create/utils/createFlowPaths";
|
||||
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../app/(app)/create/utils/flowSteps";
|
||||
import {
|
||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
} from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
describe("createFlowPaths (CR-92 §2)", () => {
|
||||
it("createFlowStepPath builds segment path", () => {
|
||||
@@ -26,9 +29,9 @@ describe("createFlowPaths (CR-92 §2)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn only", () => {
|
||||
it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn and manageStakeholders", () => {
|
||||
const sp = new URLSearchParams(
|
||||
`a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&b=2`,
|
||||
`a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&${CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY}=1&b=2`,
|
||||
);
|
||||
expect(createFlowStepPathAfterStrippingReviewReturn("final-review", sp)).toBe(
|
||||
"/create/final-review?a=1&b=2",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createFlowStateSchema,
|
||||
publishRuleBodySchema,
|
||||
putDraftBodySchema,
|
||||
uniqueStakeholderEmailsForPublish,
|
||||
} from "../../lib/server/validation/createFlowSchemas";
|
||||
|
||||
describe("assertPlainJsonValue", () => {
|
||||
@@ -175,6 +176,16 @@ describe("createFlowStateSchema", () => {
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts stakeholderEmails on draft payload", () => {
|
||||
const r = createFlowStateSchema.safeParse({
|
||||
stakeholderEmails: [" one@example.com "],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.stakeholderEmails).toEqual(["one@example.com"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("putDraftBodySchema", () => {
|
||||
@@ -224,4 +235,27 @@ describe("publishRuleBodySchema", () => {
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes stakeholderEmails", () => {
|
||||
const r = publishRuleBodySchema.safeParse({
|
||||
title: "Ok",
|
||||
document: {},
|
||||
stakeholderEmails: [" A@Example.COM ", "b@example.com"],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.stakeholderEmails).toEqual(["a@example.com", "b@example.com"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("uniqueStakeholderEmailsForPublish", () => {
|
||||
it("dedupes and drops publisher email", () => {
|
||||
expect(
|
||||
uniqueStakeholderEmailsForPublish(
|
||||
["a@b.c", "A@B.C", "x@y.z"],
|
||||
"a@b.c",
|
||||
),
|
||||
).toEqual(["x@y.z"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,37 @@ describe("useCreateFlowFinalize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes stakeholderEmails to publishRule on initial publish", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(publishRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stakeholderEmails: ["invitee@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to /create/completed without celebrate after PATCH update", async () => {
|
||||
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
exportFilenameBase,
|
||||
sectionsToCsv,
|
||||
sectionsToMarkdown,
|
||||
sectionsToPdfBlob,
|
||||
} from "../../../lib/create/ruleExport";
|
||||
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
|
||||
@@ -95,6 +96,76 @@ describe("ruleExport", () => {
|
||||
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", () => {
|
||||
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." },
|
||||
]);
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const listForUserMock = vi.fn();
|
||||
const listProfileMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
@@ -10,7 +10,7 @@ vi.mock("../../lib/server/env", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/publishedRules", () => ({
|
||||
listPublishedRulesForUser: (...args: unknown[]) => listForUserMock(...args),
|
||||
listProfileRulesForUser: (...args: unknown[]) => listProfileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
@@ -21,7 +21,7 @@ import { GET } from "../../app/api/rules/me/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
listForUserMock.mockReset();
|
||||
listProfileMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("GET /api/rules/me", () => {
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(listForUserMock).not.toHaveBeenCalled();
|
||||
expect(listProfileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 with { rules } for the session user", async () => {
|
||||
@@ -59,17 +59,20 @@ describe("GET /api/rules/me", () => {
|
||||
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
},
|
||||
];
|
||||
listForUserMock.mockResolvedValueOnce(rows);
|
||||
listProfileMock.mockResolvedValueOnce(
|
||||
rows.map((r) => ({ ...r, role: "owner" as const })),
|
||||
);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/rules/me?limit=10"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(listForUserMock).toHaveBeenCalledWith("user-1", 10);
|
||||
expect(listProfileMock).toHaveBeenCalledWith("user-1", 10);
|
||||
const body = (await res.json()) as {
|
||||
rules: Array<{ id: string; title: string }>;
|
||||
rules: Array<{ id: string; title: string; role: string }>;
|
||||
};
|
||||
expect(body.rules).toHaveLength(1);
|
||||
expect(body.rules[0].id).toBe("r1");
|
||||
expect(body.rules[0].role).toBe("owner");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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