calendar/backend/src/routes/attachments.js
jtricerolph bf5557d277 Add calendar app — shared events calendar with departments, staff tagging, bank holidays and two-way phone sync
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>
2026-07-24 16:36:37 +00:00

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 }
})
}