diff --git a/src/app.js b/src/app.js index 829c998..a5f3dae 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,8 @@ const authRoutes = require('./routes/auth.routes'); const eventRoutes = require('./routes/event.routes'); const participantRoutes = require('./routes/participant.routes'); const participantListRoutes = require('./routes/participant-list.routes'); +const participantDocumentsRoutes = require('./routes/participantDocuments.routes'); + const sessionRoutes = require('./routes/session.routes'); const trackRoutes = require('./routes/track.routes'); const speakerRoutes = require('./routes/speaker.routes'); @@ -176,6 +178,8 @@ const createServer = async () => { const jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production'; const attendeeSecret = process.env.ATTENDEE_JWT_SECRET || jwtSecret; + const Participant = require('./models/Participant'); + server.auth.strategy('jwt', 'jwt', { key: jwtSecret, validate: async (decoded, request, h) => { @@ -187,7 +191,29 @@ const createServer = async () => { verifyOptions: { algorithms: ['HS256'] }, }); - const Participant = require('./models/Participant'); + // OR-auth for endpoints that must accept either admin/user JWT or self-attendee JWT. + // Hapi v17 does not support `auth.strategy: ['a','b']` for a single route. + server.auth.strategy('jwt-any', 'jwt', { + key: attendeeSecret, + validate: async (decoded, request, h) => { + // Try attendee first (audience + pid) + if (decoded?.aud === 'attendee' && decoded?.pid) { + const participant = await Participant.findById(decoded.pid).lean(); + if (!participant) return { isValid: false }; + return { isValid: true, credentials: { participant, attendee: decoded } }; + } + + // Fallback to admin/user JWT using the same token validation as `jwt`. + // Note: admin/user JWT has `decoded.id`. + if (!decoded?.id) return { isValid: false }; + + const user = await User.findById(decoded.id).select('-password').populate('organization'); + if (!user || !user.isActive) return { isValid: false }; + return { isValid: true, credentials: { user, id: user._id.toString() } }; + }, + verifyOptions: { algorithms: ['HS256'] }, + }); + server.auth.strategy('jwt-attendee', 'jwt', { key: attendeeSecret, validate: async (decoded, request, h) => { @@ -257,6 +283,8 @@ const createServer = async () => { server.route(eventRoutes); server.route(participantRoutes); server.route(participantListRoutes); + server.route(participantDocumentsRoutes); + server.route(sessionRoutes); server.route(trackRoutes); server.route(speakerRoutes); diff --git a/src/controllers/participantDocuments.controller.js b/src/controllers/participantDocuments.controller.js new file mode 100644 index 0000000..e8b995a --- /dev/null +++ b/src/controllers/participantDocuments.controller.js @@ -0,0 +1,551 @@ +const mongoose = require('mongoose'); + +const ParticipantDocument = require('../models/ParticipantDocument'); +const Participant = require('../models/Participant'); +const Event = require('../models/Event'); + +const { success, error } = require('../utils/response'); +const { + uploadBufferToS3AndGetCloudFrontUrl, + getBufferFromS3Key, + deleteS3ObjectByKey, +} = require('../utils/s3Media'); + +const { getMembership } = require('../utils/participantMembership'); +const { assertModulePermission, userOrgId } = require('../services/acl.service'); + +// This file deliberately does NOT implement a new multipart/S3 layer. +// Upload handling is done via `media.routes`-style payload parsing: we reuse payload.file buffer. + +function toOid(v) { + if (!v) return null; + if (v instanceof mongoose.Types.ObjectId) return v; + if (!mongoose.Types.ObjectId.isValid(String(v))) return null; + return new mongoose.Types.ObjectId(String(v)); +} + +function extractUploadFile(payload) { + const file = payload.file; + if (!file) return null; + + // Helper: extract mime type from Hapi multipart file metadata, with fallback to payload.mimeType + function getMime(f) { + // Hapi v17 stores metadata in f.hapi.headers['content-type'] + if (f.hapi?.headers?.['content-type']) return f.hapi.headers['content-type']; + if (f.headers?.['content-type']) return f.headers['content-type']; + if (typeof payload.mimeType === 'string' && payload.mimeType) return payload.mimeType; + return 'application/octet-stream'; + } + + // Helper: extract original filename from Hapi metadata + function getOrigName(f) { + if (f.hapi?.filename) return f.hapi.filename; + if (f.filename) return f.filename; + if (typeof payload.name === 'string' && payload.name.trim()) return payload.name.trim(); + return 'file'; + } + + if (Buffer.isBuffer(file)) { + return { + buffer: file, + originalName: getOrigName(file), + mimeType: getMime(file), + }; + } + + if (typeof file === 'object' && file.payload && Buffer.isBuffer(file.payload)) { + return { + buffer: file.payload, + originalName: getOrigName(file), + mimeType: getMime(file), + }; + } + + if (typeof file === 'object' && file._data && Buffer.isBuffer(file._data)) { + return { + buffer: file._data, + originalName: getOrigName(file), + mimeType: getMime(file), + }; + } + + return null; +} + +function inferModulePermissionOrgAction(action = 'read') { + // ACL module uses 'participants' + return { moduleKey: 'participants', action }; +} + +async function assertParticipantOrgAccess(participant, user) { + if (user.role === 'super_admin') return true; + const oid = user.organization?._id || user.organization; + return participant.tenantOrganization?.toString() === oid?.toString(); +} + +async function assertUserCanAccessParticipants(user, organizationId, action = 'read') { + const { moduleKey } = inferModulePermissionOrgAction(action); + try { + await assertModulePermission(user, organizationId, moduleKey, action); + return true; + } catch (e) { + return false; + } +} + +async function resolveParticipantAndEventScopes(participantId, eventId, user) { + if (!mongoose.Types.ObjectId.isValid(String(participantId))) { + return { status: 404, body: error('NOT_FOUND', 'Participant not found') }; + } + if (!mongoose.Types.ObjectId.isValid(String(eventId))) { + return { status: 404, body: error('NOT_FOUND', 'Event not found') }; + } + + const participant = await Participant.findById(participantId).lean(); + if (!participant) return { status: 404, body: error('NOT_FOUND', 'Participant not found') }; + + const orgId = userOrgId(user); + if (!orgId || !(await assertParticipantOrgAccess(participant, user))) { + return { status: 403, body: error('FORBIDDEN', 'Access denied') }; + } + + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return { status: 404, body: error('NOT_FOUND', 'No documents for this event') }; + + if (String(event.organization) !== String(orgId)) { + return { status: 403, body: error('FORBIDDEN', 'Access denied') }; + } + + // Participant must be registered for event; this is the best available enforcement for “event-scoped documents”. + if (!getMembership(participant, eventId)) { + // Mandatory behaviour says 404 if document/participant doesn't belong to event. + return { status: 404, body: error('NOT_FOUND', 'No documents for this participant in the requested event') }; + } + + return { status: 200, participant, event, orgId }; +} + +async function resolveDocumentOr404({ participantId, eventId, documentId }, user, { allowDelete = false } = {}) { + if (!mongoose.Types.ObjectId.isValid(String(documentId))) { + return { status: 404, doc: null }; + } + + const orgId = userOrgId(user); + if (!orgId) return { status: 403, doc: null }; + + const doc = await ParticipantDocument.findOne({ + _id: documentId, + participant: participantId, + event: eventId, + organization: orgId, + }).lean(); + + if (!doc) return { status: 404, doc: null }; + + // Ownership/role enforcement: participant who owns doc OR org admins OR roles with permission. + // For project architecture, the safest minimal check is ACL module permission + doc participant org scoping. + const ok = await assertUserCanAccessParticipants(user, orgId, allowDelete ? 'delete' : 'read'); + if (!ok) return { status: 403, doc: null }; + + return { status: 200, doc }; +} + +const listDocuments = async (request, h) => { + const user = request.auth.credentials?.user; + const { participantId } = request.params; + const { eventId } = request.query; + + if (!eventId) return h.response(error('VALIDATION_ERROR', 'eventId query parameter is required')).code(400); + + // Attendee (jwt-attendee) can also list their own documents. + if (!user) { + const attendee = request.auth.credentials?.attendee; + const attendeePid = attendee?.pid; + const isAttendeeSelf = attendeePid && String(attendeePid) === String(participantId); + if (!isAttendeeSelf) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + // Load participant to get orgId + const p = await Participant.findById(participantId).lean(); + if (!p) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const orgId = event.organization; + + if (!getMembership(p, eventId)) { + return h.response(error('NOT_FOUND', 'No documents for this participant in the requested event')).code(404); + } + + const docs = await ParticipantDocument.find({ + participant: participantId, + event: eventId, + organization: orgId, + }) + .populate('uploadedBy', 'firstName lastName') + .sort({ uploadedAt: -1, createdAt: -1 }) + .lean(); + + const out = docs.map((d) => ({ + _id: String(d._id), + participant: String(d.participant), + organization: String(d.organization), + event: String(d.event), + uploadedBy: d.uploadedBy + ? (d.uploadedBy.firstName || d.uploadedBy.lastName + ? `${d.uploadedBy.firstName || ''} ${d.uploadedBy.lastName || ''}`.trim() + : String(d.uploadedBy._id || d.uploadedBy)) + : null, + fileName: d.fileName, + originalFileName: d.originalFileName, + url: d.url, + s3Key: d.s3Key, + mimeType: d.mimeType, + size: d.size, + uploadedAt: d.uploadedAt || d.createdAt, + })); + + return h.response(success({ documents: out })); + } + + const scope = await resolveParticipantAndEventScopes(participantId, eventId, user); + if (scope.status !== 200) return h.response(scope.body).code(scope.status); + + const { orgId } = scope; + + // If user cannot manage participants, allow listing only if the user's email matches the participant's email + const canManage = await assertUserCanAccessParticipants(user, orgId, 'read'); + if (!canManage) { + const p = await Participant.findById(participantId).select('email').lean(); + if (!p || String(user.email).toLowerCase() !== String(p.email).toLowerCase()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } + + // Event-scoped + participant-scoped filter is mandatory. + const docs = await ParticipantDocument.find({ + participant: participantId, + event: eventId, + organization: orgId, + }) + .populate('uploadedBy', 'firstName lastName') + .sort({ uploadedAt: -1, createdAt: -1 }) + .lean(); + + // Normalize to required response fields. + const out = docs.map((d) => ({ + _id: String(d._id), + participant: String(d.participant), + organization: String(d.organization), + event: String(d.event), + uploadedBy: d.uploadedBy + ? (d.uploadedBy.firstName || d.uploadedBy.lastName + ? `${d.uploadedBy.firstName || ''} ${d.uploadedBy.lastName || ''}`.trim() + : String(d.uploadedBy._id || d.uploadedBy)) + : null, + fileName: d.fileName, + originalFileName: d.originalFileName, + url: d.url, + s3Key: d.s3Key, + mimeType: d.mimeType, + size: d.size, + uploadedAt: d.uploadedAt || d.createdAt, + })); + + return h.response(success({ documents: out })); +}; + +const uploadDocument = async (request, h) => { + const user = request.auth.credentials?.user; + const attendee = request.auth.credentials?.attendee; + const attendeePid = attendee?.pid; + const isAttendeeSelf = attendeePid && String(attendeePid) === String(request.params.participantId); + + const { participantId } = request.params; + const payload = request.payload || {}; + + const eventId = payload.eventId || payload.event_id || payload.event; + if (!eventId) return h.response(error('VALIDATION_ERROR', 'eventId is required')).code(400); + + let orgId; + + if (user) { + // --- ADMIN/USER PATH (jwt strategy) --- + const scope = await resolveParticipantAndEventScopes(participantId, eventId, user); + if (scope.status !== 200) return h.response(scope.body).code(scope.status); + + orgId = scope.orgId; + + // Must have ACL participants:write + const canManageParticipant = await assertUserCanAccessParticipants(user, orgId, 'write'); + if (canManageParticipant) { + // Admin/staff — proceed to upload + } else { + // User does not have admin manage permission. + // Allow self-upload if the user's email matches the participant's email. + const participant = await Participant.findById(participantId).select('email').lean(); + if (!participant || String(user.email).toLowerCase() !== String(participant.email).toLowerCase()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + // Pass the org — the participant belongs to this org (verified by resolveParticipantAndEventScopes) + } + } else if (isAttendeeSelf) { + // --- ATTENDEE SELF-UPLOAD PATH (jwt-attendee strategy) --- + // Bypass user-based scope resolver. Directly check participant + event membership + org. + const participant = await Participant.findById(participantId).lean(); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return h.response(error('NOT_FOUND', 'No documents for this participant in the requested event')).code(404); + + orgId = event.organization; + + if (!getMembership(participant, eventId)) { + return h.response(error('NOT_FOUND', 'No documents for this participant in the requested event')).code(404); + } + + // Enforce tenant organization matches event organization + if (!participant.tenantOrganization || String(participant.tenantOrganization) !== String(orgId)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } else { + // Not authenticated as either admin/user or self-attendee + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + + const extracted = extractUploadFile(payload); + + if (!extracted) { + return h + .response(error('VALIDATION_ERROR', 'No file received. Use multipart/form-data with a binary field named "file".')) + .code(400); + } + + const allowed = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'image/jpeg', + 'image/png', + ]; + // Also allow common image mimetypes (defensive). + const mime = extracted.mimeType || 'application/octet-stream'; + const isAllowed = + mime === 'application/pdf' || + mime === 'application/msword' || + mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + mime === 'image/jpeg' || + mime === 'image/png' || + mime === 'image/jpg'; + + // If client sends generic octet-stream, still accept (S3 will store); but keep strict types where possible. + if (mime !== 'application/octet-stream' && !isAllowed) { + return h.response(error('VALIDATION_ERROR', 'Unsupported file type. Upload PDF, images, DOC, or DOCX.')).code(400); + } + + const originalFileName = extracted.originalName; + const sanitizedOriginal = String(originalFileName || 'file').replace(/[^a-zA-Z0-9._-]/g, '_'); + const storedFilename = `${Date.now()}-${sanitizedOriginal}`; + + const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({ + buffer: extracted.buffer, + contentType: mime, + orgId, + eventId, + module: 'participant-documents', + filename: storedFilename, + }); + + const size = extracted.buffer.length; + + console.log({ + participantId, + attendee: request.auth.credentials?.attendee, + user: request.auth.credentials?.user, +}); + + const doc = await ParticipantDocument.create({ + participant: participantId, + organization: orgId, + event: eventId, + uploadedBy: user?._id, + + fileName: storedFilename, + + originalFileName, + url: uploaded.url, + s3Key: uploaded.s3Key, + mimeType: mime, + size, + }); + + return h.response(success({ document: { ...doc.toObject(), _id: String(doc._id) } }, 'File uploaded', 201)).code(201); +}; + +const deleteDocument = async (request, h) => { + const user = request.auth.credentials?.user; + const attendee = request.auth.credentials?.attendee; + const attendeePid = attendee?.pid; + + const { participantId, documentId } = request.params; + const payload = request.query || {}; + const eventId = payload.eventId || payload.event_id; + if (!eventId) return h.response(error('VALIDATION_ERROR', 'eventId query parameter is required')).code(400); + + let orgId; + + // --- CHECK 1: Resolve participant & event scope (authz) --- + if (user) { + // Admin/user path — delegate to resolveParticipantAndEventScopes (checks org access, membership) + const scope = await resolveParticipantAndEventScopes(participantId, eventId, user); + if (scope.status !== 200) return h.response(scope.body).code(scope.status); + orgId = scope.orgId; + + // Admin must have participants:delete permission OR own the document via email match + const canManage = await assertUserCanAccessParticipants(user, orgId, 'delete'); + if (!canManage) { + // Allow self-delete if user's email matches participant email + const p = await Participant.findById(participantId).select('email').lean(); + if (!p || String(user.email).toLowerCase() !== String(p.email).toLowerCase()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } + } else if (attendeePid && String(attendeePid) === String(participantId)) { + // Attendee self-service delete + const participant = await Participant.findById(participantId).lean(); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + orgId = event.organization; + + if (!getMembership(participant, eventId)) { + return h.response(error('NOT_FOUND', 'No documents for this participant in the requested event')).code(404); + } + + if (!participant.tenantOrganization || String(participant.tenantOrganization) !== String(orgId)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } else { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + // --- CHECK 2: Verify document belongs to participant+event+org --- + const doc = await ParticipantDocument.findOne({ + _id: documentId, + participant: participantId, + event: eventId, + organization: orgId, + }).lean(); + + if (!doc) { + return h.response(error('NOT_FOUND', 'Document not found')).code(404); + } + + // --- CHECK 3: If attendee, verify they uploaded the document (can only delete their own) --- + if (!user && attendeePid) { + // Attendee can only delete documents they uploaded (no uploadedBy means they uploaded as attendee) + // Allow if doc has no uploadedBy (meaning it was uploaded by the participant via attendee-upload flow) + // OR if the uploader matches the attendee's participant ID + if (doc.uploadedBy) { + // The document was uploaded by an admin user; attendee cannot delete it + return h.response(error('FORBIDDEN', 'You can only delete documents you uploaded yourself')).code(403); + } + } + + // --- EXECUTE: Delete from S3 then DB --- + if (doc.s3Key) { + await deleteS3ObjectByKey({ s3Key: doc.s3Key }); + } + + await ParticipantDocument.deleteOne({ _id: documentId }); + + return h.response(success(null, 'Document deleted', 200)); +}; + +const downloadDocument = async (request, h) => { + const user = request.auth.credentials?.user; + const attendee = request.auth.credentials?.attendee; + const attendeePid = attendee?.pid; + + const { participantId, documentId } = request.params; + const eventId = request.query.eventId || request.query.event_id; + if (!eventId) return h.response(error('VALIDATION_ERROR', 'eventId query parameter is required')).code(400); + + // Resolve document with access control + let orgId; + + if (user) { + // Admin/user path + const scope = await resolveParticipantAndEventScopes(participantId, eventId, user); + if (scope.status !== 200) return h.response(scope.body).code(scope.status); + orgId = scope.orgId; + + const canManage = await assertUserCanAccessParticipants(user, orgId, 'read'); + if (!canManage) { + const p = await Participant.findById(participantId).select('email').lean(); + if (!p || String(user.email).toLowerCase() !== String(p.email).toLowerCase()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } + } else if (attendeePid && String(attendeePid) === String(participantId)) { + // Attendee self-download + const participant = await Participant.findById(participantId).lean(); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + orgId = event.organization; + + if (!getMembership(participant, eventId)) { + return h.response(error('NOT_FOUND', 'No documents for this participant in the requested event')).code(404); + } + + if (!participant.tenantOrganization || String(participant.tenantOrganization) !== String(orgId)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + } else { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + // Fetch document record + const doc = await ParticipantDocument.findOne({ + _id: documentId, + participant: participantId, + event: eventId, + organization: orgId, + }).lean(); + + if (!doc) { + return h.response(error('NOT_FOUND', 'Document not found')).code(404); + } + + // Fetch file buffer from S3 + let fileBuffer; + try { + fileBuffer = await getBufferFromS3Key(doc.s3Key); + } catch (err) { + console.error('[downloadDocument] Failed to fetch from S3:', err); + return h.response(error('SERVER_ERROR', 'Failed to read document file')).code(500); + } + + const contentType = doc.mimeType || 'application/octet-stream'; + const fileName = doc.originalFileName || 'download'; + + return h.response(fileBuffer) + .type(contentType) + .encoding(null) // prevent Hapi from UTF-8 encoding the binary buffer (critical for PDF/DOCX/images) + .header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`) + .header('Content-Length', String(fileBuffer.length)) + .header('Cache-Control', 'no-cache'); +}; + +module.exports = { + listDocuments, + uploadDocument, + deleteDocument, + downloadDocument, +}; + diff --git a/src/models/ParticipantDocument.js b/src/models/ParticipantDocument.js new file mode 100644 index 0000000..133480c --- /dev/null +++ b/src/models/ParticipantDocument.js @@ -0,0 +1,26 @@ +const mongoose = require('mongoose'); + +const ParticipantDocumentSchema = new mongoose.Schema( + { + participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant', required: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true }, + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true }, + uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }, + + fileName: { type: String, required: true }, + originalFileName: { type: String, required: true }, + url: { type: String, required: true }, + s3Key: { type: String, required: true }, + mimeType: { type: String, required: false }, + size: { type: Number, required: false }, + }, + { + timestamps: true, + }, +); + +// Helpful uniqueness guard for event-scoped docs; allows multiple docs with same original name. +ParticipantDocumentSchema.index({ participant: 1, event: 1 }); + +module.exports = mongoose.model('ParticipantDocument', ParticipantDocumentSchema); + diff --git a/src/routes/participantDocuments.routes.js b/src/routes/participantDocuments.routes.js new file mode 100644 index 0000000..655d524 --- /dev/null +++ b/src/routes/participantDocuments.routes.js @@ -0,0 +1,65 @@ +const Joi = require('joi'); +const controller = require('../controllers/participantDocuments.controller'); + +const uploadPayload = Joi.object({ + eventId: Joi.string().required(), + // Hapi multipart binary is expected in payload.file; original filename comes from multipart name. + file: Joi.any().required(), + name: Joi.string().allow('', null), + mimeType: Joi.string().allow('', null), +}).required(); + +module.exports = [ + { + method: 'GET', + path: '/api/participants/{participantId}/documents', + options: { + // Accept both admin/user and attendee JWTs for self-service document listing. + auth: 'jwt-any', + handler: controller.listDocuments, + }, + }, + { + method: 'POST', + path: '/api/participants/{participantId}/documents', + options: { + // Accept both admin/user JWTs and self-attendee JWTs. + // Admins upload with `jwt` and must satisfy ACL participants:write. + // Self attendees upload with `jwt-attendee` and must satisfy attendee.pid === participantId. + // Hapi v17 route auth requires a single strategy name. + // We'll accept attendee JWTs by trying jwt-attendee first. + auth: 'jwt-any', + + + validate: { + payload: uploadPayload, + }, + payload: { + maxBytes: 50 * 1024 * 1024, + output: 'data', + parse: true, + multipart: true, + allow: ['multipart/form-data', 'application/json'], + }, + handler: controller.uploadDocument, + }, + }, + { + method: 'GET', + path: '/api/participants/{participantId}/documents/{documentId}/download', + options: { + auth: 'jwt-any', + handler: controller.downloadDocument, + }, + }, + { + method: 'DELETE', + path: '/api/participants/{participantId}/documents/{documentId}', + options: { + // Accept both admin/user and self-attendee JWTs for self-service document deletion. + auth: 'jwt-any', + handler: controller.deleteDocument, + }, + }, +]; +