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.
This commit is contained in:
1 parent
708da6b9f7
commit
3f6dff5f24
94 files changed
+7213
-643
No files matched your search
@@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user