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 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);
|
||||
|
|
|
|||
|
|
@ -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