Show invited people the Rule, let them copy its cards for their community, and replace an expired invitation link.
This commit is contained in:
1 parent
727a92a9ac
commit
3c984e00c5
37 files changed
+1962
-19
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
* 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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
Reference in new issue
Block a user