Participant documents
This commit is contained in:
parent
ae0c9c7a4f
commit
2810786d54
30
src/app.js
30
src/app.js
|
|
@ -6,6 +6,8 @@ const authRoutes = require('./routes/auth.routes');
|
||||||
const eventRoutes = require('./routes/event.routes');
|
const eventRoutes = require('./routes/event.routes');
|
||||||
const participantRoutes = require('./routes/participant.routes');
|
const participantRoutes = require('./routes/participant.routes');
|
||||||
const participantListRoutes = require('./routes/participant-list.routes');
|
const participantListRoutes = require('./routes/participant-list.routes');
|
||||||
|
const participantDocumentsRoutes = require('./routes/participantDocuments.routes');
|
||||||
|
|
||||||
const sessionRoutes = require('./routes/session.routes');
|
const sessionRoutes = require('./routes/session.routes');
|
||||||
const trackRoutes = require('./routes/track.routes');
|
const trackRoutes = require('./routes/track.routes');
|
||||||
const speakerRoutes = require('./routes/speaker.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 jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production';
|
||||||
const attendeeSecret = process.env.ATTENDEE_JWT_SECRET || jwtSecret;
|
const attendeeSecret = process.env.ATTENDEE_JWT_SECRET || jwtSecret;
|
||||||
|
|
||||||
|
const Participant = require('./models/Participant');
|
||||||
|
|
||||||
server.auth.strategy('jwt', 'jwt', {
|
server.auth.strategy('jwt', 'jwt', {
|
||||||
key: jwtSecret,
|
key: jwtSecret,
|
||||||
validate: async (decoded, request, h) => {
|
validate: async (decoded, request, h) => {
|
||||||
|
|
@ -187,7 +191,29 @@ const createServer = async () => {
|
||||||
verifyOptions: { algorithms: ['HS256'] },
|
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', {
|
server.auth.strategy('jwt-attendee', 'jwt', {
|
||||||
key: attendeeSecret,
|
key: attendeeSecret,
|
||||||
validate: async (decoded, request, h) => {
|
validate: async (decoded, request, h) => {
|
||||||
|
|
@ -257,6 +283,8 @@ const createServer = async () => {
|
||||||
server.route(eventRoutes);
|
server.route(eventRoutes);
|
||||||
server.route(participantRoutes);
|
server.route(participantRoutes);
|
||||||
server.route(participantListRoutes);
|
server.route(participantListRoutes);
|
||||||
|
server.route(participantDocumentsRoutes);
|
||||||
|
|
||||||
server.route(sessionRoutes);
|
server.route(sessionRoutes);
|
||||||
server.route(trackRoutes);
|
server.route(trackRoutes);
|
||||||
server.route(speakerRoutes);
|
server.route(speakerRoutes);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
Loading…
Reference in New Issue