const mongoose = require('mongoose'); const Participant = require('../models/Participant'); const Event = require('../models/Event'); const ParticipantList = require('../models/ParticipantList'); const { success, error } = require('../utils/response'); const { parseParticipantCsv, splitTags, escapeCsvField } = require('../utils/csvParticipant'); const { getMembership, flattenParticipantForEvent, baseQueryForEvent } = require('../utils/participantMembership'); const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); const EXPORT_HEADERS_FIXED = [ 'firstName', 'lastName', 'email', 'phone', 'organization', 'designation', 'status', 'tags', 'notes', 'registrationDate', 'eventId', 'eventName', ]; async function assertEventAccess(eventId, user) { const event = await Event.findById(eventId); if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; if ( user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() ) { return { error: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; } return { event }; } 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 resolveCheckInEventId(participant, queryEventId) { if (queryEventId && mongoose.Types.ObjectId.isValid(queryEventId)) { if (!getMembership(participant, queryEventId)) return { err: error('BAD_REQUEST', 'Participant is not registered for this event') }; return { eventId: String(queryEventId) }; } const m = participant.eventMemberships || []; if (m.length === 1) return { eventId: String(m[0].event) }; return { err: error('BAD_REQUEST', 'event_id query is required when participant has multiple events') }; } /** Tenant-only $match for portfolio participant lists (never unscoped). */ function listAllTenantOnlyMatch(user, queryOrganization, payloadOrganization) { const scoped = resolveScopedOrganizationId(user, queryOrganization ?? payloadOrganization); if (scoped.error) return { error: scoped.error }; return { tenantMatch: { tenantOrganization: scoped.organizationId } }; } function listAllCommonStages(query, options = {}) { const { skipSort = false } = options; const { q, tag } = query; const stages = []; stages.push({ $addFields: { _allTags: { $setUnion: [ { $ifNull: ['$tags', []] }, { $reduce: { input: { $ifNull: ['$eventMemberships', []] }, initialValue: [], in: { $setUnion: ['$$value', { $ifNull: ['$$this.tags', []] }] }, }, }, ], }, _primary: { $reduce: { input: { $ifNull: ['$eventMemberships', []] }, initialValue: null, in: { $cond: [ { $or: [ { $eq: ['$$value', null] }, { $and: [ { $ne: ['$$this.registrationDate', null] }, { $or: [ { $eq: ['$$value.registrationDate', null] }, { $gt: ['$$this.registrationDate', '$$value.registrationDate'] }, ], }, ], }, ], }, '$$this', '$$value', ], }, }, }, }, }); if (tag && String(tag).trim()) { stages.push({ $match: { _allTags: String(tag).trim() } }); } if (q && String(q).trim()) { const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(search, 'i'); stages.push({ $match: { $or: [ { firstName: re }, { lastName: re }, { email: re }, { phone: re }, { organization: re }, { designation: re }, ], }, }); } if (!skipSort) stages.push({ $sort: { updatedAt: -1 } }); return stages; } function listAllProjectStages() { return [ { $lookup: { from: 'events', localField: '_primary.event', foreignField: '_id', as: '_evPrimary', }, }, { $addFields: { _eventDoc: { $arrayElemAt: ['$_evPrimary', 0] }, }, }, { $project: { _id: 1, firstName: 1, lastName: 1, email: 1, phone: 1, organization: 1, designation: 1, notes: 1, registrationDate: '$_primary.registrationDate', status: '$_primary.status', checkedIn: '$_primary.checkedIn', checkedInAt: '$_primary.checkedInAt', tags: '$_allTags', customFields: 1, event: { $cond: [ { $ne: ['$_eventDoc', null] }, { _id: '$_eventDoc._id', name: '$_eventDoc.name' }, null, ], }, }, }, ]; } const BULK_MAX = 500; /** Shared pipeline for event-scoped participant queries (list, matching-ids, tags). */ function eventParticipantPreStages(eventId, orgId, query = {}) { const { status, designation, q, tag } = query; const eid = new mongoose.Types.ObjectId(String(eventId)); const stages = [ { $match: { tenantOrganization: orgId } }, { $unwind: '$eventMemberships' }, { $match: { 'eventMemberships.event': eid } }, ]; if (status) stages.push({ $match: { 'eventMemberships.status': status } }); if (designation) stages.push({ $match: { designation } }); if (q && String(q).trim()) { const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(search, 'i'); stages.push({ $match: { $or: [ { firstName: re }, { lastName: re }, { email: re }, { phone: re }, { organization: re }, { designation: re }, ], }, }); } if (tag && String(tag).trim()) { stages.push({ $addFields: { _allTags: { $setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }], }, }, }); stages.push({ $match: { _allTags: String(tag).trim() } }); } return stages; } async function assertEventListAccess(eventId, user) { const event = await Event.findById(eventId); if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; if ( user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() ) { return { error: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; } return { event }; } const listDistinctTags = async (request, h) => { const user = request.auth.credentials.user; const r = listAllTenantOnlyMatch(user, request.query.organization); if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); const { tenantMatch } = r; const rows = await Participant.aggregate([ { $match: tenantMatch }, { $addFields: { _allTags: { $setUnion: [ { $ifNull: ['$tags', []] }, { $reduce: { input: { $ifNull: ['$eventMemberships', []] }, initialValue: [], in: { $setUnion: ['$$value', { $ifNull: ['$$this.tags', []] }] }, }, }, ], }, }, }, { $unwind: { path: '$_allTags', preserveNullAndEmptyArrays: false } }, { $group: { _id: '$_allTags' } }, { $sort: { _id: 1 } }, { $limit: 200 }, ]); const tags = rows.map((r) => r._id).filter((t) => t != null && String(t).trim() !== ''); return h.response(success({ tags })); }; const listMatchingIds = async (request, h) => { const user = request.auth.credentials.user; const r = listAllTenantOnlyMatch(user, request.query.organization); if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); const { tenantMatch } = r; const { q, tag } = request.query; const stages = [ { $match: tenantMatch }, ...listAllCommonStages({ q, tag }, { skipSort: true }), { $project: { _id: 1 } }, { $limit: BULK_MAX + 1 }, ]; const rows = await Participant.aggregate(stages); const capped = rows.length > BULK_MAX; const trimmed = capped ? rows.slice(0, BULK_MAX) : rows; const ids = trimmed.map((r) => String(r._id)); return h.response(success({ ids, total: ids.length, capped })); }; function assertFixedListOrgAccess(list, user) { if (user.role === 'super_admin') return true; const oid = user.organization?._id || user.organization; return list.tenantOrganization?.toString() === oid?.toString(); } const bulkParticipants = async (request, h) => { const user = request.auth.credentials.user; const { action, participantIds: idsRaw, selectAllMatching, q, tag: filterTag, tags: tagPayload, eventIds: evIdsRaw, listId, eventId: bulkEventId, status: filterStatus, designation: filterDesignation, } = request.payload || {}; let participantIds = [...new Set((idsRaw || []).map((x) => String(x)))].filter((id) => mongoose.Types.ObjectId.isValid(id)); if (selectAllMatching) { if (bulkEventId) { const access = await assertEventListAccess(bulkEventId, user); if (access.error) return access.error(h); const stages = [ ...eventParticipantPreStages(bulkEventId, access.event.organization, { q, tag: filterTag, status: filterStatus, designation: filterDesignation, }), { $project: { _id: 1 } }, { $limit: BULK_MAX + 1 }, ]; const rows = await Participant.aggregate(stages); if (rows.length > BULK_MAX) { return h .response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`)) .code(400); } participantIds = rows.map((r) => String(r._id)); } else { const r = listAllTenantOnlyMatch(user, request.query.organization, request.payload?.organization); if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); const { tenantMatch } = r; const stages = [ { $match: tenantMatch }, ...listAllCommonStages({ q, tag: filterTag }, { skipSort: true }), { $project: { _id: 1 } }, { $limit: BULK_MAX + 1 }, ]; const rows = await Participant.aggregate(stages); if (rows.length > BULK_MAX) { return h .response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`)) .code(400); } participantIds = rows.map((r) => String(r._id)); } } if (!participantIds.length) { return h.response(error('VALIDATION_ERROR', 'No participants selected')).code(400); } if (participantIds.length > BULK_MAX) { return h.response(error('VALIDATION_ERROR', `At most ${BULK_MAX} participants per action`)).code(400); } const oids = participantIds.map((id) => new mongoose.Types.ObjectId(id)); const participants = await Participant.find({ _id: { $in: oids } }); if (participants.length !== participantIds.length) { return h.response(error('BAD_REQUEST', 'Some participants were not found')).code(400); } for (const p of participants) { if (!assertParticipantOrgAccess(p, user)) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } } if (action === 'delete') { await Participant.deleteMany({ _id: { $in: oids } }); return h.response(success({ deleted: participants.length })); } if (action === 'add_tags' || action === 'remove_tags') { const tagList = (tagPayload || []).map((t) => String(t).trim()).filter(Boolean); if (!tagList.length) return h.response(error('VALIDATION_ERROR', 'tags required')).code(400); const removeSet = new Set(tagList); for (const p of participants) { if (action === 'add_tags') { p.tags = [...new Set([...(p.tags || []), ...tagList])]; } else { p.tags = (p.tags || []).filter((t) => !removeSet.has(t)); } await p.save(); } return h.response(success({ updated: participants.length })); } if (action === 'assign_events') { const eventIds = [...new Set((evIdsRaw || []).filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))))].map( (id) => String(id) ); if (!eventIds.length) return h.response(error('VALIDATION_ERROR', 'eventIds required')).code(400); for (const eid of eventIds) { const r = await assertEventAccess(eid, user); if (r.error) return r.error(h); } let membershipsAdded = 0; for (const p of participants) { for (const eid of eventIds) { if (!getMembership(p, eid)) { p.eventMemberships.push({ event: eid, status: 'pending', registrationDate: new Date(), }); membershipsAdded += 1; } } await p.save(); } return h.response(success({ updated: participants.length, membershipsAdded })); } if (action === 'add_to_fixed_list') { if (!listId || !mongoose.Types.ObjectId.isValid(String(listId))) { return h.response(error('VALIDATION_ERROR', 'listId required')).code(400); } const list = await ParticipantList.findById(listId); if (!list) return h.response(error('NOT_FOUND', 'List not found')).code(404); if (list.type !== 'fixed') return h.response(error('BAD_REQUEST', 'Only fixed lists accept member ids')).code(400); if (!assertFixedListOrgAccess(list, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const seen = new Set((list.participantIds || []).map((x) => String(x))); for (const pid of participantIds) { if (!seen.has(pid)) { list.participantIds.push(new mongoose.Types.ObjectId(pid)); seen.add(pid); } } await list.save(); return h.response(success({ updated: participants.length, listSize: list.participantIds.length })); } if (action === 'remove_from_event') { if (!bulkEventId || !mongoose.Types.ObjectId.isValid(String(bulkEventId))) { return h.response(error('VALIDATION_ERROR', 'eventId required')).code(400); } const access = await assertEventListAccess(bulkEventId, user); if (access.error) return access.error(h); let removed = 0; for (const p of participants) { if (!getMembership(p, bulkEventId)) continue; p.eventMemberships = p.eventMemberships.filter((m) => String(m.event) !== String(bulkEventId)); if (p.eventMemberships.length === 0) { await p.deleteOne(); } else { await p.save(); } removed += 1; } return h.response(success({ removed })); } return h.response(error('BAD_REQUEST', 'Unknown action')).code(400); }; const listAll = async (request, h) => { const user = request.auth.credentials.user; const r = listAllTenantOnlyMatch(user, request.query.organization); if (r.error) return h.response(error(r.error.code, r.error.message)).code(400); const { tenantMatch } = r; const { page = 1, limit = 20, q, tag } = request.query; const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); const pageNum = Math.max(parseInt(page, 10) || 1, 1); const skip = (pageNum - 1) * limitNum; const preFacet = [{ $match: tenantMatch }, ...listAllCommonStages({ q, tag })]; const facet = await Participant.aggregate([ ...preFacet, { $facet: { meta: [{ $count: 'total' }], rows: [{ $skip: skip }, { $limit: limitNum }, ...listAllProjectStages()], }, }, ]); const total = facet[0]?.meta[0]?.total ?? 0; const participants = facet[0]?.rows ?? []; return h .response(success({ participants, total, page: pageNum, limit: limitNum })) .header('X-Total-Count', String(total)); }; const listEventDistinctTags = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const access = await assertEventListAccess(eventId, user); if (access.error) return access.error(h); const rows = await Participant.aggregate([ ...eventParticipantPreStages(eventId, access.event.organization, {}), { $addFields: { _allTags: { $setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }], }, }, }, { $unwind: { path: '$_allTags', preserveNullAndEmptyArrays: false } }, { $group: { _id: '$_allTags' } }, { $sort: { _id: 1 } }, { $limit: 200 }, ]); const tags = rows.map((r) => r._id).filter((t) => t != null && String(t).trim() !== ''); return h.response(success({ tags })); }; const listEventMatchingIds = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const access = await assertEventListAccess(eventId, user); if (access.error) return access.error(h); const { q, tag, status, designation } = request.query; const stages = [ ...eventParticipantPreStages(eventId, access.event.organization, { q, tag, status, designation }), { $project: { _id: 1 } }, { $limit: BULK_MAX + 1 }, ]; const rows = await Participant.aggregate(stages); const capped = rows.length > BULK_MAX; const trimmed = capped ? rows.slice(0, BULK_MAX) : rows; const ids = trimmed.map((r) => String(r._id)); return h.response(success({ ids, total: ids.length, capped })); }; const list = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const access = await assertEventListAccess(eventId, user); if (access.error) return access.error(h); const orgId = access.event.organization; const { page = 1, limit = 20, status, q, designation, tag } = request.query; const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); const pageNum = Math.max(parseInt(page, 10) || 1, 1); const skip = (pageNum - 1) * limitNum; const preStages = eventParticipantPreStages(eventId, orgId, { status, designation, q, tag }); const facet = await Participant.aggregate([ ...preStages, { $facet: { meta: [{ $count: 'total' }], rows: [ { $sort: { 'eventMemberships.registrationDate': -1 } }, { $skip: skip }, { $limit: limitNum }, { $project: { _id: 1, firstName: 1, lastName: 1, email: 1, phone: 1, organization: 1, designation: 1, notes: 1, customFields: 1, payment: 1, emailVerified: 1, event: { $literal: eventId }, status: '$eventMemberships.status', checkedIn: '$eventMemberships.checkedIn', checkedInAt: '$eventMemberships.checkedInAt', guestToken: '$eventMemberships.guestToken', tags: { $setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }], }, registrationDate: '$eventMemberships.registrationDate', }, }, ], }, }, ]); const total = facet[0]?.meta[0]?.total ?? 0; const participants = facet[0]?.rows ?? []; return h.response(success({ participants, total, page: pageNum, limit: limitNum })).header('X-Total-Count', String(total)); }; const create = async (request, h) => { const { eventId } = request.params; const event = await Event.findById(eventId); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const user = request.auth.credentials.user; if ( user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() ) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const orgId = event.organization; const payload = { ...request.payload }; const tags = Array.isArray(payload.tags) ? payload.tags : []; delete payload.tags; const existing = await Participant.findOne({ tenantOrganization: orgId, email: String(payload.email || '').trim().toLowerCase(), }); if (existing) { if (getMembership(existing, eventId)) { return h.response(error('CONFLICT', 'Participant already exists for this event')).code(409); } existing.eventMemberships.push({ event: eventId, status: 'pending', registrationDate: new Date(), }); Object.assign(existing, payload); if (tags.length) { existing.tags = [...new Set([...(existing.tags || []), ...tags])]; } await existing.save(); const flat = flattenParticipantForEvent(existing.toObject(), eventId); return h.response(success(flat, 'Participant added', 201)).code(201); } const doc = await Participant.create({ ...payload, tenantOrganization: orgId, tags, eventMemberships: [ { event: eventId, status: 'pending', registrationDate: new Date(), }, ], }); const flat = flattenParticipantForEvent(doc.toObject(), eventId); return h.response(success(flat, 'Participant added', 201)).code(201); }; const get = async (request, h) => { const participant = await Participant.findById(request.params.id).lean(); if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); const user = request.auth.credentials.user; if (!assertParticipantOrgAccess(participant, user)) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const eventId = request.query.event_id; if (eventId) { const flat = flattenParticipantForEvent(participant, eventId); if (!flat) return h.response(error('NOT_FOUND', 'No membership for this event')).code(404); const ev = await Event.findById(eventId).select('name').lean(); return h.response(success({ ...flat, event: ev })); } return h.response(success(participant)); }; const update = async (request, h) => { const participant = await Participant.findById(request.params.id); if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); const user = request.auth.credentials.user; if (!assertParticipantOrgAccess(participant, user)) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const eventId = request.query.event_id; const payload = { ...request.payload }; const tags = payload.tags; delete payload.tags; const assignable = ['email', 'firstName', 'lastName', 'phone', 'organization', 'designation', 'accommodation', 'notes']; for (const key of assignable) { if (Object.prototype.hasOwnProperty.call(payload, key) && payload[key] !== undefined) { participant[key] = payload[key]; } } if (tags !== undefined) { participant.tags = Array.isArray(tags) ? tags : []; } await participant.save(); const out = eventId ? flattenParticipantForEvent(participant.toObject(), eventId) : participant.toObject(); return h.response(success(out)); }; const remove = async (request, h) => { const participant = await Participant.findById(request.params.id); if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); const user = request.auth.credentials.user; if (!assertParticipantOrgAccess(participant, user)) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const eventId = request.query.event_id; if (eventId) { participant.eventMemberships = participant.eventMemberships.filter((m) => String(m.event) !== String(eventId)); if (participant.eventMemberships.length === 0) { await participant.deleteOne(); } else { await participant.save(); } } else { await participant.deleteOne(); } return h.response(success(null, 'Participant removed')).code(200); }; const checkIn = async (request, h) => { const participant = await Participant.findById(request.params.id); if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); const user = request.auth.credentials.user; if (!assertParticipantOrgAccess(participant, user)) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const { eventId, err } = await resolveCheckInEventId(participant, request.query.event_id); if (err) return h.response(err).code(400); const m = getMembership(participant, eventId); m.checkedIn = true; m.checkedInAt = new Date(); m.status = 'checked_in'; participant.markModified('eventMemberships'); await participant.save(); const flat = flattenParticipantForEvent(participant.toObject(), eventId); return h.response(success(flat, 'Checked in')); }; const exportCsv = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const r = await assertEventAccess(eventId, user); if (r.error) return r.error(h); const raw = await Participant.find({ tenantOrganization: r.event.organization, ...baseQueryForEvent(eventId), }) .sort({ updatedAt: -1 }) .lean(); const ev = await Event.findById(eventId).select('name').lean(); const eventName = ev?.name ?? ''; const headerLine = EXPORT_HEADERS_FIXED.join(','); const dataLines = raw.map((p) => { const flat = flattenParticipantForEvent(p, eventId); if (!flat) return null; return EXPORT_HEADERS_FIXED.map((key) => { let v = flat[key]; if (key === 'tags' && Array.isArray(v)) v = v.join('; '); if (key === 'registrationDate' && v) v = new Date(v).toISOString(); if (key === 'eventId') v = String(eventId); if (key === 'eventName') v = eventName; return escapeCsvField(v); }).join(','); }).filter(Boolean); const csv = `\uFEFF${[headerLine, ...dataLines].join('\n')}`; return h .response(csv) .type('text/csv; charset=utf-8') .header('Content-Disposition', 'attachment; filename="participants-export.csv"') .code(200); }; const importCsv = async (request, h) => { const { eventId } = request.params; const user = request.auth.credentials.user; const r = await assertEventAccess(eventId, user); if (r.error) return r.error(h); const orgId = r.event.organization; const { csv } = request.payload; const { rows } = parseParticipantCsv(csv); if (rows.length === 0) { return h.response(error('VALIDATION_ERROR', 'No data rows found in CSV')).code(400); } let created = 0; let skippedDuplicate = 0; const rowErrors = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const lineNum = i + 2; const email = String(row.email || '').trim().toLowerCase(); const firstName = String(row.firstName || row.firstname || '').trim(); const lastName = String(row.lastName || row.lastname || '').trim(); if (!email) { rowErrors.push({ line: lineNum, message: 'Email is required' }); continue; } if (!firstName) { rowErrors.push({ line: lineNum, message: 'First name is required' }); continue; } if (!lastName) { rowErrors.push({ line: lineNum, message: 'Last name is required' }); continue; } const phone = String(row.phone || '').trim() || undefined; const organization = String(row.organization || '').trim() || undefined; const designation = String(row.designation || '').trim() || undefined; const notes = String(row.notes || '').trim() || undefined; const tags = splitTags(row.tags); try { let p = await Participant.findOne({ tenantOrganization: orgId, email }); if (p) { if (getMembership(p, eventId)) { skippedDuplicate += 1; continue; } p.eventMemberships.push({ event: eventId, status: 'pending', registrationDate: new Date(), }); if (tags.length) { p.tags = [...new Set([...(p.tags || []), ...tags])]; } await p.save(); created += 1; } else { await Participant.create({ tenantOrganization: orgId, email, firstName, lastName, phone, organization, designation, notes, tags: tags.length ? tags : [], eventMemberships: [ { event: eventId, status: 'pending', registrationDate: new Date(), }, ], }); created += 1; } } catch (e) { if (e && e.code === 11000) { skippedDuplicate += 1; } else { rowErrors.push({ line: lineNum, message: e.message || 'Create failed' }); } } } return h.response( success({ created, skippedDuplicate, failed: rowErrors.length, errors: rowErrors.slice(0, 50), }) ); }; const exportAllCsv = async (request, h) => { const user = request.auth.credentials.user; const scoped = resolveScopedOrganizationId(user, request.query.organization); if (scoped.error) return h.response(error(scoped.error.code, scoped.error.message)).code(400); const tenantMatch = { tenantOrganization: scoped.organizationId }; const eventDocs = await Event.find({ organization: scoped.organizationId }).select('_id name').lean(); const eventMap = Object.fromEntries(eventDocs.map((e) => [String(e._id), e.name ?? ''])); const { q, tag } = request.query; const stages = [{ $match: tenantMatch }, ...listAllCommonStages({ q, tag }), ...listAllProjectStages()]; const rows = await Participant.aggregate(stages); const headerLine = EXPORT_HEADERS_FIXED.join(','); const dataLines = rows.map((flat) => EXPORT_HEADERS_FIXED.map((key) => { let v = flat[key]; if (key === 'tags' && Array.isArray(v)) v = v.join('; '); if (key === 'registrationDate' && v) v = new Date(v).toISOString(); if (key === 'status' && (v === undefined || v === null)) v = ''; if (key === 'eventId') v = flat.event?._id ? String(flat.event._id) : ''; if (key === 'eventName') { v = flat.event?.name ?? (flat.event?._id ? eventMap[String(flat.event._id)] ?? '' : ''); } return escapeCsvField(v); }).join(',') ); const csv = `\uFEFF${[headerLine, ...dataLines].join('\n')}`; return h .response(csv) .type('text/csv; charset=utf-8') .header('Content-Disposition', 'attachment; filename="all-participants-export.csv"') .code(200); }; const importGlobalCsv = async (request, h) => { const user = request.auth.credentials.user; const { csv, eventIds: eventIdsRaw, createCustomList, listName, listDescription, } = request.payload || {}; const orgRaw = user.organization?._id || user.organization; if (!orgRaw || !mongoose.Types.ObjectId.isValid(String(orgRaw))) { return h .response(error('VALIDATION_ERROR', 'Your account must belong to an organization to import participants')) .code(400); } const tenantOrganization = orgRaw instanceof mongoose.Types.ObjectId ? orgRaw : new mongoose.Types.ObjectId(String(orgRaw)); if (createCustomList && (!listName || !String(listName).trim())) { return h.response(error('VALIDATION_ERROR', 'List name is required when creating a custom list')).code(400); } const { rows } = parseParticipantCsv(csv || ''); if (rows.length === 0) { return h.response(error('VALIDATION_ERROR', 'No data rows found in CSV')).code(400); } const eventIds = (Array.isArray(eventIdsRaw) ? eventIdsRaw : []) .filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))) .map((id) => String(id)); for (const eid of eventIds) { const r = await assertEventAccess(eid, user); if (r.error) return r.error(h); } let participantsCreated = 0; let membershipsAdded = 0; let profilesUpdated = 0; let skippedDuplicate = 0; const rowErrors = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const lineNum = i + 2; const email = String(row.email || '').trim().toLowerCase(); const firstName = String(row.firstName || row.firstname || '').trim(); const lastName = String(row.lastName || row.lastname || '').trim(); if (!email) { rowErrors.push({ line: lineNum, message: 'Email is required' }); continue; } if (!firstName || !lastName) { rowErrors.push({ line: lineNum, message: 'First and last name required' }); continue; } const phone = String(row.phone || '').trim() || undefined; const organization = String(row.organization || '').trim() || undefined; const designation = String(row.designation || '').trim() || undefined; const notes = String(row.notes || '').trim() || undefined; const tags = splitTags(row.tags); try { let p = await Participant.findOne({ tenantOrganization, email }); if (!p) { await Participant.create({ tenantOrganization, email, firstName, lastName, phone, organization, designation, notes, tags: tags.length ? tags : [], eventMemberships: eventIds.map((eid) => ({ event: eid, status: 'pending', registrationDate: new Date(), })), }); participantsCreated += 1; continue; } Object.assign(p, { firstName, lastName, phone, organization, designation, notes }); if (tags.length) { p.tags = [...new Set([...(p.tags || []), ...tags])]; } let newMemberships = 0; for (const eid of eventIds) { if (getMembership(p, eid)) { skippedDuplicate += 1; continue; } p.eventMemberships.push({ event: eid, status: 'pending', registrationDate: new Date(), }); newMemberships += 1; } await p.save(); if (newMemberships > 0) { membershipsAdded += newMemberships; } else { profilesUpdated += 1; } } catch (e) { if (e && e.code === 11000) skippedDuplicate += 1; else rowErrors.push({ line: lineNum, message: e.message || 'failed' }); } } let listId; let listLinked = 0; if (createCustomList) { const orderedIds = []; const seenId = new Set(); for (const row of rows) { const em = String(row.email || '').trim().toLowerCase(); if (!em) continue; const found = await Participant.findOne({ tenantOrganization, email: em }).select('_id').lean(); if (found) { const sid = String(found._id); if (!seenId.has(sid)) { seenId.add(sid); orderedIds.push(found._id); listLinked += 1; } } } const list = await ParticipantList.create({ name: String(listName).trim(), description: listDescription ? String(listDescription).trim() : '', tenantOrganization, createdBy: user._id, visibility: 'private', type: 'fixed', autoUpdate: false, participantIds: orderedIds, }); listId = String(list._id); } return h.response( success({ participantsCreated, membershipsAdded, profilesUpdated, skippedDuplicate, failed: rowErrors.length, errors: rowErrors.slice(0, 50), listId, listRows: createCustomList ? rows.length : undefined, listLinked: createCustomList ? listLinked : undefined, }) ); }; module.exports = { listAll, listDistinctTags, listMatchingIds, listEventDistinctTags, listEventMatchingIds, bulkParticipants, list, create, get, update, remove, checkIn, exportCsv, importCsv, exportAllCsv, importGlobalCsv, };