Finish a Rule, invite members, and let invited people read or copy it #100
No files matched your search
@@ -62,6 +62,10 @@ import {
|
|||||||
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_COPIED_QUERY,
|
||||||
|
CREATE_FLOW_COPIED_VALUE,
|
||||||
|
} from "../../../lib/create/invitePaths";
|
||||||
import {
|
import {
|
||||||
clearPendingKeepRuleLogin,
|
clearPendingKeepRuleLogin,
|
||||||
hasPendingKeepRuleLogin,
|
hasPendingKeepRuleLogin,
|
||||||
@@ -220,6 +224,7 @@ function CreateFlowLayoutContent({
|
|||||||
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
||||||
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
|
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [completedCopiedReady, setCompletedCopiedReady] = useState(false);
|
||||||
type LeaveConfirmChoice = "leave" | "save" | "cancel";
|
type LeaveConfirmChoice = "leave" | "save" | "cancel";
|
||||||
const leaveConfirmResolverRef = useRef<
|
const leaveConfirmResolverRef = useRef<
|
||||||
((choice: LeaveConfirmChoice) => void) | null
|
((choice: LeaveConfirmChoice) => void) | null
|
||||||
@@ -548,9 +553,12 @@ function CreateFlowLayoutContent({
|
|||||||
if (currentStep !== "completed") {
|
if (currentStep !== "completed") {
|
||||||
setCompletedFlowBanner(null);
|
setCompletedFlowBanner(null);
|
||||||
setCompletedHasPublishedRule(false);
|
setCompletedHasPublishedRule(false);
|
||||||
|
setCompletedCopiedReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
|
const last = readLastPublishedRule();
|
||||||
|
setCompletedHasPublishedRule(Boolean(last));
|
||||||
|
setCompletedCopiedReady(Boolean(last?.copiedFromTitle));
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -913,7 +921,13 @@ function CreateFlowLayoutContent({
|
|||||||
onExit={(opts) => void handleExit(opts)}
|
onExit={(opts) => void handleExit(opts)}
|
||||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||||
className={`relative z-[210] shrink-0 ${
|
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()}
|
}`.trim()}
|
||||||
/>
|
/>
|
||||||
{currentStep === "community-save" ? (
|
{currentStep === "community-save" ? (
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||||
} from "../../utils/flowSteps";
|
} from "../../utils/flowSteps";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_COPIED_QUERY,
|
||||||
|
CREATE_FLOW_COPIED_VALUE,
|
||||||
|
} from "../../../../../lib/create/invitePaths";
|
||||||
|
import { communityPossessive } from "../../../../../lib/create/ruleInviteCardMeta";
|
||||||
|
|
||||||
function emptyCompletedDocumentState(): {
|
function emptyCompletedDocumentState(): {
|
||||||
headerTitle: string;
|
headerTitle: string;
|
||||||
@@ -80,6 +85,11 @@ export function CompletedScreen() {
|
|||||||
const celebrateInUrl =
|
const celebrateInUrl =
|
||||||
searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) ===
|
searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) ===
|
||||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE;
|
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 mdUp = useCreateFlowMdUp();
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const completed = m.create.reviewAndComplete.completed;
|
const completed = m.create.reviewAndComplete.completed;
|
||||||
@@ -88,6 +98,7 @@ export function CompletedScreen() {
|
|||||||
const [toastDismissed, setToastDismissed] = useState(false);
|
const [toastDismissed, setToastDismissed] = useState(false);
|
||||||
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
|
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
|
||||||
const [showCelebrateToast] = useState(celebrateInUrl);
|
const [showCelebrateToast] = useState(celebrateInUrl);
|
||||||
|
const [showCopiedReady] = useState(copiedInUrl);
|
||||||
const [askOrganizerOpen, setAskOrganizerOpen] = useState(false);
|
const [askOrganizerOpen, setAskOrganizerOpen] = useState(false);
|
||||||
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
|
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
|
||||||
const [headerDescription, setHeaderDescription] = useState<
|
const [headerDescription, setHeaderDescription] = useState<
|
||||||
@@ -141,11 +152,17 @@ export function CompletedScreen() {
|
|||||||
}, [ruleIdParam, router]);
|
}, [ruleIdParam, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!celebrateInUrl) return;
|
if (!celebrateInUrl && !copiedInUrl) return;
|
||||||
router.replace("/create/completed");
|
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) ? (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -154,8 +171,19 @@ export function CompletedScreen() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="toast"
|
type="toast"
|
||||||
status="default"
|
status="default"
|
||||||
title={completed.toastTitle}
|
title={
|
||||||
description={completed.toastDescription}
|
showCopiedReady
|
||||||
|
? completed.copyReadyToastTitle
|
||||||
|
: completed.toastTitle
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
showCopiedReady
|
||||||
|
? completed.copyReadyToastDescription.replace(
|
||||||
|
"{possessive}",
|
||||||
|
communityPossessive(copiedFromTitle ?? ""),
|
||||||
|
)
|
||||||
|
: completed.toastDescription
|
||||||
|
}
|
||||||
hasLeadingIcon
|
hasLeadingIcon
|
||||||
hasBodyText
|
hasBodyText
|
||||||
onClose={() => setToastDismissed(true)}
|
onClose={() => setToastDismissed(true)}
|
||||||
@@ -166,7 +194,7 @@ export function CompletedScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
<div className={`flex min-h-0 w-full flex-1 flex-col overflow-hidden ${surfaceClass} md:h-full`}>
|
||||||
<div
|
<div
|
||||||
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
>
|
>
|
||||||
@@ -180,7 +208,7 @@ export function CompletedScreen() {
|
|||||||
size="L"
|
size="L"
|
||||||
palette="inverse"
|
palette="inverse"
|
||||||
/>
|
/>
|
||||||
{headerTitle ? (
|
{headerTitle && !surfaceCopied ? (
|
||||||
<p className="mt-[var(--space-400)] max-w-[640px] text-small-paragraph text-[var(--color-content-invert-secondary)]">
|
<p className="mt-[var(--space-400)] max-w-[640px] text-small-paragraph text-[var(--color-content-invert-secondary)]">
|
||||||
{completed.askOrganizerLead}{" "}
|
{completed.askOrganizerLead}{" "}
|
||||||
<InlineTextButton
|
<InlineTextButton
|
||||||
@@ -196,10 +224,33 @@ export function CompletedScreen() {
|
|||||||
className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
className={`pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-0% via-50% to-transparent md:block ${
|
||||||
|
surfaceCopied
|
||||||
|
? "from-[var(--color-lavender-lavender150,#d1bdf3)]/55 via-[var(--color-lavender-lavender150,#d1bdf3)]/20"
|
||||||
|
: "from-[var(--color-teal-teal50,#c9fef9)]/55 via-[var(--color-teal-teal50,#c9fef9)]/20"
|
||||||
|
}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="w-full min-w-0 py-0 md:pb-8">
|
<div className="w-full min-w-0 py-0 md:pb-8">
|
||||||
|
{copiedNameHit && copiedFromTitle ? (
|
||||||
|
<div className="mb-[var(--space-400)]">
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="warning"
|
||||||
|
title={completed.copyNameHitTitle.replace(
|
||||||
|
"{name}",
|
||||||
|
copiedFromTitle,
|
||||||
|
)}
|
||||||
|
description={completed.copyNameHitDescription
|
||||||
|
.replace("{entry}", copiedNameHit.entryTitle)
|
||||||
|
.replace("{category}", copiedNameHit.categoryName)}
|
||||||
|
hasLeadingIcon
|
||||||
|
hasBodyText
|
||||||
|
hasTrailingIcon={false}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<CommunityRule
|
<CommunityRule
|
||||||
sections={documentSections}
|
sections={documentSections}
|
||||||
useCardStyle={!mdUp}
|
useCardStyle={!mdUp}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getSessionUser } from "../../../../lib/server/session";
|
||||||
|
import { getAcceptedStakeholderInviteView } from "../../../../lib/server/publishedRules";
|
||||||
|
import { inviteRuleCountsSpoken } from "../../../../lib/create/ruleInviteCardMeta";
|
||||||
|
import {
|
||||||
|
INVITE_COPY_VALUE,
|
||||||
|
invitedRulePath,
|
||||||
|
} from "../../../../lib/create/invitePaths";
|
||||||
|
import { InvitedRuleView } from "../_components/InvitedRuleView";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ ruleId: string }>;
|
||||||
|
searchParams: Promise<{ copy?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function InviteRulePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
|
const { ruleId } = await params;
|
||||||
|
const query = await searchParams;
|
||||||
|
const copy = query.copy === INVITE_COPY_VALUE;
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
const next = invitedRulePath(ruleId, { copy });
|
||||||
|
redirect(`/login?next=${encodeURIComponent(next)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = await getAcceptedStakeholderInviteView({
|
||||||
|
userId: user.id,
|
||||||
|
ruleId,
|
||||||
|
});
|
||||||
|
if (!view) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InvitedRuleView
|
||||||
|
ruleId={view.ruleId}
|
||||||
|
title={view.title}
|
||||||
|
summary={view.summary}
|
||||||
|
document={view.document}
|
||||||
|
updatedAt={view.updatedAt}
|
||||||
|
inviterEmail={view.inviterEmail}
|
||||||
|
viewerEmail={user.email}
|
||||||
|
countsSpoken={inviteRuleCountsSpoken(view.document)}
|
||||||
|
openCopyDialog={copy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import Login from "../../../components/modals/Login";
|
||||||
|
import Button from "../../../components/buttons/Button";
|
||||||
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
|
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
|
import { INVITE_EMAIL_PATTERN } from "../../../../lib/create/parseInviteEmails";
|
||||||
|
import { resendExpiredStakeholderInvite } from "../../../../lib/create/api";
|
||||||
|
|
||||||
|
const expiredPageBgClass =
|
||||||
|
"min-h-[100dvh] bg-[var(--color-surface-invert-brand-primary)]";
|
||||||
|
|
||||||
|
export function ExpiredInviteScreen() {
|
||||||
|
const t = useTranslation("pages.invite");
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token") ?? "";
|
||||||
|
const titleId = useId();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [emailError, setEmailError] = useState("");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const heading = sent
|
||||||
|
? { title: t("sentTitle"), description: t("sentBody") }
|
||||||
|
: { title: t("expiredTitle"), description: t("expiredDescription") };
|
||||||
|
|
||||||
|
async function sendLink() {
|
||||||
|
const trimmed = email.trim().toLowerCase();
|
||||||
|
if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
|
||||||
|
setEmailError(t("emailInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token.length < 10) {
|
||||||
|
setFormError(t("missingToken"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailError("");
|
||||||
|
setFormError("");
|
||||||
|
setSubmitting(true);
|
||||||
|
const result = await resendExpiredStakeholderInvite(token, trimmed);
|
||||||
|
setSubmitting(false);
|
||||||
|
if (result.ok === false) {
|
||||||
|
if (result.retryAfterMs) {
|
||||||
|
const seconds = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
|
||||||
|
setFormError(t("rateLimited").replace("{seconds}", String(seconds)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormError(result.error || t("genericError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSent(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={expiredPageBgClass}>
|
||||||
|
<Login
|
||||||
|
isOpen
|
||||||
|
usePortal={false}
|
||||||
|
backdropVariant="solid"
|
||||||
|
onClose={() => {
|
||||||
|
router.push("/");
|
||||||
|
}}
|
||||||
|
ariaLabelledBy={titleId}
|
||||||
|
belowCard={
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("backToHome")}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ContentLockup
|
||||||
|
titleId={titleId}
|
||||||
|
title={heading.title}
|
||||||
|
description={heading.description}
|
||||||
|
variant="login"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
{formError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!sent ? (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void sendLink();
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
label={t("emailLabel")}
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => {
|
||||||
|
setEmail(event.target.value);
|
||||||
|
setEmailError("");
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
error={Boolean(emailError)}
|
||||||
|
showHelpIcon
|
||||||
|
maxLength={EMAIL_MAX_LEN}
|
||||||
|
/>
|
||||||
|
{emailError ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
|
||||||
|
>
|
||||||
|
{emailError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
|
{t("notInvitedAddress")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="large"
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
|
||||||
|
>
|
||||||
|
{t("sendNewLink")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Login>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Logo from "../../../components/asset/Logo";
|
||||||
|
import Button from "../../../components/buttons/Button";
|
||||||
|
import Alert from "../../../components/modals/Alert";
|
||||||
|
import CommunityRule from "../../../components/type/CommunityRule";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import { makeCopyPublishedRule } from "../../../../lib/create/api";
|
||||||
|
import { communityPossessive } from "../../../../lib/create/ruleInviteCardMeta";
|
||||||
|
import { CREATE_FLOW_COPIED_QUERY, CREATE_FLOW_COPIED_VALUE } from "../../../../lib/create/invitePaths";
|
||||||
|
import { formatInviteLastChanged } from "../../../../lib/create/inviteLastChanged";
|
||||||
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
|
import { downloadStoredRuleAsPdf } from "../../../../lib/create/ruleExport";
|
||||||
|
import { createCompletedPath } from "../../create/utils/createFlowPaths";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../create/components/CreateFlowHeaderLockup";
|
||||||
|
import { useCreateFlowMdUp } from "../../create/hooks/useCreateFlowMdUp";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
|
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
||||||
|
} from "../../create/components/createFlowLayoutTokens";
|
||||||
|
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||||
|
import { MakeACopyDialog } from "./MakeACopyDialog";
|
||||||
|
import type { CommunityRuleSection } from "../../../components/type/CommunityRule/CommunityRule.types";
|
||||||
|
|
||||||
|
const outlineButtonClass =
|
||||||
|
"md:!text-x-small-label !text-xx-small-label !px-[var(--space-150)] md:!px-[var(--space-200)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
|
||||||
|
|
||||||
|
const SURFACE = "bg-[var(--color-teal-teal50,#c9fef9)]";
|
||||||
|
|
||||||
|
export type InvitedRuleViewProps = {
|
||||||
|
ruleId: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
updatedAt: string;
|
||||||
|
inviterEmail: string | null;
|
||||||
|
viewerEmail: string;
|
||||||
|
countsSpoken: string;
|
||||||
|
openCopyDialog?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvitedRuleView({
|
||||||
|
ruleId,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
document,
|
||||||
|
updatedAt,
|
||||||
|
inviterEmail,
|
||||||
|
viewerEmail,
|
||||||
|
countsSpoken,
|
||||||
|
openCopyDialog = false,
|
||||||
|
}: InvitedRuleViewProps) {
|
||||||
|
const t = useTranslation("pages.invite");
|
||||||
|
const router = useRouter();
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const [copyOpen, setCopyOpen] = useState(openCopyDialog);
|
||||||
|
const [copyBusy, setCopyBusy] = useState(false);
|
||||||
|
const [copyError, setCopyError] = useState<string | null>(null);
|
||||||
|
const [exportError, setExportError] = useState(false);
|
||||||
|
|
||||||
|
const sections: CommunityRuleSection[] =
|
||||||
|
parsePublishedDocumentForCommunityRuleDisplay(document);
|
||||||
|
const possessive = communityPossessive(title);
|
||||||
|
const lastChanged = formatInviteLastChanged(updatedAt);
|
||||||
|
const bannerTitle = inviterEmail
|
||||||
|
? t("bannerTitle").replace("{email}", inviterEmail)
|
||||||
|
: t("bannerTitleNoEmail");
|
||||||
|
const whoCanEdit = inviterEmail ?? t("whoCanEditUnknown");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openCopyDialog) return;
|
||||||
|
router.replace(`/invite/${encodeURIComponent(ruleId)}`);
|
||||||
|
}, [openCopyDialog, ruleId, router]);
|
||||||
|
|
||||||
|
const handlePdf = useCallback(() => {
|
||||||
|
try {
|
||||||
|
downloadStoredRuleAsPdf({
|
||||||
|
id: ruleId,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
document,
|
||||||
|
});
|
||||||
|
setExportError(false);
|
||||||
|
} catch {
|
||||||
|
setExportError(true);
|
||||||
|
}
|
||||||
|
}, [document, ruleId, summary, title]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
async (communityName: string) => {
|
||||||
|
setCopyBusy(true);
|
||||||
|
setCopyError(null);
|
||||||
|
const result = await makeCopyPublishedRule(ruleId, communityName);
|
||||||
|
setCopyBusy(false);
|
||||||
|
if (result.ok === false) {
|
||||||
|
setCopyError(t("copyFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: result.id,
|
||||||
|
title: result.title,
|
||||||
|
summary: result.summary,
|
||||||
|
document: result.document,
|
||||||
|
copiedFromTitle: result.copiedFromTitle,
|
||||||
|
copiedNameHit: result.copiedNameHit,
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
createCompletedPath({
|
||||||
|
[CREATE_FLOW_COPIED_QUERY]: CREATE_FLOW_COPIED_VALUE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[ruleId, router, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} ${SURFACE}`}>
|
||||||
|
<header
|
||||||
|
className={`${SURFACE} w-full print:hidden`}
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className={`mx-auto flex w-full max-w-[1920px] items-center justify-between ${CREATE_FLOW_PAGE_GUTTER_CLASS} py-[var(--space-300)] md:py-[var(--space-400)]`}
|
||||||
|
aria-label={t("navAria")}
|
||||||
|
>
|
||||||
|
<Logo size="createFlow" wordmark palette="inverse" />
|
||||||
|
<Button
|
||||||
|
buttonType="outline"
|
||||||
|
palette="inverse"
|
||||||
|
size="xsmall"
|
||||||
|
type="button"
|
||||||
|
ariaLabel={t("makeACopyAria")}
|
||||||
|
className={outlineButtonClass}
|
||||||
|
onClick={() => {
|
||||||
|
setCopyError(null);
|
||||||
|
setCopyOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("makeACopy")}
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex shrink-0 flex-col justify-start min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={title}
|
||||||
|
description={summary ?? undefined}
|
||||||
|
justification="left"
|
||||||
|
size="L"
|
||||||
|
palette="inverse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
<div className="flex w-full min-w-0 flex-col gap-[var(--space-600)] py-0 md:pb-8">
|
||||||
|
<div className="print:hidden">
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="default"
|
||||||
|
title={bannerTitle}
|
||||||
|
description={t("bannerDescription")}
|
||||||
|
hasLeadingIcon
|
||||||
|
hasBodyText
|
||||||
|
hasTrailingIcon={false}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-[var(--space-300)] print:hidden">
|
||||||
|
<Button
|
||||||
|
buttonType="outline"
|
||||||
|
palette="inverse"
|
||||||
|
size="xsmall"
|
||||||
|
type="button"
|
||||||
|
className={outlineButtonClass}
|
||||||
|
onClick={handlePdf}
|
||||||
|
>
|
||||||
|
{t("downloadPdf")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="outline"
|
||||||
|
palette="inverse"
|
||||||
|
size="xsmall"
|
||||||
|
type="button"
|
||||||
|
className={outlineButtonClass}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
{t("print")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{exportError ? (
|
||||||
|
<p role="alert" className="text-small-paragraph print:hidden">
|
||||||
|
{t("exportFailedDescription")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<CommunityRule
|
||||||
|
sections={sections}
|
||||||
|
useCardStyle={!mdUp}
|
||||||
|
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full flex-col border-t border-[var(--color-content-invert-secondary)] print:hidden">
|
||||||
|
<h2 className="sr-only">{t("factsHeading")}</h2>
|
||||||
|
<FactRow label={t("whoCanEdit")} value={whoCanEdit} />
|
||||||
|
<FactRow label={t("whoCanSee")} value={t("whoCanSeeValue")} />
|
||||||
|
<FactRow label={t("lastChanged")} value={lastChanged} />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full min-w-0 flex-col gap-[var(--space-400)] rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-600)] text-[var(--color-content-invert-primary)] print:hidden">
|
||||||
|
<p className="font-bricolage text-x-small-heading">
|
||||||
|
{t("copyCardTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-small-paragraph">
|
||||||
|
{t("copyCardBody").replace("{counts}", countsSpoken)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
buttonType="outline"
|
||||||
|
palette="inverse"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => {
|
||||||
|
setCopyError(null);
|
||||||
|
setCopyOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("makeACopy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-small-paragraph text-[var(--color-content-invert-secondary)] print:hidden">
|
||||||
|
{t("privacy")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MakeACopyDialog
|
||||||
|
isOpen={copyOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!copyBusy) setCopyOpen(false);
|
||||||
|
}}
|
||||||
|
possessive={possessive}
|
||||||
|
countsSpoken={countsSpoken}
|
||||||
|
viewerEmail={viewerEmail}
|
||||||
|
busy={copyBusy}
|
||||||
|
error={copyError}
|
||||||
|
onConfirm={(communityName) => {
|
||||||
|
void handleCopy(communityName);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FactRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-[var(--space-400)] border-b border-[var(--color-content-invert-secondary)] py-[var(--space-300)]">
|
||||||
|
<p className="text-small-label text-[var(--color-content-invert-secondary)]">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-right text-small-paragraph text-[var(--color-content-invert-primary)]">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Button from "../../../components/buttons/Button";
|
||||||
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
|
import Dialog from "../../../components/modals/Dialog";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
const COMMUNITY_NAME_MAX = 48;
|
||||||
|
|
||||||
|
export function MakeACopyDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
possessive,
|
||||||
|
countsSpoken,
|
||||||
|
viewerEmail,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
possessive: string;
|
||||||
|
countsSpoken: string;
|
||||||
|
viewerEmail: string;
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onConfirm: (_title: string) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslation("pages.invite");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
|
const trimmed = title.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!busy) onClose();
|
||||||
|
}}
|
||||||
|
title={t("dialogTitle")}
|
||||||
|
description={t("dialogDescription")
|
||||||
|
.replace("{possessive}", possessive)
|
||||||
|
.replace("{counts}", countsSpoken)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="medium"
|
||||||
|
buttonType="outline"
|
||||||
|
palette="default"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="medium"
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
setNameError(t("nameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConfirm(trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("confirm")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full min-w-0 flex-col gap-[var(--space-500)]">
|
||||||
|
<TextInput
|
||||||
|
id="make-copy-community-name"
|
||||||
|
inputSize="small"
|
||||||
|
showHelpIcon={false}
|
||||||
|
label={t("communityNameLabel")}
|
||||||
|
placeholder={t("communityNamePlaceholder")}
|
||||||
|
value={title}
|
||||||
|
maxLength={COMMUNITY_NAME_MAX}
|
||||||
|
error={Boolean(nameError)}
|
||||||
|
textHint={
|
||||||
|
nameError ||
|
||||||
|
t("characterCountTemplate")
|
||||||
|
.replace("{current}", String(title.length))
|
||||||
|
.replace("{max}", String(COMMUNITY_NAME_MAX))
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value);
|
||||||
|
setNameError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="grid w-full grid-cols-1 gap-[var(--space-300)] sm:grid-cols-2">
|
||||||
|
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
|
||||||
|
<p className="text-small-label">{t("comesWithTitle")}</p>
|
||||||
|
<p className="mt-[var(--space-200)] text-small-paragraph">
|
||||||
|
{t("comesWithBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
|
||||||
|
<p className="text-small-label">{t("staysWithTitle")}</p>
|
||||||
|
<p className="mt-[var(--space-200)] text-small-paragraph">
|
||||||
|
{t("staysWithBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
|
{t("saveTo").replace("{email}", viewerEmail)}
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../../lib/siteMetadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/invite/expired", {
|
||||||
|
title: messages.metadata.inviteExpired.title,
|
||||||
|
description: messages.metadata.inviteExpired.description,
|
||||||
|
robots: NO_INDEX_ROBOTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ExpiredInviteLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import { ExpiredInviteScreen } from "../_components/ExpiredInviteScreen";
|
||||||
|
|
||||||
|
function ExpiredInviteFallback() {
|
||||||
|
const t = useTranslation("pages.invite");
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] items-center justify-center bg-[var(--color-surface-invert-brand-primary)]">
|
||||||
|
<p className="text-[14px] text-[var(--color-content-default-primary)]">
|
||||||
|
{t("loadingFallback")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExpiredInvitePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ExpiredInviteFallback />}>
|
||||||
|
<ExpiredInviteScreen />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/invite", {
|
||||||
|
title: messages.metadata.invite.title,
|
||||||
|
description: messages.metadata.invite.description,
|
||||||
|
robots: NO_INDEX_ROBOTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function InviteLayout({ children }: { children: ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -345,7 +345,7 @@ export function ProfilePageView({
|
|||||||
{
|
{
|
||||||
id: "view",
|
id: "view",
|
||||||
label: t("viewPublic"),
|
label: t("viewPublic"),
|
||||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
href: `/invite/${encodeURIComponent(rule.id)}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "../../../../../lib/server/db";
|
||||||
|
import {
|
||||||
|
getSessionPepper,
|
||||||
|
isDatabaseConfigured,
|
||||||
|
} from "../../../../../lib/server/env";
|
||||||
|
import { hashSessionToken, newSessionToken } from "../../../../../lib/server/hash";
|
||||||
|
import { sendRuleStakeholderInviteEmail } from "../../../../../lib/server/mail";
|
||||||
|
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||||
|
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||||
|
import { stakeholderInviteVerifyUrl } from "../../../../../lib/server/ruleStakeholderInviteOps";
|
||||||
|
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../lib/server/ruleStakeholders";
|
||||||
|
import {
|
||||||
|
dbUnavailable,
|
||||||
|
errorJson,
|
||||||
|
rateLimited,
|
||||||
|
serverMisconfigured,
|
||||||
|
} from "../../../../../lib/server/responses";
|
||||||
|
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||||
|
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
||||||
|
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
||||||
|
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||||
|
import { resendExpiredStakeholderInviteBodySchema } from "../../../../../lib/server/validation/createFlowSchemas";
|
||||||
|
|
||||||
|
function clientIp(request: NextRequest): string {
|
||||||
|
return (
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
request.headers.get("x-real-ip") ??
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = apiRoute(
|
||||||
|
"invites.ruleStakeholder.resendExpired",
|
||||||
|
async (request: NextRequest, _ctx, { requestId }) => {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readLimitedJson(request);
|
||||||
|
if (body.ok === false) return body.response;
|
||||||
|
const parsed = resendExpiredStakeholderInviteBodySchema.safeParse(body.value);
|
||||||
|
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||||
|
|
||||||
|
const { token, email } = parsed.data;
|
||||||
|
const rl = rateLimitKey(
|
||||||
|
`invite-resend-expired:${clientIp(request)}:${email}`,
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
if (rl.ok === false) {
|
||||||
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pepper: string;
|
||||||
|
try {
|
||||||
|
pepper = getSessionPepper();
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, {
|
||||||
|
phase: "getSessionPepper",
|
||||||
|
});
|
||||||
|
return serverMisconfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashSessionToken(token, pepper);
|
||||||
|
const row = await prisma.ruleStakeholder.findUnique({
|
||||||
|
where: { inviteTokenHash: tokenHash },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
inviteTokenHash: true,
|
||||||
|
inviteExpiresAt: true,
|
||||||
|
invitedBy: { select: { email: true } },
|
||||||
|
rule: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
summary: true,
|
||||||
|
document: true,
|
||||||
|
user: { select: { email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row || row.email !== email) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevHash = row.inviteTokenHash;
|
||||||
|
const prevExp = row.inviteExpiresAt;
|
||||||
|
const nextToken = newSessionToken();
|
||||||
|
const newHash = hashSessionToken(nextToken, pepper);
|
||||||
|
const newExp = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||||
|
|
||||||
|
await prisma.ruleStakeholder.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
inviteTokenHash: newHash,
|
||||||
|
inviteExpiresAt: newExp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const origin = getPublicOrigin(request);
|
||||||
|
const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken);
|
||||||
|
const inviterEmail =
|
||||||
|
row.invitedBy?.email ?? row.rule.user?.email ?? "CommunityRule";
|
||||||
|
try {
|
||||||
|
await sendRuleStakeholderInviteEmail({
|
||||||
|
to: row.email,
|
||||||
|
verifyUrl,
|
||||||
|
origin,
|
||||||
|
communityName: row.rule.title,
|
||||||
|
inviterEmail,
|
||||||
|
summary: row.rule.summary,
|
||||||
|
document: row.rule.document,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, {
|
||||||
|
phase: "sendRuleStakeholderInviteEmail",
|
||||||
|
});
|
||||||
|
await prisma.ruleStakeholder
|
||||||
|
.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
inviteTokenHash: prevHash,
|
||||||
|
inviteExpiresAt: prevExp,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return errorJson(
|
||||||
|
"mail_failed",
|
||||||
|
"Could not resend stakeholder invite",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
} from "../../../../../lib/server/session";
|
} from "../../../../../lib/server/session";
|
||||||
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
||||||
|
import {
|
||||||
|
INVITE_COPY_QUERY,
|
||||||
|
INVITE_COPY_VALUE,
|
||||||
|
inviteExpiredPath,
|
||||||
|
invitedRulePath,
|
||||||
|
} from "../../../../../lib/create/invitePaths";
|
||||||
|
|
||||||
const SCOPE = "invites.ruleStakeholder.verify";
|
const SCOPE = "invites.ruleStakeholder.verify";
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ export async function GET(request: NextRequest) {
|
|||||||
) {
|
) {
|
||||||
return redirectWithRequestId(
|
return redirectWithRequestId(
|
||||||
request,
|
request,
|
||||||
"/login?error=expired_link",
|
inviteExpiredPath(token),
|
||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,9 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
await setSessionCookie(sessionToken, expiresAt);
|
await setSessionCookie(sessionToken, expiresAt);
|
||||||
|
|
||||||
const dest = `/rules/${encodeURIComponent(row.ruleId)}`;
|
const copy =
|
||||||
|
request.nextUrl.searchParams.get(INVITE_COPY_QUERY) === INVITE_COPY_VALUE;
|
||||||
|
const dest = invitedRulePath(row.ruleId, { copy });
|
||||||
return redirectWithRequestId(request, dest, requestId);
|
return redirectWithRequestId(request, dest, requestId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logRouteError(SCOPE, requestId, err);
|
logRouteError(SCOPE, requestId, err);
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "../../../../../lib/server/db";
|
||||||
|
import { isDatabaseConfigured } from "../../../../../lib/server/env";
|
||||||
|
import {
|
||||||
|
dbUnavailable,
|
||||||
|
forbidden,
|
||||||
|
notFound,
|
||||||
|
rateLimited,
|
||||||
|
unauthorized,
|
||||||
|
} from "../../../../../lib/server/responses";
|
||||||
|
import { getSessionUser } from "../../../../../lib/server/session";
|
||||||
|
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||||
|
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||||
|
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
||||||
|
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||||
|
import {
|
||||||
|
makeCopyRuleBodySchema,
|
||||||
|
publishedRuleDocumentSchema,
|
||||||
|
} from "../../../../../lib/server/validation/createFlowSchemas";
|
||||||
|
import {
|
||||||
|
findCopiedNameHit,
|
||||||
|
stripCopiedRuleDocument,
|
||||||
|
} from "../../../../../lib/create/copiedRuleDocument";
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export const POST = apiRoute<RouteContext>(
|
||||||
|
"rules.byId.makeCopy",
|
||||||
|
async (request, context) => {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await readLimitedJson(request);
|
||||||
|
if (body.ok === false) return body.response;
|
||||||
|
const parsed = makeCopyRuleBodySchema.safeParse(body.value);
|
||||||
|
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||||
|
|
||||||
|
const rl = rateLimitKey(`rule-make-copy:${user.id}:${id}`, 60_000);
|
||||||
|
if (rl.ok === false) {
|
||||||
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stakeholder = await prisma.ruleStakeholder.findFirst({
|
||||||
|
where: { ruleId: id, userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
rule: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
document: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!stakeholder) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
if (stakeholder.rule.userId === user.id) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = stripCopiedRuleDocument(stakeholder.rule.document);
|
||||||
|
const documentParsed = publishedRuleDocumentSchema.safeParse(stripped);
|
||||||
|
if (!documentParsed.success) {
|
||||||
|
return jsonFromZodError(documentParsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedNameHit = findCopiedNameHit(
|
||||||
|
documentParsed.data,
|
||||||
|
stakeholder.rule.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRule = await prisma.publishedRule.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
title: parsed.data.title,
|
||||||
|
summary: null,
|
||||||
|
document: documentParsed.data as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rule: {
|
||||||
|
id: newRule.id,
|
||||||
|
title: newRule.title,
|
||||||
|
summary: newRule.summary,
|
||||||
|
document: documentParsed.data,
|
||||||
|
copiedFromTitle: stakeholder.rule.title,
|
||||||
|
copiedNameHit,
|
||||||
|
createdAt: newRule.createdAt,
|
||||||
|
updatedAt: newRule.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -561,6 +561,119 @@ export type DuplicateRuleResult =
|
|||||||
| { ok: true; id: string; title: string }
|
| { ok: true; id: string; title: string }
|
||||||
| { ok: false; error: string; status: number };
|
| { ok: false; error: string; status: number };
|
||||||
|
|
||||||
|
export type MakeCopyRuleResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
copiedFromTitle: string;
|
||||||
|
copiedNameHit: {
|
||||||
|
categoryName: string;
|
||||||
|
entryTitle: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
| { ok: false; error: string; status: number };
|
||||||
|
|
||||||
|
export async function makeCopyPublishedRule(
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<MakeCopyRuleResult> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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(
|
export async function duplicatePublishedRule(
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<DuplicateRuleResult> {
|
): Promise<DuplicateRuleResult> {
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const copy = structuredClone(document) as Record<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { CopiedNameHit } from "./copiedRuleDocument";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridges final-review → completed without query strings, and re-opens a rule
|
* Bridges final-review → completed without query strings, and re-opens a rule
|
||||||
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id]. Profile
|
* 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";
|
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
|
||||||
|
|
||||||
@@ -10,6 +12,9 @@ export type StoredLastPublishedRule = {
|
|||||||
title: string;
|
title: string;
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
document: Record<string, unknown>;
|
document: Record<string, unknown>;
|
||||||
|
/** Present after Make a copy; drives the copy-ready banner and lavender surface. */
|
||||||
|
copiedFromTitle?: string;
|
||||||
|
copiedNameHit?: CopiedNameHit | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
|
export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
|
||||||
@@ -39,11 +44,33 @@ export function readLastPublishedRule(): StoredLastPublishedRule | null {
|
|||||||
} else {
|
} else {
|
||||||
summary = undefined;
|
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<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof hit.categoryName === "string" &&
|
||||||
|
typeof hit.entryTitle === "string"
|
||||||
|
) {
|
||||||
|
copiedNameHit = {
|
||||||
|
categoryName: hit.categoryName,
|
||||||
|
entryTitle: hit.entryTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: o.id,
|
id: o.id,
|
||||||
title: o.title,
|
title: o.title,
|
||||||
summary,
|
summary,
|
||||||
document: doc as Record<string, unknown>,
|
document: doc as Record<string, unknown>,
|
||||||
|
...(copiedFromTitle ? { copiedFromTitle } : {}),
|
||||||
|
...(copiedNameHit ? { copiedNameHit } : {}),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
|
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
|
||||||
|
import { formatInviteCountWord } from "./parseInviteEmails";
|
||||||
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
|
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
|
||||||
import sharedTerms from "../../messages/en/create/sharedTerms.json";
|
import sharedTerms from "../../messages/en/create/sharedTerms.json";
|
||||||
|
|
||||||
@@ -37,8 +38,11 @@ export function clipCommunityInviteDescription(
|
|||||||
return trimmed.slice(0, DESCRIPTION_MAX).trimEnd();
|
return trimmed.slice(0, DESCRIPTION_MAX).trimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */
|
/** Values vs method cards on a published Rule. Shared terms do not count. */
|
||||||
export function inviteRuleCountsLabel(document: unknown): string {
|
export function inviteRuleValueAndCardCounts(document: unknown): {
|
||||||
|
values: number;
|
||||||
|
cards: number;
|
||||||
|
} {
|
||||||
const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
|
const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
|
||||||
let values = 0;
|
let values = 0;
|
||||||
let cards = 0;
|
let cards = 0;
|
||||||
@@ -50,7 +54,21 @@ export function inviteRuleCountsLabel(document: unknown): string {
|
|||||||
cards += n;
|
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 valuePart = values === 1 ? "1 VALUE" : `${values} VALUES`;
|
||||||
const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`;
|
const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`;
|
||||||
return `${valuePart} AND ${cardPart}`;
|
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}`;
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@ export function isChromelessNavigationPath(
|
|||||||
if (!pathname) {
|
if (!pathname) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (pathname.startsWith("/create") || pathname === "/login") {
|
if (
|
||||||
|
pathname.startsWith("/create") ||
|
||||||
|
pathname === "/login" ||
|
||||||
|
pathname.startsWith("/invite")
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname);
|
return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname);
|
||||||
|
|||||||
@@ -224,3 +224,60 @@ export async function listProfileRulesForUser(
|
|||||||
});
|
});
|
||||||
return combined.slice(0, cap);
|
return combined.slice(0, cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StakeholderInviteView = {
|
||||||
|
ruleId: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
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<StakeholderInviteView | null> {
|
||||||
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,6 +223,25 @@ export const postRuleStakeholderBodySchema = z.object({
|
|||||||
email: stakeholderEmailSchema,
|
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<typeof makeCopyRuleBodySchema>;
|
||||||
|
|
||||||
|
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<
|
export type PostRuleStakeholderBody = z.infer<
|
||||||
typeof postRuleStakeholderBodySchema
|
typeof postRuleStakeholderBodySchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"toastTitle": "Your Rule is finished",
|
"toastTitle": "Your Rule is finished",
|
||||||
"toastDescription": "This is what the people you invite will see.",
|
"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?",
|
"askOrganizerLead": "Has your Rule changed how your community works?",
|
||||||
"askOrganizerLink": "Tell an organizer what changed.",
|
"askOrganizerLink": "Tell an organizer what changed.",
|
||||||
"shareNoRuleTitle": "Nothing to share yet",
|
"shareNoRuleTitle": "Nothing to share yet",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import login from "./pages/login.json";
|
|||||||
import profile from "./pages/profile.json";
|
import profile from "./pages/profile.json";
|
||||||
import notFoundPage from "./pages/notFoundPage.json";
|
import notFoundPage from "./pages/notFoundPage.json";
|
||||||
import ruleDetail from "./pages/ruleDetail.json";
|
import ruleDetail from "./pages/ruleDetail.json";
|
||||||
|
import invite from "./pages/invite.json";
|
||||||
import navigation from "./navigation.json";
|
import navigation from "./navigation.json";
|
||||||
import metadata from "./metadata.json";
|
import metadata from "./metadata.json";
|
||||||
import modalsShare from "./modals/share.json";
|
import modalsShare from "./modals/share.json";
|
||||||
@@ -114,6 +115,7 @@ export default {
|
|||||||
profile,
|
profile,
|
||||||
notFoundPage,
|
notFoundPage,
|
||||||
ruleDetail,
|
ruleDetail,
|
||||||
|
invite,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
community: {
|
community: {
|
||||||
|
|||||||
@@ -50,6 +50,14 @@
|
|||||||
"title": "Log in",
|
"title": "Log in",
|
||||||
"description": "Enter your email and we'll send you a magic link to sign in. No password needed!"
|
"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": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"description": "Manage your CommunityRules, saved progress, and account."
|
"description": "Manage your CommunityRules, saved progress, and account."
|
||||||
|
|||||||
@@ -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…"
|
||||||
|
}
|
||||||
@@ -130,6 +130,24 @@ describe("CompletedScreen", () => {
|
|||||||
).not.toBeInTheDocument();
|
).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(<CompletedScreen />);
|
||||||
|
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", () => {
|
it("shows post-publish toast in status region when celebrate query is set", () => {
|
||||||
mockSearchParams({
|
mockSearchParams({
|
||||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||||
|
|||||||
@@ -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(<ExpiredInviteScreen />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<InvitedRuleView
|
||||||
|
ruleId="rule-1"
|
||||||
|
title="Juniper Street Food Share"
|
||||||
|
summary="Neighbors sharing food."
|
||||||
|
document={document}
|
||||||
|
updatedAt="2026-11-03T00:00:00.000Z"
|
||||||
|
inviterEmail="maya@example.com"
|
||||||
|
viewerEmail="reader@example.com"
|
||||||
|
countsSpoken="one value and zero cards"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,7 @@ describe("CreateFlowTextFieldScreen (community description)", () => {
|
|||||||
<CreateFlowTextFieldScreen
|
<CreateFlowTextFieldScreen
|
||||||
messageNamespace="create.community.communityContext"
|
messageNamespace="create.community.communityContext"
|
||||||
stateField="communityContext"
|
stateField="communityContext"
|
||||||
locationField="communityLocation"
|
locationField
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
multiline
|
multiline
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ describe("isChromelessNavigationPath", () => {
|
|||||||
["/create", true],
|
["/create", true],
|
||||||
["/create/completed", true],
|
["/create/completed", true],
|
||||||
["/login", true],
|
["/login", true],
|
||||||
|
["/invite/expired", true],
|
||||||
|
["/invite/rule-1", true],
|
||||||
["/use-cases/mutual-aid-colorado/rule", true],
|
["/use-cases/mutual-aid-colorado/rule", true],
|
||||||
["/use-cases/food-not-bombs/rule/", true],
|
["/use-cases/food-not-bombs/rule/", true],
|
||||||
["/", false],
|
["/", false],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
communityInviteInitials,
|
communityInviteInitials,
|
||||||
communityPossessive,
|
communityPossessive,
|
||||||
inviteRuleCountsLabel,
|
inviteRuleCountsLabel,
|
||||||
|
inviteRuleCountsSpoken,
|
||||||
} from "../../lib/create/ruleInviteCardMeta";
|
} from "../../lib/create/ruleInviteCardMeta";
|
||||||
|
|
||||||
describe("communityInviteInitials", () => {
|
describe("communityInviteInitials", () => {
|
||||||
@@ -61,3 +62,26 @@ describe("inviteRuleCountsLabel", () => {
|
|||||||
).toBe("3 VALUES AND 2 CARDS");
|
).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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 { 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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,7 +70,7 @@ describe("GET /api/invites/rule-stakeholder/verify", () => {
|
|||||||
|
|
||||||
expect(res.status).toBeGreaterThanOrEqual(300);
|
expect(res.status).toBeGreaterThanOrEqual(300);
|
||||||
expect(res.status).toBeLessThan(400);
|
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();
|
expect(setCookieMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,4 +97,50 @@ describe("GET /api/invites/rule-stakeholder/verify", () => {
|
|||||||
);
|
);
|
||||||
expect(upsertMock).not.toHaveBeenCalled();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user