Show invited people the Rule, let them copy its cards for their community, and replace an expired invitation link.

This commit is contained in:
adilallo committed 2026-11-06 09:16:06 -07:00
1 parent 727a92a9ac
commit 3c984e00c5
37 files changed
+1962 -19

No files matched your search

+113
View File
@@ -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<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(
id: string,
): Promise<DuplicateRuleResult> {
+59
View File
@@ -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;
}
+10
View File
@@ -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",
});
}
+20
View File
@@ -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)}`;
}
+28 -1
View File
@@ -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<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 {
@@ -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<string, unknown>;
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<string, unknown>,
...(copiedFromTitle ? { copiedFromTitle } : {}),
...(copiedNameHit ? { copiedNameHit } : {}),
};
} catch {
return null;
+20 -2
View File
@@ -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}`;
}
+5 -1
View File
@@ -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);
+57
View File
@@ -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<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,
});
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<
typeof postRuleStakeholderBodySchema
>;