import { requireAuth, requireCap, hasCap } from '../auth.js' import { pool } from '../db.js' import { logEvent } from '../lib/task-core.js' import { mkdir, unlink, writeFile } from 'fs/promises' import { randomUUID } from 'crypto' import { join } from 'path' import sharp from 'sharp' 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') { if (!ALLOWED_IMAGES.includes(part.mimetype)) { return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' }) } // Buffer the upload then process with sharp: // auto-rotate (fixes phone EXIF orientation), resize to 1800px max, re-encode as JPEG const chunks = [] for await (const chunk of part.file) chunks.push(chunk) const raw = Buffer.concat(chunks) const processed = await sharp(raw) .rotate() .resize(1800, 1800, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 82, progressive: true }) .toBuffer() const filename = randomUUID() + '.jpg' const dir = join(UPLOADS_DIR, 'tasks', String(taskId)) await mkdir(dir, { recursive: true }) await writeFile(join(dir, filename), processed) fileData = { originalName: part.filename, savedAs: filename, size: processed.length } } 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.originalName, filePath, 'image/jpeg', fileData.size, stage, req.user.email] ) await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name, photoId: ins[0].id }) 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 } }) }