Fastify/Postgres backend + React frontend matching the stack's app conventions, plus a hand-rolled RFC 4791 CalDAV server (caldav-adapter turned out Koa-only in practice) so calendars subscribe as genuine two-way sync in Apple/Google/Outlook. v1 scope: multiple colour-coded calendars, department/staff event tagging via live Workforce lookups, month/week/day/list views, dashboard, file attachments, activity log, and an auto-synced UK bank holidays calendar. Verified end-to-end locally against real Postgres: REST CRUD, CalDAV discovery/PROPFIND/REPORT/PUT/sync-collection, all-day date handling, system-calendar write protection, and activity logging across both the web and CalDAV write paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
67 lines
2.9 KiB
JavaScript
67 lines
2.9 KiB
JavaScript
import { requireAuth, requireCap } from '../auth.js'
|
|
import { pool } from '../db.js'
|
|
import { logActivity } from '../lib/activity.js'
|
|
import { mkdir, unlink, writeFile } from 'fs/promises'
|
|
import { randomUUID } from 'crypto'
|
|
import { join, extname } from 'path'
|
|
|
|
export async function attachmentRoutes(app, opts) {
|
|
const UPLOADS_DIR = opts.uploadsDir
|
|
app.addHook('preHandler', requireAuth)
|
|
|
|
// POST /api/events/:id/attachments — multipart, field "file"
|
|
app.post('/api/events/:id/attachments', { preHandler: requireCap('edit') }, async (req, reply) => {
|
|
const eventId = parseInt(req.params.id)
|
|
const { rows } = await pool.query('SELECT id, calendar_id FROM events WHERE id = $1 AND deleted_at IS NULL', [eventId])
|
|
if (!rows.length) return reply.status(404).send({ error: 'Event not found' })
|
|
const event = rows[0]
|
|
|
|
const part = await req.file()
|
|
if (!part) return reply.status(400).send({ error: 'No file uploaded' })
|
|
|
|
const chunks = []
|
|
for await (const chunk of part.file) chunks.push(chunk)
|
|
const buffer = Buffer.concat(chunks)
|
|
|
|
const ext = extname(part.filename || '') || ''
|
|
const storedName = `${randomUUID()}${ext}`
|
|
const storedPath = `events/${eventId}/${storedName}`
|
|
const dir = join(UPLOADS_DIR, 'events', String(eventId))
|
|
await mkdir(dir, { recursive: true })
|
|
await writeFile(join(dir, storedName), buffer)
|
|
|
|
const { rows: ins } = await pool.query(
|
|
`INSERT INTO event_attachments (event_id, filename, stored_path, mime_type, size_bytes, uploaded_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
|
[eventId, part.filename || storedName, storedPath, part.mimetype || null, buffer.length, req.user.email]
|
|
)
|
|
const attachment = ins[0]
|
|
|
|
await logActivity(pool, {
|
|
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'attachment',
|
|
entityId: attachment.id, calendarId: event.calendar_id, summary: `Added attachment "${attachment.filename}"`,
|
|
})
|
|
|
|
return reply.status(201).send({ ...attachment, url: `/api/uploads/${attachment.stored_path}` })
|
|
})
|
|
|
|
// DELETE /api/attachments/:id
|
|
app.delete('/api/attachments/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT a.*, e.calendar_id FROM event_attachments a JOIN events e ON e.id = a.event_id WHERE a.id = $1`,
|
|
[req.params.id]
|
|
)
|
|
if (!rows.length) return reply.status(404).send({ error: 'Attachment not found' })
|
|
const attachment = rows[0]
|
|
|
|
await unlink(join(UPLOADS_DIR, attachment.stored_path)).catch(() => {})
|
|
await pool.query('DELETE FROM event_attachments WHERE id = $1', [attachment.id])
|
|
|
|
await logActivity(pool, {
|
|
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'attachment',
|
|
entityId: attachment.id, calendarId: attachment.calendar_id, summary: `Deleted attachment "${attachment.filename}"`,
|
|
})
|
|
|
|
return { ok: true }
|
|
})
|
|
}
|