Participant documents

This commit is contained in:
Tanuja Pansare 2026-07-22 10:19:17 +05:30
parent ae0c9c7a4f
commit 2810786d54
4 changed files with 671 additions and 1 deletions

View File

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

View File

@ -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,
};

View File

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

View File

@ -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,
},
},
];