Finish a Rule, invite members, and let invited people read or copy it #100
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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);
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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}©=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}©=1`);
|
||||||
|
expect(mail.html).toContain(`href="${VERIFY_URL}"`);
|
||||||
|
expect(mail.html).toContain(`href="${VERIFY_URL}&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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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=",
|
||||||
|
),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user