Maintenance log book app — initial scaffold

Multi-department fault log: NewBook-synced room locations + manual
locations with categories, six-state task flow (submitted/in progress/
hold-parts/hold-later/temporary fix/fixed), photos per stage, priorities
with unusable flag and per-task NewBook out-of-order push, costs on
resolve, comment/audit thread, recurring task templates with
note-to-template carryover, asset register, contractor register with
document attachments, staff/contractor allocation, occupancy-aware
summary filter, searchable history with CSV export, email notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 21:28:57 +00:00
commit 6ca395097e
47 changed files with 6727 additions and 0 deletions

View file

@ -0,0 +1,80 @@
import { pool, getConfig } from '../db.js'
import { notifyAssignment, notifyUrgent } from './mailer.js'
export const PRIORITIES = ['low', 'medium', 'high', 'urgent']
export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed']
// Legal state transitions. temporary_fix still counts as open.
export const TRANSITIONS = {
submitted: ['in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
in_progress: ['submitted', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
hold_parts: ['submitted', 'in_progress', 'hold_scheduled', 'temporary_fix', 'fixed'],
hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'temporary_fix', 'fixed'],
temporary_fix: ['submitted', 'in_progress', 'fixed'],
fixed: ['submitted'], // reopen only
}
export async function logEvent(taskId, eventType, { fromStatus = null, toStatus = null, note = null, userName = null } = {}) {
await pool.query(
`INSERT INTO task_events (task_id, event_type, from_status, to_status, note, user_name)
VALUES ($1, $2, $3, $4, $5, $6)`,
[taskId, eventType, fromStatus, toStatus, note, userName]
)
}
// Shared by the tasks route and the recurring-template scheduler.
// input.assigned_* fall back to the configured defaults when absent.
export async function createTask(input, actor) {
const config = await getConfig()
let assignedType = input.assigned_type || config.default_assigned_type || 'staff'
let assignedTo = input.assigned_to ?? null
let assignedToName = input.assigned_to_name ?? null
let contractorId = input.contractor_id ?? null
if (!input.assigned_type && assignedTo == null && contractorId == null) {
if (assignedType === 'contractor' && config.default_contractor_id) {
contractorId = config.default_contractor_id
} else if (config.default_assignee) {
assignedType = 'staff'
assignedTo = config.default_assignee
assignedToName = config.default_assignee_name || config.default_assignee
}
}
if (assignedType === 'contractor') { assignedTo = null; assignedToName = null }
else contractorId = null
const { rows } = await pool.query(
`INSERT INTO tasks (title, description, location_id, asset_id, template_id, priority, unusable,
due_date, assigned_type, assigned_to, assigned_to_name, contractor_id,
created_by, created_by_name)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[
input.title, input.description || null, input.location_id, input.asset_id || null,
input.template_id || null, input.priority || 'medium', input.unusable === true,
input.due_date || null, assignedType, assignedTo, assignedToName, contractorId,
actor.email, actor.name,
]
)
const task = rows[0]
await logEvent(task.id, 'created', { toStatus: 'submitted', userName: actor.name })
const { rows: locRows } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
const locationName = locRows[0]?.name || `#${task.location_id}`
// Notifications are fire-and-forget; mailer swallows errors.
if (config.notify_on_assign) {
if (task.assigned_type === 'staff' && task.assigned_to && task.assigned_to !== actor.email) {
notifyAssignment(task, locationName, task.assigned_to)
} else if (task.assigned_type === 'contractor' && task.contractor_id) {
const { rows: c } = await pool.query('SELECT email FROM contractors WHERE id = $1', [task.contractor_id])
if (c[0]?.email) notifyAssignment(task, locationName, c[0].email)
}
}
if (config.notify_on_urgent && task.priority === 'urgent' && config.urgent_notify_email) {
notifyUrgent(task, locationName, config.urgent_notify_email)
}
return task
}