Files
threadcount-community/scripts/send-plans-notice.cjs
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

89 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The notice to existing rooms that plans are coming — docs/launch/plans-notice.md is the text.
*
* node scripts/send-plans-notice.cjs # dry run: who would get it, and the date
* node scripts/send-plans-notice.cjs --send # send, go-live 60 days from today
* node scripts/send-plans-notice.cjs --send --date 2026-11-14
*
* Runs on the production box with its .env (SMTP_* and DATABASE_URL). Goes to every active ADMIN
* of every non-demo facility that is grandfathered — which, before plans are live, is every
* facility. Refuses without --send. After a real send it appends a line to each facility's plan
* note so the console shows who was told and when. One mail per administrator, plain text, with
* replies going to hello@threadcount.tech. The date must be at least sixty days out: that is the
* notice period the Terms promise.
*/
require("dotenv/config");
const nodemailer = require("nodemailer");
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const args = process.argv.slice(2);
const SEND = args.includes("--send");
const dateArg = args[args.indexOf("--date") + 1];
const DAY = 86_400_000;
const MIN_NOTICE_DAYS = 60;
const goLive = args.includes("--date") ? new Date(dateArg + "T00:00:00+10:00") : new Date(Date.now() + MIN_NOTICE_DAYS * DAY);
if (Number.isNaN(goLive.getTime())) { console.error("--date must be YYYY-MM-DD"); process.exit(1); }
if (goLive.getTime() - Date.now() < (MIN_NOTICE_DAYS - 1) * DAY) { console.error(`The go-live date must be at least ${MIN_NOTICE_DAYS} days out — the Terms promise sixty days' notice.`); process.exit(1); }
const DATE = goLive.toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric", timeZone: "Australia/Brisbane" });
const TODAY = new Date().toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric", timeZone: "Australia/Brisbane" });
function body(first, facility) {
return `Hello ${first},
A note from the person who runs ThreadCount, sixty days ahead, because the pricing page said you would hear before the website did.
On ${DATE} ThreadCount will start charging new facilities for hosting. Nothing changes for ${facility}. You signed up while it was free, and it stays free for you — every feature, every report, both apps, no ceiling on staff records — for as long as your facility exists. There is nothing to do, nothing to sign, and nothing on your screens will look different on the day.
What is changing, for facilities created from ${DATE}:
- The software itself stays free. The code will be published so anyone can run it on their own server, with everything in it.
- Hosting on threadcount.tech stays free for a room under 60 staff records.
- A larger facility hosted on threadcount.tech will pay $1,290 a year (or $129 a month), which covers the servers, 35 days of backups, and a person who answers email the next business day.
- Health services running several facilities will be able to buy them together, with one sign-in across sites.
The Terms of Service now carry a Fees section that writes the grandfathering down, so it does not depend on a promise in an email: https://threadcount.tech/terms
Why now: running it for other people costs money and time, and I would rather charge new rooms plainly than let the thing quietly stop being maintained. Charging the rooms that trusted it first was never on the table.
If you would prefer to pay anyway, or your health service wants the multi-site arrangement, reply to this email. Otherwise, carry on exactly as you are.
Kyle
ThreadCount · hello@threadcount.tech
`;
}
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
(async () => {
const facilities = await prisma.facility.findMany({
where: { isDemo: false, grandfathered: true },
select: { id: true, name: true, planNote: true, users: { where: { role: "ADMIN", inactive: false }, select: { email: true, first: true } } },
orderBy: { createdAt: "asc" },
});
console.log(`${SEND ? "Sending" : "Dry run"} · go-live ${DATE} · ${facilities.length} facilities`);
for (const f of facilities) console.log(` ${f.name}: ${f.users.length} administrator${f.users.length === 1 ? "" : "s"}${f.planNote.includes("Plans notice sent") ? " · ALREADY NOTIFIED" : ""}`);
if (!SEND) { console.log("\nNothing sent. Add --send to send."); await prisma.$disconnect(); return; }
if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) { console.error("SMTP is not configured in this environment."); process.exit(1); }
const port = parseInt(process.env.SMTP_PORT || "587", 10);
const t = nodemailer.createTransport({ host: process.env.SMTP_HOST, port, secure: port === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } });
const subject = `ThreadCount is introducing plans on ${DATE}. Yours stays free.`;
let sent = 0, failed = 0;
for (const f of facilities) {
if (f.planNote.includes("Plans notice sent")) { console.log(` skip ${f.name} — already notified`); continue; }
for (const u of f.users) {
try {
await t.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: u.email, replyTo: "hello@threadcount.tech", subject, text: body(u.first || "there", f.name) });
sent++;
} catch (e) {
failed++; console.error(` FAILED ${f.name}: ${e.message}`);
}
}
const line = `Plans notice sent ${TODAY}, go-live ${DATE}`;
await prisma.facility.update({ where: { id: f.id }, data: { planNote: (f.planNote ? f.planNote + "\n" : "") + line } });
}
console.log(`\nSent ${sent}, failed ${failed}. Go-live ${DATE}: turn Switches Plans on that day.`);
await prisma.$disconnect();
})().catch((e) => { console.error(e); process.exit(1); });