Files
open-safetylog-ehs/server/notifications.ts
T
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
Add OSHA Form 301 and Osha page components
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information.
- Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA.
- Added functionality for printing and downloading CSV files for OSHA submissions.
- Integrated loading and error handling states for data fetching.
2026-10-06 13:09:57 -07:00

183 lines
8.1 KiB
TypeScript

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 { OSHA_REPORTING_CONTACT, severeInjuryReport } from '../shared/osha.ts'
import { correctiveActions, incidents, injuredPersons, 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<Promise<void>>()
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<void> {
await Promise.all([...this.pending])
}
private dispatch(task: () => Promise<void>) {
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}`)}`,
})
}
})
}
/** 1904.39: fatalities must be reported to OSHA within 8 hours; hospitalizations, amputations and eye losses within 24. */
severeInjury(caseId: string) {
this.dispatch(async () => {
const [row] = await this.db
.select({ person: injuredPersons, incident: incidents })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(eq(injuredPersons.id, caseId))
const report = row && severeInjuryReport(row.person)
if (!report) return
const recipients = await this.db
.select({ email: users.email })
.from(users)
.where(and(eq(users.organizationId, row.incident.organizationId), eq(users.active, true), inArray(users.role, ['safety_manager', 'admin'])))
const reference = incidentReference(row.incident.number)
for (const { email } of recipients) {
await this.mailer.send({
to: email,
subject: `Report to OSHA within ${report.withinHours} hours: ${reference}`,
text: `${reference} (${row.incident.title}) was recorded with: ${report.reasons.join(', ')}. OSHA requires a direct report within ${report.withinHours} hours of learning about it (29 CFR 1904.39).\n\n${OSHA_REPORTING_CONTACT}\n\nAfter reporting, record the date and time on the case: ${this.link(`/incidents/${row.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<number> {
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
}
}