import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-orm' import { incidentReference } from '../shared/format.ts' import type { Config } from './config.ts' import type { Db } from './db/client.ts' import { correctiveActions, incidents, users } from './db/schema.ts' import { addDays, localDate } from './lib/dates.ts' import type { Mailer } from './mail.ts' const severityLabel = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } as const /** Sends emails in the background so slow mail servers never delay or fail a request. */ export class Notifier { private readonly pending = new Set>() private readonly db: Db private readonly mailer: Mailer private readonly config: Config constructor(db: Db, mailer: Mailer, config: Config) { this.db = db this.mailer = mailer this.config = config } /** Waits for queued emails (tests, shutdown). */ async flush(): Promise { await Promise.all([...this.pending]) } private dispatch(task: () => Promise) { const run = task() .catch((error) => console.error('[notify] failed:', error)) .finally(() => this.pending.delete(run)) this.pending.add(run) } private link(path: string) { return new URL(path, this.config.publicUrl).toString() } magicLink(email: string, token: string) { this.dispatch(() => this.mailer.send({ to: email, subject: 'Your sign-in link', text: `Use this link to sign in to Open SafetyLog EHS. It works once and expires in 15 minutes.\n\n${this.link(`/auth/magic?token=${token}`)}\n\nIf you didn't ask for it, you can ignore this email.`, }), ) } passwordReset(email: string, token: string, invite: { organization: string; invitedBy: string } | null) { const link = this.link(`/auth/reset?token=${token}`) this.dispatch(() => this.mailer.send( invite ? { to: email, subject: `You've been invited to ${invite.organization} on Open SafetyLog EHS`, text: `${invite.invitedBy} added you to ${invite.organization}'s safety workspace.\n\nSet your password to get started (link valid for 7 days):\n\n${link}`, } : { to: email, subject: 'Reset your password', text: `Use this link to choose a new password. It works once and expires in 1 hour.\n\n${link}\n\nIf you didn't ask for it, you can ignore this email; your password hasn't changed.`, }, ), ) } /** Alerts safety managers and admins about high and critical reports. */ incidentReported(incidentId: string) { this.dispatch(async () => { const [incident] = await this.db.select().from(incidents).where(eq(incidents.id, incidentId)) if (!incident || (incident.severity !== 'high' && incident.severity !== 'critical')) return const recipients = await this.db .select({ email: users.email }) .from(users) .where( and( eq(users.organizationId, incident.organizationId), eq(users.active, true), inArray(users.role, ['safety_manager', 'admin']), incident.reporterId ? ne(users.id, incident.reporterId) : undefined, ), ) const reference = incidentReference(incident.number) for (const { email } of recipients) { await this.mailer.send({ to: email, subject: `[${severityLabel[incident.severity]}] ${reference}: ${incident.title}`, text: `A ${severityLabel[incident.severity].toLowerCase()}-severity incident was reported.\n\n${reference}: ${incident.title}\nOccurred: ${incident.occurredAt.toLocaleString('en-US')}\n\n${this.link(`/incidents/${incident.number}`)}`, }) } }) } actionAssigned(actionId: string, assignedBy: string) { this.dispatch(async () => { const [row] = await this.db .select({ action: correctiveActions, email: users.email, number: incidents.number, incidentTitle: incidents.title }) .from(correctiveActions) .innerJoin(users, eq(users.id, correctiveActions.assigneeId)) .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) .where(and(eq(correctiveActions.id, actionId), eq(users.active, true))) if (!row) return const due = row.action.dueDate ? `Due: ${row.action.dueDate}\n` : '' await this.mailer.send({ to: row.email, subject: `Action assigned: ${row.action.title}`, text: `${assignedBy} assigned you a corrective action on ${incidentReference(row.number)} (${row.incidentTitle}).\n\n${row.action.title}\n${due}\n${this.link(`/incidents/${row.number}`)}`, }) }) } /** * Emails each assignee a digest of open actions due tomorrow or earlier, at most once a day per action. * Returns how many emails were queued. */ async sendDueReminders(today = localDate()): Promise { const rows = await this.db .select({ action: correctiveActions, email: users.email, number: incidents.number }) .from(correctiveActions) .innerJoin(users, eq(users.id, correctiveActions.assigneeId)) .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) .where( and( inArray(correctiveActions.status, ['open', 'in_progress']), eq(users.active, true), isNotNull(correctiveActions.dueDate), lte(correctiveActions.dueDate, addDays(today, 1)), or(isNull(correctiveActions.reminderSentOn), lt(correctiveActions.reminderSentOn, today)), ), ) const byEmail = Map.groupBy(rows, (row) => row.email) for (const [email, items] of byEmail) { const lines = items.map(({ action, number }) => { const when = action.dueDate! < today ? `OVERDUE since ${action.dueDate}` : action.dueDate === today ? 'due today' : 'due tomorrow' return `- ${action.title} (${incidentReference(number)}), ${when}\n ${this.link(`/incidents/${number}`)}` }) const overdue = items.filter(({ action }) => action.dueDate! < today).length this.dispatch(() => this.mailer.send({ to: email, subject: overdue ? `${overdue} overdue corrective action${overdue === 1 ? '' : 's'}` : 'Corrective actions due soon', text: `These corrective actions assigned to you need attention:\n\n${lines.join('\n')}\n\nAll your actions: ${this.link('/actions')}`, }), ) } if (rows.length) { await this.db .update(correctiveActions) .set({ reminderSentOn: today }) .where(inArray(correctiveActions.id, rows.map(({ action }) => action.id))) } return byEmail.size } }