1084 lines
35 KiB
JavaScript
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,
|
|
};
|