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:
commit
6ca395097e
47 changed files with 6727 additions and 0 deletions
70
backend/src/routes/photos.js
Normal file
70
backend/src/routes/photos.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { requireAuth, requireCap, hasCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { logEvent } from '../lib/task-core.js'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { mkdir, unlink } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { extname, join } from 'path'
|
||||
|
||||
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
const STAGES = ['report', 'progress', 'resolution']
|
||||
|
||||
export async function photoRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// POST /api/tasks/:id/photos — multipart: file + optional stage field
|
||||
app.post('/api/tasks/:id/photos', { preHandler: requireCap('report') }, async (req, reply) => {
|
||||
const taskId = parseInt(req.params.id)
|
||||
const { rows } = await pool.query('SELECT id FROM tasks WHERE id = $1', [taskId])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
|
||||
|
||||
let fileData = null, stage = 'report'
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type === 'file') {
|
||||
fileData = part
|
||||
// must consume the file stream inside the loop — save it now
|
||||
if (!ALLOWED_IMAGES.includes(part.mimetype)) {
|
||||
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
|
||||
}
|
||||
const ext = extname(part.filename) || '.jpg'
|
||||
const filename = randomUUID() + ext
|
||||
const dir = join(UPLOADS_DIR, 'tasks', String(taskId))
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
let size = 0
|
||||
const dest = createWriteStream(join(dir, filename))
|
||||
for await (const chunk of part.file) { dest.write(chunk); size += chunk.length }
|
||||
await new Promise(r => dest.end(r))
|
||||
|
||||
fileData = { filename: part.filename, mimetype: part.mimetype, savedAs: filename, size }
|
||||
} else {
|
||||
const val = await part.value
|
||||
if (part.fieldname === 'stage' && STAGES.includes(String(val))) stage = String(val)
|
||||
}
|
||||
}
|
||||
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
|
||||
|
||||
const filePath = `/tasks/${taskId}/${fileData.savedAs}`
|
||||
const { rows: ins } = await pool.query(
|
||||
`INSERT INTO task_photos (task_id, file_name, file_path, mime_type, file_size, stage, uploaded_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[taskId, fileData.filename, filePath, fileData.mimetype, fileData.size, stage, req.user.email]
|
||||
)
|
||||
await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name })
|
||||
return ins[0]
|
||||
})
|
||||
|
||||
// DELETE /api/photos/:id — uploader or update cap
|
||||
app.delete('/api/photos/:id', { preHandler: requireCap('report') }, async (req, reply) => {
|
||||
const { rows } = await pool.query('SELECT * FROM task_photos WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
|
||||
const photo = rows[0]
|
||||
if (photo.uploaded_by !== req.user.email && !hasCap(req, 'update')) {
|
||||
return reply.status(403).send({ error: 'Can only delete your own photos' })
|
||||
}
|
||||
await unlink(join(UPLOADS_DIR, photo.file_path)).catch(() => {})
|
||||
await pool.query('DELETE FROM task_photos WHERE id = $1', [req.params.id])
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue