diff --git a/.gitignore b/.gitignore index a28b5ab..a6fd955 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ npm-cache/ !public/favicon-32x32.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) !public/content/blog/*-ornament-*.png diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts index 222c170..61618de 100644 --- a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts @@ -46,7 +46,7 @@ export const POST = apiRoute( email: true, inviteTokenHash: 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( }, }); - const verifyUrl = stakeholderInviteVerifyUrl(getPublicOrigin(request), token); + const origin = getPublicOrigin(request); + const verifyUrl = stakeholderInviteVerifyUrl(origin, token); 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) { logRouteError("rules.stakeholders.resend", requestId, err, { phase: "sendRuleStakeholderInviteEmail", diff --git a/app/api/rules/[id]/stakeholders/route.ts b/app/api/rules/[id]/stakeholders/route.ts index 02fe87b..13840be 100644 --- a/app/api/rules/[id]/stakeholders/route.ts +++ b/app/api/rules/[id]/stakeholders/route.ts @@ -31,7 +31,7 @@ type RouteContext = { params: Promise<{ id: string }> }; async function ownedRuleMeta(ruleId: string, userId: string) { return prisma.publishedRule.findFirst({ 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( origin, ruleId: rule.id, ruleTitle: rule.title, + summary: rule.summary, + document: rule.document, email, invitedByUserId: user.id, + inviterEmail: user.email, pepper, }); diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 246ea1f..56de731 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -208,7 +208,15 @@ export const POST = apiRoute( try { for (const inv of invites) { 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) { logRouteError("rules.publish", requestId, err, { diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts index 5e28dc5..d4df3c3 100644 --- a/lib/assetUtils.ts +++ b/lib/assetUtils.ts @@ -226,6 +226,8 @@ export function contentCatalogSlugForFallback( export const ASSETS = { // Brand logo 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 AVATAR_1: "assets/marketing/avatar-1.png", diff --git a/lib/create/ruleInviteCardMeta.ts b/lib/create/ruleInviteCardMeta.ts new file mode 100644 index 0000000..5ff0d13 --- /dev/null +++ b/lib/create/ruleInviteCardMeta.ts @@ -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}`; +} diff --git a/lib/server/mail.ts b/lib/server/mail.ts index fc02755..1fa6705 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -6,6 +6,10 @@ import { type WorkshopInquiryMailInput, } from "../workshopSignupMail"; import { getSmtpUrl } from "./env"; +import { + buildRuleStakeholderInviteMail, + type RuleStakeholderInviteMailInput, +} from "./ruleStakeholderInviteMail"; function escapeHtml(value: string): string { 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( - to: string, - verifyUrl: string, - ruleTitle: string, + input: RuleStakeholderInviteMailInput, ): Promise { - const { text, html } = buildVerifyLinkParts( - 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", - ); + const { subject, text, html } = buildRuleStakeholderInviteMail(input); await sendHtmlMail({ - to, - subject: `You're invited to view a Community Rule: ${ruleTitle}`, + to: input.to, + subject, text, 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}`, }); } diff --git a/lib/server/ruleStakeholderInviteMail.ts b/lib/server/ruleStakeholderInviteMail.ts new file mode 100644 index 0000000..b8df030 --- /dev/null +++ b/lib/server/ruleStakeholderInviteMail.ts @@ -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, """); +} + +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 = + `Writing a Rule for another community?` + + ` Make a copy` + + ` 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 + ? `

${description}

` + : ""; + + return ` + + + + +${heading} + + +
${escapeHtml( + PREVIEW_TEXT, + )}
+ + + + +
+ + + + + + + +
+ + + + + +
CommunityRule + +
+
+

${heading}

+

${intro}

+

+ Read the Rule +

+

Or paste this link into a browser

+

+ ${verifyHref} +

+ + + + +
+ + + + + +
+
${initials}
+
${name}
+ ${cardBody} +

+ ${counts} +

+
+
+

${privacyHtml}

+

${ownLinkHtml}

+

${copyHtml}

+
+

${why}

+

${madeBy}

+

+ Privacy +    + About CommunityRule +

+
+
+
+ +`; +} + +function privacyHtmlWithLead(privacy: string): string { + const lead = "Every Rule starts private."; + if (privacy.startsWith(lead)) { + return `${escapeHtml(lead)}${escapeHtml( + privacy.slice(lead.length), + )}`; + } + return escapeHtml(privacy); +} + +function leadInHtml(lead: string, full: string): string { + if (full.startsWith(lead)) { + return `${escapeHtml(lead)}${escapeHtml( + full.slice(lead.length), + )}`; + } + return escapeHtml(full); +} diff --git a/lib/server/ruleStakeholderInviteOps.ts b/lib/server/ruleStakeholderInviteOps.ts index 91516da..946ca50 100644 --- a/lib/server/ruleStakeholderInviteOps.ts +++ b/lib/server/ruleStakeholderInviteOps.ts @@ -18,8 +18,11 @@ export async function createRuleStakeholderInviteAndSendMail(opts: { origin: string; ruleId: string; ruleTitle: string; + summary?: string | null; + document?: unknown; email: string; invitedByUserId: string; + inviterEmail: string; pepper: string; }): Promise<{ ok: true } | { ok: false }> { const token = newSessionToken(); @@ -38,7 +41,15 @@ export async function createRuleStakeholderInviteAndSendMail(opts: { const verifyUrl = stakeholderInviteVerifyUrl(opts.origin, token); 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 }; } catch (err) { logRouteError(opts.scope, opts.requestId, err, { diff --git a/public/assets/logos/community-rule-mark.png b/public/assets/logos/community-rule-mark.png new file mode 100644 index 0000000..cbaed36 Binary files /dev/null and b/public/assets/logos/community-rule-mark.png differ diff --git a/tests/unit/ruleInviteCardMeta.test.ts b/tests/unit/ruleInviteCardMeta.test.ts new file mode 100644 index 0000000..2616e1d --- /dev/null +++ b/tests/unit/ruleInviteCardMeta.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/ruleStakeholderInviteMail.test.ts b/tests/unit/ruleStakeholderInviteMail.test.ts new file mode 100644 index 0000000..9856167 --- /dev/null +++ b/tests/unit/ruleStakeholderInviteMail.test.ts @@ -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", + ); + }); +}); diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts index 5431be2..1126321 100644 --- a/tests/unit/rulesPublishPostRoute.test.ts +++ b/tests/unit/rulesPublishPostRoute.test.ts @@ -148,9 +148,14 @@ describe("POST /api/rules", () => { expect(res.status).toBe(200); expect(transactionMock).toHaveBeenCalledTimes(1); expect(sendInviteMock).toHaveBeenCalledTimes(1); - expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com"); - expect(String(sendInviteMock.mock.calls[0][1])).toContain( - "/api/invites/rule-stakeholder/verify?token=", + expect(sendInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "stakeholder@example.com", + communityName: "Published title", + verifyUrl: expect.stringContaining( + "/api/invites/rule-stakeholder/verify?token=", + ), + }), ); expect(publishedRuleCreateMock).not.toHaveBeenCalled(); }); diff --git a/tests/unit/rulesStakeholderMutationsRoute.test.ts b/tests/unit/rulesStakeholderMutationsRoute.test.ts index 9498b12..309b024 100644 --- a/tests/unit/rulesStakeholderMutationsRoute.test.ts +++ b/tests/unit/rulesStakeholderMutationsRoute.test.ts @@ -130,7 +130,13 @@ describe("POST /api/rules/[id]/stakeholders", () => { routeCtx, ); 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 { 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(updateMock).toHaveBeenCalled(); expect(sendInviteEmailMock).toHaveBeenCalledWith( - "inv@example.com", - expect.stringContaining("/api/invites/rule-stakeholder/verify?token="), - "My rule", + expect.objectContaining({ + to: "inv@example.com", + communityName: "My rule", + inviterEmail: owner.email, + verifyUrl: expect.stringContaining( + "/api/invites/rule-stakeholder/verify?token=", + ), + }), ); }); });