Send the invitation as a light letter that names the community, the inviter by email, and who can read the Rule.

This commit is contained in:
adilallo committed 2026-11-06 09:00:44 -07:00
1 parent 70ceb73104
commit 727a92a9ac
14 files changed
+534 -26

No files matched your search

+3
View File
@@ -44,6 +44,9 @@ npm-cache/
!public/favicon-32x32.png !public/favicon-32x32.png
!public/apple-touch-icon.png !public/apple-touch-icon.png
# Invitation email mark (2x PNG; Gmail and Outlook drop SVG)
!public/assets/logos/community-rule-mark.png
# Per-article body ornaments (Figma Content page Template image fills) # Per-article body ornaments (Figma Content page Template image fills)
!public/content/blog/*-ornament-*.png !public/content/blog/*-ornament-*.png
@@ -46,7 +46,7 @@ export const POST = apiRoute<RouteContext>(
email: true, email: true,
inviteTokenHash: true, inviteTokenHash: true,
inviteExpiresAt: true, inviteExpiresAt: true,
rule: { select: { userId: true, title: true } }, rule: { select: { userId: true, title: true, summary: true, document: true } },
}, },
}); });
@@ -93,9 +93,18 @@ export const POST = apiRoute<RouteContext>(
}, },
}); });
const verifyUrl = stakeholderInviteVerifyUrl(getPublicOrigin(request), token); const origin = getPublicOrigin(request);
const verifyUrl = stakeholderInviteVerifyUrl(origin, token);
try { try {
await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title); await sendRuleStakeholderInviteEmail({
to: row.email,
verifyUrl,
origin,
communityName: row.rule.title,
inviterEmail: user.email,
summary: row.rule.summary,
document: row.rule.document,
});
} catch (err) { } catch (err) {
logRouteError("rules.stakeholders.resend", requestId, err, { logRouteError("rules.stakeholders.resend", requestId, err, {
phase: "sendRuleStakeholderInviteEmail", phase: "sendRuleStakeholderInviteEmail",
+4 -1
View File
@@ -31,7 +31,7 @@ type RouteContext = { params: Promise<{ id: string }> };
async function ownedRuleMeta(ruleId: string, userId: string) { async function ownedRuleMeta(ruleId: string, userId: string) {
return prisma.publishedRule.findFirst({ return prisma.publishedRule.findFirst({
where: { id: ruleId, userId }, where: { id: ruleId, userId },
select: { id: true, title: true }, select: { id: true, title: true, summary: true, document: true },
}); });
} }
@@ -159,8 +159,11 @@ export const POST = apiRoute<RouteContext>(
origin, origin,
ruleId: rule.id, ruleId: rule.id,
ruleTitle: rule.title, ruleTitle: rule.title,
summary: rule.summary,
document: rule.document,
email, email,
invitedByUserId: user.id, invitedByUserId: user.id,
inviterEmail: user.email,
pepper, pepper,
}); });
+9 -1
View File
@@ -208,7 +208,15 @@ export const POST = apiRoute(
try { try {
for (const inv of invites) { for (const inv of invites) {
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title); await sendRuleStakeholderInviteEmail({
to: inv.email,
verifyUrl,
origin,
communityName: title,
inviterEmail: user.email,
summary,
document,
});
} }
} catch (err) { } catch (err) {
logRouteError("rules.publish", requestId, err, { logRouteError("rules.publish", requestId, err, {
+2
View File
@@ -226,6 +226,8 @@ export function contentCatalogSlugForFallback(
export const ASSETS = { export const ASSETS = {
// Brand logo // Brand logo
LOGO: "assets/logos/community-rule.svg", LOGO: "assets/logos/community-rule.svg",
/** 2x cream mark for invitation email (Gmail/Outlook drop SVG). */
LOGO_MARK: "assets/logos/community-rule-mark.png",
// Avatars // Avatars
AVATAR_1: "assets/marketing/avatar-1.png", AVATAR_1: "assets/marketing/avatar-1.png",
+56
View File
@@ -0,0 +1,56 @@
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
import sharedTerms from "../../messages/en/create/sharedTerms.json";
const DESCRIPTION_MAX = 140;
/** Initials for the invite email disc when the group has no photo. */
export function communityInviteInitials(name: string): string {
const words = name
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
if (words.length === 0) return "·";
if (words.length === 1) {
const word = words[0] ?? "";
return word.slice(0, 2).toUpperCase();
}
const first = words[0]?.charAt(0) ?? "";
const second = words[1]?.charAt(0) ?? "";
return `${first}${second}`.toUpperCase();
}
/** Possessive for footer copy (`Share’s` / `Members’`). */
export function communityPossessive(name: string): string {
const trimmed = name.trim();
if (trimmed.length === 0) return "this community’s";
if (/s$/i.test(trimmed)) return `${trimmed}’`;
return `${trimmed}’s`;
}
/** Community description on the invite card, cut at 140 characters. */
export function clipCommunityInviteDescription(
raw: string | null | undefined,
): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
if (trimmed.length <= DESCRIPTION_MAX) return trimmed;
return trimmed.slice(0, DESCRIPTION_MAX).trimEnd();
}
/** Tag on the invite Rule card (`3 VALUES AND 6 CARDS`). Shared terms do not count. */
export function inviteRuleCountsLabel(document: unknown): string {
const sections = parsePublishedDocumentForCommunityRuleDisplay(document);
let values = 0;
let cards = 0;
for (const section of sections) {
const n = section.entries.length;
if (section.categoryName === RULE_SECTION_CATEGORY.values) {
values += n;
} else if (section.categoryName !== sharedTerms.sectionTitle) {
cards += n;
}
}
const valuePart = values === 1 ? "1 VALUE" : `${values} VALUES`;
const cardPart = cards === 1 ? "1 CARD" : `${cards} CARDS`;
return `${valuePart} AND ${cardPart}`;
}
+11 -13
View File
@@ -6,6 +6,10 @@ import {
type WorkshopInquiryMailInput, type WorkshopInquiryMailInput,
} from "../workshopSignupMail"; } from "../workshopSignupMail";
import { getSmtpUrl } from "./env"; import { getSmtpUrl } from "./env";
import {
buildRuleStakeholderInviteMail,
type RuleStakeholderInviteMailInput,
} from "./ruleStakeholderInviteMail";
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value return value
@@ -93,24 +97,18 @@ export async function sendMagicLinkEmail(
}); });
} }
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */ /** Stakeholder invite after finishing a Rule (one-time link, same Mailhog pattern as magic link). */
export async function sendRuleStakeholderInviteEmail( export async function sendRuleStakeholderInviteEmail(
to: string, input: RuleStakeholderInviteMailInput,
verifyUrl: string,
ruleTitle: string,
): Promise<void> { ): Promise<void> {
const { text, html } = buildVerifyLinkParts( const { subject, text, html } = buildRuleStakeholderInviteMail(input);
verifyUrl,
`You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 60 minutes and works once:`,
"If you did not expect this, you can ignore this email.",
"Open the rule",
);
await sendHtmlMail({ await sendHtmlMail({
to, to: input.to,
subject: `You're invited to view a Community Rule: ${ruleTitle}`, subject,
text, text,
html, html,
devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`, replyTo: input.inviterEmail.trim().toLowerCase(),
devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`,
}); });
} }
+249
View File
@@ -0,0 +1,249 @@
import { ASSETS, getAssetPath } from "../assetUtils";
import {
clipCommunityInviteDescription,
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
} from "../create/ruleInviteCardMeta";
export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK);
const PREVIEW_TEXT =
"It covers how they talk, who belongs, how they decide and what happens when they disagree.";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function ruleStakeholderInviteCopyUrl(verifyUrl: string): string {
return `${verifyUrl}${verifyUrl.includes("?") ? "&" : "?"}copy=1`;
}
export type RuleStakeholderInviteMailInput = {
to: string;
verifyUrl: string;
origin: string;
communityName: string;
inviterEmail: string;
summary?: string | null;
document?: unknown;
};
export type RuleStakeholderInviteMailContent = {
subject: string;
previewText: string;
text: string;
html: string;
};
export function buildRuleStakeholderInviteMail(
input: RuleStakeholderInviteMailInput,
): RuleStakeholderInviteMailContent {
const communityName = input.communityName.trim() || "A community";
const inviterEmail = input.inviterEmail.trim().toLowerCase();
const origin = input.origin.replace(/\/$/, "");
const verifyUrl = input.verifyUrl;
const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl);
const possessive = communityPossessive(communityName);
const description = clipCommunityInviteDescription(input.summary);
const initials = communityInviteInitials(communityName);
const countsLabel = inviteRuleCountsLabel(input.document);
const subject = `${communityName} invited you to read their Rule`;
const intro = `${inviterEmail} sent the invitation. Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.`;
const privacy = `Every Rule starts private. Only the people ${communityName} invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.`;
const ownLink =
"Your own link. Forwarding this email shares it. Ask the community to invite anyone else who should read the Rule.";
const copyLead =
"Writing a Rule for another community? Make a copy of this one and change what does not fit.";
const why = `You got this email because ${inviterEmail} added your address to ${possessive} Rule on CommunityRule. If you do not know the community, you can ignore this email.`;
const madeBy =
"CommunityRule is made by the Media Economies Design Lab at the University of Colorado Boulder.";
const privacyUrl = `${origin}/privacy`;
const aboutUrl = `${origin}/about`;
const logoUrl = `${origin}${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`;
const text = [
subject,
"",
intro,
"",
"Read the Rule",
verifyUrl,
"",
privacy,
"",
ownLink,
"",
copyLead,
copyUrl,
"",
why,
"",
madeBy,
`Privacy ${privacyUrl}`,
].join("\n");
const html = inviteHtml({
subject,
intro,
verifyUrl,
copyUrl,
communityName,
description,
initials,
countsLabel,
privacy,
ownLink,
copyLead,
why,
madeBy,
privacyUrl,
aboutUrl,
logoUrl,
});
return { subject, previewText: PREVIEW_TEXT, text, html };
}
function inviteHtml(opts: {
subject: string;
intro: string;
verifyUrl: string;
copyUrl: string;
communityName: string;
description: string;
initials: string;
countsLabel: string;
privacy: string;
ownLink: string;
copyLead: string;
why: string;
madeBy: string;
privacyUrl: string;
aboutUrl: string;
logoUrl: string;
}): string {
const heading = escapeHtml(opts.subject);
const intro = escapeHtml(opts.intro);
const verifyHref = escapeHtml(opts.verifyUrl);
const copyHref = escapeHtml(opts.copyUrl);
const name = escapeHtml(opts.communityName);
const description = escapeHtml(opts.description);
const initials = escapeHtml(opts.initials);
const counts = escapeHtml(opts.countsLabel);
const privacyHtml = privacyHtmlWithLead(opts.privacy);
const ownLinkHtml = leadInHtml("Your own link.", opts.ownLink);
const copyHtml =
`<strong style="color:#000000;">Writing a Rule for another community?</strong>` +
` <a href="${copyHref}" style="color:#000000;text-decoration:underline;">Make a copy</a>` +
` of this one and change what does not fit.`;
const why = escapeHtml(opts.why);
const madeBy = escapeHtml(opts.madeBy);
const privacyUrl = escapeHtml(opts.privacyUrl);
const aboutUrl = escapeHtml(opts.aboutUrl);
const logoUrl = escapeHtml(opts.logoUrl);
const cardBody = description
? `<p style="margin:16px 0 0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:18px;font-weight:500;color:#000000;">${description}</p>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:0;background:#e4e4e4;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(
PREVIEW_TEXT,
)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#e4e4e4;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:600px;background:#ffffff;">
<tr>
<td style="background:#000000;padding:24px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:22px;line-height:27px;color:#ffffff;padding-right:6px;">CommunityRule</td>
<td>
<img src="${logoUrl}" width="27" height="27" alt="" style="display:block;border:0;width:27px;height:27px;" />
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:40px 32px 56px;">
<h1 style="margin:0 0 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:36px;line-height:44px;font-weight:700;color:#000000;">${heading}</h1>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:18px;line-height:24px;color:#303030;">${intro}</p>
<p style="margin:0 0 16px;">
<a href="${verifyHref}" style="display:inline-block;background:#141414;color:#ffffff;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:20px;font-weight:500;text-decoration:none;border-radius:9999px;padding:12px 16px;">Read the Rule</a>
</p>
<p style="margin:0 0 4px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">Or paste this link into a browser</p>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;">
<a href="${verifyHref}" style="color:#000000;text-decoration:underline;">${verifyHref}</a>
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#c9fef9;border-radius:12px;">
<tr>
<td style="padding:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-bottom:1px solid #000000;">
<tr>
<td width="74" valign="middle" style="padding:8px 8px 8px 4px;">
<div style="width:56px;height:56px;border-radius:9999px;background:#fffdd2;font-family:Inter,Arial,sans-serif;font-size:24px;line-height:56px;font-weight:500;color:#000000;text-align:center;">${initials}</div>
</td>
<td valign="middle" style="border-left:1px solid #000000;padding:12px 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:24px;line-height:32px;font-weight:700;color:#000000;">${name}</td>
</tr>
</table>
${cardBody}
<p style="margin:16px 0 0;">
<span style="display:inline-block;background:#303030;color:#c9fef9;font-family:Inter,Arial,sans-serif;font-size:10px;line-height:12px;font-weight:500;letter-spacing:0.2px;text-transform:uppercase;border-radius:2px;padding:2px 4px;">${counts}</span>
</p>
</td>
</tr>
</table>
<div style="height:1px;background:#e4e4e4;margin:32px 0;"></div>
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${privacyHtml}</p>
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${ownLinkHtml}</p>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${copyHtml}</p>
<div style="border-top:1px solid #e4e4e4;padding-top:24px;">
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${why}</p>
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${madeBy}</p>
<p style="margin:0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">
<a href="${privacyUrl}" style="color:#464646;text-decoration:underline;">Privacy</a>
&nbsp;&nbsp;
<a href="${aboutUrl}" style="color:#464646;text-decoration:underline;">About CommunityRule</a>
</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function privacyHtmlWithLead(privacy: string): string {
const lead = "Every Rule starts private.";
if (privacy.startsWith(lead)) {
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
privacy.slice(lead.length),
)}`;
}
return escapeHtml(privacy);
}
function leadInHtml(lead: string, full: string): string {
if (full.startsWith(lead)) {
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
full.slice(lead.length),
)}`;
}
return escapeHtml(full);
}
+12 -1
View File
@@ -18,8 +18,11 @@ export async function createRuleStakeholderInviteAndSendMail(opts: {
origin: string; origin: string;
ruleId: string; ruleId: string;
ruleTitle: string; ruleTitle: string;
summary?: string | null;
document?: unknown;
email: string; email: string;
invitedByUserId: string; invitedByUserId: string;
inviterEmail: string;
pepper: string; pepper: string;
}): Promise<{ ok: true } | { ok: false }> { }): Promise<{ ok: true } | { ok: false }> {
const token = newSessionToken(); const token = newSessionToken();
@@ -38,7 +41,15 @@ export async function createRuleStakeholderInviteAndSendMail(opts: {
const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token); const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token);
try { try {
await sendRuleStakeholderInviteEmail(opts.email, verifyUrl, opts.ruleTitle); await sendRuleStakeholderInviteEmail({
to: opts.email,
verifyUrl,
origin: opts.origin,
communityName: opts.ruleTitle,
inviterEmail: opts.inviterEmail,
summary: opts.summary,
document: opts.document,
});
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
logRouteError(opts.scope, opts.requestId, err, { logRouteError(opts.scope, opts.requestId, err, {
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
clipCommunityInviteDescription,
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
} from "../../lib/create/ruleInviteCardMeta";
describe("communityInviteInitials", () => {
it("uses the first two words", () => {
expect(communityInviteInitials("Juniper Street Food Share")).toBe("JS");
});
it("uses two letters of a single word", () => {
expect(communityInviteInitials("Garden")).toBe("GA");
});
});
describe("communityPossessive", () => {
it("adds a curly ’s when the name does not end in s", () => {
expect(communityPossessive("Juniper Street Food Share")).toBe(
"Juniper Street Food Share’s",
);
});
it("adds only a curly apostrophe when the name ends in s", () => {
expect(communityPossessive("Members")).toBe("Members’");
});
});
describe("clipCommunityInviteDescription", () => {
it("cuts at 140 characters", () => {
const long = "a".repeat(160);
expect(clipCommunityInviteDescription(long)).toHaveLength(140);
});
});
describe("inviteRuleCountsLabel", () => {
it("counts values and other cards, not shared terms", () => {
expect(
inviteRuleCountsLabel({
sections: [
{
categoryName: "Values",
entries: [
{ title: "Care", body: "We care." },
{ title: "Share", body: "We share." },
{ title: "Listen", body: "We listen." },
],
},
{
categoryName: "Communication",
entries: [{ title: "Open channel", body: "We talk in the hall." }],
},
{
categoryName: "Membership",
entries: [{ title: "Open admission", body: "Anyone who shares the work." }],
},
],
}),
).toBe("3 VALUES AND 2 CARDS");
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
RULE_STAKEHOLDER_INVITE_LOGO_PATH,
buildRuleStakeholderInviteMail,
ruleStakeholderInviteCopyUrl,
} from "../../lib/server/ruleStakeholderInviteMail";
const VERIFY_URL =
"https://communityrule.info/api/invites/rule-stakeholder/verify?token=abc";
describe("ruleStakeholderInviteCopyUrl", () => {
it("adds copy=1 to the invitation link", () => {
expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe(
`${VERIFY_URL}&copy=1`,
);
});
});
describe("buildRuleStakeholderInviteMail", () => {
const mail = buildRuleStakeholderInviteMail({
to: "rosa@example.org",
verifyUrl: VERIFY_URL,
origin: "https://communityrule.info",
communityName: "Juniper Street Food Share",
inviterEmail: "maya@example.org",
summary:
"A food share that runs every Sunday on Juniper Street. Volunteers collect surplus from two grocers and a farm stand and share it with neighbors.",
document: {
sections: [
{
categoryName: "Values",
entries: [
{ title: "Care", body: "We care." },
{ title: "Share", body: "We share." },
{ title: "Listen", body: "We listen." },
],
},
{
categoryName: "Communication",
entries: Array.from({ length: 6 }, (_, i) => ({
title: `Card ${i + 1}`,
body: "Body",
})),
},
],
},
});
it("uses the launch subject, preview, and privacy line", () => {
expect(mail.subject).toBe(
"Juniper Street Food Share invited you to read their Rule",
);
expect(mail.previewText).toBe(
"It covers how they talk, who belongs, how they decide and what happens when they disagree.",
);
expect(mail.text).toContain(
"Every Rule starts private. Only the people Juniper Street Food Share invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.",
);
expect(mail.html).toContain("Every Rule starts private.");
});
it("names the inviter by email and points replies at that address in copy", () => {
expect(mail.text).toContain("maya@example.org sent the invitation.");
expect(mail.text).toContain(
"how they talk, who belongs, how they decide and what happens when they disagree.",
);
expect(mail.html).not.toContain("Maya Ortiz");
});
it("includes read and copy links, footer, and the 2x mark", () => {
expect(mail.text).toContain(VERIFY_URL);
expect(mail.text).toContain(`${VERIFY_URL}&copy=1`);
expect(mail.html).toContain(`href="${VERIFY_URL}"`);
expect(mail.html).toContain(`href="${VERIFY_URL}&amp;copy=1"`);
expect(mail.html).toContain("Read the Rule");
expect(mail.html).toContain("Make a copy");
expect(mail.html).toContain("3 VALUES AND 6 CARDS");
expect(mail.html).toContain("JS");
expect(mail.html).toContain("#e4e4e4");
expect(mail.html).toContain("font-size:14px;line-height:20px;color:#464646");
expect(mail.html).toContain(
`https://communityrule.info${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`,
);
expect(mail.html).toContain("https://communityrule.info/privacy");
expect(mail.html).toContain("https://communityrule.info/about");
expect(mail.text).toContain(
"Juniper Street Food Share’s Rule on CommunityRule",
);
});
});
+8 -3
View File
@@ -148,9 +148,14 @@ describe("POST /api/rules", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(transactionMock).toHaveBeenCalledTimes(1); expect(transactionMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock).toHaveBeenCalledTimes(1); expect(sendInviteMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com"); expect(sendInviteMock).toHaveBeenCalledWith(
expect(String(sendInviteMock.mock.calls[0][1])).toContain( expect.objectContaining({
"/api/invites/rule-stakeholder/verify?token=", to: "stakeholder@example.com",
communityName: "Published title",
verifyUrl: expect.stringContaining(
"/api/invites/rule-stakeholder/verify?token=",
),
}),
); );
expect(publishedRuleCreateMock).not.toHaveBeenCalled(); expect(publishedRuleCreateMock).not.toHaveBeenCalled();
}); });
@@ -130,7 +130,13 @@ describe("POST /api/rules/[id]/stakeholders", () => {
routeCtx, routeCtx,
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(createInviteMock).toHaveBeenCalled(); expect(createInviteMock).toHaveBeenCalledWith(
expect.objectContaining({
email: "inv@example.com",
ruleTitle: "My rule",
inviterEmail: owner.email,
}),
);
const body = (await res.json()) as { const body = (await res.json()) as {
stakeholder: { email: string; status: string }; stakeholder: { email: string; status: string };
}; };
@@ -214,9 +220,14 @@ describe("POST /api/rules/[id]/stakeholders/[stakeholderId]/resend", () => {
expect(await res.json()).toEqual({ ok: true }); expect(await res.json()).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalled(); expect(updateMock).toHaveBeenCalled();
expect(sendInviteEmailMock).toHaveBeenCalledWith( expect(sendInviteEmailMock).toHaveBeenCalledWith(
"inv@example.com", expect.objectContaining({
expect.stringContaining("/api/invites/rule-stakeholder/verify?token="), to: "inv@example.com",
"My rule", communityName: "My rule",
inviterEmail: owner.email,
verifyUrl: expect.stringContaining(
"/api/invites/rule-stakeholder/verify?token=",
),
}),
); );
}); });
}); });