EventBuzz_Backend/src/controllers/participant.controller.js

1084 lines
35 KiB
JavaScript

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