ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 18:27:45 +10:00
commit 7d650e4c10
298 changed files with 45868 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
/* Every email ThreadCount can send to a customer, rendered with sample data for review.
*
* npx tsx scripts/preview-all-mail.ts → .scratch/mail-preview/all/{index.html, *.html, all.json}
* node scripts/send-mail-previews.cjs <address> → sends all.json through the box's SMTP (run on the box)
*
* Covers the coordinator emails (welcome, password reset, the plans notice), the billing emails
* (lib/billing-mail.cjs) and the staff notices. Every one carries plain text and HTML; the text is
* the original wording, the HTML the shared layout (lib/mail-html.cjs). Sample names are the fictional Riverside General
* Hospital; addresses are example.com. Internal alerts (contact form, ops) are not customer mail. */
import fs from "node:fs";
import path from "node:path";
import { approvalEmail, decisionEmail, readyEmail, roundEmail, waitlistEmail } from "../lib/approvallink";
import { resetEmail } from "../lib/reset";
import { welcomeEmail } from "../lib/accountmail";
import { PRICES, TRIAL_DAYS } from "../lib/plan";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const billing = { templates: require("../lib/billing-mail.cjs") };
type Mail = { key: string; group: string; subject: string; text: string; html?: string; when: string };
const out: Mail[] = [];
const add = (key: string, group: string, when: string, m: { subject: string; text: string; html?: string }) => out.push({ key, group, when, ...m });
const site = "https://threadcount.tech";
const fac = "Riverside General Hospital";
const ctx = { facility: fac, contact: "billing@example.com" };
const lines = [{ qty: 2, item: "Scrub top, navy", size: "M", status: "approved" }, { qty: 1, item: "Fleece jacket", size: "L", status: "declined", reason: "Already holds two" }];
// ---- coordinator
add("welcome", "Coordinator", "Right after sign-up, to the coordinator's address", welcomeEmail("Dana", fac));
add("password-reset", "Coordinator", "When someone asks for a reset", resetEmail("Dana", `${site}/reset?token=example`));
{
// The notice script owns its words; render its html the same way it does when sending.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { layout } = require("../lib/mail-html.cjs") as { layout: (o: Record<string, unknown>) => { html: string } };
const text = [`Hi Dana,`, "", `From 12 November 2026 ThreadCount will charge new facilities for hosting. ${fac} was set up before that, so it stays exactly as it is: hosted, every feature, free, for as long as it exists. Nothing changes for you and there is nothing to do.`, "", "The pricing page explains what new facilities will pay and why. Reply to this email if you have a question.", "", "— ThreadCount"].join("\n");
add("plans-notice", "Coordinator", "One-off notice sent 13 Sep 2026 to the facilities that existed before plans", {
subject: "ThreadCount is introducing plans on 12 November 2026. Yours stays free.", text,
html: layout({ eyebrow: "Plans", title: "Yours stays free", preheader: `${fac} stays hosted, with everything, free.`, intro: [`Hi Dana,`, `From 12 November 2026 ThreadCount will charge new facilities for hosting. ${fac} was set up before that, so it stays exactly as it is: hosted, every feature, free, for as long as it exists. Nothing changes for you and there is nothing to do.`], closing: ["The pricing page explains what new facilities will pay and why. Reply to this email if you have a question."], cta: { label: "Read the Fees section", href: `${site}/terms` }, footer: { facility: fac, links: [["Pricing", `${site}/pricing`], ["Support", `${site}/support`]] } }).html,
});
}
// ---- billing (HTML) — the same sample calls as scripts/preview-billing-mail.cjs
const T = billing.templates as unknown as Record<string, (f: typeof ctx, o: Record<string, unknown>) => { subject: string; text: string; html: string }>;
const DAY = 86_400_000;
const now = new Date("2026-09-13T00:00:00+10:00");
const trialEnd = new Date(now.getTime() + TRIAL_DAYS * DAY);
const paidUntil = new Date("2026-10-13T00:00:00+10:00");
const card = { brand: "Visa", last4: "4242" };
const money = { monthlyCents: PRICES.hostedMonthly * 100, annualCents: PRICES.hostedAnnual * 100 };
const tries: [string, string, () => { subject: string; text: string; html: string }][] = [
["trial-started", "Right after a trial sign-up", () => T.trialStarted(ctx, { first: "Dana", endsAt: trialEnd })],
["trial-day-1", "Day 1 of the trial: get the register in (daily job)", () => T.trialTip(ctx, { day: 1, endsAt: trialEnd, ...money })],
["trial-day-3", "Day 3: the counter app (daily job)", () => T.trialTip(ctx, { day: 3, endsAt: trialEnd, ...money })],
["trial-day-7", "Day 7: reports and the month-end pack (daily job)", () => T.trialTip(ctx, { day: 7, endsAt: trialEnd, ...money })],
["trial-day-21", "Day 21: what a plan includes (daily job)", () => T.trialTip(ctx, { day: 21, endsAt: trialEnd, ...money })],
["trial-day-28", "Day 28: two days left (daily job)", () => T.trialTip(ctx, { day: 28, endsAt: trialEnd, ...money })],
["trial-7-days", "7 days before the trial ends (daily job)", () => T.trialEndingSoon(ctx, { daysLeft: 7, endsAt: trialEnd, graceEndsAt: new Date(trialEnd.getTime() + 14 * DAY), ...money })],
["trial-1-day", "The day before the trial ends (daily job)", () => T.trialEndingSoon(ctx, { daysLeft: 1, endsAt: trialEnd, graceEndsAt: new Date(trialEnd.getTime() + 14 * DAY), ...money })],
["trial-ended", "The day the trial ends: 14 days of grace begin (daily job)", () => T.trialEnded(ctx, { endsAt: trialEnd, graceEndsAt: new Date(trialEnd.getTime() + 14 * DAY), ...money })],
["payment-received-monthly", "After each successful monthly card payment (webhook)", () => T.paymentReceived(ctx, { cadence: "month", periodStart: now, periodEnd: paidUntil, subtotalCents: 12900, taxCents: 1290, totalCents: 14190, currency: "AUD", card, invoiceNumber: "TC-0001-0001", invoiceUrl: `${site}/app/settings?tab=plan`, nextChargeAt: paidUntil, nextChargeCents: 14190 })],
["payment-received-yearly", "After a yearly card payment (webhook)", () => T.paymentReceived(ctx, { cadence: "year", periodStart: now, periodEnd: new Date("2027-09-13T00:00:00+10:00"), subtotalCents: 129000, taxCents: 12900, totalCents: 141900, currency: "AUD", card, invoiceNumber: "TC-0001-0002", invoiceUrl: `${site}/app/settings?tab=plan`, nextChargeAt: new Date("2027-09-13T00:00:00+10:00"), nextChargeCents: 141900 })],
["payment-failed", "When a renewal charge fails (webhook)", () => T.paymentFailed(ctx, { cadence: "month", totalCents: 14190, currency: "AUD", card, paidUntil, graceEndsAt: new Date(paidUntil.getTime() + 14 * DAY), reason: "the card was declined" })],
["plan-will-end", "When the facility stops the plan at period end", () => T.subscriptionCancelled(ctx, { scheduled: true, endsAt: paidUntil, graceEndsAt: new Date(paidUntil.getTime() + 14 * DAY) })],
["plan-ended", "When the subscription actually ends", () => T.subscriptionCancelled(ctx, { scheduled: false, endsAt: paidUntil, graceEndsAt: new Date(paidUntil.getTime() + 14 * DAY) })],
["read-only", "The day grace runs out (daily job)", () => T.readOnlyNow(ctx, { wasTrial: true, endsAt: trialEnd, ...money })],
["card-updated", "After the card on file is changed", () => T.cardUpdated(ctx, { card, nextChargeAt: paidUntil })],
];
for (const [key, when, fn] of tries) {
try { add(key, "Billing", when, fn()); } catch (e) { add(key, "Billing", when, { subject: `(render failed: ${(e as Error).message})`, text: "" }); }
}
// ---- staff notices (plain text plus the shared HTML layout)
add("staff-approval-needed", "Staff", "To a manager when a wearer asks for uniform", approvalEmail({ managerFirst: "Priya", subjectName: "Ali Hassan", lines, reason: "Starting on the ward", note: "", url: `${site}/a/example`, facility: fac }));
add("staff-approval-needed-on-behalf", "Staff", "To a manager when the counter raises a request for someone", approvalEmail({ managerFirst: "Priya", subjectName: "Ali Hassan", raisedByName: "Dana (linen room)", lines, reason: "", note: "Replacement after damage", url: `${site}/a/example`, facility: fac }));
add("staff-approved", "Staff", "To the wearer when the manager approves", decisionEmail({ staffFirst: "Ali", managerName: "Priya Nair", approved: true, lines: lines.filter((l) => l.status === "approved"), facility: fac }));
add("staff-partly-approved", "Staff", "To the wearer when part of the request is declined", decisionEmail({ staffFirst: "Ali", managerName: "Priya Nair", approved: true, lines, facility: fac }));
add("staff-declined", "Staff", "To the wearer when the request is declined", decisionEmail({ staffFirst: "Ali", managerName: "Priya Nair", approved: false, reason: "Over the entitlement for this year", lines, facility: fac }));
const noteLines = [{ qty: 2, item: "Scrub top, navy", size: "M" }];
add("staff-ready", "Staff", "To the wearer when the bag is ready at the linen room", readyEmail({ first: "Ali", code: "R-1042", lines: noteLines, collectCode: "7391", holdUntil: "Fri 20 Sep", foot: `${fac} · ThreadCount` }));
add("staff-round", "Staff", "To the wearer when the bag goes out on the ward round", roundEmail({ first: "Ali", code: "R-1042", lines: noteLines, dept: "Ward 4B", foot: `${fac} · ThreadCount` }));
add("staff-waitlist", "Staff", "To the wearer when a size they waited for comes in", waitlistEmail({ item: "Scrub top, navy", size: "M", until: "Thu 19 Sep, 4:00 pm", foot: `${fac} · ThreadCount` }));
// ---- write
const dir = path.join(process.cwd(), ".scratch", "mail-preview", "all");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "all.json"), JSON.stringify(out, null, 1));
const esc = (s: string) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] as string));
for (const m of out) fs.writeFileSync(path.join(dir, m.key + ".html"), m.html || `<pre style="font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap;max-width:70ch">${esc(m.text)}</pre>`);
console.log(`${out.length} emails rendered → ${dir} (${out.filter((m) => m.html).length} with html)`);
for (const m of out) console.log(` ${m.group.padEnd(11)} ${m.key.padEnd(34)} ${m.subject}`);