diff --git a/.gitignore b/.gitignore index a28b5ab..a6fd955 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ npm-cache/ !public/favicon-32x32.png !public/apple-touch-icon.png +# Invitation email mark (2x PNG; Gmail and Outlook drop SVG) +!public/assets/logos/community-rule-mark.png + # Per-article body ornaments (Figma Content page Template image fills) !public/content/blog/*-ornament-*.png diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 33d3f3c..33ce2ab 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -62,6 +62,10 @@ import { } from "../../../lib/create/publishedDocumentToCreateFlowState"; import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; +import { + CREATE_FLOW_COPIED_QUERY, + CREATE_FLOW_COPIED_VALUE, +} from "../../../lib/create/invitePaths"; import { clearPendingKeepRuleLogin, hasPendingKeepRuleLogin, @@ -90,6 +94,7 @@ import { isCommunityNameStepBlocked, isCommunitySizeStepBlocked, isCommunityStructureStepBlocked, + isFinalReviewContinueBlocked, } from "./utils/createFlowFooterLabels"; import { EMPTY_SETUP_TRAITS, @@ -198,9 +203,7 @@ function CreateFlowLayoutContent({ const editingPublishedRuleIdTrimmed = state.editingPublishedRuleId?.trim() ?? ""; const isConfirmStakeholdersManagePublished = - currentStep === "confirm-stakeholders" && - manageStakeholdersIntent && - editingPublishedRuleIdTrimmed.length > 0; + currentStep === "confirm-stakeholders" && manageStakeholdersIntent; const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const { markAttempted, clearAttempted } = useCreateFlowContinueAttempt(); @@ -221,6 +224,7 @@ function CreateFlowLayoutContent({ const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const [completedHasPublishedRule, setCompletedHasPublishedRule] = useState(false); + const [completedCopiedReady, setCompletedCopiedReady] = useState(false); type LeaveConfirmChoice = "leave" | "save" | "cancel"; const leaveConfirmResolverRef = useRef< ((choice: LeaveConfirmChoice) => void) | null @@ -276,6 +280,7 @@ function CreateFlowLayoutContent({ : createFlowStepPathWithSyncDraft("final-review"); const { + publishBannerTitle, publishBannerMessage, setPublishBannerMessage, isPublishing, @@ -449,7 +454,10 @@ function CreateFlowLayoutContent({ }, [currentStep]); useEffect(() => { - if (currentStep !== "edit-rule") return; + const editingFromCompleted = + currentStep === "final-review" && + (state.editingPublishedRuleId?.trim() ?? "").length > 0; + if (currentStep !== "edit-rule" && !editingFromCompleted) return; const last = readLastPublishedRule(); if (!last) { router.replace(CREATE_ROUTES.completed); @@ -514,13 +522,43 @@ function CreateFlowLayoutContent({ state.customMethodCardMetaById, ]); + useEffect(() => { + if (currentStep !== "confirm-stakeholders") return; + if (!manageStakeholdersIntent) return; + if (editingPublishedRuleIdTrimmed.length > 0) return; + const last = readLastPublishedRule(); + if (!last) { + router.replace(CREATE_ROUTES.completed); + return; + } + const patch = createFlowStateFromPublishedRule(last); + const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch); + updateState({ + ...patch, + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, + }); + }, [ + currentStep, + manageStakeholdersIntent, + editingPublishedRuleIdTrimmed.length, + router, + updateState, + state.methodSectionsPinCommitted, + ]); + useEffect(() => { if (currentStep !== "completed") { setCompletedFlowBanner(null); setCompletedHasPublishedRule(false); + setCompletedCopiedReady(false); return; } - setCompletedHasPublishedRule(Boolean(readLastPublishedRule())); + const last = readLastPublishedRule(); + setCompletedHasPublishedRule(Boolean(last)); + setCompletedCopiedReady(Boolean(last?.copiedFromTitle)); }, [currentStep]); useEffect(() => { @@ -704,6 +742,7 @@ function CreateFlowLayoutContent({ key: "publish", status: "danger" as const, title: + publishBannerTitle ?? messages.create.reviewAndComplete.publish.publishBannerTitle, description: publishBannerMessage, onClose: () => setPublishBannerMessage(null), @@ -861,7 +900,7 @@ function CreateFlowLayoutContent({ editingPublishedRuleId: last.id, sections: [], }); - router.push(createFlowStepPath("edit-rule")); + router.push(createFlowStepPath("final-review")); } : undefined } @@ -882,7 +921,13 @@ function CreateFlowLayoutContent({ onExit={(opts) => void handleExit(opts)} buttonPalette={isCompletedStep ? "inverse" : undefined} className={`relative z-[210] shrink-0 ${ - isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : "" + isCompletedStep + ? completedCopiedReady || + searchParams.get(CREATE_FLOW_COPIED_QUERY) === + CREATE_FLOW_COPIED_VALUE + ? "!bg-[var(--color-lavender-lavender150,#d1bdf3)]" + : "!bg-[var(--color-teal-teal50,#c9fef9)]" + : "" }`.trim()} /> {currentStep === "community-save" ? ( @@ -912,7 +957,7 @@ function CreateFlowLayoutContent({ progressBar={ !isTemplateReviewRoute && !isCreateTemplatesPickerRoute && - !isFinalReviewLike && + currentStep !== "edit-rule" && reviewReturnTarget !== "edit-rule" } proportionBarProgress={proportionBarProgress} @@ -1124,12 +1169,14 @@ function CreateFlowLayoutContent({ buttonType="filled" palette="default" size="xsmall" - disabled={isPublishing} + disabled={ + isPublishing || editingPublishedRuleIdTrimmed.length === 0 + } className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { router.push( createFlowStepPathAfterStrippingReviewReturn( - "edit-rule", + reviewReturnTarget ?? "completed", searchParams, ), ); @@ -1140,20 +1187,74 @@ function CreateFlowLayoutContent({ .footerDone } + ) : currentStep === "confirm-stakeholders" ? ( +
+ + +
) : nextStep || isFinalReviewLike ? ( ) : null } diff --git a/app/(app)/create/components/MembershipInvitePreviewCard.tsx b/app/(app)/create/components/MembershipInvitePreviewCard.tsx new file mode 100644 index 0000000..099cc1f --- /dev/null +++ b/app/(app)/create/components/MembershipInvitePreviewCard.tsx @@ -0,0 +1,43 @@ +"use client"; + +import type { MembershipInvitePreview } from "../../../../lib/create/membershipInvitePreview"; + +export function MembershipInvitePreviewCard({ + preview, + fromYourRuleLabel, + whoBelongsLabel, + joiningLabel, +}: { + preview: MembershipInvitePreview; + fromYourRuleLabel: string; + whoBelongsLabel: string; + joiningLabel: string; +}) { + return ( +
+

+ {fromYourRuleLabel} +

+
+

+ {preview.categoryName} +

+

+ {preview.title} +

+ {preview.eligibility ? ( +

+ {whoBelongsLabel} + {` ${preview.eligibility}`} +

+ ) : null} + {preview.joiningProcess ? ( +

+ {joiningLabel} + {` ${preview.joiningProcess}`} +

+ ) : null} +
+
+ ); +} diff --git a/app/(app)/create/hooks/useCreateFlowFinalize.ts b/app/(app)/create/hooks/useCreateFlowFinalize.ts index 53bec38..dd67bcd 100644 --- a/app/(app)/create/hooks/useCreateFlowFinalize.ts +++ b/app/(app)/create/hooks/useCreateFlowFinalize.ts @@ -17,21 +17,27 @@ import { createFlowStepPath } from "../utils/createFlowPaths"; type AppRouterLike = { push: (_href: string) => void }; type OpenLogin = (args: { - variant: "default" | "saveProgress" | "keepRule"; + variant: "default" | "saveProgress" | "keepRule" | "invite"; nextPath: string; backdropVariant: "blurredYellow"; }) => void; type SessionUser = { id: string; email: string }; +export type FinalizeOptions = { + /** When true, finish without sending the current email list. */ + skipInvites?: boolean; +}; + export type UseCreateFlowFinalizeResult = { + publishBannerTitle: string | null; publishBannerMessage: string | null; setPublishBannerMessage: (_message: string | null) => void; isPublishing: boolean; - finalize: () => Promise; + finalize: (_opts?: FinalizeOptions) => Promise; }; -/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */ +/** Invite / Finish → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */ export function useCreateFlowFinalize({ state, router, @@ -55,65 +61,67 @@ export function useCreateFlowFinalize({ /** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */ onGuestPublished?: (_info: { skippedInvites: boolean }) => void; }): UseCreateFlowFinalizeResult { + const [publishBannerTitle, setPublishBannerTitle] = useState( + null, + ); const [publishBannerMessage, setPublishBannerMessage] = useState< string | null >(null); const [isPublishing, setIsPublishing] = useState(false); - const finalize = useCallback(async () => { - if (sessionUser === undefined) return; - - setPublishBannerMessage(null); - setIsPublishing(true); - const prepared = await stateWithPendingCommunityAvatar(state); - if (prepared.ok === false) { - setIsPublishing(false); - setPublishBannerMessage(messages.create.upload.errors.generic); - return; - } - if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) { - updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl }); - } - const payloadResult = buildPublishPayload(prepared.state); - if (payloadResult.ok === false) { - setIsPublishing(false); - setPublishBannerMessage( - payloadResult.error === "missingCommunityName" - ? messages.create.reviewAndComplete.publish.missingCommunityName - : payloadResult.error, + const showFailedBanner = useCallback( + (message: string, sendingInvites: boolean) => { + const inviteCopy = messages.create.reviewAndComplete.confirmStakeholders; + if (sendingInvites) { + setPublishBannerTitle(inviteCopy.sendFailedTitle); + setPublishBannerMessage(inviteCopy.sendFailedDescription); + return; + } + setPublishBannerTitle( + messages.create.reviewAndComplete.publish.publishBannerTitle, ); - return; - } - const { title, summary, document: ruleDocument } = payloadResult; + setPublishBannerMessage(message); + }, + [], + ); - const editingId = - typeof state.editingPublishedRuleId === "string" - ? state.editingPublishedRuleId.trim() - : ""; + const finalize = useCallback( + async (opts?: FinalizeOptions) => { + if (sessionUser === undefined) return; + const skipInvites = opts?.skipInvites === true; - if (editingId.length > 0 && sessionUser === null) { - setIsPublishing(false); - writeLastPublishedRule({ - id: editingId, - title, - summary: summary ?? null, - document: ruleDocument, - }); - updateState({ editingPublishedRuleId: undefined }); - writePendingKeepRuleLogin(); - onGuestPublished?.({ skippedInvites: false }); - router.push(createFlowStepPath("completed")); - return; - } + setPublishBannerTitle(null); + setPublishBannerMessage(null); + setIsPublishing(true); + const prepared = await stateWithPendingCommunityAvatar(state); + if (prepared.ok === false) { + setIsPublishing(false); + showFailedBanner(messages.create.upload.errors.generic, false); + return; + } + if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) { + updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl }); + } + const payloadResult = buildPublishPayload(prepared.state); + if (payloadResult.ok === false) { + setIsPublishing(false); + showFailedBanner( + payloadResult.error === "missingCommunityName" + ? messages.create.reviewAndComplete.publish.missingCommunityName + : payloadResult.error, + false, + ); + return; + } + const { title, summary, document: ruleDocument } = payloadResult; - if (editingId.length > 0) { - const updateResult = await updatePublishedRule(editingId, { - title, - summary: summary ?? null, - document: ruleDocument, - }); - setIsPublishing(false); - if (updateResult.ok === true) { + const editingId = + typeof state.editingPublishedRuleId === "string" + ? state.editingPublishedRuleId.trim() + : ""; + + if (editingId.length > 0 && sessionUser === null) { + setIsPublishing(false); writeLastPublishedRule({ id: editingId, title, @@ -121,10 +129,87 @@ export function useCreateFlowFinalize({ document: ruleDocument, }); updateState({ editingPublishedRuleId: undefined }); + writePendingKeepRuleLogin(); + onGuestPublished?.({ skippedInvites: false }); router.push(createFlowStepPath("completed")); return; } - if (updateResult.status === 401) { + + if (editingId.length > 0) { + const updateResult = await updatePublishedRule(editingId, { + title, + summary: summary ?? null, + document: ruleDocument, + }); + setIsPublishing(false); + if (updateResult.ok === true) { + writeLastPublishedRule({ + id: editingId, + title, + summary: summary ?? null, + document: ruleDocument, + }); + updateState({ editingPublishedRuleId: undefined }); + router.push(createFlowStepPath("completed")); + return; + } + if (updateResult.status === 401) { + openLogin({ + variant: sessionUser === null ? "keepRule" : "default", + nextPath: + sessionUser === null + ? createFlowStepPath("completed") + : loginReturnPath, + backdropVariant: "blurredYellow", + }); + return; + } + showFailedBanner( + updateResult.error.trim() !== "" + ? updateResult.error + : messages.create.reviewAndComplete.publish.genericPublishFailed, + false, + ); + return; + } + + const isGuest = sessionUser === null; + const rawStakeholderEmails = skipInvites + ? [] + : (state.stakeholderEmails ?? []).filter( + (e) => typeof e === "string" && e.trim() !== "", + ); + const stakeholderEmails = isGuest ? [] : rawStakeholderEmails; + const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0; + const sendingInvites = stakeholderEmails.length > 0; + + const publishResult = await publishRule({ + title, + summary, + document: ruleDocument, + ...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}), + }); + setIsPublishing(false); + if (publishResult.ok === true) { + writeLastPublishedRule({ + id: publishResult.id, + title, + summary: summary ?? null, + document: ruleDocument, + }); + if (isGuest) { + writePendingKeepRuleLogin(); + onGuestPublished?.({ skippedInvites: skippedGuestInvites }); + } + router.push( + createFlowStepPath("completed", { + [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, + }), + ); + return; + } + if (publishResult.status === 401) { openLogin({ variant: sessionUser === null ? "keepRule" : "default", nextPath: @@ -135,76 +220,34 @@ export function useCreateFlowFinalize({ }); return; } - setPublishBannerMessage( - updateResult.error.trim() !== "" - ? updateResult.error + showFailedBanner( + publishResult.error.trim() !== "" + ? publishResult.error : messages.create.reviewAndComplete.publish.genericPublishFailed, + sendingInvites, ); - return; - } + }, + [ + state, + router, + openLogin, + updateState, + loginReturnPath, + sessionUser, + onGuestPublished, + showFailedBanner, + ], + ); - const isGuest = sessionUser === null; - const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter( - (e) => typeof e === "string" && e.trim() !== "", - ); - const stakeholderEmails = isGuest ? [] : rawStakeholderEmails; - const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0; - - const publishResult = await publishRule({ - title, - summary, - document: ruleDocument, - ...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}), - }); - setIsPublishing(false); - if (publishResult.ok === true) { - writeLastPublishedRule({ - id: publishResult.id, - title, - summary: summary ?? null, - document: ruleDocument, - }); - if (isGuest) { - writePendingKeepRuleLogin(); - onGuestPublished?.({ skippedInvites: skippedGuestInvites }); - } - router.push( - createFlowStepPath("completed", { - [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: - CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, - }), - ); - return; - } - if (publishResult.status === 401) { - openLogin({ - variant: sessionUser === null ? "keepRule" : "default", - nextPath: - sessionUser === null - ? createFlowStepPath("completed") - : loginReturnPath, - backdropVariant: "blurredYellow", - }); - return; - } - setPublishBannerMessage( - publishResult.error.trim() !== "" - ? publishResult.error - : messages.create.reviewAndComplete.publish.genericPublishFailed, - ); - }, [ - state, - router, - openLogin, - updateState, - loginReturnPath, - sessionUser, - onGuestPublished, - ]); + const clearPublishBanner = useCallback((message: string | null) => { + if (message === null) setPublishBannerTitle(null); + setPublishBannerMessage(message); + }, []); return { + publishBannerTitle, publishBannerMessage, - setPublishBannerMessage, + setPublishBannerMessage: clearPublishBanner, isPublishing, finalize, }; diff --git a/app/(app)/create/hooks/useCreateFlowNavigation.ts b/app/(app)/create/hooks/useCreateFlowNavigation.ts index 92780e1..70e3a1f 100644 --- a/app/(app)/create/hooks/useCreateFlowNavigation.ts +++ b/app/(app)/create/hooks/useCreateFlowNavigation.ts @@ -38,7 +38,7 @@ const blurActiveElement = (): void => { * still needs identity (direct from a template) walks name / description / * photo, then Back from `community-name` returns to * `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip - * identity and land on `confirm-stakeholders`; Back from that step returns + * identity and land on `final-review`; Back from that step returns * to the template instead of `conflict-management`. * * Template review footer Back uses {@link buildTemplateReviewHref}’s diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index e44321b..ae70e53 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -43,7 +43,7 @@ export type UseTemplateReviewActionsResult = { * `/create/community-name` with a pin so Next/Back collect only name → * description → photo, and drops leftover setup answers along with the * other identity fields. In-flow (`?fromFlow=1`, community steps already - * done) routes to `/create/confirm-stakeholders` and keeps those answers. + * done) routes to `/create/final-review` and keeps those answers. */ handleUseWithoutChanges: () => Promise; }; @@ -268,7 +268,7 @@ export function useTemplateReviewActions({ }); router.push( fromCreateWizard - ? createFlowStepPath("confirm-stakeholders") + ? createFlowStepPath("final-review") : createFlowStepPath("community-name"), ); }, [ diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index 56a587e..9e86423 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from "next/navigation"; import CommunityRule from "../../../../components/type/CommunityRule"; import type { CommunityRuleSection } from "../../../../components/type/CommunityRule/CommunityRule.types"; import Alert from "../../../../components/modals/Alert"; +import AskOrganizerInquiryModal from "../../../../components/modals/AskOrganizerInquiry"; +import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import { useMessages } from "../../../../contexts/MessagesContext"; import { fetchPublishedRuleDetail } from "../../../../../lib/create/api"; import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections"; @@ -24,6 +26,11 @@ import { CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, } from "../../utils/flowSteps"; +import { + CREATE_FLOW_COPIED_QUERY, + CREATE_FLOW_COPIED_VALUE, +} from "../../../../../lib/create/invitePaths"; +import { communityPossessive } from "../../../../../lib/create/ruleInviteCardMeta"; function emptyCompletedDocumentState(): { headerTitle: string; @@ -78,6 +85,11 @@ export function CompletedScreen() { const celebrateInUrl = searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) === CREATE_FLOW_COMPLETED_CELEBRATE_VALUE; + const copiedInUrl = + searchParams.get(CREATE_FLOW_COPIED_QUERY) === CREATE_FLOW_COPIED_VALUE; + const storedForCopy = ruleIdParam ? null : readLastPublishedRule(); + const copiedFromTitle = storedForCopy?.copiedFromTitle; + const copiedNameHit = storedForCopy?.copiedNameHit ?? null; const mdUp = useCreateFlowMdUp(); const m = useMessages(); const completed = m.create.reviewAndComplete.completed; @@ -86,6 +98,8 @@ export function CompletedScreen() { const [toastDismissed, setToastDismissed] = useState(false); /** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */ const [showCelebrateToast] = useState(celebrateInUrl); + const [showCopiedReady] = useState(copiedInUrl); + const [askOrganizerOpen, setAskOrganizerOpen] = useState(false); const [headerTitle, setHeaderTitle] = useState(initial.headerTitle); const [headerDescription, setHeaderDescription] = useState< string | undefined @@ -138,11 +152,17 @@ export function CompletedScreen() { }, [ruleIdParam, router]); useEffect(() => { - if (!celebrateInUrl) return; + if (!celebrateInUrl && !copiedInUrl) return; router.replace("/create/completed"); - }, [celebrateInUrl, router]); + }, [celebrateInUrl, copiedInUrl, router]); - const toast = showCelebrateToast && !toastDismissed ? ( + const surfaceCopied = showCopiedReady || Boolean(copiedFromTitle); + const surfaceClass = surfaceCopied + ? "bg-[var(--color-lavender-lavender150,#d1bdf3)]" + : "bg-[var(--color-teal-teal50,#c9fef9)]"; + + const toast = + !toastDismissed && (showCopiedReady || showCelebrateToast) ? (
setToastDismissed(true)} @@ -163,12 +194,12 @@ export function CompletedScreen() { return ( <> -
+
+ {headerTitle && !surfaceCopied ? ( +

+ {completed.askOrganizerLead}{" "} + setAskOrganizerOpen(true)} + > + {completed.askOrganizerLink} + +

+ ) : null}
{belowCard ? ( -
e.stopPropagation()}> +
e.stopPropagation()} + > {belowCard}
) : null} diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 2699de3..827358b 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -44,7 +44,11 @@ function MailIconInline() { const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export type LoginFormVariant = "default" | "saveProgress" | "keepRule"; +export type LoginFormVariant = + | "default" + | "saveProgress" + | "keepRule" + | "invite"; function loginFormHeading( variant: LoginFormVariant, @@ -66,6 +70,12 @@ function loginFormHeading( description: t("keepRuleSubtitle"), }; } + if (variant === "invite") { + return { + title: t("inviteTitle"), + description: t("inviteSubtitle"), + }; + } return { title: t("title"), description: t("subtitle") }; } @@ -74,7 +84,11 @@ function shouldAttachCreateFlowDraft( nextPath: string, ): boolean { if (variant === "keepRule") return false; - return variant === "saveProgress" || nextPath.includes("syncDraft=1"); + return ( + variant === "saveProgress" || + variant === "invite" || + nextPath.includes("syncDraft=1") + ); } export type LoginFormProps = { @@ -266,46 +280,48 @@ export default function LoginForm({ {emailError}

) : null} - - {onDismiss ? ( +
- ) : null} -

- {t("legalPrefix")} - - {tFooter("legal.termsOfService")} - - {t("legalAnd")} - - {tFooter("legal.privacyPolicy")} - - {t("legalSuffix")} -

+ {onDismiss ? ( + + ) : null} +

+ {t("legalPrefix")} + + {tFooter("legal.termsOfService")} + + {t("legalAnd")} + + {tFooter("legal.privacyPolicy")} + + {t("legalSuffix")} +

+
) : null}
diff --git a/app/contexts/AuthModalContext.tsx b/app/contexts/AuthModalContext.tsx index bc715a2..55d4364 100644 --- a/app/contexts/AuthModalContext.tsx +++ b/app/contexts/AuthModalContext.tsx @@ -8,10 +8,15 @@ import { useState, type ReactNode, } from "react"; +import { useTranslation } from "./MessagesContext"; import Login from "../components/modals/Login"; import LoginForm from "../components/modals/Login/LoginForm"; -export type AuthModalLoginVariant = "default" | "saveProgress" | "keepRule"; +export type AuthModalLoginVariant = + | "default" + | "saveProgress" + | "keepRule" + | "invite"; export type AuthModalBackdropVariant = "solid" | "blurredYellow"; @@ -35,6 +40,7 @@ type AuthModalContextValue = { const AuthModalContext = createContext(null); export function AuthModalProvider({ children }: { children: ReactNode }) { + const tLogin = useTranslation("pages.login"); const [open, setOpen] = useState(false); const [opts, setOpts] = useState({}); @@ -72,6 +78,17 @@ export function AuthModalProvider({ children }: { children: ReactNode }) { backdropVariant={backdropVariant} usePortal ariaLabelledBy="login-modal-heading" + belowCard={ + opts.variant === "invite" ? ( + + ) : undefined + } > ; + copiedFromTitle: string; + copiedNameHit: { + categoryName: string; + entryTitle: string; + } | null; + } + | { ok: false; error: string; status: number }; + +export async function makeCopyPublishedRule( + id: string, + title: string, +): Promise { + try { + const res = await fetch( + `/api/rules/${encodeURIComponent(id)}/make-copy`, + { + method: "POST", + credentials: "include", + headers: jsonHeaders, + body: JSON.stringify({ title }), + }, + ); + const data = (await safeParseJsonResponse(res)) as { + rule?: { + id: string; + title: string; + summary?: string | null; + document?: Record; + copiedFromTitle?: string; + copiedNameHit?: { + categoryName: string; + entryTitle: string; + } | null; + }; + } | null; + const rule = data && typeof data === "object" ? data.rule : undefined; + const document = rule?.document; + if ( + !res.ok || + !rule || + typeof rule.id !== "string" || + typeof rule.title !== "string" || + !document || + typeof document !== "object" || + Array.isArray(document) + ) { + const fromBody = + data && typeof data === "object" ? readApiErrorMessage(data) : null; + const msg = + fromBody && fromBody !== "Request failed" + ? fromBody + : PUBLISH_FAILED_FALLBACK; + return { + ok: false as const, + error: msg, + status: res.status, + }; + } + return { + ok: true, + id: rule.id, + title: rule.title, + summary: typeof rule.summary === "string" ? rule.summary : null, + document, + copiedFromTitle: + typeof rule.copiedFromTitle === "string" ? rule.copiedFromTitle : "", + copiedNameHit: rule.copiedNameHit ?? null, + }; + } catch { + return { + ok: false as const, + error: DRAFT_SAVE_NETWORK_ERROR, + status: 0, + }; + } +} + +export async function resendExpiredStakeholderInvite( + token: string, + email: string, +): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> { + try { + const res = await fetch("/api/invites/rule-stakeholder/resend", { + method: "POST", + credentials: "include", + headers: jsonHeaders, + body: JSON.stringify({ token, email }), + }); + const data = await safeParseJsonResponse(res); + if (!res.ok) { + const fromBody = + data && typeof data === "object" ? readApiErrorMessage(data) : null; + return { + ok: false as const, + error: fromBody && fromBody !== "Request failed" + ? fromBody + : DRAFT_SAVE_NETWORK_ERROR, + retryAfterMs: retryAfterFromResponse(res, data), + }; + } + return { ok: true }; + } catch { + return { ok: false as const, error: DRAFT_SAVE_NETWORK_ERROR }; + } +} + export async function duplicatePublishedRule( id: string, ): Promise { diff --git a/lib/create/buildFinalReviewCategories.ts b/lib/create/buildFinalReviewCategories.ts index d4f4be8..939f9bb 100644 --- a/lib/create/buildFinalReviewCategories.ts +++ b/lib/create/buildFinalReviewCategories.ts @@ -50,6 +50,56 @@ export type FinalReviewCategoryNames = { conflict: string; }; +const CANONICAL_FACET_ORDER: readonly TemplateFacetGroupKey[] = [ + "coreValues", + "communication", + "membership", + "decisionApproaches", + "conflictManagement", +] as const; + +function nameForGroup( + groupKey: TemplateFacetGroupKey, + names: FinalReviewCategoryNames, +): string { + switch (groupKey) { + case "coreValues": + return names.values; + case "communication": + return names.communication; + case "membership": + return names.membership; + case "decisionApproaches": + return names.decisions; + case "conflictManagement": + return names.conflict; + } +} + +/** Always five section rows, in values-first order, including empty ones. */ +export function ensureCanonicalFinalReviewRows( + rows: readonly FinalReviewCategoryRowDetailed[], + names: FinalReviewCategoryNames, +): FinalReviewCategoryRowDetailed[] { + const byGroup = new Map(); + for (const row of rows) { + if (row.groupKey == null) continue; + byGroup.set(row.groupKey, { + ...row, + name: nameForGroup(row.groupKey, names), + }); + } + return CANONICAL_FACET_ORDER.map((groupKey) => { + const existing = byGroup.get(groupKey); + if (existing) return existing; + return { + name: nameForGroup(groupKey, names), + groupKey, + entries: [], + }; + }); +} + type MethodPreset = { id: string; label: string }; /** @@ -230,7 +280,7 @@ export function buildFinalReviewCategoryRowsDetailed( if (entries.length === 0) continue; rows.push({ name: s.categoryName, groupKey, entries }); } - return rows; + return ensureCanonicalFinalReviewRows(rows, names); } const rows: FinalReviewCategoryRowDetailed[] = [ @@ -276,7 +326,7 @@ export function buildFinalReviewCategoryRowsDetailed( ), }, ]; - return rows.filter((r) => r.entries.length > 0); + return ensureCanonicalFinalReviewRows(rows, names); } /** @@ -293,9 +343,8 @@ export function buildFinalReviewCategoryRowsDetailed( * against the curated message `methods[]` list to get the display labels, * matching what the user saw as chips in-flow. * - * Empty categories are filtered out so the review card doesn't render headings - * with no chips. If nothing in state resolves to any chip, the caller should - * fall back to the demo categories shipped in `finalReview.json`. + * Empty categories stay on the card so add chips and flags can show. The five + * named rows always render, in values-first order. */ export function buildFinalReviewCategoriesFromState( state: CreateFlowState, diff --git a/lib/create/communityInitials.ts b/lib/create/communityInitials.ts new file mode 100644 index 0000000..6e6d7f6 --- /dev/null +++ b/lib/create/communityInitials.ts @@ -0,0 +1,7 @@ +/** One or two letters from a community name for the Rule card mark. */ +export function communityInitialsFromTitle(title: string): string { + const parts = title.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase(); +} diff --git a/lib/create/copiedRuleDocument.ts b/lib/create/copiedRuleDocument.ts new file mode 100644 index 0000000..9098a8a --- /dev/null +++ b/lib/create/copiedRuleDocument.ts @@ -0,0 +1,59 @@ +import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections"; + +const IDENTITY_KEYS = [ + "communityAvatarUrl", + "communityAvatarFileName", + "communityContext", + "communityLocation", + "communitySaveEmail", + "title", +] as const; + +export type CopiedNameHit = { + categoryName: string; + entryTitle: string; +}; + +/** Copy cards and text; drop name, description, photo, and save-email. */ +export function stripCopiedRuleDocument( + document: unknown, +): Record { + if (!document || typeof document !== "object" || Array.isArray(document)) { + return {}; + } + const copy = structuredClone(document) as Record; + for (const key of IDENTITY_KEYS) { + delete copy[key]; + } + return copy; +} + +function entryHaystack(entry: CommunityRuleSection["entries"][number]): string { + const blockText = (entry.blocks ?? []) + .map((block) => `${block.label} ${block.body}`) + .join("\n"); + return `${entry.title}\n${entry.body}\n${blockText}`; +} + +/** First line that still names the original community after a copy. */ +export function findCopiedNameHit( + document: unknown, + originalName: string, +): CopiedNameHit | null { + const name = originalName.trim(); + if (name.length < 2) return null; + const needle = name.toLowerCase(); + const sections = parsePublishedDocumentForCommunityRuleDisplay(document); + for (const section of sections) { + for (const entry of section.entries) { + if (entryHaystack(entry).toLowerCase().includes(needle)) { + return { + categoryName: section.categoryName, + entryTitle: entry.title, + }; + } + } + } + return null; +} diff --git a/lib/create/finalReviewChipWarnings.ts b/lib/create/finalReviewChipWarnings.ts new file mode 100644 index 0000000..aa1e7c5 --- /dev/null +++ b/lib/create/finalReviewChipWarnings.ts @@ -0,0 +1,90 @@ +import type { + CommunicationMethodDetailEntry, + ConflictManagementDetailEntry, + CreateFlowState, + DecisionApproachDetailEntry, + MembershipMethodDetailEntry, +} from "../../app/(app)/create/types"; +import type { TemplateFacetGroupKey } from "./templateReviewMapping"; + +function missingText(...values: readonly (string | undefined)[]): boolean { + return values.some((value) => (value ?? "").trim().length === 0); +} + +function communicationMissingWords( + saved: CommunicationMethodDetailEntry | undefined, +): boolean { + if (!saved) return true; + return missingText( + saved.corePrinciple, + saved.logisticsAdmin, + saved.codeOfConduct, + ); +} + +function membershipMissingWords( + saved: MembershipMethodDetailEntry | undefined, +): boolean { + if (!saved) return true; + return missingText( + saved.eligibility, + saved.joiningProcess, + saved.expectations, + ); +} + +function decisionMissingWords( + saved: DecisionApproachDetailEntry | undefined, +): boolean { + if (!saved) return true; + return missingText( + saved.corePrinciple, + saved.stepByStepInstructions, + saved.objectionsDeadlocks, + ); +} + +function conflictMissingWords( + saved: ConflictManagementDetailEntry | undefined, +): boolean { + if (!saved) return true; + return missingText( + saved.corePrinciple, + saved.processProtocol, + saved.restorationFallbacks, + ); +} + +/** + * A review chip shows a warning when its value is missing meaning or signals, + * or a card is missing its words. + */ +export function finalReviewChipHasWarning( + state: CreateFlowState, + groupKey: TemplateFacetGroupKey | null, + overrideKey: string | null, +): boolean { + if (!groupKey || !overrideKey) return true; + switch (groupKey) { + case "coreValues": { + const saved = state.coreValueDetailsByChipId?.[overrideKey]; + return missingText(saved?.meaning, saved?.signals); + } + case "communication": + return communicationMissingWords( + state.communicationMethodDetailsById?.[overrideKey], + ); + case "membership": + return membershipMissingWords( + state.membershipMethodDetailsById?.[overrideKey], + ); + case "decisionApproaches": + return decisionMissingWords( + state.decisionApproachDetailsById?.[overrideKey], + ); + case "conflictManagement": + return conflictMissingWords( + state.conflictManagementDetailsById?.[overrideKey], + ); + } +} diff --git a/lib/create/inviteLastChanged.ts b/lib/create/inviteLastChanged.ts new file mode 100644 index 0000000..feb01f1 --- /dev/null +++ b/lib/create/inviteLastChanged.ts @@ -0,0 +1,10 @@ +/** Short date for the invited-view facts table (`6 Nov 2026`). */ +export function formatInviteLastChanged(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} diff --git a/lib/create/invitePaths.ts b/lib/create/invitePaths.ts new file mode 100644 index 0000000..e390f7b --- /dev/null +++ b/lib/create/invitePaths.ts @@ -0,0 +1,20 @@ +export const INVITE_COPY_QUERY = "copy"; +export const INVITE_COPY_VALUE = "1"; +export const CREATE_FLOW_COPIED_QUERY = "copied"; +export const CREATE_FLOW_COPIED_VALUE = "1"; + +export function invitedRulePath( + ruleId: string, + opts?: { copy?: boolean }, +): string { + const base = `/invite/${encodeURIComponent(ruleId)}`; + if (opts?.copy) { + return `${base}?${INVITE_COPY_QUERY}=${INVITE_COPY_VALUE}`; + } + return base; +} + +export function inviteExpiredPath(token?: string | null): string { + if (!token) return "/invite/expired"; + return `/invite/expired?token=${encodeURIComponent(token)}`; +} diff --git a/lib/create/lastPublishedRule.ts b/lib/create/lastPublishedRule.ts index 8b06363..35fe69c 100644 --- a/lib/create/lastPublishedRule.ts +++ b/lib/create/lastPublishedRule.ts @@ -1,7 +1,9 @@ +import type { CopiedNameHit } from "./copiedRuleDocument"; + /** * Bridges final-review → completed without query strings, and re-opens a rule * from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id]. Profile - * "Manage" links here; "View" uses `/rules/[id]`. + * "Manage" links here; stakeholder "View" uses `/invite/[id]`. */ export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished"; @@ -10,6 +12,9 @@ export type StoredLastPublishedRule = { title: string; summary?: string | null; document: Record; + /** Present after Make a copy; drives the copy-ready banner and lavender surface. */ + copiedFromTitle?: string; + copiedNameHit?: CopiedNameHit | null; }; export function writeLastPublishedRule(data: StoredLastPublishedRule): void { @@ -39,11 +44,33 @@ export function readLastPublishedRule(): StoredLastPublishedRule | null { } else { summary = undefined; } + let copiedFromTitle: string | undefined; + if ( + typeof o.copiedFromTitle === "string" && + o.copiedFromTitle.trim().length > 0 + ) { + copiedFromTitle = o.copiedFromTitle; + } + let copiedNameHit: CopiedNameHit | null | undefined; + if (o.copiedNameHit && typeof o.copiedNameHit === "object") { + const hit = o.copiedNameHit as Record; + if ( + typeof hit.categoryName === "string" && + typeof hit.entryTitle === "string" + ) { + copiedNameHit = { + categoryName: hit.categoryName, + entryTitle: hit.entryTitle, + }; + } + } return { id: o.id, title: o.title, summary, document: doc as Record, + ...(copiedFromTitle ? { copiedFromTitle } : {}), + ...(copiedNameHit ? { copiedNameHit } : {}), }; } catch { return null; diff --git a/lib/create/membershipInvitePreview.ts b/lib/create/membershipInvitePreview.ts new file mode 100644 index 0000000..4e1639b --- /dev/null +++ b/lib/create/membershipInvitePreview.ts @@ -0,0 +1,43 @@ +import type { CreateFlowState } from "../../app/(app)/create/types"; +import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections"; +import { + membershipPresetFor, + publishedMethodDisplayLabel, +} from "./finalReviewChipPresets"; + +export type MembershipInvitePreview = { + categoryName: string; + title: string; + eligibility: string; + joiningProcess: string; +}; + +/** First selected membership method, for the Invite your members card. */ +export function membershipInvitePreview( + state: CreateFlowState, +): MembershipInvitePreview | null { + const ids = state.selectedMembershipMethodIds ?? []; + const firstId = ids.find((id) => typeof id === "string" && id.trim() !== ""); + if (!firstId) return null; + + const saved = state.membershipMethodDetailsById?.[firstId]; + const preset = membershipPresetFor(firstId); + const eligibility = (saved?.eligibility ?? "").trim() || preset.eligibility; + const joiningProcess = + (saved?.joiningProcess ?? "").trim() || preset.joiningProcess; + const title = + publishedMethodDisplayLabel( + "membership", + firstId, + state.customMethodCardMetaById, + ) || firstId; + + if (!title && !eligibility && !joiningProcess) return null; + + return { + categoryName: RULE_SECTION_CATEGORY.membership, + title, + eligibility, + joiningProcess, + }; +} diff --git a/lib/create/parseInviteEmails.ts b/lib/create/parseInviteEmails.ts new file mode 100644 index 0000000..42b0293 --- /dev/null +++ b/lib/create/parseInviteEmails.ts @@ -0,0 +1,82 @@ +/** Shared invite-field email check (create-flow Invite your members). */ +export const INVITE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const COUNT_WORDS = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", +] as const; + +/** Lowercase count for paste-result copy (`five`, or digits past ten). */ +export function formatInviteCountWord(n: number): string { + if (Number.isInteger(n) && n >= 0 && n <= 10) { + return COUNT_WORDS[n] ?? String(n); + } + return String(n); +} + +export function splitInvitePasteTokens(raw: string): string[] { + return raw + .split(/[\s,;]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 0); +} + +export type ParseInvitePasteResult = { + added: string[]; + invalidRemainder: string; + skippedDuplicate: number; +}; + +/** + * Split a pasted list into valid new addresses and leftovers that stay in the + * field. Tokens that are not emails, duplicates, or that would exceed `max` + * remain in {@link ParseInvitePasteResult.invalidRemainder}. + */ +export function parseInvitePaste( + raw: string, + existing: readonly string[], + max: number, +): ParseInvitePasteResult { + const tokens = splitInvitePasteTokens(raw); + const added: string[] = []; + const invalid: string[] = []; + const seen = new Set( + existing + .map((email) => email.trim().toLowerCase()) + .filter((email) => email.length > 0), + ); + let skippedDuplicate = 0; + + for (const token of tokens) { + const lower = token.toLowerCase(); + if (!INVITE_EMAIL_PATTERN.test(lower)) { + invalid.push(token); + continue; + } + if (seen.has(lower)) { + skippedDuplicate += 1; + continue; + } + if (existing.length + added.length >= max) { + invalid.push(token); + continue; + } + seen.add(lower); + added.push(lower); + } + + return { + added, + invalidRemainder: invalid.join(", "), + skippedDuplicate, + }; +} diff --git a/lib/create/ruleInviteCardMeta.ts b/lib/create/ruleInviteCardMeta.ts new file mode 100644 index 0000000..8ddaef7 --- /dev/null +++ b/lib/create/ruleInviteCardMeta.ts @@ -0,0 +1,74 @@ +import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections"; +import { formatInviteCountWord } from "./parseInviteEmails"; +import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections"; +import sharedTerms from "../../messages/en/create/sharedTerms.json"; + +const DESCRIPTION_MAX = 140; + +/** Initials for the invite email disc when the group has no photo. */ +export function communityInviteInitials(name: string): string { + const words = name + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + if (words.length === 0) return "·"; + if (words.length === 1) { + const word = words[0] ?? ""; + return word.slice(0, 2).toUpperCase(); + } + const first = words[0]?.charAt(0) ?? ""; + const second = words[1]?.charAt(0) ?? ""; + return `${first}${second}`.toUpperCase(); +} + +/** Possessive for footer copy (`Share’s` / `Members’`). */ +export function communityPossessive(name: string): string { + const trimmed = name.trim(); + if (trimmed.length === 0) return "this community’s"; + if (/s$/i.test(trimmed)) return `${trimmed}’`; + return `${trimmed}’s`; +} + +/** Community description on the invite card, cut at 140 characters. */ +export function clipCommunityInviteDescription( + raw: string | null | undefined, +): string { + const trimmed = typeof raw === "string" ? raw.trim() : ""; + if (trimmed.length <= DESCRIPTION_MAX) return trimmed; + return trimmed.slice(0, DESCRIPTION_MAX).trimEnd(); +} + +/** Values vs method cards on a published Rule. Shared terms do not count. */ +export function inviteRuleValueAndCardCounts(document: unknown): { + values: number; + cards: number; +} { + const sections = parsePublishedDocumentForCommunityRuleDisplay(document); + let values = 0; + let cards = 0; + for (const section of sections) { + const n = section.entries.length; + if (section.categoryName === RULE_SECTION_CATEGORY.values) { + values += n; + } else if (section.categoryName !== sharedTerms.sectionTitle) { + cards += n; + } + } + return { values, cards }; +} + +/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */ +export function inviteRuleCountsLabel(document: unknown): string { + const { values, cards } = inviteRuleValueAndCardCounts(document); + const valuePart = values === 1 ? "1 VALUE" : `${values} VALUES`; + const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`; + return `${valuePart} AND ${cardPart}`; +} + +/** Spoken counts for Make a copy (`three values and six cards`). */ +export function inviteRuleCountsSpoken(document: unknown): string { + const { values, cards } = inviteRuleValueAndCardCounts(document); + const valueNoun = values === 1 ? "value" : "values"; + const cardNoun = cards === 1 ? "card" : "cards"; + return `${formatInviteCountWord(values)} ${valueNoun} and ${formatInviteCountWord(cards)} ${cardNoun}`; +} diff --git a/lib/navigationChromelessPath.ts b/lib/navigationChromelessPath.ts index 420bca0..95366e8 100644 --- a/lib/navigationChromelessPath.ts +++ b/lib/navigationChromelessPath.ts @@ -8,7 +8,11 @@ export function isChromelessNavigationPath( if (!pathname) { return false; } - if (pathname.startsWith("/create") || pathname === "/login") { + if ( + pathname.startsWith("/create") || + pathname === "/login" || + pathname.startsWith("/invite") + ) { return true; } return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname); diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 181024e..aad88b3 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -6,6 +6,11 @@ import { type WorkshopInquiryMailInput, } from "../workshopSignupMail"; import { getSmtpUrl } from "./env"; +import { + buildRuleStakeholderInviteMail, + stakeholderInviteReplyTo, + type RuleStakeholderInviteMailInput, +} from "./ruleStakeholderInviteMail"; function escapeHtml(value: string): string { return value @@ -80,37 +85,32 @@ export async function sendMagicLinkEmail( ): Promise { const { text, html } = buildVerifyLinkParts( verifyUrl, - "Open this link to sign in (it expires in 60 minutes):", + "Open this link to log in (it expires in 60 minutes):", "If you did not request this, you can ignore this email.\n\nSent because you saved a draft at communityrule.info", - "Sign in", + "Log in", ); await sendHtmlMail({ to, - subject: "Sign in to Community Rule", + subject: "Log in to Community Rule", text, html, devLog: `[dev] Magic link for ${to}: ${verifyUrl}`, }); } -/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */ +/** Stakeholder invite after finishing a Rule (one-time link, same Mailhog pattern as magic link). */ export async function sendRuleStakeholderInviteEmail( - to: string, - verifyUrl: string, - ruleTitle: string, + input: RuleStakeholderInviteMailInput, ): Promise { - const { text, html } = buildVerifyLinkParts( - verifyUrl, - `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 60 minutes and works once:`, - "If you did not expect this, you can ignore this email.", - "Open the rule", - ); + const { subject, text, html } = buildRuleStakeholderInviteMail(input); + const replyTo = stakeholderInviteReplyTo(input.inviterEmail); await sendHtmlMail({ - to, - subject: `You're invited to view a Community Rule: ${ruleTitle}`, + to: input.to, + subject, text, html, - devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`, + ...(replyTo ? { replyTo } : {}), + devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`, }); } diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts index f6d67ea..a3ae502 100644 --- a/lib/server/publishedRules.ts +++ b/lib/server/publishedRules.ts @@ -224,3 +224,60 @@ export async function listProfileRulesForUser( }); return combined.slice(0, cap); } + +export type StakeholderInviteView = { + ruleId: string; + title: string; + summary: string | null; + document: Record; + updatedAt: string; + inviterEmail: string | null; +}; + +/** + * Published rule an accepted stakeholder may read with invited chrome. + * `userId` is set only after the invite link is used. + */ +export async function getAcceptedStakeholderInviteView(opts: { + userId: string; + ruleId: string; +}): Promise { + if (!isDatabaseConfigured()) return null; + const userId = opts.userId.trim(); + const ruleId = opts.ruleId.trim(); + if (userId.length === 0 || ruleId.length === 0) return null; + try { + const row = await prisma.ruleStakeholder.findFirst({ + where: { ruleId, userId }, + select: { + invitedBy: { select: { email: true } }, + rule: { + select: { + id: true, + title: true, + summary: true, + document: true, + updatedAt: true, + }, + }, + }, + }); + if (!row) return null; + const document = + row.rule.document !== null && + typeof row.rule.document === "object" && + !Array.isArray(row.rule.document) + ? (row.rule.document as Record) + : {}; + return { + ruleId: row.rule.id, + title: row.rule.title, + summary: row.rule.summary, + document, + updatedAt: row.rule.updatedAt.toISOString(), + inviterEmail: row.invitedBy?.email ?? null, + }; + } catch { + return null; + } +} diff --git a/lib/server/ruleStakeholderInviteMail.ts b/lib/server/ruleStakeholderInviteMail.ts new file mode 100644 index 0000000..86b72ed --- /dev/null +++ b/lib/server/ruleStakeholderInviteMail.ts @@ -0,0 +1,271 @@ +import { ASSETS, getAssetPath } from "../assetUtils"; +import { + clipCommunityInviteDescription, + communityInviteInitials, + communityPossessive, + inviteRuleCountsLabel, +} from "../create/ruleInviteCardMeta"; + +export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK); + +/** Letter name when neither the inviter nor the rule owner has an email. */ +export const RULE_STAKEHOLDER_INVITER_FALLBACK = "Someone from this community"; + +const MAILBOX_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Reply-To only when the inviter value is a mailbox. */ +export function stakeholderInviteReplyTo( + inviterEmail: string, +): string | undefined { + const mailbox = inviterEmail.trim().toLowerCase(); + if (!MAILBOX_PATTERN.test(mailbox)) return undefined; + return mailbox; +} + +/** Email addresses stay lowercase; any other label is kept for the letter. */ +export function stakeholderInviteInviterLabel(inviterEmail: string): string { + const replyTo = stakeholderInviteReplyTo(inviterEmail); + if (replyTo) return replyTo; + const trimmed = inviterEmail.trim(); + return trimmed.length > 0 ? trimmed : RULE_STAKEHOLDER_INVITER_FALLBACK; +} + +const PREVIEW_TEXT = + "It covers how they talk, who belongs, how they decide and what happens when they disagree."; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function ruleStakeholderInviteCopyUrl(verifyUrl: string): string { + return `${verifyUrl}${verifyUrl.includes("?") ? "&" : "?"}copy=1`; +} + +export type RuleStakeholderInviteMailInput = { + to: string; + verifyUrl: string; + origin: string; + communityName: string; + inviterEmail: string; + summary?: string | null; + document?: unknown; +}; + +export type RuleStakeholderInviteMailContent = { + subject: string; + previewText: string; + text: string; + html: string; +}; + +export function buildRuleStakeholderInviteMail( + input: RuleStakeholderInviteMailInput, +): RuleStakeholderInviteMailContent { + const communityName = input.communityName.trim() || "A community"; + const inviterEmail = stakeholderInviteInviterLabel(input.inviterEmail); + const origin = input.origin.replace(/\/$/, ""); + const verifyUrl = input.verifyUrl; + const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl); + const possessive = communityPossessive(communityName); + const description = clipCommunityInviteDescription(input.summary); + const initials = communityInviteInitials(communityName); + const countsLabel = inviteRuleCountsLabel(input.document); + const subject = `${communityName} invited you to read their Rule`; + const intro = `${inviterEmail} sent the invitation. Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.`; + const privacy = `Every Rule starts private. Only the people ${communityName} invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.`; + const ownLink = + "Your own link. Forwarding this email shares it. Ask the community to invite anyone else who should read the Rule."; + const copyLead = + "Writing a Rule for another community? Make a copy of this one and change what does not fit."; + const why = `You got this email because ${inviterEmail} added your address to ${possessive} Rule on CommunityRule. If you do not know the community, you can ignore this email.`; + const madeBy = + "CommunityRule is made by the Media Economies Design Lab at the University of Colorado Boulder."; + const privacyUrl = `${origin}/privacy`; + const aboutUrl = `${origin}/about`; + const logoUrl = `${origin}${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`; + + const text = [ + subject, + "", + intro, + "", + "Read the Rule", + verifyUrl, + "", + privacy, + "", + ownLink, + "", + copyLead, + copyUrl, + "", + why, + "", + madeBy, + `Privacy ${privacyUrl}`, + ].join("\n"); + + const html = inviteHtml({ + subject, + intro, + verifyUrl, + copyUrl, + communityName, + description, + initials, + countsLabel, + privacy, + ownLink, + copyLead, + why, + madeBy, + privacyUrl, + aboutUrl, + logoUrl, + }); + + return { subject, previewText: PREVIEW_TEXT, text, html }; +} + +function inviteHtml(opts: { + subject: string; + intro: string; + verifyUrl: string; + copyUrl: string; + communityName: string; + description: string; + initials: string; + countsLabel: string; + privacy: string; + ownLink: string; + copyLead: string; + why: string; + madeBy: string; + privacyUrl: string; + aboutUrl: string; + logoUrl: string; +}): string { + const heading = escapeHtml(opts.subject); + const intro = escapeHtml(opts.intro); + const verifyHref = escapeHtml(opts.verifyUrl); + const copyHref = escapeHtml(opts.copyUrl); + const name = escapeHtml(opts.communityName); + const description = escapeHtml(opts.description); + const initials = escapeHtml(opts.initials); + const counts = escapeHtml(opts.countsLabel); + const privacyHtml = privacyHtmlWithLead(opts.privacy); + const ownLinkHtml = leadInHtml("Your own link.", opts.ownLink); + const copyHtml = + `Writing a Rule for another community?` + + ` Make a copy` + + ` of this one and change what does not fit.`; + const why = escapeHtml(opts.why); + const madeBy = escapeHtml(opts.madeBy); + const privacyUrl = escapeHtml(opts.privacyUrl); + const aboutUrl = escapeHtml(opts.aboutUrl); + const logoUrl = escapeHtml(opts.logoUrl); + const cardBody = description + ? `

${description}

` + : ""; + + return ` + + + + +${heading} + + +
${escapeHtml( + PREVIEW_TEXT, + )}
+ + + + +
+ + + + + + + +
+ + + + + +
CommunityRule + +
+
+

${heading}

+

${intro}

+

+ Read the Rule +

+

Or paste this link into a browser

+

+ ${verifyHref} +

+ + + + +
+ + + + + +
+
${initials}
+
${name}
+ ${cardBody} +

+ ${counts} +

+
+
+

${privacyHtml}

+

${ownLinkHtml}

+

${copyHtml}

+
+

${why}

+

${madeBy}

+

+ Privacy +    + About CommunityRule +

+
+
+
+ +`; +} + +function privacyHtmlWithLead(privacy: string): string { + const lead = "Every Rule starts private."; + if (privacy.startsWith(lead)) { + return `${escapeHtml(lead)}${escapeHtml( + privacy.slice(lead.length), + )}`; + } + return escapeHtml(privacy); +} + +function leadInHtml(lead: string, full: string): string { + if (full.startsWith(lead)) { + return `${escapeHtml(lead)}${escapeHtml( + full.slice(lead.length), + )}`; + } + return escapeHtml(full); +} diff --git a/lib/server/ruleStakeholderInviteOps.ts b/lib/server/ruleStakeholderInviteOps.ts index 91516da..946ca50 100644 --- a/lib/server/ruleStakeholderInviteOps.ts +++ b/lib/server/ruleStakeholderInviteOps.ts @@ -18,8 +18,11 @@ export async function createRuleStakeholderInviteAndSendMail(opts: { origin: string; ruleId: string; ruleTitle: string; + summary?: string | null; + document?: unknown; email: string; invitedByUserId: string; + inviterEmail: string; pepper: string; }): Promise<{ ok: true } | { ok: false }> { const token = newSessionToken(); @@ -38,7 +41,15 @@ export async function createRuleStakeholderInviteAndSendMail(opts: { const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token); try { - await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle); + await sendRuleStakeholderInviteEmail({ + to: opts.email, + verifyUrl, + origin: opts.origin, + communityName: opts.ruleTitle, + inviterEmail: opts.inviterEmail, + summary: opts.summary, + document: opts.document, + }); return { ok: true }; } catch (err) { logRouteError(opts.scope, opts.requestId, err, { diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 56e027b..52b8c40 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -223,6 +223,25 @@ export const postRuleStakeholderBodySchema = z.object({ email: stakeholderEmailSchema, }); +export const makeCopyRuleBodySchema = z.object({ + title: z + .string() + .max(48) + .transform((s) => s.trim()) + .refine((s) => s.length > 0, { message: "title required" }), +}); + +export type MakeCopyRuleBody = z.infer; + +export const resendExpiredStakeholderInviteBodySchema = z.object({ + token: z.string().min(10).max(200), + email: stakeholderEmailSchema, +}); + +export type ResendExpiredStakeholderInviteBody = z.infer< + typeof resendExpiredStakeholderInviteBodySchema +>; + export type PostRuleStakeholderBody = z.infer< typeof postRuleStakeholderBodySchema >; diff --git a/messages/en/components/controlsChrome.json b/messages/en/components/controlsChrome.json index d60b4a9..cd14c12 100644 --- a/messages/en/components/controlsChrome.json +++ b/messages/en/components/controlsChrome.json @@ -7,6 +7,7 @@ "chipClose": "Close", "chipSelect": "Select {label}", "chipDeselect": "Deselect {label}", + "chipMissingWords": "{label}, missing words", "selectPlaceholder": "Choose an option", "selectAriaLabel": "Select an option", "multiSelectAddDefault": "Add organization type", diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json index eb0c060..644239c 100644 --- a/messages/en/create/footer.json +++ b/messages/en/create/footer.json @@ -12,7 +12,7 @@ "confirmDetails": "Confirm details", "confirmDescription": "Confirm description", "confirmSize": "Confirm size", - "publishCommunityRule": "Publish CommunityRule", + "publishCommunityRule": "Finish", "confirmStakeholders": "Confirm Stakeholders", "confirmCoreValues": "Confirm values", "confirmCommunication": "Confirm", diff --git a/messages/en/create/reviewAndComplete/completed.json b/messages/en/create/reviewAndComplete/completed.json index 51c7274..43872d3 100644 --- a/messages/en/create/reviewAndComplete/completed.json +++ b/messages/en/create/reviewAndComplete/completed.json @@ -1,8 +1,14 @@ { - "toastTitle": "This is what folks see when you share your CommunityRule", - "toastDescription": "Your group can use this document as an operating manual.", + "toastTitle": "Your Rule is finished", + "toastDescription": "This is what the people you invite will see.", + "copyReadyToastTitle": "Your copy is ready", + "copyReadyToastDescription": "It started from {possessive} Rule. Read it through and change what does not fit before you invite your members.", + "copyNameHitTitle": "One line names {name}", + "copyNameHitDescription": "It is in {entry} under {category}.", + "askOrganizerLead": "Has your Rule changed how your community works?", + "askOrganizerLink": "Tell an organizer what changed.", "shareNoRuleTitle": "Nothing to share yet", - "shareNoRuleDescription": "Open this page from your profile after publishing, or publish a rule first.", + "shareNoRuleDescription": "Open this page from your profile after you finish, or finish a rule first.", "shareLinkCopiedTitle": "Link copied", "shareLinkCopiedDescription": "Paste the link anywhere to share your public CommunityRule.", "shareCopyFailedTitle": "Could not copy link", @@ -16,7 +22,7 @@ "exportEmptyDocumentTitle": "Could not export", "exportEmptyDocumentDescription": "Your rule document is not available to export yet.", "guestInvitesSkippedTitle": "Stakeholder invites were not sent", - "guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.", - "guestClaimHintTitle": "Sign in to keep this rule on your profile", - "guestClaimHintDescription": "Sign in on this same browser to attach the rule to your account so you can edit it later." + "guestInvitesSkippedDescription": "Log in to invite people by email. Until then, share the public link to this CommunityRule.", + "guestClaimHintTitle": "Log in to keep this rule on your profile", + "guestClaimHintDescription": "Log in on this same browser to attach the rule to your account so you can edit it later." } diff --git a/messages/en/create/reviewAndComplete/confirmStakeholders.json b/messages/en/create/reviewAndComplete/confirmStakeholders.json index 27324bb..6bd8c93 100644 --- a/messages/en/create/reviewAndComplete/confirmStakeholders.json +++ b/messages/en/create/reviewAndComplete/confirmStakeholders.json @@ -1,25 +1,44 @@ { - "title": "Do other stakeholders need to be involved in creating your community?", - "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!", - "invalidEmail": "Enter a valid email address.", + "title": "Invite people to read your Rule", + "description": "Everyone you add gets a private link to read the Rule.", + "fromYourRule": "Who belongs, in your Rule’s words", + "whoBelongsLabel": "Who belongs.", + "joiningLabel": "Joining.", + "emailLabel": "Add someone by email", + "emailPlaceholder": "name@example.org", + "add": "Add", + "you": "You", + "removeAria": "Remove {email}", + "showMore": "Show {count} more", + "countOne": "1 person gets a private link to read the Rule.", + "countMany": "{count} people get a private link to read the Rule.", + "privacy": "Every Rule starts private. Only the people you invite can read it until the person who holds the pen turns on a link.", + "finishWithoutInviting": "Finish without inviting", + "sendInvitations": "Send invitations", + "invalidEmail": "Not an email address. Fix it and select Add, or clear it.", "duplicateEmail": "That email is already on the list.", - "maxStakeholders": "You can add up to 30 stakeholder emails.", + "maxStakeholders": "You can add up to 30 emails.", + "pasteAdded": "Added {count} {people} from your list.", + "pasteInvalidOne": "One is not an email address.", + "pasteInvalidMany": "{count} are not email addresses.", + "peopleNounOne": "person", + "peopleNounMany": "people", + "sendFailedTitle": "Some invitations didn’t send", + "sendFailedDescription": "Check the addresses and try again", "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", + "lockupTitle": "Invite people to read your Rule", + "lockupDescription": "Everyone you add gets a private link to read the Rule.", + "emailLabel": "Add someone by email", + "emailPlaceholder": "name@example.org", + "addInvite": "Add", "loading": "Loading…", - "loadFailed": "Could not load stakeholders. Refresh and try again.", + "loadFailed": "Could not load the list. Refresh and try again.", "pending": "Pending", "accepted": "Accepted", "remove": "Remove", "resend": "Resend invite", - "empty": "No stakeholders yet.", - "invalidEmail": "Enter a valid email address.", + "empty": "No one invited yet.", + "invalidEmail": "Not an email address. Fix it and select Add, or clear it.", "actionFailed": "Something went wrong. Try again.", "rateLimited": "Too many requests. Try again in {seconds} seconds.", "removeAria": "Remove {email}", diff --git a/messages/en/create/reviewAndComplete/finalReview.json b/messages/en/create/reviewAndComplete/finalReview.json index ee0cde5..59a6508 100644 --- a/messages/en/create/reviewAndComplete/finalReview.json +++ b/messages/en/create/reviewAndComplete/finalReview.json @@ -1,9 +1,15 @@ { - "title": "Review your CommunityRule", - "description": "Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again.", + "title": "Review your Rule", + "description": "Here's what other people will see.", "editPublishedTitle": "Edit your CommunityRule", - "editPublishedDescription": "Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule.", + "editPublishedDescription": "Update what others see on your Rule. Save & Exit or Finish applies the changes.", "ruleCardTitleFallback": "Your community", + "addValue": "Add value", + "addCard": "Add card", + "flags": { + "noValues": "No values yet. You can add them any time.", + "noCard": "No card yet. Add one before you finish." + }, "chipEditModal": { "saveButton": "Save", "readOnlyCloseButton": "Close", @@ -35,7 +41,7 @@ }, { "name": "Membership", - "chips": ["Open Admission"] + "chips": ["Open admission"] }, { "name": "Decision-making", diff --git a/messages/en/create/reviewAndComplete/publish.json b/messages/en/create/reviewAndComplete/publish.json index 34362e9..94ff407 100644 --- a/messages/en/create/reviewAndComplete/publish.json +++ b/messages/en/create/reviewAndComplete/publish.json @@ -1,6 +1,6 @@ { - "publishBannerTitle": "Couldn't publish", - "missingCommunityName": "Add a community name before publishing.", - "publishButtonPublishing": "Publishing…", + "publishBannerTitle": "Couldn't finish", + "missingCommunityName": "Add a community name before you finish.", + "publishButtonPublishing": "Finishing…", "genericPublishFailed": "Something went wrong. Try again." } diff --git a/messages/en/create/upload.json b/messages/en/create/upload.json index 2e46eb0..244c14e 100644 --- a/messages/en/create/upload.json +++ b/messages/en/create/upload.json @@ -2,7 +2,7 @@ "errors": { "generic": "Something went wrong while uploading. Try again.", "tooLarge": "That file is too large. Community photos can be up to 5 MB; attachments up to 10 MB.", - "unauthorized": "Sign in to upload files. Use Save progress if you started without an account.", + "unauthorized": "Log in to upload files. Use Save progress if you started without an account.", "misconfigured": "Uploads are not available on this server yet.", "empty": "That file is empty. Choose a file that has content.", "svg": "SVG files aren't supported. Export a JPEG, PNG, WebP, or GIF instead.", diff --git a/messages/en/index.ts b/messages/en/index.ts index 42e82ec..04f350b 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -34,6 +34,7 @@ import login from "./pages/login.json"; import profile from "./pages/profile.json"; import notFoundPage from "./pages/notFoundPage.json"; import ruleDetail from "./pages/ruleDetail.json"; +import invite from "./pages/invite.json"; import navigation from "./navigation.json"; import metadata from "./metadata.json"; import modalsShare from "./modals/share.json"; @@ -114,6 +115,7 @@ export default { profile, notFoundPage, ruleDetail, + invite, }, create: { community: { diff --git a/messages/en/metadata.json b/messages/en/metadata.json index 21d2de0..4a9a967 100644 --- a/messages/en/metadata.json +++ b/messages/en/metadata.json @@ -50,6 +50,14 @@ "title": "Log in", "description": "Enter your email and we'll send you a magic link to sign in. No password needed!" }, + "invite": { + "title": "Invitation", + "description": "Read a CommunityRule you were invited to see." + }, + "inviteExpired": { + "title": "This link has expired", + "description": "Ask for a new invitation link to read the Rule." + }, "profile": { "title": "Profile", "description": "Manage your CommunityRules, saved progress, and account." @@ -157,8 +165,8 @@ "keywords": ["terms of service", "license", "communityrule"] }, "cookies": { - "title": "Cookies Settings", - "description": "CommunityRule uses cookies to keep you signed in and to let you claim a rule you published without an account.", + "title": "Cookies", + "description": "CommunityRule uses cookies to keep you logged in and to let you claim a rule you finished without logging in.", "keywords": ["cookies", "session", "privacy", "communityrule"] } } diff --git a/messages/en/pages/cookies.json b/messages/en/pages/cookies.json index 7416759..f26d42d 100644 --- a/messages/en/pages/cookies.json +++ b/messages/en/pages/cookies.json @@ -1,21 +1,21 @@ { - "_comment": "Cookies Settings. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle.", + "_comment": "Cookies. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle. The footer does not link here.", "banner": { - "title": "Cookies Settings", - "description": "CommunityRule uses cookies to keep you signed in and to let you claim a rule you published without an account.", + "title": "Cookies", + "description": "CommunityRule uses cookies to keep you logged in and to let you claim a rule you finished without logging in.", "author": "Media Economies Design Lab", "date": "2026-08-15" }, "updated": "Last updated August 15, 2026.", "intro": [ - "CommunityRule sets a cookie named cr_session after you sign in with a magic link. It keeps you signed in for up to 30 days. If you publish a CommunityRule without signing in, we also set cr_rule_claim so that signing in later on this browser can attach that rule to your account. There are no advertising or analytics cookies to turn off." + "CommunityRule sets a cookie named cr_session after you log in with a magic link. It keeps you logged in for up to 30 days. Set when you finish a Rule without logging in: we also set cr_rule_claim so that logging in later on this browser can attach that rule to your account. There are no advertising or analytics cookies to turn off." ], "sections": [ { "heading": "The session cookie", "paragraphs": [ - "Sign out to clear the session cookie on this browser. Blocking it will sign you out and keep you from staying signed in. Browsing templates does not set this cookie.", - "The claim cookie is set only when you publish without an account. It is httpOnly, lasts up to 30 days, and is cleared after a successful sign-in claim. Clearing this site’s cookies means you cannot attach that guest-published rule to an account from this browser." + "Log out to clear the session cookie on this browser. Blocking it will log you out and keep you from staying logged in. Browsing templates does not set this cookie.", + "Set when you finish a Rule without logging in. It is httpOnly, lasts up to 30 days, and is cleared after a successful login claim. Clearing this site’s cookies means you cannot attach that guest-finished rule to an account from this browser." ] }, { diff --git a/messages/en/pages/invite.json b/messages/en/pages/invite.json new file mode 100644 index 0000000..c7afd8c --- /dev/null +++ b/messages/en/pages/invite.json @@ -0,0 +1,50 @@ +{ + "_comment": "Invited Rule reader, Make a copy, and expired invitation link.", + "makeACopy": "Make a copy", + "makeACopyAria": "Make a copy of this Rule", + "navAria": "Invitation", + "bannerTitle": "{email} invited you to read this Rule", + "bannerTitleNoEmail": "You were invited to read this Rule", + "bannerDescription": "Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.", + "downloadPdf": "Download PDF", + "print": "Print", + "factsHeading": "About this Rule", + "whoCanEdit": "Who can edit", + "whoCanEditUnknown": "The person who invited you", + "whoCanSee": "Who can see it", + "whoCanSeeValue": "Only people the community invites", + "lastChanged": "Last changed", + "copyCardTitle": "Start a Rule from this one", + "copyCardBody": "You get its {counts} word for word. The name, description, photo, invited people and history stay with the original.", + "privacy": "Every Rule starts private. Only the people the community invites can read it until the person who holds the pen turns on a link.", + "exportFailedTitle": "Could not export", + "exportFailedDescription": "Something went wrong. Refresh the page and try again.", + "dialogTitle": "Make a copy for your community", + "dialogDescription": "Your copy starts with {possessive} {counts} word for word.", + "communityNameLabel": "Community name", + "communityNamePlaceholder": "Enter community name", + "characterCountTemplate": "{current}/{max}", + "comesWithTitle": "Comes with the copy", + "comesWithBody": "The cards and the text on them.", + "staysWithTitle": "Stays with the original", + "staysWithBody": "The name, description, photo, invited people and history.", + "saveTo": "The copy saves to {email}.", + "cancel": "Cancel", + "confirm": "Make a copy", + "copyFailed": "Could not make a copy. Try again.", + "nameRequired": "Add your community’s name to continue.", + "expiredTitle": "This link has expired", + "expiredDescription": "Enter the email the invitation was sent to. We’ll send a new link that works for 60 minutes.", + "emailLabel": "Email address", + "emailPlaceholder": "you@example.com", + "sendNewLink": "Send a new link", + "notInvitedAddress": "Not the address you were invited at?", + "backToHome": "← Back to home", + "sentTitle": "Check your email", + "sentBody": "If that address still has an invitation, we sent a new link. Open it within 60 minutes.", + "emailInvalid": "Enter a valid email address.", + "genericError": "Something went wrong. Try again.", + "rateLimited": "Too many requests. Try again in {seconds} seconds.", + "missingToken": "This page needs the original invitation link. Ask the person who invited you to send it again.", + "loadingFallback": "Loading…" +} diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index 9fb560d..eff0895 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -1,16 +1,19 @@ { "title": "Log in to CommunityRule", - "subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!", + "subtitle": "Enter your email and we'll send you a magic link to log in. No password needed!", "saveProgressTitle": "Save your progress?", - "saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.", - "keepRuleTitle": "Sign in to keep this rule on your profile", + "saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to log in. If you don't save now you could lose your progress.", + "keepRuleTitle": "Log in to keep this rule on your profile", "keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.", + "inviteTitle": "Log in to invite your members", + "inviteSubtitle": "Only a logged-in person can invite. Enter your email and we’ll send you a magic link to log in.", + "backToYourRule": "← Back to your Rule", "continueWithoutSaving": "Continue without saving", "emailLabel": "Email address", "emailPlaceholder": "you@example.com", "sendMagicLink": "Send me a magic link", "successTitle": "Check your email", - "successBody": "We sent a sign-in link. Open it on this device to continue. If you don't see it, check spam or promotions.", + "successBody": "We sent a login link. Open it on this device to continue. If you don't see it, check spam or promotions.", "legalPrefix": "By continuing, you agree to our ", "legalAnd": " and ", "legalSuffix": ".", @@ -21,9 +24,9 @@ "emailInvalid": "Enter a valid email address.", "rateLimited": "Too many requests. Try again in {seconds} seconds.", "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.", + "invalidLink": "That login link is not valid. Request a new one from the login page.", + "expiredLink": "That login link has expired. Request a new one from the login page.", "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." + "stakeholderWrongAccount": "Log out and open the invite link again, or use the same email you were invited with." } } diff --git a/messages/en/pages/privacy.json b/messages/en/pages/privacy.json index 5d58890..6dd10c6 100644 --- a/messages/en/pages/privacy.json +++ b/messages/en/pages/privacy.json @@ -14,14 +14,14 @@ { "heading": "What we collect", "paragraphs": [ - "Your email, to sign you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account. If you publish without signing in, a claim cookie on this browser so a later sign-in can attach that Rule to your account.", + "Your email, to log you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account. If you finish a Rule without logging in, a claim cookie on this browser so a later login can attach that Rule to your account.", "We do not sell this information or use it for advertising. As a public university project, some records may be subject to the Colorado Open Records Act." ] }, { "heading": "Where it is stored", "paragraphs": [ - "Accounts, drafts, published Rules, uploads, and the recommendation table live on servers MEDLab operates. The app is not hosted on a third-party cloud app platform.", + "Accounts, drafts, finished Rules, uploads, and the recommendation table live on servers MEDLab operates. The app is not hosted on a third-party cloud app platform.", "Sign-in and invite emails are sent through MEDLab’s mail system, which relays through Amazon SES to deliver them. Ordinary server logs may include the request URL and IP address." ] }, @@ -35,7 +35,7 @@ { "heading": "Your account", "paragraphs": [ - "You can sign out, delete a Rule you published, or delete your account from your profile. Deleting your account removes your login and draft. Published Rules are public and stay public unless you delete them first.", + "You can log out, delete a Rule you finished, or delete your account from your profile. Logging out clears the session cookie on this browser. Deleting your account deletes every Rule you made. The people you invited lose access to them.", "Questions: [medlab@colorado.edu](mailto:medlab@colorado.edu)." ] } diff --git a/messages/en/pages/profile.json b/messages/en/pages/profile.json index 5dd5377..81ba486 100644 --- a/messages/en/pages/profile.json +++ b/messages/en/pages/profile.json @@ -1,21 +1,23 @@ { "pageTitle": "Your profile", "welcomeTitle": "Congrats {{name}}!", - "welcomeBodyFirstRule": "You’ve made your first CommunityRule. Make sure to link it in your community resources and evolve it as your group grows and changes.", + "welcomeBodyFirstRule": "Invite the people in your community to read it.", "welcomeBodyNoRules": "Create your first CommunityRule to capture how your group makes decisions—then link it from your community resources as you grow.", - "yourOptionsHeading": "Your Options", + "yourOptionsHeading": "Your options", "optionCreateCustom": "Create new custom Rule", "optionCreateTemplate": "Create new Rule from template", "optionLogout": "Log out of CommunityRule", "optionChangeEmail": "Change your account email", "intro": "Manage your CommunityRules, saved progress, and account.", - "signInPrompt": "Sign in to view your profile and saved rules.", + "signInPrompt": "Log in to view your profile and saved rules.", "signInCta": "Log in", "loading": "Loading…", - "yourRulesHeading": "Your CommunityRules", - "yourRulesEmpty": "You have not published a rule yet. Create one to see it here.", + "yourRulesHeading": "Your Rules", + "yourRulesEmpty": "You have not finished a rule yet. Create one to see it here.", "draftHeading": "Saved in progress", - "draftInProgressBadge": "In progress", + "draftInProgressBadge": "Draft", + "finishedBadge": "Finished", + "inviteMembers": "Invite your members", "continueDraft": "Continue", "createHeading": "Create", "createCustomDescription": "Start a new custom rule from the beginning.", @@ -26,7 +28,7 @@ "manageRule": "Manage", "duplicate": "Duplicate", "deleteRule": "Delete", - "deleteRuleConfirm": "Delete this published rule? This cannot be undone.", + "deleteRuleConfirm": "Delete this finished rule? This cannot be undone.", "deleteRuleModalTitle": "Delete this rule?", "deleteRuleModalBody": "This cannot be undone.", "deleteRuleCancel": "Cancel", @@ -50,13 +52,13 @@ "emailChangeVerifyInvalid": "That confirmation link is not valid.", "emailChangeVerifyTaken": "That address was claimed by another account while you were confirming. Try a different email.", "emailChangeRateLimited": "Too many requests. Try again in about {{seconds}} seconds.", - "signOut": "Sign out", + "signOut": "Log out", "deleteAccount": "Delete account", - "deleteAccountIntro": "Permanently delete your account and sign-in data. Your published rules will remain visible without an owner name.", + "deleteAccountIntro": "This deletes your account and every Rule you made. The people you invited lose access to them. Export any Rule you want to keep first.", "deleteAccountCancel": "Cancel", "deleteAccountConfirm": "Delete my account", "deleteAccountModalTitle": "Delete your account?", - "deleteAccountModalBody": "This will remove your account and sign you out. Published rules you created stay public as anonymous community rules.", + "deleteAccountModalBody": "This deletes your account and every Rule you made. The people you invited lose access to them. Export any Rule you want to keep first.", "actionError": "Something went wrong. Try again.", "rulesLoadBannerTitle": "Could not load your CommunityRules", "rulesLoadBannerDescription": "Check your connection and refresh the page.", diff --git a/public/assets/logos/community-rule-mark.png b/public/assets/logos/community-rule-mark.png new file mode 100644 index 0000000..cbaed36 Binary files /dev/null and b/public/assets/logos/community-rule-mark.png differ diff --git a/stories/pages/ConfirmStakeholdersPage.stories.js b/stories/pages/ConfirmStakeholdersPage.stories.js index 85c2d0c..31ca160 100644 --- a/stories/pages/ConfirmStakeholdersPage.stories.js +++ b/stories/pages/ConfirmStakeholdersPage.stories.js @@ -1,14 +1,14 @@ import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen"; export default { - title: "Pages/Create Flow/Confirm stakeholders", + title: "Pages/Create Flow/Invite your members", component: ConfirmStakeholdersScreen, parameters: { layout: "fullscreen", docs: { description: { component: - "Stacked lockup + MultiSelect; draft congratulations banner; before final review.", + "Invite people to read the Rule after final review. Membership card beside the email list; Finish without inviting and Send invitations in the footer.", }, }, }, diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx index 5bfb662..787b63b 100644 --- a/tests/components/Chip.test.tsx +++ b/tests/components/Chip.test.tsx @@ -64,4 +64,14 @@ describe("Chip", () => { expect(chip).toHaveAttribute("aria-pressed", "true"); expect(chip.querySelector("svg[aria-hidden]")).not.toBeNull(); }); + + it("shows a warning icon and names the missing words", () => { + render( + , + ); + const chip = screen.getByRole("button", { + name: "Signal, missing words", + }); + expect(chip.querySelector("svg")).not.toBeNull(); + }); }); diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx index 6e0dc01..ee91968 100644 --- a/tests/components/CompletedPage.test.tsx +++ b/tests/components/CompletedPage.test.tsx @@ -67,7 +67,7 @@ describe("CompletedScreen", () => { expect(screen.queryByText("Values")).not.toBeInTheDocument(); }); - it("does not pin the title on small screens", () => { + it("keeps the heading in place while the Rule column scrolls", () => { sessionStorage.setItem( CREATE_FLOW_LAST_PUBLISHED_KEY, JSON.stringify(storedRuleFixture), @@ -78,11 +78,25 @@ describe("CompletedScreen", () => { }); const column = heading.closest("div[class*='md:justify-center']"); expect(column).toBeTruthy(); - expect(column.className).not.toMatch(/\bsticky\b/); + expect(column.className).toMatch(/\bshrink-0\b/); const ruleColumn = screen .getByText("Fixture value title") - .closest("div[class*='md:overflow-y-auto']"); - expect(ruleColumn?.className).toMatch(/max-md:shrink-0/); + .closest("div[class*='overflow-y-auto']"); + expect(ruleColumn?.className).toMatch(/overflow-y-auto/); + }); + + it("offers Ask an organizer under the finished Rule heading", () => { + sessionStorage.setItem( + CREATE_FLOW_LAST_PUBLISHED_KEY, + JSON.stringify(storedRuleFixture), + ); + render(); + expect( + screen.getByText("Has your Rule changed how your community works?"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Tell an organizer what changed." }), + ).toBeInTheDocument(); }); it("renders header and document from sessionStorage", () => { @@ -106,16 +120,34 @@ describe("CompletedScreen", () => { render(); expect( screen.queryByText( - "This is what folks see when you share your CommunityRule", + "Your Rule is finished", ), ).not.toBeInTheDocument(); expect( screen.queryByText( - "Your group can use this document as an operating manual.", + "This is what the people you invite will see.", ), ).not.toBeInTheDocument(); }); + it("shows copy-ready toast when copied query is set", () => { + sessionStorage.setItem( + CREATE_FLOW_LAST_PUBLISHED_KEY, + JSON.stringify({ + ...storedRuleFixture, + copiedFromTitle: "Juniper Street Food Share", + }), + ); + mockSearchParams({ copied: "1" }); + render(); + expect(screen.getByText("Your copy is ready")).toBeInTheDocument(); + expect( + screen.getByText( + "It started from Juniper Street Food Share’s Rule. Read it through and change what does not fit before you invite your members.", + ), + ).toBeInTheDocument(); + }); + it("shows post-publish toast in status region when celebrate query is set", () => { mockSearchParams({ [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: @@ -123,20 +155,16 @@ describe("CompletedScreen", () => { }); render(); expect( - screen.getByText( - "This is what folks see when you share your CommunityRule", - ), + screen.getByText("Your Rule is finished"), ).toBeInTheDocument(); expect( - screen.getByText( - "Your group can use this document as an operating manual.", - ), + screen.getByText("This is what the people you invite will see."), ).toBeInTheDocument(); const statusRegions = screen.getAllByRole("status"); expect(statusRegions.length).toBeGreaterThanOrEqual(1); expect( statusRegions.some((el) => - el.textContent?.includes("This is what folks see when you share"), + el.textContent?.includes("Your Rule is finished"), ), ).toBe(true); }); diff --git a/tests/components/ConfirmStakeholdersPage.test.tsx b/tests/components/ConfirmStakeholdersPage.test.tsx index a878344..8428235 100644 --- a/tests/components/ConfirmStakeholdersPage.test.tsx +++ b/tests/components/ConfirmStakeholdersPage.test.tsx @@ -1,42 +1,128 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useEffect } from "react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen"; +import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; +import type { CreateFlowState } from "../../app/(app)/create/types"; + +vi.mock("../../lib/create/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchAuthSession: vi.fn(async () => ({ + user: { id: "u1", email: "maya@example.org" }, + })), + }; +}); + +function SeedCreateFlow({ patch }: { patch: Partial }) { + const { updateState } = useCreateFlow(); + useEffect(() => { + updateState(patch); + }, [patch, updateState]); + return null; +} describe("ConfirmStakeholdersScreen", () => { - it("renders title and description", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the invite heading and email field", () => { render(); expect( screen.getByRole("heading", { - name: /Do other stakeholders need to be involved/i, + name: "Invite people to read your Rule", }), ).toBeInTheDocument(); + expect( + screen.getByText("Everyone you add gets a private link to read the Rule."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add" }), + ).toBeInTheDocument(); expect( screen.getByText( - /Add their email addresses\. When you publish, we'll send each person a one-time link/i, + /Every Rule starts private\. Only the people you invite can read it/, ), ).toBeInTheDocument(); }); - it("renders Add stakeholder control", () => { + it("does not show the old draft toast", () => { render(); expect( - screen.getByRole("button", { name: "Add stakeholder" }), - ).toBeInTheDocument(); - }); - - it("shows draft toast and can dismiss it", async () => { - const user = userEvent.setup(); - render(); - expect( - screen.getByText(/Congratulations! You've drafted your CommunityRule!/i), - ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Close" })); - expect( - screen.queryByText( - /Congratulations! You've drafted your CommunityRule!/i, - ), + screen.queryByText(/Congratulations! You've drafted your CommunityRule!/i), ).not.toBeInTheDocument(); }); + + it("adds an email and shows the owner as You", async () => { + const user = userEvent.setup(); + render(); + expect(await screen.findByText("maya@example.org")).toBeInTheDocument(); + expect(screen.getByText("You")).toBeInTheDocument(); + + await user.type( + screen.getByRole("textbox", { name: /Add someone by email/i }), + "rosa@example.org", + ); + await user.click(screen.getByRole("button", { name: "Add" })); + expect(screen.getByText("rosa@example.org")).toBeInTheDocument(); + expect( + screen.getByText("2 people get a private link to read the Rule."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Remove maya@example.org/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Remove rosa@example.org/i }), + ).toBeInTheDocument(); + }); + + it("keeps a pasted invalid address in the field and reports what was added", async () => { + const user = userEvent.setup(); + render(); + const field = await screen.findByRole("textbox", { + name: /Add someone by email/i, + }); + field.focus(); + await user.paste("rosa@example.org, jo@example, sam@example.org"); + expect(await screen.findByText("rosa@example.org")).toBeInTheDocument(); + expect(screen.getByText("sam@example.org")).toBeInTheDocument(); + expect(field).toHaveValue("jo@example"); + expect( + screen.getByText( + /Added two people from your list\. One is not an email address\./, + ), + ).toBeInTheDocument(); + expect( + screen.getByText("Not an email address. Fix it and select Add, or clear it."), + ).toBeInTheDocument(); + }); + + it("shows the membership card from the selected method", async () => { + render( + <> + + + , + ); + expect(await screen.findByText("Open admission")).toBeInTheDocument(); + expect(screen.getByText("Who belongs, in your Rule’s words")).toBeInTheDocument(); + expect( + screen.getByText(/Anyone who shares the work belongs/), + ).toBeInTheDocument(); + }); }); diff --git a/tests/components/ExpiredInviteScreen.test.tsx b/tests/components/ExpiredInviteScreen.test.tsx new file mode 100644 index 0000000..74afa48 --- /dev/null +++ b/tests/components/ExpiredInviteScreen.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderWithProviders as render, screen } from "../utils/test-utils"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/vitest"; +import { ExpiredInviteScreen } from "../../app/(app)/invite/_components/ExpiredInviteScreen"; + +const { navMock, resendMock } = vi.hoisted(() => ({ + navMock: { + searchParams: new URLSearchParams("token=abcdefghijkl"), + push: vi.fn(), + }, + resendMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: navMock.push, + replace: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + }), + useSearchParams: () => navMock.searchParams, + usePathname: () => "/invite/expired", +})); + +vi.mock("../../lib/create/api", () => ({ + resendExpiredStakeholderInvite: (...args: unknown[]) => resendMock(...args), +})); + +describe("ExpiredInviteScreen", () => { + beforeEach(() => { + navMock.searchParams = new URLSearchParams("token=abcdefghijkl"); + navMock.push.mockReset(); + resendMock.mockReset(); + resendMock.mockResolvedValue({ ok: true }); + }); + + it("asks for the invited email and sends a new link", async () => { + const user = userEvent.setup(); + render(); + + expect( + screen.getByRole("heading", { name: "This link has expired" }), + ).toBeInTheDocument(); + await user.type( + screen.getByLabelText("Email address"), + "inv@example.com", + ); + await user.click( + screen.getByRole("button", { name: "Send a new link" }), + ); + + expect(resendMock).toHaveBeenCalledWith( + "abcdefghijkl", + "inv@example.com", + ); + expect( + await screen.findByRole("heading", { name: "Check your email" }), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 25b9237..6bf107d 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -14,7 +14,9 @@ import { clearCoreValueDetailsLocalStorage } from "../../app/(app)/create/utils/ import type { CreateFlowState } from "../../app/(app)/create/types"; function catalogChipName(label: string) { - return new RegExp(`^(Select|Deselect) ${label}$`); + return new RegExp( + `^((Select|Deselect) ${label}|${label}, missing words)$`, + ); } async function confirmDiscardCustomizeEdits() { @@ -75,7 +77,7 @@ describe("FinalReviewScreen", () => { render(); expect( screen.getByRole("heading", { - name: "Review your CommunityRule", + name: "Review your Rule", }), ).toBeInTheDocument(); }); @@ -83,9 +85,7 @@ describe("FinalReviewScreen", () => { it("renders lockup description", () => { render(); expect( - screen.getByText( - /Here's what other people will see. Make sure everything looks good before you publish. Once the rule is published, you must use one of your decision-making mechanisms to edit it again./i, - ), + screen.getByText("Here's what other people will see."), ).toBeInTheDocument(); }); @@ -182,11 +182,19 @@ describe("FinalReviewScreen", () => { expect(screen.getByText("Conflict management")).toBeInTheDocument(); }); - it("renders category chips", () => { + it("shows add chips and flags when no cards or values are picked", () => { render(); - expect(screen.getByText("Consciousness")).toBeInTheDocument(); - expect(screen.getByText("Signal")).toBeInTheDocument(); - expect(screen.getByText("Open Admission")).toBeInTheDocument(); + expect( + screen.getByText("No values yet. You can add them any time."), + ).toBeInTheDocument(); + expect( + screen.getAllByText("No card yet. Add one before you finish.").length, + ).toBe(4); + expect( + screen.getByRole("button", { name: "Add value" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Add card" })).toHaveLength(4); + expect(screen.queryByText("Consciousness")).not.toBeInTheDocument(); }); }); @@ -225,10 +233,10 @@ describe("FinalReviewScreen — prefilled selections", () => { expect(screen.getByText("Lazy consensus")).toBeInTheDocument(); expect(screen.getByText("Conflict ladder")).toBeInTheDocument(); - // Demo chips from `finalReview.json` must not leak through once the - // user has real selections. The fallback still says "Open Admission". - expect(screen.queryByText("Open Admission")).not.toBeInTheDocument(); expect(screen.queryByText("Consciousness")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Signal, missing words" }), + ).toBeInTheDocument(); }); it("warns when binding arbitration sits next to a join that records no yes", async () => { @@ -1288,20 +1296,20 @@ describe("FinalReviewScreen — edit published title and description", () => { ).toBeInTheDocument(); expect( screen.getByText( - /Update what others see on your public rule. Save & Exit or Publish applies changes to your published CommunityRule./i, + /Update what others see on your Rule. Save & Exit or Finish applies the changes./i, ), ).toBeInTheDocument(); }); - it("does not expose click-to-edit title or description on default final review", () => { + it("exposes click-to-edit title and description on default final review", () => { render( , ); - expect(screen.queryByTestId("rule-title-edit")).not.toBeInTheDocument(); - expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument(); + expect(screen.getByTestId("rule-title-edit")).toBeInTheDocument(); + expect(screen.getByTestId("rule-description-edit")).toBeInTheDocument(); }); it("opens Save modal from title click and updates title", async () => { diff --git a/tests/components/InvitedRuleView.test.tsx b/tests/components/InvitedRuleView.test.tsx new file mode 100644 index 0000000..bd497d4 --- /dev/null +++ b/tests/components/InvitedRuleView.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders as render, screen } from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import { InvitedRuleView } from "../../app/(app)/invite/_components/InvitedRuleView"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: vi.fn(), + push: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + }), +})); + +const document = { + sections: [ + { + categoryName: "Values", + entries: [ + { + title: "Care", + body: "We care for each other.", + }, + ], + }, + ], +}; + +describe("InvitedRuleView", () => { + it("names the inviter by email and offers Make a copy", () => { + render( + , + ); + + expect( + screen.getByText("maya@example.com invited you to read this Rule"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.", + ), + ).toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: "Make a copy of this Rule" }) + .length, + ).toBeGreaterThan(0); + expect( + screen.getByText("Only people the community invites"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Every Rule starts private. Only the people the community invites can read it until the person who holds the pen turns on a link.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Care")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index f94d757..2c368d7 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -124,7 +124,7 @@ describe("LoginForm", () => { expect( await screen.findByRole("heading", { name: /check your email/i }), ).toBeInTheDocument(); - expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); + expect(screen.getByText(/we sent a login link/i)).toBeInTheDocument(); expect(screen.getByText(/check spam or promotions/i)).toBeInTheDocument(); }); @@ -191,7 +191,7 @@ describe("LoginForm", () => { ); expect( screen.getByRole("heading", { - name: /sign in to keep this rule on your profile/i, + name: /log in to keep this rule on your profile/i, }), ).toBeInTheDocument(); await user.type( @@ -212,6 +212,44 @@ describe("LoginForm", () => { window.localStorage.removeItem("create-flow-anonymous"); }); + it("invite variant attaches a create-flow draft", async () => { + const user = userEvent.setup(); + window.localStorage.setItem( + "create-flow-anonymous", + JSON.stringify({ title: "Guest draft" }), + ); + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderWithProviders( + + + , + ); + expect( + screen.getByRole("heading", { + name: /log in to invite your members/i, + }), + ).toBeInTheDocument(); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "guest@example.com", + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith( + "guest@example.com", + "/create/confirm-stakeholders?syncDraft=1", + expect.objectContaining({ title: "Guest draft" }), + ); + }); + expect(setTransferPendingFlag).toHaveBeenCalled(); + window.localStorage.removeItem("create-flow-anonymous"); + }); + it("keepRule continue without saving calls onDismiss", async () => { const user = userEvent.setup(); const onDismiss = vi.fn(); @@ -300,7 +338,7 @@ describe("LoginForm", () => { navMock.searchParams = new URLSearchParams("error=expired_link"); renderLoginForm(); expect( - screen.getByText(/that sign-in link has expired/i), + screen.getByText(/that login link has expired/i), ).toBeInTheDocument(); }); @@ -308,7 +346,7 @@ describe("LoginForm", () => { navMock.searchParams = new URLSearchParams("error=invalid_link"); renderLoginForm(); expect( - screen.getByText(/that sign-in link is not valid/i), + screen.getByText(/that login link is not valid/i), ).toBeInTheDocument(); }); diff --git a/tests/components/MultiSelect.test.tsx b/tests/components/MultiSelect.test.tsx index ac8ca39..b86abd2 100644 --- a/tests/components/MultiSelect.test.tsx +++ b/tests/components/MultiSelect.test.tsx @@ -287,4 +287,18 @@ describe("MultiSelect – behaviour specifics", () => { ); expect(screen.getByText("Pick one")).toBeInTheDocument(); }); + + it("shows a flag after the add chip in the same row", () => { + render( + , + ); + expect( + screen.getByText("No card yet. Add one before you finish."), + ).toBeInTheDocument(); + }); }); diff --git a/tests/components/TextPage.test.tsx b/tests/components/TextPage.test.tsx index 65f7f58..4b0cdbe 100644 --- a/tests/components/TextPage.test.tsx +++ b/tests/components/TextPage.test.tsx @@ -46,7 +46,7 @@ describe("CreateFlowTextFieldScreen (community description)", () => { , diff --git a/tests/contexts/AuthModalContext.test.tsx b/tests/contexts/AuthModalContext.test.tsx index fe67d4f..84fbc22 100644 --- a/tests/contexts/AuthModalContext.test.tsx +++ b/tests/contexts/AuthModalContext.test.tsx @@ -87,6 +87,17 @@ function LoginTrigger() { > Open keep rule then leave + @@ -192,7 +203,7 @@ describe("AuthModalProvider (header overlay)", () => { }); expect( screen.getByRole("heading", { - name: /sign in to keep this rule on your profile/i, + name: /log in to keep this rule on your profile/i, }), ).toBeInTheDocument(); await user.type( @@ -253,4 +264,44 @@ describe("AuthModalProvider (header overlay)", () => { }); expect(screen.getByText("Left completed")).toBeInTheDocument(); }); + + it("invite openLogin shows back-to-rule and attaches a draft", async () => { + const user = userEvent.setup(); + window.localStorage.setItem( + "create-flow-anonymous", + JSON.stringify({ title: "Guest draft" }), + ); + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole("button", { name: /open invite login/i })); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + expect( + screen.getByRole("heading", { name: /log in to invite your members/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /back to your rule/i }), + ).toBeInTheDocument(); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "guest@example.com", + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith( + "guest@example.com", + "/create/confirm-stakeholders?syncDraft=1", + expect.objectContaining({ title: "Guest draft" }), + ); + }); + expect(setTransferPendingFlag).toHaveBeenCalled(); + window.localStorage.removeItem("create-flow-anonymous"); + }); }); diff --git a/tests/pages/profile.test.tsx b/tests/pages/profile.test.tsx index ac62914..88ffb34 100644 --- a/tests/pages/profile.test.tsx +++ b/tests/pages/profile.test.tsx @@ -53,6 +53,7 @@ function viewProps( onCloseDeleteRule: vi.fn(), onConfirmDeleteRule: vi.fn(), onDuplicateRule: vi.fn(), + onInviteMembers: vi.fn(), onContinueDraft: vi.fn(), onDeleteDraft: vi.fn(), onCloseDeleteDraft: vi.fn(), @@ -142,4 +143,45 @@ describe("ProfilePageView", () => { await user.click(confirm); expect(onConfirmDeleteAccount).toHaveBeenCalledTimes(1); }); + + test("finished owner cards offer Invite your members beside Duplicate and Delete", async () => { + const user = userEvent.setup(); + const onInviteMembers = vi.fn(); + render( + , + ); + + expect( + screen.getByText(copy.welcomeBodyFirstRule), + ).toBeInTheDocument(); + expect(screen.getByText(copy.finishedBadge)).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: copy.yourRulesHeading }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: copy.inviteMembers }), + ); + expect(onInviteMembers).toHaveBeenCalledWith("rule-1"); + expect( + screen.getByRole("button", { name: copy.duplicate }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: copy.deleteRule }), + ).toBeInTheDocument(); + }); }); diff --git a/tests/unit/buildFinalReviewCategories.test.ts b/tests/unit/buildFinalReviewCategories.test.ts index f9f893d..3ae1ad5 100644 --- a/tests/unit/buildFinalReviewCategories.test.ts +++ b/tests/unit/buildFinalReviewCategories.test.ts @@ -10,9 +10,21 @@ const NAMES = { conflict: "Conflict management", }; +function rowsWith( + chipsByName: Partial>, +): { name: string; chips: string[] }[] { + return [ + "Values", + "Communication", + "Membership", + "Decision-making", + "Conflict management", + ].map((name) => ({ name, chips: chipsByName[name] ?? [] })); +} + describe("buildFinalReviewCategoriesFromState", () => { - it("returns [] when state has no selections and no sections", () => { - expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual([]); + it("returns five empty rows when state has no selections and no sections", () => { + expect(buildFinalReviewCategoriesFromState({}, NAMES)).toEqual(rowsWith({})); }); it("resolves method ids to labels from message presets", () => { @@ -33,7 +45,7 @@ describe("buildFinalReviewCategoriesFromState", () => { expect(byName.get("Membership")).toEqual(["Open admission"]); expect(byName.get("Decision-making")).toEqual(["Lazy consensus"]); expect(byName.get("Conflict management")).toEqual(["Conflict ladder"]); - expect(byName.has("Values")).toBe(false); + expect(byName.get("Values")).toEqual([]); }); it("derives core values via buildCoreValuesForDocument (snapshot + selected ids)", () => { @@ -46,9 +58,9 @@ describe("buildFinalReviewCategoriesFromState", () => { ], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([ - { name: "Values", chips: ["Accessibility", "Resilience"] }, - ]); + expect(rows).toEqual( + rowsWith({ Values: ["Accessibility", "Resilience"] }), + ); }); it("drops unknown ids silently instead of inserting empty labels", () => { @@ -56,7 +68,7 @@ describe("buildFinalReviewCategoriesFromState", () => { selectedCommunicationMethodIds: ["signal", "bogus-id"], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]); + expect(rows).toEqual(rowsWith({ Communication: ["Signal"] })); }); it("resolves user-authored method ids from customMethodCardMetaById", () => { @@ -77,7 +89,7 @@ describe("buildFinalReviewCategoriesFromState", () => { selectedCommunicationMethodIds: ["signal", "signal"], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]); + expect(rows).toEqual(rowsWith({ Communication: ["Signal"] })); }); it("uses section titles for method facets when selections were cleared (use-without-changes)", () => { @@ -98,10 +110,12 @@ describe("buildFinalReviewCategoriesFromState", () => { selectedCommunicationMethodIds: [], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([ - { name: "Values", chips: ["Consciousness", "Ecology"] }, - { name: "Communication", chips: ["Signal"] }, - ]); + expect(rows).toEqual( + rowsWith({ + Values: ["Consciousness", "Ecology"], + Communication: ["Signal"], + }), + ); }); it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => { @@ -115,9 +129,9 @@ describe("buildFinalReviewCategoriesFromState", () => { selectedCommunicationMethodIds: ["in-person-meetings"], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([ - { name: "Communication", chips: ["In-person meetings"] }, - ]); + expect(rows).toEqual( + rowsWith({ Communication: ["In-person meetings"] }), + ); }); it("shows custom communication chips when sections exist and selections include a UUID", () => { @@ -153,10 +167,12 @@ describe("buildFinalReviewCategoriesFromState", () => { ], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([ - { name: "Values", chips: ["Accessibility"] }, - { name: "Communication", chips: ["Signal"] }, - ]); + expect(rows).toEqual( + rowsWith({ + Values: ["Accessibility"], + Communication: ["Signal"], + }), + ); }); it("prefers the chip snapshot over a duplicated Values section in sections", () => { @@ -177,8 +193,6 @@ describe("buildFinalReviewCategoriesFromState", () => { ], }; const rows = buildFinalReviewCategoriesFromState(state, NAMES); - expect(rows).toEqual([ - { name: "Values", chips: ["Accessibility"] }, - ]); + expect(rows).toEqual(rowsWith({ Values: ["Accessibility"] })); }); }); diff --git a/tests/unit/copiedRuleDocument.test.ts b/tests/unit/copiedRuleDocument.test.ts new file mode 100644 index 0000000..0feff1f --- /dev/null +++ b/tests/unit/copiedRuleDocument.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + findCopiedNameHit, + stripCopiedRuleDocument, +} from "../../lib/create/copiedRuleDocument"; + +describe("stripCopiedRuleDocument", () => { + it("drops name, description, photo, and save-email", () => { + const out = stripCopiedRuleDocument({ + title: "Juniper Street Food Share", + communityContext: "A neighborhood share.", + communityAvatarUrl: "https://example.test/a.png", + communityAvatarFileName: "a.png", + communityLocation: "Boulder", + communitySaveEmail: "a@example.com", + sections: [ + { + categoryName: "Values", + entries: [{ title: "Care", body: "We care." }], + }, + ], + }); + expect(out.title).toBeUndefined(); + expect(out.communityContext).toBeUndefined(); + expect(out.communityAvatarUrl).toBeUndefined(); + expect(out.communityAvatarFileName).toBeUndefined(); + expect(out.communityLocation).toBeUndefined(); + expect(out.communitySaveEmail).toBeUndefined(); + expect(out.sections).toEqual([ + { + categoryName: "Values", + entries: [{ title: "Care", body: "We care." }], + }, + ]); + }); +}); + +describe("findCopiedNameHit", () => { + it("returns the first entry that still names the original group", () => { + expect( + findCopiedNameHit( + { + sections: [ + { + categoryName: "Values", + entries: [ + { + title: "Solidarity", + body: "Juniper Street Food Share members share food.", + }, + ], + }, + ], + }, + "Juniper Street Food Share", + ), + ).toEqual({ + categoryName: "Values", + entryTitle: "Solidarity", + }); + }); + + it("returns null when the name is gone", () => { + expect( + findCopiedNameHit( + { + sections: [ + { + categoryName: "Values", + entries: [{ title: "Care", body: "We care." }], + }, + ], + }, + "Juniper Street Food Share", + ), + ).toBeNull(); + }); +}); diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts index bbbbd7b..6b474de 100644 --- a/tests/unit/createFooterMessages.test.ts +++ b/tests/unit/createFooterMessages.test.ts @@ -20,10 +20,12 @@ describe("create footer messages", () => { expect(messages.create.footer.confirmSize).toBe("Confirm size"); }); - it("exposes publishCommunityRule for the final-review footer CTA", () => { - expect(messages.create.footer.publishCommunityRule).toBe( - "Publish CommunityRule", - ); + it("exposes Continue for the final-review footer CTA", () => { + expect(messages.create.footer.continue).toBe("Continue"); + }); + + it("exposes Finish for the edit-rule footer CTA", () => { + expect(messages.create.footer.publishCommunityRule).toBe("Finish"); }); it("exposes Continue and Skip for the setup questions step", () => { diff --git a/tests/unit/finalReviewContinue.test.ts b/tests/unit/finalReviewContinue.test.ts new file mode 100644 index 0000000..2351fcf --- /dev/null +++ b/tests/unit/finalReviewContinue.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { isFinalReviewContinueBlocked } from "../../app/(app)/create/utils/createFlowFooterLabels"; +import { communityInitialsFromTitle } from "../../lib/create/communityInitials"; +import { finalReviewChipHasWarning } from "../../lib/create/finalReviewChipWarnings"; +import type { CreateFlowState } from "../../app/(app)/create/types"; + +const completeCards: CreateFlowState = { + title: "Oak Park Commons", + selectedCommunicationMethodIds: ["signal"], + selectedMembershipMethodIds: ["open-admission"], + selectedDecisionApproachIds: ["lazy-consensus"], + selectedConflictManagementIds: ["conflict-ladder"], +}; + +describe("isFinalReviewContinueBlocked", () => { + it("blocks when the community name is empty", () => { + expect( + isFinalReviewContinueBlocked({ + ...completeCards, + title: " ", + }), + ).toBe(true); + }); + + it("blocks when any of the four questions has no card", () => { + expect( + isFinalReviewContinueBlocked({ + ...completeCards, + selectedMembershipMethodIds: [], + }), + ).toBe(true); + }); + + it("does not block when values are empty and the four questions have cards", () => { + expect(isFinalReviewContinueBlocked(completeCards)).toBe(false); + }); +}); + +describe("finalReviewChipHasWarning", () => { + it("warns when a value is missing meaning or signals", () => { + expect( + finalReviewChipHasWarning( + { + coreValueDetailsByChipId: { + "1": { meaning: "Care", signals: "" }, + }, + }, + "coreValues", + "1", + ), + ).toBe(true); + }); + + it("warns when a method card has no saved words", () => { + expect( + finalReviewChipHasWarning({}, "communication", "signal"), + ).toBe(true); + }); + + it("does not warn when meaning and signals are both filled", () => { + expect( + finalReviewChipHasWarning( + { + coreValueDetailsByChipId: { + "1": { meaning: "Care", signals: "We talk over people." }, + }, + }, + "coreValues", + "1", + ), + ).toBe(false); + }); +}); + +describe("communityInitialsFromTitle", () => { + it("uses the first letters of the first two words", () => { + expect(communityInitialsFromTitle("Oak Park Commons")).toBe("OP"); + }); + + it("uses two letters from a single word", () => { + expect(communityInitialsFromTitle("Neighbors")).toBe("NE"); + }); +}); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index d833026..652b58c 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -16,9 +16,9 @@ import { } from "../../app/(app)/create/utils/flowSteps"; describe("flowSteps", () => { - it("places confirm-stakeholders immediately before final-review", () => { - const i = FLOW_STEP_ORDER.indexOf("confirm-stakeholders"); - const j = FLOW_STEP_ORDER.indexOf("final-review"); + it("places final-review immediately before confirm-stakeholders", () => { + const i = FLOW_STEP_ORDER.indexOf("final-review"); + const j = FLOW_STEP_ORDER.indexOf("confirm-stakeholders"); expect(i).toBeGreaterThanOrEqual(0); expect(j).toBe(i + 1); }); @@ -27,8 +27,8 @@ describe("flowSteps", () => { expect(getNextStep("communication-methods")).toBe("membership-methods"); expect(getNextStep("membership-methods")).toBe("decision-approaches"); expect(getNextStep("decision-approaches")).toBe("conflict-management"); - expect(getNextStep("conflict-management")).toBe("confirm-stakeholders"); - expect(getNextStep("confirm-stakeholders")).toBe("final-review"); + expect(getNextStep("conflict-management")).toBe("final-review"); + expect(getNextStep("final-review")).toBe("confirm-stakeholders"); }); it("getNextStep returns null for last step or invalid", () => { @@ -39,7 +39,8 @@ describe("flowSteps", () => { }); it("getPreviousStep returns prior step or null", () => { - expect(getPreviousStep("final-review")).toBe("confirm-stakeholders"); + expect(getPreviousStep("final-review")).toBe("conflict-management"); + expect(getPreviousStep("confirm-stakeholders")).toBe("final-review"); expect(getPreviousStep("informational")).toBeNull(); expect(getPreviousStep(null)).toBeNull(); }); @@ -91,19 +92,17 @@ describe("flowSteps", () => { expect(getPreviousStep("communication-methods", opts)).toBe("core-values"); }); - it("useWithoutChangesIdentityOnly walks name → description → photo → stakeholders", () => { + it("useWithoutChangesIdentityOnly walks name → description → photo → final-review", () => { const opts = { useWithoutChangesIdentityOnly: true } as const; expect(getNextStep("community-name", opts)).toBe("community-context"); expect(getNextStep("community-context", opts)).toBe("community-upload"); expect(getNextStep("community-size", opts)).toBe("community-upload"); expect(getNextStep("setup-questions", opts)).toBe("community-upload"); - expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders"); - expect(getNextStep("confirm-stakeholders", opts)).toBe("final-review"); + expect(getNextStep("community-upload", opts)).toBe("final-review"); + expect(getNextStep("final-review", opts)).toBe("confirm-stakeholders"); expect(getPreviousStep("community-context", opts)).toBe("community-name"); expect(getPreviousStep("community-upload", opts)).toBe("community-context"); - expect(getPreviousStep("confirm-stakeholders", opts)).toBe( - "community-upload", - ); + expect(getPreviousStep("final-review", opts)).toBe("community-upload"); expect(getPreviousStep("community-name", opts)).toBeNull(); }); @@ -112,16 +111,14 @@ describe("flowSteps", () => { skipCommunitySave: true, useWithoutChangesIdentityOnly: true, } as const; - expect(getNextStep("community-upload", opts)).toBe("confirm-stakeholders"); - expect(getPreviousStep("confirm-stakeholders", opts)).toBe( - "community-upload", - ); + expect(getNextStep("community-upload", opts)).toBe("final-review"); + expect(getPreviousStep("final-review", opts)).toBe("community-upload"); }); - it("resolveCreateFlowBackTarget returns template review when use-without slug is set on confirm-stakeholders", () => { + it("resolveCreateFlowBackTarget returns template review when use-without slug is set on final-review", () => { expect( resolveCreateFlowBackTarget( - "confirm-stakeholders", + "final-review", undefined, "mutual-aid-mondays", ), @@ -138,10 +135,10 @@ describe("flowSteps", () => { ).toEqual({ kind: "templateReview", slug: "mutual-aid-mondays" }); }); - it("resolveCreateFlowBackTarget uses photo step behind stakeholders on the identity-only path", () => { + it("resolveCreateFlowBackTarget uses photo step behind final-review on the identity-only path", () => { expect( resolveCreateFlowBackTarget( - "confirm-stakeholders", + "final-review", { useWithoutChangesIdentityOnly: true }, "mutual-aid-mondays", ), @@ -151,13 +148,13 @@ describe("flowSteps", () => { it("resolveCreateFlowBackTarget falls back to linear previous when slug is absent", () => { expect( resolveCreateFlowBackTarget("confirm-stakeholders", undefined, undefined), - ).toEqual({ kind: "step", step: "conflict-management" }); + ).toEqual({ kind: "step", step: "final-review" }); }); it("resolveCreateFlowBackTarget ignores whitespace-only slug", () => { expect( resolveCreateFlowBackTarget("confirm-stakeholders", undefined, " "), - ).toEqual({ kind: "step", step: "conflict-management" }); + ).toEqual({ kind: "step", step: "final-review" }); }); it("buildTemplateReviewHref encodes slug and optional fromFlow query", () => { @@ -186,7 +183,7 @@ describe("flowSteps", () => { ).toBeNull(); }); - it("parseReviewReturnSearchParam accepts final-review, edit-rule, and review", () => { + it("parseReviewReturnSearchParam accepts final-review, edit-rule, review, and completed", () => { expect( parseReviewReturnSearchParam( new URLSearchParams("reviewReturn=final-review"), @@ -200,6 +197,11 @@ describe("flowSteps", () => { expect( parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=review")), ).toBe("review"); + expect( + parseReviewReturnSearchParam( + new URLSearchParams("reviewReturn=completed"), + ), + ).toBe("completed"); expect( parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")), ).toBeNull(); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx index 126555d..6934b21 100644 --- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -306,6 +306,71 @@ describe("useCreateFlowFinalize", () => { ); }); + it("omits stakeholder emails when skipInvites is set", 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", + sessionUser: signedIn, + }), + ); + + await act(async () => { + await result.current.finalize({ skipInvites: true }); + }); + + expect(publishRule).toHaveBeenCalledWith({ + title: "Published title", + summary: "Published summary", + document: {}, + }); + }); + + it("uses the send-failed banner when invitations do not send", async () => { + vi.mocked(publishRule).mockResolvedValue({ + ok: false, + status: 500, + error: "mail failed", + }); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: { + ...emptyState, + stakeholderEmails: ["invitee@example.com"], + }, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review", + sessionUser: signedIn, + }), + ); + + await act(async () => { + await result.current.finalize({ skipInvites: false }); + }); + + expect(result.current.publishBannerTitle).toBe( + messages.create.reviewAndComplete.confirmStakeholders.sendFailedTitle, + ); + expect(result.current.publishBannerMessage).toBe( + messages.create.reviewAndComplete.confirmStakeholders.sendFailedDescription, + ); + }); + it("routes to /create/completed without celebrate after PATCH update", async () => { vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true }); diff --git a/tests/unit/hooks/useTemplateReviewActions.test.tsx b/tests/unit/hooks/useTemplateReviewActions.test.tsx index 458f276..2f36b56 100644 --- a/tests/unit/hooks/useTemplateReviewActions.test.tsx +++ b/tests/unit/hooks/useTemplateReviewActions.test.tsx @@ -129,7 +129,7 @@ describe("useTemplateReviewActions", () => { await result.current.handleUseWithoutChanges(); }); - expect(router.push).toHaveBeenCalledWith("/create/confirm-stakeholders"); + expect(router.push).toHaveBeenCalledWith("/create/final-review"); expect(applied?.title).toBe("Neighborhood"); expect(applied?.communityContext).toBe("We meet weekly"); expect(applied?.setupTraits).toEqual({ employer: true }); diff --git a/tests/unit/invitePaths.test.ts b/tests/unit/invitePaths.test.ts new file mode 100644 index 0000000..bcc3813 --- /dev/null +++ b/tests/unit/invitePaths.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + inviteExpiredPath, + invitedRulePath, +} from "../../lib/create/invitePaths"; + +describe("invitedRulePath", () => { + it("encodes the rule id", () => { + expect(invitedRulePath("ab/c")).toBe("/invite/ab%2Fc"); + }); + + it("appends copy=1", () => { + expect(invitedRulePath("rule-1", { copy: true })).toBe( + "/invite/rule-1?copy=1", + ); + }); +}); + +describe("inviteExpiredPath", () => { + it("omits the token when missing", () => { + expect(inviteExpiredPath()).toBe("/invite/expired"); + }); + + it("encodes the token", () => { + expect(inviteExpiredPath("a+b")).toBe("/invite/expired?token=a%2Bb"); + }); +}); diff --git a/tests/unit/lib/navigationChromelessPath.test.ts b/tests/unit/lib/navigationChromelessPath.test.ts index d832a53..cf72948 100644 --- a/tests/unit/lib/navigationChromelessPath.test.ts +++ b/tests/unit/lib/navigationChromelessPath.test.ts @@ -6,6 +6,8 @@ describe("isChromelessNavigationPath", () => { ["/create", true], ["/create/completed", true], ["/login", true], + ["/invite/expired", true], + ["/invite/rule-1", true], ["/use-cases/mutual-aid-colorado/rule", true], ["/use-cases/food-not-bombs/rule/", true], ["/", false], diff --git a/tests/unit/membershipInvitePreview.test.ts b/tests/unit/membershipInvitePreview.test.ts new file mode 100644 index 0000000..2dc04cf --- /dev/null +++ b/tests/unit/membershipInvitePreview.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { membershipInvitePreview } from "../../lib/create/membershipInvitePreview"; +import type { CreateFlowState } from "../../app/(app)/create/types"; + +describe("membershipInvitePreview", () => { + it("returns null when no membership method is selected", () => { + expect(membershipInvitePreview({} as CreateFlowState)).toBeNull(); + }); + + it("uses the first selected membership method and saved words", () => { + const preview = membershipInvitePreview({ + selectedMembershipMethodIds: ["open-admission"], + membershipMethodDetailsById: { + "open-admission": { + eligibility: "Anyone who shares the work belongs.", + joiningProcess: "Work two Sunday shares, then ask to join.", + expectations: "", + }, + }, + } as CreateFlowState); + expect(preview?.title).toBe("Open admission"); + expect(preview?.categoryName).toBe("Membership"); + expect(preview?.eligibility).toBe("Anyone who shares the work belongs."); + expect(preview?.joiningProcess).toBe( + "Work two Sunday shares, then ask to join.", + ); + }); +}); diff --git a/tests/unit/parseInviteEmails.test.ts b/tests/unit/parseInviteEmails.test.ts new file mode 100644 index 0000000..0940c65 --- /dev/null +++ b/tests/unit/parseInviteEmails.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + formatInviteCountWord, + parseInvitePaste, + splitInvitePasteTokens, +} from "../../lib/create/parseInviteEmails"; + +describe("parseInviteEmails", () => { + it("splits comma, semicolon, and whitespace lists", () => { + expect( + splitInvitePasteTokens("rosa@example.org, dev@example.org; sam@example.org\nlee@example.org"), + ).toEqual([ + "rosa@example.org", + "dev@example.org", + "sam@example.org", + "lee@example.org", + ]); + }); + + it("adds valid addresses and leaves invalid tokens in the remainder", () => { + const result = parseInvitePaste( + "rosa@example.org, jo@example, sam@example.org", + ["maya@example.org"], + 30, + ); + expect(result.added).toEqual(["rosa@example.org", "sam@example.org"]); + expect(result.invalidRemainder).toBe("jo@example"); + }); + + it("skips duplicates already on the list", () => { + const result = parseInvitePaste( + "maya@example.org, rosa@example.org", + ["maya@example.org"], + 30, + ); + expect(result.added).toEqual(["rosa@example.org"]); + expect(result.skippedDuplicate).toBe(1); + }); + + it("keeps overflow addresses in the remainder once the max is hit", () => { + const result = parseInvitePaste( + "a@example.org, b@example.org, c@example.org", + [], + 2, + ); + expect(result.added).toEqual(["a@example.org", "b@example.org"]); + expect(result.invalidRemainder).toBe("c@example.org"); + }); + + it("uses words for counts through ten", () => { + expect(formatInviteCountWord(5)).toBe("five"); + expect(formatInviteCountWord(11)).toBe("11"); + }); +}); diff --git a/tests/unit/ruleInviteCardMeta.test.ts b/tests/unit/ruleInviteCardMeta.test.ts new file mode 100644 index 0000000..2470ef2 --- /dev/null +++ b/tests/unit/ruleInviteCardMeta.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + clipCommunityInviteDescription, + communityInviteInitials, + communityPossessive, + inviteRuleCountsLabel, + inviteRuleCountsSpoken, +} from "../../lib/create/ruleInviteCardMeta"; + +describe("communityInviteInitials", () => { + it("uses the first two words", () => { + expect(communityInviteInitials("Juniper Street Food Share")).toBe("JS"); + }); + + it("uses two letters of a single word", () => { + expect(communityInviteInitials("Garden")).toBe("GA"); + }); +}); + +describe("communityPossessive", () => { + it("adds a curly ’s when the name does not end in s", () => { + expect(communityPossessive("Juniper Street Food Share")).toBe( + "Juniper Street Food Share’s", + ); + }); + + it("adds only a curly apostrophe when the name ends in s", () => { + expect(communityPossessive("Members")).toBe("Members’"); + }); +}); + +describe("clipCommunityInviteDescription", () => { + it("cuts at 140 characters", () => { + const long = "a".repeat(160); + expect(clipCommunityInviteDescription(long)).toHaveLength(140); + }); +}); + +describe("inviteRuleCountsLabel", () => { + it("counts values and other cards, not shared terms", () => { + expect( + inviteRuleCountsLabel({ + sections: [ + { + categoryName: "Values", + entries: [ + { title: "Care", body: "We care." }, + { title: "Share", body: "We share." }, + { title: "Listen", body: "We listen." }, + ], + }, + { + categoryName: "Communication", + entries: [{ title: "Open channel", body: "We talk in the hall." }], + }, + { + categoryName: "Membership", + entries: [{ title: "Open admission", body: "Anyone who shares the work." }], + }, + ], + }), + ).toBe("3 VALUES AND 2 CARDS"); + }); +}); + +describe("inviteRuleCountsSpoken", () => { + it("uses lowercase words", () => { + expect( + inviteRuleCountsSpoken({ + sections: [ + { + categoryName: "Values", + entries: [ + { title: "Care", body: "We care." }, + { title: "Share", body: "We share." }, + { title: "Listen", body: "We listen." }, + ], + }, + { + categoryName: "Communication", + entries: [{ title: "Open channel", body: "How we talk." }], + }, + ], + }), + ).toBe("three values and one card"); + }); +}); diff --git a/tests/unit/ruleStakeholderInviteMail.test.ts b/tests/unit/ruleStakeholderInviteMail.test.ts new file mode 100644 index 0000000..a2f3e97 --- /dev/null +++ b/tests/unit/ruleStakeholderInviteMail.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + RULE_STAKEHOLDER_INVITE_LOGO_PATH, + RULE_STAKEHOLDER_INVITER_FALLBACK, + buildRuleStakeholderInviteMail, + ruleStakeholderInviteCopyUrl, + stakeholderInviteInviterLabel, + stakeholderInviteReplyTo, +} from "../../lib/server/ruleStakeholderInviteMail"; + +const VERIFY_URL = + "https://communityrule.info/api/invites/rule-stakeholder/verify?token=abc"; + +describe("stakeholder invite reply address", () => { + it("uses a mailbox as Reply-To and keeps a missing address out of the header", () => { + expect(stakeholderInviteReplyTo(" Maya@Example.org ")).toBe( + "maya@example.org", + ); + expect(stakeholderInviteReplyTo(RULE_STAKEHOLDER_INVITER_FALLBACK)).toBe( + undefined, + ); + expect(stakeholderInviteReplyTo("CommunityRule")).toBe(undefined); + }); + + it("names a missing inviter without turning the label into an address", () => { + expect(stakeholderInviteInviterLabel("")).toBe( + RULE_STAKEHOLDER_INVITER_FALLBACK, + ); + expect(stakeholderInviteInviterLabel("Maya@Example.org")).toBe( + "maya@example.org", + ); + const mail = buildRuleStakeholderInviteMail({ + to: "rosa@example.org", + verifyUrl: VERIFY_URL, + origin: "https://communityrule.info", + communityName: "Juniper Street Food Share", + inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK, + }); + expect(mail.text).toContain( + `${RULE_STAKEHOLDER_INVITER_FALLBACK} sent the invitation.`, + ); + }); +}); + +describe("ruleStakeholderInviteCopyUrl", () => { + it("adds copy=1 to the invitation link", () => { + expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe( + `${VERIFY_URL}©=1`, + ); + }); +}); + +describe("buildRuleStakeholderInviteMail", () => { + const mail = buildRuleStakeholderInviteMail({ + to: "rosa@example.org", + verifyUrl: VERIFY_URL, + origin: "https://communityrule.info", + communityName: "Juniper Street Food Share", + inviterEmail: "maya@example.org", + summary: + "A food share that runs every Sunday on Juniper Street. Volunteers collect surplus from two grocers and a farm stand and share it with neighbors.", + document: { + sections: [ + { + categoryName: "Values", + entries: [ + { title: "Care", body: "We care." }, + { title: "Share", body: "We share." }, + { title: "Listen", body: "We listen." }, + ], + }, + { + categoryName: "Communication", + entries: Array.from({ length: 6 }, (_, i) => ({ + title: `Card ${i + 1}`, + body: "Body", + })), + }, + ], + }, + }); + + it("uses the launch subject, preview, and privacy line", () => { + expect(mail.subject).toBe( + "Juniper Street Food Share invited you to read their Rule", + ); + expect(mail.previewText).toBe( + "It covers how they talk, who belongs, how they decide and what happens when they disagree.", + ); + expect(mail.text).toContain( + "Every Rule starts private. Only the people Juniper Street Food Share invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.", + ); + expect(mail.html).toContain("Every Rule starts private."); + }); + + it("names the inviter by email and points replies at that address in copy", () => { + expect(mail.text).toContain("maya@example.org sent the invitation."); + expect(mail.text).toContain( + "how they talk, who belongs, how they decide and what happens when they disagree.", + ); + expect(mail.html).not.toContain("Maya Ortiz"); + }); + + it("includes read and copy links, footer, and the 2x mark", () => { + expect(mail.text).toContain(VERIFY_URL); + expect(mail.text).toContain(`${VERIFY_URL}©=1`); + expect(mail.html).toContain(`href="${VERIFY_URL}"`); + expect(mail.html).toContain(`href="${VERIFY_URL}&copy=1"`); + expect(mail.html).toContain("Read the Rule"); + expect(mail.html).toContain("Make a copy"); + expect(mail.html).toContain("3 VALUES AND 6 CARDS"); + expect(mail.html).toContain("JS"); + expect(mail.html).toContain("#e4e4e4"); + expect(mail.html).toContain("font-size:14px;line-height:20px;color:#464646"); + expect(mail.html).toContain( + `https://communityrule.info${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`, + ); + expect(mail.html).toContain("https://communityrule.info/privacy"); + expect(mail.html).toContain("https://communityrule.info/about"); + expect(mail.text).toContain( + "Juniper Street Food Share’s Rule on CommunityRule", + ); + }); +}); diff --git a/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts b/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts new file mode 100644 index 0000000..95f9e90 --- /dev/null +++ b/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts @@ -0,0 +1,148 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUniqueMock = vi.fn(); +const updateMock = vi.fn(); +const sendInviteMock = vi.fn(); +const getSessionPepperMock = vi.fn(() => "pepper"); +const rateLimitKeyMock = vi.fn( + ( + _key: string, + _minIntervalMs: number, + ): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }), +); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, + getSessionPepper: () => getSessionPepperMock(), +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + ruleStakeholder: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + update: (...args: unknown[]) => updateMock(...args), + }, + }, +})); + +vi.mock("../../lib/server/hash", () => ({ + hashSessionToken: (token: string) => `hash-${token}`, + newSessionToken: () => "new-token", +})); + +vi.mock("../../lib/server/mail", () => ({ + sendRuleStakeholderInviteEmail: (...args: unknown[]) => + sendInviteMock(...args), +})); + +vi.mock("../../lib/server/rateLimit", () => ({ + rateLimitKey: (key: string, minIntervalMs: number) => + rateLimitKeyMock(key, minIntervalMs), +})); + +import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../lib/server/ruleStakeholderInviteMail"; +import { POST } from "../../app/api/invites/rule-stakeholder/resend/route"; + +beforeEach(() => { + findUniqueMock.mockReset(); + updateMock.mockReset(); + sendInviteMock.mockReset(); + getSessionPepperMock.mockReset(); + rateLimitKeyMock.mockReset(); + getSessionPepperMock.mockReturnValue("pepper"); + rateLimitKeyMock.mockReturnValue({ ok: true as const }); + updateMock.mockResolvedValue({}); + sendInviteMock.mockResolvedValue(undefined); +}); + +describe("POST /api/invites/rule-stakeholder/resend", () => { + it("returns ok without sending when the token does not match", async () => { + findUniqueMock.mockResolvedValueOnce(null); + const res = await POST( + new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { + method: "POST", + body: JSON.stringify({ + token: "a".repeat(12), + email: "inv@example.com", + }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + expect(res.status).toBe(200); + expect(sendInviteMock).not.toHaveBeenCalled(); + }); + + it("rotates the token and sends mail when email matches", async () => { + findUniqueMock.mockResolvedValueOnce({ + id: "st-1", + email: "inv@example.com", + inviteTokenHash: "hash-old", + inviteExpiresAt: new Date(Date.now() - 60_000), + invitedBy: { email: "owner@example.com" }, + rule: { + title: "Juniper Street Food Share", + summary: null, + document: {}, + user: { email: "owner@example.com" }, + }, + }); + + const res = await POST( + new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { + method: "POST", + body: JSON.stringify({ + token: "a".repeat(12), + email: "inv@example.com", + }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalled(); + expect(sendInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "inv@example.com", + communityName: "Juniper Street Food Share", + inviterEmail: "owner@example.com", + }), + ); + }); + + it("does not use a fake mailbox when the inviter and owner have no email", async () => { + findUniqueMock.mockResolvedValueOnce({ + id: "st-1", + email: "inv@example.com", + inviteTokenHash: "hash-old", + inviteExpiresAt: new Date(Date.now() - 60_000), + invitedBy: null, + rule: { + title: "Juniper Street Food Share", + summary: null, + document: {}, + user: { email: null }, + }, + }); + + const res = await POST( + new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { + method: "POST", + body: JSON.stringify({ + token: "a".repeat(12), + email: "inv@example.com", + }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + + expect(res.status).toBe(200); + expect(sendInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK, + }), + ); + }); +}); diff --git a/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts index 7f1b1cc..c99740e 100644 --- a/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts +++ b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts @@ -70,7 +70,7 @@ describe("GET /api/invites/rule-stakeholder/verify", () => { expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); - expect(res.headers.get("location")).toContain("/rules/rule-1"); + expect(res.headers.get("location")).toContain("/invite/rule-1"); expect(setCookieMock).toHaveBeenCalled(); }); @@ -97,4 +97,50 @@ describe("GET /api/invites/rule-stakeholder/verify", () => { ); expect(upsertMock).not.toHaveBeenCalled(); }); + + it("redirects expired tokens to the expired invitation page", async () => { + getSessionUserMock.mockResolvedValue(null); + findUniqueMock.mockResolvedValue({ + id: "st1", + email: "inv@example.com", + ruleId: "rule-1", + inviteExpiresAt: new Date(Date.now() - 60_000), + }); + const token = "z".repeat(12); + + const res = await GET( + new NextRequest( + `https://x.test/api/invites/rule-stakeholder/verify?token=${token}`, + ), + ); + + expect(res.headers.get("location")).toContain("/invite/expired"); + expect(res.headers.get("location")).toContain(`token=${token}`); + expect(upsertMock).not.toHaveBeenCalled(); + }); + + it("keeps copy=1 on the invited view 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)}©=1`, + ), + ); + + expect(res.headers.get("location")).toContain("/invite/rule-1"); + expect(res.headers.get("location")).toContain("copy=1"); + }); }); diff --git a/tests/unit/rulesMakeCopyRoute.test.ts b/tests/unit/rulesMakeCopyRoute.test.ts new file mode 100644 index 0000000..7fd5740 --- /dev/null +++ b/tests/unit/rulesMakeCopyRoute.test.ts @@ -0,0 +1,145 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findFirstStakeholderMock = vi.fn(); +const createRuleMock = vi.fn(); +const getSessionUserMock = vi.fn(); +const rateLimitKeyMock = vi.fn( + ( + _key: string, + _minIntervalMs: number, + ): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }), +); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, +})); + +vi.mock("../../lib/server/session", () => ({ + getSessionUser: () => getSessionUserMock(), +})); + +vi.mock("../../lib/server/rateLimit", () => ({ + rateLimitKey: (key: string, minIntervalMs: number) => + rateLimitKeyMock(key, minIntervalMs), +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + ruleStakeholder: { + findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args), + }, + publishedRule: { + create: (...args: unknown[]) => createRuleMock(...args), + }, + }, +})); + +import { POST } from "../../app/api/rules/[id]/make-copy/route"; + +const viewer = { id: "user-2", email: "reader@example.com" }; +const routeCtx = { params: Promise.resolve({ id: "rule-1" }) }; + +beforeEach(() => { + findFirstStakeholderMock.mockReset(); + createRuleMock.mockReset(); + getSessionUserMock.mockReset(); + rateLimitKeyMock.mockReset(); + rateLimitKeyMock.mockReturnValue({ ok: true as const }); + getSessionUserMock.mockResolvedValue(viewer); +}); + +describe("POST /api/rules/[id]/make-copy", () => { + it("returns 401 when unauthenticated", async () => { + getSessionUserMock.mockResolvedValueOnce(null); + const res = await POST( + new NextRequest("https://x.test/api/rules/rule-1/make-copy", { + method: "POST", + body: JSON.stringify({ title: "New group" }), + headers: { "content-type": "application/json" }, + }), + routeCtx, + ); + expect(res.status).toBe(401); + }); + + it("returns 404 when the viewer is not an invited reader", async () => { + findFirstStakeholderMock.mockResolvedValueOnce(null); + const res = await POST( + new NextRequest("https://x.test/api/rules/rule-1/make-copy", { + method: "POST", + body: JSON.stringify({ title: "New group" }), + headers: { "content-type": "application/json" }, + }), + routeCtx, + ); + expect(res.status).toBe(404); + expect(createRuleMock).not.toHaveBeenCalled(); + }); + + it("copies cards and text, not the original name or description", async () => { + findFirstStakeholderMock.mockResolvedValueOnce({ + id: "st-1", + rule: { + id: "rule-1", + title: "Juniper Street Food Share", + userId: "owner-1", + document: { + title: "Juniper Street Food Share", + communityContext: "A neighborhood share.", + communityAvatarUrl: "https://example.test/a.png", + sections: [ + { + categoryName: "Values", + entries: [ + { + title: "Care", + body: "Juniper Street Food Share cares.", + }, + ], + }, + ], + }, + }, + }); + createRuleMock.mockResolvedValueOnce({ + id: "rule-copy", + title: "New group", + summary: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }); + + const res = await POST( + new NextRequest("https://x.test/api/rules/rule-1/make-copy", { + method: "POST", + body: JSON.stringify({ title: "New group" }), + headers: { "content-type": "application/json" }, + }), + routeCtx, + ); + expect(res.status).toBe(200); + expect(createRuleMock).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: viewer.id, + title: "New group", + summary: null, + document: expect.not.objectContaining({ + title: "Juniper Street Food Share", + communityContext: "A neighborhood share.", + communityAvatarUrl: "https://example.test/a.png", + }), + }), + }); + const body = (await res.json()) as { + rule: { + id: string; + copiedFromTitle: string; + copiedNameHit: { entryTitle: string } | null; + }; + }; + expect(body.rule.id).toBe("rule-copy"); + expect(body.rule.copiedFromTitle).toBe("Juniper Street Food Share"); + expect(body.rule.copiedNameHit?.entryTitle).toBe("Care"); + }); +}); diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts index 5431be2..8af7e13 100644 --- a/tests/unit/rulesPublishPostRoute.test.ts +++ b/tests/unit/rulesPublishPostRoute.test.ts @@ -148,9 +148,14 @@ describe("POST /api/rules", () => { 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(sendInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "stakeholder@example.com", + communityName: "Published title", + verifyUrl: expect.stringContaining( + "/api/invites/rule-stakeholder/verify?token=", + ), + }), ); expect(publishedRuleCreateMock).not.toHaveBeenCalled(); }); @@ -197,6 +202,55 @@ describe("POST /api/rules", () => { }); }); + it("keeps the rule when a later invite send fails after one succeeded", async () => { + const created = { + id: "rule-partial", + title: "Published title", + summary: null, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + transactionMock.mockImplementation( + async ( + fn: (tx: { + publishedRule: { create: typeof vi.fn }; + ruleStakeholder: { create: typeof vi.fn }; + }) => Promise, + ) => { + const tx = { + publishedRule: { + create: vi.fn().mockResolvedValue(created), + }, + ruleStakeholder: { + create: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }, + ); + sendInviteMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("smtp down")); + + const res = await POST( + new NextRequest("https://x.test/api/rules", { + method: "POST", + body: JSON.stringify({ + title: "Published title", + summary: null, + document: {}, + stakeholderEmails: ["first@example.com", "second@example.com"], + }), + }), + undefined, + ); + + expect(res.status).toBe(200); + expect(sendInviteMock).toHaveBeenCalledTimes(2); + expect(publishedRuleDeleteMock).not.toHaveBeenCalled(); + const body = (await res.json()) as { rule: { id: string } }; + expect(body.rule.id).toBe("rule-partial"); + }); + it("creates an unlisted rule when there is no session", async () => { getSessionUserMock.mockResolvedValueOnce(null); publishedRuleCreateMock.mockResolvedValueOnce({ diff --git a/tests/unit/rulesStakeholderMutationsRoute.test.ts b/tests/unit/rulesStakeholderMutationsRoute.test.ts index 9498b12..309b024 100644 --- a/tests/unit/rulesStakeholderMutationsRoute.test.ts +++ b/tests/unit/rulesStakeholderMutationsRoute.test.ts @@ -130,7 +130,13 @@ describe("POST /api/rules/[id]/stakeholders", () => { routeCtx, ); expect(res.status).toBe(201); - expect(createInviteMock).toHaveBeenCalled(); + expect(createInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + email: "inv@example.com", + ruleTitle: "My rule", + inviterEmail: owner.email, + }), + ); const body = (await res.json()) as { stakeholder: { email: string; status: string }; }; @@ -214,9 +220,14 @@ describe("POST /api/rules/[id]/stakeholders/[stakeholderId]/resend", () => { expect(await res.json()).toEqual({ ok: true }); expect(updateMock).toHaveBeenCalled(); expect(sendInviteEmailMock).toHaveBeenCalledWith( - "inv@example.com", - expect.stringContaining("/api/invites/rule-stakeholder/verify?token="), - "My rule", + expect.objectContaining({ + to: "inv@example.com", + communityName: "My rule", + inviterEmail: owner.email, + verifyUrl: expect.stringContaining( + "/api/invites/rule-stakeholder/verify?token=", + ), + }), ); }); });