ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/* 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); });
|
||||
Reference in New Issue
Block a user