Files
open-safetylog-ehs/server/notifications.ts
T
jayjennings 3f6dff5f24
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat: implement permissions and actions management
- Add permissions logic for status options in corrective actions.
- Create tests for permissions functionality.
- Implement queries for user authentication and actions management.
- Develop Actions page to display and manage corrective actions.
- Create authentication pages for login, setup, and password reset.
- Add IncidentDetail page to view incident details and associated actions.
- Implement Settings page for user management and password changes.
2026-10-06 11:20:44 -07:00

157 lines
6.6 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 { 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<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}`)}`,
})
}
})
}
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
}
}