@@ -180,7 +208,7 @@ export function CompletedScreen() {
size="L"
palette="inverse"
/>
- {headerTitle ? (
+ {headerTitle && !surfaceCopied ? (
{completed.askOrganizerLead}{" "}
+ {copiedNameHit && copiedFromTitle ? (
+
+ ) : null}
;
+ 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 (
+
+ );
+}
diff --git a/app/(app)/invite/_components/ExpiredInviteScreen.tsx b/app/(app)/invite/_components/ExpiredInviteScreen.tsx
new file mode 100644
index 0000000..9639497
--- /dev/null
+++ b/app/(app)/invite/_components/ExpiredInviteScreen.tsx
@@ -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 (
+
+
{
+ router.push("/");
+ }}
+ ariaLabelledBy={titleId}
+ belowCard={
+
+ {t("backToHome")}
+
+ }
+ >
+
+
+ {formError ? (
+
+ {formError}
+
+ ) : null}
+ {!sent ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/(app)/invite/_components/InvitedRuleView.tsx b/app/(app)/invite/_components/InvitedRuleView.tsx
new file mode 100644
index 0000000..bf0430a
--- /dev/null
+++ b/app/(app)/invite/_components/InvitedRuleView.tsx
@@ -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;
+ 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(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {exportError ? (
+
+ {t("exportFailedDescription")}
+
+ ) : null}
+
+
+
{t("factsHeading")}
+
+
+
+
+
+
+ {t("copyCardTitle")}
+
+
+ {t("copyCardBody").replace("{counts}", countsSpoken)}
+
+
+
+
+ {t("privacy")}
+
+
+
+
+
+
+
{
+ if (!copyBusy) setCopyOpen(false);
+ }}
+ possessive={possessive}
+ countsSpoken={countsSpoken}
+ viewerEmail={viewerEmail}
+ busy={copyBusy}
+ error={copyError}
+ onConfirm={(communityName) => {
+ void handleCopy(communityName);
+ }}
+ />
+
+ );
+}
+
+function FactRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/app/(app)/invite/_components/MakeACopyDialog.tsx b/app/(app)/invite/_components/MakeACopyDialog.tsx
new file mode 100644
index 0000000..8ad11fc
--- /dev/null
+++ b/app/(app)/invite/_components/MakeACopyDialog.tsx
@@ -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(null);
+ const trimmed = title.trim();
+
+ return (
+
+ );
+}
diff --git a/app/(app)/invite/expired/layout.tsx b/app/(app)/invite/expired/layout.tsx
new file mode 100644
index 0000000..41d48f6
--- /dev/null
+++ b/app/(app)/invite/expired/layout.tsx
@@ -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;
+}
diff --git a/app/(app)/invite/expired/page.tsx b/app/(app)/invite/expired/page.tsx
new file mode 100644
index 0000000..3720a68
--- /dev/null
+++ b/app/(app)/invite/expired/page.tsx
@@ -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 (
+
+
+ {t("loadingFallback")}
+
+
+ );
+}
+
+export default function ExpiredInvitePage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/app/(app)/invite/layout.tsx b/app/(app)/invite/layout.tsx
new file mode 100644
index 0000000..ee922e6
--- /dev/null
+++ b/app/(app)/invite/layout.tsx
@@ -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;
+}
diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx
index f8b8970..239cd11 100644
--- a/app/(app)/profile/_components/ProfilePage.view.tsx
+++ b/app/(app)/profile/_components/ProfilePage.view.tsx
@@ -345,7 +345,7 @@ export function ProfilePageView({
{
id: "view",
label: t("viewPublic"),
- href: `/rules/${encodeURIComponent(rule.id)}`,
+ href: `/invite/${encodeURIComponent(rule.id)}`,
},
]
: [
diff --git a/app/api/invites/rule-stakeholder/resend/route.ts b/app/api/invites/rule-stakeholder/resend/route.ts
new file mode 100644
index 0000000..666b7d9
--- /dev/null
+++ b/app/api/invites/rule-stakeholder/resend/route.ts
@@ -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 });
+ },
+);
diff --git a/app/api/invites/rule-stakeholder/verify/route.ts b/app/api/invites/rule-stakeholder/verify/route.ts
index 1f8ef10..c36a562 100644
--- a/app/api/invites/rule-stakeholder/verify/route.ts
+++ b/app/api/invites/rule-stakeholder/verify/route.ts
@@ -17,6 +17,12 @@ import {
setSessionCookie,
} from "../../../../../lib/server/session";
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
+import {
+ INVITE_COPY_QUERY,
+ INVITE_COPY_VALUE,
+ inviteExpiredPath,
+ invitedRulePath,
+} from "../../../../../lib/create/invitePaths";
const SCOPE = "invites.ruleStakeholder.verify";
@@ -66,7 +72,7 @@ export async function GET(request: NextRequest) {
) {
return redirectWithRequestId(
request,
- "/login?error=expired_link",
+ inviteExpiredPath(token),
requestId,
);
}
@@ -104,7 +110,9 @@ export async function GET(request: NextRequest) {
);
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);
} catch (err) {
logRouteError(SCOPE, requestId, err);
diff --git a/app/api/rules/[id]/make-copy/route.ts b/app/api/rules/[id]/make-copy/route.ts
new file mode 100644
index 0000000..01cbea2
--- /dev/null
+++ b/app/api/rules/[id]/make-copy/route.ts
@@ -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(
+ "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,
+ },
+ });
+ },
+);
diff --git a/lib/create/api.ts b/lib/create/api.ts
index 5815226..760e6ec 100644
--- a/lib/create/api.ts
+++ b/lib/create/api.ts
@@ -561,6 +561,119 @@ export type DuplicateRuleResult =
| { ok: true; id: string; title: string }
| { ok: false; error: string; status: number };
+export type MakeCopyRuleResult =
+ | {
+ ok: true;
+ id: string;
+ title: string;
+ summary: string | null;
+ document: Record;
+ copiedFromTitle: string;
+ copiedNameHit: {
+ categoryName: string;
+ entryTitle: string;
+ } | null;
+ }
+ | { ok: false; error: string; status: number };
+
+export async function makeCopyPublishedRule(
+ id: string,
+ title: string,
+): Promise {
+ try {
+ const res = await fetch(
+ `/api/rules/${encodeURIComponent(id)}/make-copy`,
+ {
+ method: "POST",
+ credentials: "include",
+ headers: jsonHeaders,
+ body: JSON.stringify({ title }),
+ },
+ );
+ const data = (await safeParseJsonResponse(res)) as {
+ rule?: {
+ id: string;
+ title: string;
+ summary?: string | null;
+ document?: Record;
+ copiedFromTitle?: string;
+ copiedNameHit?: {
+ categoryName: string;
+ entryTitle: string;
+ } | null;
+ };
+ } | null;
+ const rule = data && typeof data === "object" ? data.rule : undefined;
+ const document = rule?.document;
+ if (
+ !res.ok ||
+ !rule ||
+ typeof rule.id !== "string" ||
+ typeof rule.title !== "string" ||
+ !document ||
+ typeof document !== "object" ||
+ Array.isArray(document)
+ ) {
+ const fromBody =
+ data && typeof data === "object" ? readApiErrorMessage(data) : null;
+ const msg =
+ fromBody && fromBody !== "Request failed"
+ ? fromBody
+ : PUBLISH_FAILED_FALLBACK;
+ return {
+ ok: false as const,
+ error: msg,
+ status: res.status,
+ };
+ }
+ return {
+ ok: true,
+ id: rule.id,
+ title: rule.title,
+ summary: typeof rule.summary === "string" ? rule.summary : null,
+ document,
+ copiedFromTitle:
+ typeof rule.copiedFromTitle === "string" ? rule.copiedFromTitle : "",
+ copiedNameHit: rule.copiedNameHit ?? null,
+ };
+ } catch {
+ return {
+ ok: false as const,
+ error: DRAFT_SAVE_NETWORK_ERROR,
+ status: 0,
+ };
+ }
+}
+
+export async function resendExpiredStakeholderInvite(
+ token: string,
+ email: string,
+): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
+ try {
+ const res = await fetch("/api/invites/rule-stakeholder/resend", {
+ method: "POST",
+ credentials: "include",
+ headers: jsonHeaders,
+ body: JSON.stringify({ token, email }),
+ });
+ const data = await safeParseJsonResponse(res);
+ if (!res.ok) {
+ const fromBody =
+ data && typeof data === "object" ? readApiErrorMessage(data) : null;
+ return {
+ ok: false as const,
+ error: fromBody && fromBody !== "Request failed"
+ ? fromBody
+ : DRAFT_SAVE_NETWORK_ERROR,
+ retryAfterMs: retryAfterFromResponse(res, data),
+ };
+ }
+ return { ok: true };
+ } catch {
+ return { ok: false as const, error: DRAFT_SAVE_NETWORK_ERROR };
+ }
+}
+
export async function duplicatePublishedRule(
id: string,
): Promise {
diff --git a/lib/create/copiedRuleDocument.ts b/lib/create/copiedRuleDocument.ts
new file mode 100644
index 0000000..9098a8a
--- /dev/null
+++ b/lib/create/copiedRuleDocument.ts
@@ -0,0 +1,59 @@
+import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
+import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
+
+const IDENTITY_KEYS = [
+ "communityAvatarUrl",
+ "communityAvatarFileName",
+ "communityContext",
+ "communityLocation",
+ "communitySaveEmail",
+ "title",
+] as const;
+
+export type CopiedNameHit = {
+ categoryName: string;
+ entryTitle: string;
+};
+
+/** Copy cards and text; drop name, description, photo, and save-email. */
+export function stripCopiedRuleDocument(
+ document: unknown,
+): Record {
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
+ return {};
+ }
+ const copy = structuredClone(document) as Record;
+ for (const key of IDENTITY_KEYS) {
+ delete copy[key];
+ }
+ return copy;
+}
+
+function entryHaystack(entry: CommunityRuleSection["entries"][number]): string {
+ const blockText = (entry.blocks ?? [])
+ .map((block) => `${block.label} ${block.body}`)
+ .join("\n");
+ return `${entry.title}\n${entry.body}\n${blockText}`;
+}
+
+/** First line that still names the original community after a copy. */
+export function findCopiedNameHit(
+ document: unknown,
+ originalName: string,
+): CopiedNameHit | null {
+ const name = originalName.trim();
+ if (name.length < 2) return null;
+ const needle = name.toLowerCase();
+ const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
+ for (const section of sections) {
+ for (const entry of section.entries) {
+ if (entryHaystack(entry).toLowerCase().includes(needle)) {
+ return {
+ categoryName: section.categoryName,
+ entryTitle: entry.title,
+ };
+ }
+ }
+ }
+ return null;
+}
diff --git a/lib/create/inviteLastChanged.ts b/lib/create/inviteLastChanged.ts
new file mode 100644
index 0000000..feb01f1
--- /dev/null
+++ b/lib/create/inviteLastChanged.ts
@@ -0,0 +1,10 @@
+/** Short date for the invited-view facts table (`6 Nov 2026`). */
+export function formatInviteLastChanged(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return iso;
+ return date.toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
diff --git a/lib/create/invitePaths.ts b/lib/create/invitePaths.ts
new file mode 100644
index 0000000..e390f7b
--- /dev/null
+++ b/lib/create/invitePaths.ts
@@ -0,0 +1,20 @@
+export const INVITE_COPY_QUERY = "copy";
+export const INVITE_COPY_VALUE = "1";
+export const CREATE_FLOW_COPIED_QUERY = "copied";
+export const CREATE_FLOW_COPIED_VALUE = "1";
+
+export function invitedRulePath(
+ ruleId: string,
+ opts?: { copy?: boolean },
+): string {
+ const base = `/invite/${encodeURIComponent(ruleId)}`;
+ if (opts?.copy) {
+ return `${base}?${INVITE_COPY_QUERY}=${INVITE_COPY_VALUE}`;
+ }
+ return base;
+}
+
+export function inviteExpiredPath(token?: string | null): string {
+ if (!token) return "/invite/expired";
+ return `/invite/expired?token=${encodeURIComponent(token)}`;
+}
diff --git a/lib/create/lastPublishedRule.ts b/lib/create/lastPublishedRule.ts
index 8b06363..35fe69c 100644
--- a/lib/create/lastPublishedRule.ts
+++ b/lib/create/lastPublishedRule.ts
@@ -1,7 +1,9 @@
+import type { CopiedNameHit } from "./copiedRuleDocument";
+
/**
* Bridges final-review → completed without query strings, and re-opens a rule
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id]. Profile
- * "Manage" links here; "View" uses `/rules/[id]`.
+ * "Manage" links here; stakeholder "View" uses `/invite/[id]`.
*/
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
@@ -10,6 +12,9 @@ export type StoredLastPublishedRule = {
title: string;
summary?: string | null;
document: Record;
+ /** Present after Make a copy; drives the copy-ready banner and lavender surface. */
+ copiedFromTitle?: string;
+ copiedNameHit?: CopiedNameHit | null;
};
export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
@@ -39,11 +44,33 @@ export function readLastPublishedRule(): StoredLastPublishedRule | null {
} else {
summary = undefined;
}
+ let copiedFromTitle: string | undefined;
+ if (
+ typeof o.copiedFromTitle === "string" &&
+ o.copiedFromTitle.trim().length > 0
+ ) {
+ copiedFromTitle = o.copiedFromTitle;
+ }
+ let copiedNameHit: CopiedNameHit | null | undefined;
+ if (o.copiedNameHit && typeof o.copiedNameHit === "object") {
+ const hit = o.copiedNameHit as Record;
+ if (
+ typeof hit.categoryName === "string" &&
+ typeof hit.entryTitle === "string"
+ ) {
+ copiedNameHit = {
+ categoryName: hit.categoryName,
+ entryTitle: hit.entryTitle,
+ };
+ }
+ }
return {
id: o.id,
title: o.title,
summary,
document: doc as Record,
+ ...(copiedFromTitle ? { copiedFromTitle } : {}),
+ ...(copiedNameHit ? { copiedNameHit } : {}),
};
} catch {
return null;
diff --git a/lib/create/ruleInviteCardMeta.ts b/lib/create/ruleInviteCardMeta.ts
index 5ff0d13..8ddaef7 100644
--- a/lib/create/ruleInviteCardMeta.ts
+++ b/lib/create/ruleInviteCardMeta.ts
@@ -1,4 +1,5 @@
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
+import { formatInviteCountWord } from "./parseInviteEmails";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
import sharedTerms from "../../messages/en/create/sharedTerms.json";
@@ -37,8 +38,11 @@ export function clipCommunityInviteDescription(
return trimmed.slice(0, DESCRIPTION_MAX).trimEnd();
}
-/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */
-export function inviteRuleCountsLabel(document: unknown): string {
+/** Values vs method cards on a published Rule. Shared terms do not count. */
+export function inviteRuleValueAndCardCounts(document: unknown): {
+ values: number;
+ cards: number;
+} {
const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
let values = 0;
let cards = 0;
@@ -50,7 +54,21 @@ export function inviteRuleCountsLabel(document: unknown): string {
cards += n;
}
}
+ return { values, cards };
+}
+
+/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */
+export function inviteRuleCountsLabel(document: unknown): string {
+ const { values, cards } = inviteRuleValueAndCardCounts(document);
const valuePart = values === 1 ? "1 VALUE" : `${values} VALUES`;
const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`;
return `${valuePart} AND ${cardPart}`;
}
+
+/** Spoken counts for Make a copy (`three values and six cards`). */
+export function inviteRuleCountsSpoken(document: unknown): string {
+ const { values, cards } = inviteRuleValueAndCardCounts(document);
+ const valueNoun = values === 1 ? "value" : "values";
+ const cardNoun = cards === 1 ? "card" : "cards";
+ return `${formatInviteCountWord(values)} ${valueNoun} and ${formatInviteCountWord(cards)} ${cardNoun}`;
+}
diff --git a/lib/navigationChromelessPath.ts b/lib/navigationChromelessPath.ts
index 420bca0..95366e8 100644
--- a/lib/navigationChromelessPath.ts
+++ b/lib/navigationChromelessPath.ts
@@ -8,7 +8,11 @@ export function isChromelessNavigationPath(
if (!pathname) {
return false;
}
- if (pathname.startsWith("/create") || pathname === "/login") {
+ if (
+ pathname.startsWith("/create") ||
+ pathname === "/login" ||
+ pathname.startsWith("/invite")
+ ) {
return true;
}
return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname);
diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts
index f6d67ea..a3ae502 100644
--- a/lib/server/publishedRules.ts
+++ b/lib/server/publishedRules.ts
@@ -224,3 +224,60 @@ export async function listProfileRulesForUser(
});
return combined.slice(0, cap);
}
+
+export type StakeholderInviteView = {
+ ruleId: string;
+ title: string;
+ summary: string | null;
+ document: Record;
+ updatedAt: string;
+ inviterEmail: string | null;
+};
+
+/**
+ * Published rule an accepted stakeholder may read with invited chrome.
+ * `userId` is set only after the invite link is used.
+ */
+export async function getAcceptedStakeholderInviteView(opts: {
+ userId: string;
+ ruleId: string;
+}): Promise {
+ if (!isDatabaseConfigured()) return null;
+ const userId = opts.userId.trim();
+ const ruleId = opts.ruleId.trim();
+ if (userId.length === 0 || ruleId.length === 0) return null;
+ try {
+ const row = await prisma.ruleStakeholder.findFirst({
+ where: { ruleId, userId },
+ select: {
+ invitedBy: { select: { email: true } },
+ rule: {
+ select: {
+ id: true,
+ title: true,
+ summary: true,
+ document: true,
+ updatedAt: true,
+ },
+ },
+ },
+ });
+ if (!row) return null;
+ const document =
+ row.rule.document !== null &&
+ typeof row.rule.document === "object" &&
+ !Array.isArray(row.rule.document)
+ ? (row.rule.document as Record)
+ : {};
+ return {
+ ruleId: row.rule.id,
+ title: row.rule.title,
+ summary: row.rule.summary,
+ document,
+ updatedAt: row.rule.updatedAt.toISOString(),
+ inviterEmail: row.invitedBy?.email ?? null,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts
index 56e027b..52b8c40 100644
--- a/lib/server/validation/createFlowSchemas.ts
+++ b/lib/server/validation/createFlowSchemas.ts
@@ -223,6 +223,25 @@ export const postRuleStakeholderBodySchema = z.object({
email: stakeholderEmailSchema,
});
+export const makeCopyRuleBodySchema = z.object({
+ title: z
+ .string()
+ .max(48)
+ .transform((s) => s.trim())
+ .refine((s) => s.length > 0, { message: "title required" }),
+});
+
+export type MakeCopyRuleBody = z.infer;
+
+export const resendExpiredStakeholderInviteBodySchema = z.object({
+ token: z.string().min(10).max(200),
+ email: stakeholderEmailSchema,
+});
+
+export type ResendExpiredStakeholderInviteBody = z.infer<
+ typeof resendExpiredStakeholderInviteBodySchema
+>;
+
export type PostRuleStakeholderBody = z.infer<
typeof postRuleStakeholderBodySchema
>;
diff --git a/messages/en/create/reviewAndComplete/completed.json b/messages/en/create/reviewAndComplete/completed.json
index ee2309c..43872d3 100644
--- a/messages/en/create/reviewAndComplete/completed.json
+++ b/messages/en/create/reviewAndComplete/completed.json
@@ -1,6 +1,10 @@
{
"toastTitle": "Your Rule is finished",
"toastDescription": "This is what the people you invite will see.",
+ "copyReadyToastTitle": "Your copy is ready",
+ "copyReadyToastDescription": "It started from {possessive} Rule. Read it through and change what does not fit before you invite your members.",
+ "copyNameHitTitle": "One line names {name}",
+ "copyNameHitDescription": "It is in {entry} under {category}.",
"askOrganizerLead": "Has your Rule changed how your community works?",
"askOrganizerLink": "Tell an organizer what changed.",
"shareNoRuleTitle": "Nothing to share yet",
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 42e82ec..04f350b 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -34,6 +34,7 @@ import login from "./pages/login.json";
import profile from "./pages/profile.json";
import notFoundPage from "./pages/notFoundPage.json";
import ruleDetail from "./pages/ruleDetail.json";
+import invite from "./pages/invite.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
@@ -114,6 +115,7 @@ export default {
profile,
notFoundPage,
ruleDetail,
+ invite,
},
create: {
community: {
diff --git a/messages/en/metadata.json b/messages/en/metadata.json
index 682c911..4a9a967 100644
--- a/messages/en/metadata.json
+++ b/messages/en/metadata.json
@@ -50,6 +50,14 @@
"title": "Log in",
"description": "Enter your email and we'll send you a magic link to sign in. No password needed!"
},
+ "invite": {
+ "title": "Invitation",
+ "description": "Read a CommunityRule you were invited to see."
+ },
+ "inviteExpired": {
+ "title": "This link has expired",
+ "description": "Ask for a new invitation link to read the Rule."
+ },
"profile": {
"title": "Profile",
"description": "Manage your CommunityRules, saved progress, and account."
diff --git a/messages/en/pages/invite.json b/messages/en/pages/invite.json
new file mode 100644
index 0000000..c7afd8c
--- /dev/null
+++ b/messages/en/pages/invite.json
@@ -0,0 +1,50 @@
+{
+ "_comment": "Invited Rule reader, Make a copy, and expired invitation link.",
+ "makeACopy": "Make a copy",
+ "makeACopyAria": "Make a copy of this Rule",
+ "navAria": "Invitation",
+ "bannerTitle": "{email} invited you to read this Rule",
+ "bannerTitleNoEmail": "You were invited to read this Rule",
+ "bannerDescription": "Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.",
+ "downloadPdf": "Download PDF",
+ "print": "Print",
+ "factsHeading": "About this Rule",
+ "whoCanEdit": "Who can edit",
+ "whoCanEditUnknown": "The person who invited you",
+ "whoCanSee": "Who can see it",
+ "whoCanSeeValue": "Only people the community invites",
+ "lastChanged": "Last changed",
+ "copyCardTitle": "Start a Rule from this one",
+ "copyCardBody": "You get its {counts} word for word. The name, description, photo, invited people and history stay with the original.",
+ "privacy": "Every Rule starts private. Only the people the community invites can read it until the person who holds the pen turns on a link.",
+ "exportFailedTitle": "Could not export",
+ "exportFailedDescription": "Something went wrong. Refresh the page and try again.",
+ "dialogTitle": "Make a copy for your community",
+ "dialogDescription": "Your copy starts with {possessive} {counts} word for word.",
+ "communityNameLabel": "Community name",
+ "communityNamePlaceholder": "Enter community name",
+ "characterCountTemplate": "{current}/{max}",
+ "comesWithTitle": "Comes with the copy",
+ "comesWithBody": "The cards and the text on them.",
+ "staysWithTitle": "Stays with the original",
+ "staysWithBody": "The name, description, photo, invited people and history.",
+ "saveTo": "The copy saves to {email}.",
+ "cancel": "Cancel",
+ "confirm": "Make a copy",
+ "copyFailed": "Could not make a copy. Try again.",
+ "nameRequired": "Add your community’s name to continue.",
+ "expiredTitle": "This link has expired",
+ "expiredDescription": "Enter the email the invitation was sent to. We’ll send a new link that works for 60 minutes.",
+ "emailLabel": "Email address",
+ "emailPlaceholder": "you@example.com",
+ "sendNewLink": "Send a new link",
+ "notInvitedAddress": "Not the address you were invited at?",
+ "backToHome": "← Back to home",
+ "sentTitle": "Check your email",
+ "sentBody": "If that address still has an invitation, we sent a new link. Open it within 60 minutes.",
+ "emailInvalid": "Enter a valid email address.",
+ "genericError": "Something went wrong. Try again.",
+ "rateLimited": "Too many requests. Try again in {seconds} seconds.",
+ "missingToken": "This page needs the original invitation link. Ask the person who invited you to send it again.",
+ "loadingFallback": "Loading…"
+}
diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx
index 9834382..ee91968 100644
--- a/tests/components/CompletedPage.test.tsx
+++ b/tests/components/CompletedPage.test.tsx
@@ -130,6 +130,24 @@ describe("CompletedScreen", () => {
).not.toBeInTheDocument();
});
+ it("shows copy-ready toast when copied query is set", () => {
+ sessionStorage.setItem(
+ CREATE_FLOW_LAST_PUBLISHED_KEY,
+ JSON.stringify({
+ ...storedRuleFixture,
+ copiedFromTitle: "Juniper Street Food Share",
+ }),
+ );
+ mockSearchParams({ copied: "1" });
+ render();
+ expect(screen.getByText("Your copy is ready")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "It started from Juniper Street Food Share’s Rule. Read it through and change what does not fit before you invite your members.",
+ ),
+ ).toBeInTheDocument();
+ });
+
it("shows post-publish toast in status region when celebrate query is set", () => {
mockSearchParams({
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
diff --git a/tests/components/ExpiredInviteScreen.test.tsx b/tests/components/ExpiredInviteScreen.test.tsx
new file mode 100644
index 0000000..74afa48
--- /dev/null
+++ b/tests/components/ExpiredInviteScreen.test.tsx
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { renderWithProviders as render, screen } from "../utils/test-utils";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import { ExpiredInviteScreen } from "../../app/(app)/invite/_components/ExpiredInviteScreen";
+
+const { navMock, resendMock } = vi.hoisted(() => ({
+ navMock: {
+ searchParams: new URLSearchParams("token=abcdefghijkl"),
+ push: vi.fn(),
+ },
+ resendMock: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: navMock.push,
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ refresh: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ }),
+ useSearchParams: () => navMock.searchParams,
+ usePathname: () => "/invite/expired",
+}));
+
+vi.mock("../../lib/create/api", () => ({
+ resendExpiredStakeholderInvite: (...args: unknown[]) => resendMock(...args),
+}));
+
+describe("ExpiredInviteScreen", () => {
+ beforeEach(() => {
+ navMock.searchParams = new URLSearchParams("token=abcdefghijkl");
+ navMock.push.mockReset();
+ resendMock.mockReset();
+ resendMock.mockResolvedValue({ ok: true });
+ });
+
+ it("asks for the invited email and sends a new link", async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: "This link has expired" }),
+ ).toBeInTheDocument();
+ await user.type(
+ screen.getByLabelText("Email address"),
+ "inv@example.com",
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Send a new link" }),
+ );
+
+ expect(resendMock).toHaveBeenCalledWith(
+ "abcdefghijkl",
+ "inv@example.com",
+ );
+ expect(
+ await screen.findByRole("heading", { name: "Check your email" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/InvitedRuleView.test.tsx b/tests/components/InvitedRuleView.test.tsx
new file mode 100644
index 0000000..bd497d4
--- /dev/null
+++ b/tests/components/InvitedRuleView.test.tsx
@@ -0,0 +1,68 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderWithProviders as render, screen } from "../utils/test-utils";
+import "@testing-library/jest-dom/vitest";
+import { InvitedRuleView } from "../../app/(app)/invite/_components/InvitedRuleView";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ replace: vi.fn(),
+ push: vi.fn(),
+ prefetch: vi.fn(),
+ refresh: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ }),
+}));
+
+const document = {
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [
+ {
+ title: "Care",
+ body: "We care for each other.",
+ },
+ ],
+ },
+ ],
+};
+
+describe("InvitedRuleView", () => {
+ it("names the inviter by email and offers Make a copy", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("maya@example.com invited you to read this Rule"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("button", { name: "Make a copy of this Rule" })
+ .length,
+ ).toBeGreaterThan(0);
+ expect(
+ screen.getByText("Only people the community invites"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Every Rule starts private. Only the people the community invites can read it until the person who holds the pen turns on a link.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Care")).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/TextPage.test.tsx b/tests/components/TextPage.test.tsx
index 65f7f58..4b0cdbe 100644
--- a/tests/components/TextPage.test.tsx
+++ b/tests/components/TextPage.test.tsx
@@ -46,7 +46,7 @@ describe("CreateFlowTextFieldScreen (community description)", () => {
,
diff --git a/tests/unit/copiedRuleDocument.test.ts b/tests/unit/copiedRuleDocument.test.ts
new file mode 100644
index 0000000..0feff1f
--- /dev/null
+++ b/tests/unit/copiedRuleDocument.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import {
+ findCopiedNameHit,
+ stripCopiedRuleDocument,
+} from "../../lib/create/copiedRuleDocument";
+
+describe("stripCopiedRuleDocument", () => {
+ it("drops name, description, photo, and save-email", () => {
+ const out = stripCopiedRuleDocument({
+ title: "Juniper Street Food Share",
+ communityContext: "A neighborhood share.",
+ communityAvatarUrl: "https://example.test/a.png",
+ communityAvatarFileName: "a.png",
+ communityLocation: "Boulder",
+ communitySaveEmail: "a@example.com",
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [{ title: "Care", body: "We care." }],
+ },
+ ],
+ });
+ expect(out.title).toBeUndefined();
+ expect(out.communityContext).toBeUndefined();
+ expect(out.communityAvatarUrl).toBeUndefined();
+ expect(out.communityAvatarFileName).toBeUndefined();
+ expect(out.communityLocation).toBeUndefined();
+ expect(out.communitySaveEmail).toBeUndefined();
+ expect(out.sections).toEqual([
+ {
+ categoryName: "Values",
+ entries: [{ title: "Care", body: "We care." }],
+ },
+ ]);
+ });
+});
+
+describe("findCopiedNameHit", () => {
+ it("returns the first entry that still names the original group", () => {
+ expect(
+ findCopiedNameHit(
+ {
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [
+ {
+ title: "Solidarity",
+ body: "Juniper Street Food Share members share food.",
+ },
+ ],
+ },
+ ],
+ },
+ "Juniper Street Food Share",
+ ),
+ ).toEqual({
+ categoryName: "Values",
+ entryTitle: "Solidarity",
+ });
+ });
+
+ it("returns null when the name is gone", () => {
+ expect(
+ findCopiedNameHit(
+ {
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [{ title: "Care", body: "We care." }],
+ },
+ ],
+ },
+ "Juniper Street Food Share",
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/tests/unit/invitePaths.test.ts b/tests/unit/invitePaths.test.ts
new file mode 100644
index 0000000..bcc3813
--- /dev/null
+++ b/tests/unit/invitePaths.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import {
+ inviteExpiredPath,
+ invitedRulePath,
+} from "../../lib/create/invitePaths";
+
+describe("invitedRulePath", () => {
+ it("encodes the rule id", () => {
+ expect(invitedRulePath("ab/c")).toBe("/invite/ab%2Fc");
+ });
+
+ it("appends copy=1", () => {
+ expect(invitedRulePath("rule-1", { copy: true })).toBe(
+ "/invite/rule-1?copy=1",
+ );
+ });
+});
+
+describe("inviteExpiredPath", () => {
+ it("omits the token when missing", () => {
+ expect(inviteExpiredPath()).toBe("/invite/expired");
+ });
+
+ it("encodes the token", () => {
+ expect(inviteExpiredPath("a+b")).toBe("/invite/expired?token=a%2Bb");
+ });
+});
diff --git a/tests/unit/lib/navigationChromelessPath.test.ts b/tests/unit/lib/navigationChromelessPath.test.ts
index d832a53..cf72948 100644
--- a/tests/unit/lib/navigationChromelessPath.test.ts
+++ b/tests/unit/lib/navigationChromelessPath.test.ts
@@ -6,6 +6,8 @@ describe("isChromelessNavigationPath", () => {
["/create", true],
["/create/completed", true],
["/login", true],
+ ["/invite/expired", true],
+ ["/invite/rule-1", true],
["/use-cases/mutual-aid-colorado/rule", true],
["/use-cases/food-not-bombs/rule/", true],
["/", false],
diff --git a/tests/unit/ruleInviteCardMeta.test.ts b/tests/unit/ruleInviteCardMeta.test.ts
index 2616e1d..2470ef2 100644
--- a/tests/unit/ruleInviteCardMeta.test.ts
+++ b/tests/unit/ruleInviteCardMeta.test.ts
@@ -4,6 +4,7 @@ import {
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
+ inviteRuleCountsSpoken,
} from "../../lib/create/ruleInviteCardMeta";
describe("communityInviteInitials", () => {
@@ -61,3 +62,26 @@ describe("inviteRuleCountsLabel", () => {
).toBe("3 VALUES AND 2 CARDS");
});
});
+
+describe("inviteRuleCountsSpoken", () => {
+ it("uses lowercase words", () => {
+ expect(
+ inviteRuleCountsSpoken({
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [
+ { title: "Care", body: "We care." },
+ { title: "Share", body: "We share." },
+ { title: "Listen", body: "We listen." },
+ ],
+ },
+ {
+ categoryName: "Communication",
+ entries: [{ title: "Open channel", body: "How we talk." }],
+ },
+ ],
+ }),
+ ).toBe("three values and one card");
+ });
+});
diff --git a/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts b/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts
new file mode 100644
index 0000000..8b4c3bf
--- /dev/null
+++ b/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts
@@ -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",
+ }),
+ );
+ });
+});
diff --git a/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts
index 7f1b1cc..c99740e 100644
--- a/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts
+++ b/tests/unit/ruleStakeholderInviteVerifyRoute.test.ts
@@ -70,7 +70,7 @@ describe("GET /api/invites/rule-stakeholder/verify", () => {
expect(res.status).toBeGreaterThanOrEqual(300);
expect(res.status).toBeLessThan(400);
- expect(res.headers.get("location")).toContain("/rules/rule-1");
+ expect(res.headers.get("location")).toContain("/invite/rule-1");
expect(setCookieMock).toHaveBeenCalled();
});
@@ -97,4 +97,50 @@ describe("GET /api/invites/rule-stakeholder/verify", () => {
);
expect(upsertMock).not.toHaveBeenCalled();
});
+
+ it("redirects expired tokens to the expired invitation page", async () => {
+ getSessionUserMock.mockResolvedValue(null);
+ findUniqueMock.mockResolvedValue({
+ id: "st1",
+ email: "inv@example.com",
+ ruleId: "rule-1",
+ inviteExpiresAt: new Date(Date.now() - 60_000),
+ });
+ const token = "z".repeat(12);
+
+ const res = await GET(
+ new NextRequest(
+ `https://x.test/api/invites/rule-stakeholder/verify?token=${token}`,
+ ),
+ );
+
+ expect(res.headers.get("location")).toContain("/invite/expired");
+ expect(res.headers.get("location")).toContain(`token=${token}`);
+ expect(upsertMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps copy=1 on the invited view after a valid token", async () => {
+ getSessionUserMock.mockResolvedValue(null);
+ findUniqueMock.mockResolvedValue({
+ id: "st1",
+ email: "inv@example.com",
+ ruleId: "rule-1",
+ inviteExpiresAt: new Date(Date.now() + 60_000),
+ });
+ upsertMock.mockResolvedValue({ id: "u1", email: "inv@example.com" });
+ updateMock.mockResolvedValue({});
+ createSessionMock.mockResolvedValue({
+ token: "sess",
+ expiresAt: new Date(),
+ });
+
+ const res = await GET(
+ new NextRequest(
+ `https://x.test/api/invites/rule-stakeholder/verify?token=${"x".repeat(12)}©=1`,
+ ),
+ );
+
+ expect(res.headers.get("location")).toContain("/invite/rule-1");
+ expect(res.headers.get("location")).toContain("copy=1");
+ });
});
diff --git a/tests/unit/rulesMakeCopyRoute.test.ts b/tests/unit/rulesMakeCopyRoute.test.ts
new file mode 100644
index 0000000..7fd5740
--- /dev/null
+++ b/tests/unit/rulesMakeCopyRoute.test.ts
@@ -0,0 +1,145 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findFirstStakeholderMock = vi.fn();
+const createRuleMock = vi.fn();
+const getSessionUserMock = vi.fn();
+const rateLimitKeyMock = vi.fn(
+ (
+ _key: string,
+ _minIntervalMs: number,
+ ): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }),
+);
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => true,
+}));
+
+vi.mock("../../lib/server/session", () => ({
+ getSessionUser: () => getSessionUserMock(),
+}));
+
+vi.mock("../../lib/server/rateLimit", () => ({
+ rateLimitKey: (key: string, minIntervalMs: number) =>
+ rateLimitKeyMock(key, minIntervalMs),
+}));
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ ruleStakeholder: {
+ findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args),
+ },
+ publishedRule: {
+ create: (...args: unknown[]) => createRuleMock(...args),
+ },
+ },
+}));
+
+import { POST } from "../../app/api/rules/[id]/make-copy/route";
+
+const viewer = { id: "user-2", email: "reader@example.com" };
+const routeCtx = { params: Promise.resolve({ id: "rule-1" }) };
+
+beforeEach(() => {
+ findFirstStakeholderMock.mockReset();
+ createRuleMock.mockReset();
+ getSessionUserMock.mockReset();
+ rateLimitKeyMock.mockReset();
+ rateLimitKeyMock.mockReturnValue({ ok: true as const });
+ getSessionUserMock.mockResolvedValue(viewer);
+});
+
+describe("POST /api/rules/[id]/make-copy", () => {
+ it("returns 401 when unauthenticated", async () => {
+ getSessionUserMock.mockResolvedValueOnce(null);
+ const res = await POST(
+ new NextRequest("https://x.test/api/rules/rule-1/make-copy", {
+ method: "POST",
+ body: JSON.stringify({ title: "New group" }),
+ headers: { "content-type": "application/json" },
+ }),
+ routeCtx,
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when the viewer is not an invited reader", async () => {
+ findFirstStakeholderMock.mockResolvedValueOnce(null);
+ const res = await POST(
+ new NextRequest("https://x.test/api/rules/rule-1/make-copy", {
+ method: "POST",
+ body: JSON.stringify({ title: "New group" }),
+ headers: { "content-type": "application/json" },
+ }),
+ routeCtx,
+ );
+ expect(res.status).toBe(404);
+ expect(createRuleMock).not.toHaveBeenCalled();
+ });
+
+ it("copies cards and text, not the original name or description", async () => {
+ findFirstStakeholderMock.mockResolvedValueOnce({
+ id: "st-1",
+ rule: {
+ id: "rule-1",
+ title: "Juniper Street Food Share",
+ userId: "owner-1",
+ document: {
+ title: "Juniper Street Food Share",
+ communityContext: "A neighborhood share.",
+ communityAvatarUrl: "https://example.test/a.png",
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [
+ {
+ title: "Care",
+ body: "Juniper Street Food Share cares.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+ createRuleMock.mockResolvedValueOnce({
+ id: "rule-copy",
+ title: "New group",
+ summary: null,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ });
+
+ const res = await POST(
+ new NextRequest("https://x.test/api/rules/rule-1/make-copy", {
+ method: "POST",
+ body: JSON.stringify({ title: "New group" }),
+ headers: { "content-type": "application/json" },
+ }),
+ routeCtx,
+ );
+ expect(res.status).toBe(200);
+ expect(createRuleMock).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ userId: viewer.id,
+ title: "New group",
+ summary: null,
+ document: expect.not.objectContaining({
+ title: "Juniper Street Food Share",
+ communityContext: "A neighborhood share.",
+ communityAvatarUrl: "https://example.test/a.png",
+ }),
+ }),
+ });
+ const body = (await res.json()) as {
+ rule: {
+ id: string;
+ copiedFromTitle: string;
+ copiedNameHit: { entryTitle: string } | null;
+ };
+ };
+ expect(body.rule.id).toBe("rule-copy");
+ expect(body.rule.copiedFromTitle).toBe("Juniper Street Food Share");
+ expect(body.rule.copiedNameHit?.entryTitle).toBe("Care");
+ });
+});