383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const Participant = require('../models/Participant');
|
|
const Event = require('../models/Event');
|
|
const PaymentTransaction = require('../models/PaymentTransaction');
|
|
const TicketManualOrder = require('../models/TicketManualOrder');
|
|
const EventTicket = require('../models/EventTicket');
|
|
const { resolveParticipantTicket, membershipForEvent } = require('../utils/resolveParticipantTicket');
|
|
const { buildQrImageUrl } = require('../utils/participantQr');
|
|
const { ensureParticipantCheckInQrUrl } = require('../utils/participantCheckInQr');
|
|
const { faqAnswerText, emergencyContactFromBot } = require('../utils/faqAnswer');
|
|
const { loadEventRegistrationLinks } = require('../utils/registrationLinks');
|
|
|
|
const TICKET_INTENTS = new Set([
|
|
'ticket_qr_display',
|
|
'ticket_email_qr',
|
|
'ticket_status_lookup',
|
|
'ticket_how_to_register',
|
|
'ticket_document_receipt',
|
|
'ticket_transfer_request',
|
|
'ticket_upgrade',
|
|
]);
|
|
|
|
const REGISTER_FAQ_CATEGORIES = new Set([
|
|
'registration_update',
|
|
'refund_policy',
|
|
'how_to_register',
|
|
'ticket_purchase',
|
|
]);
|
|
const REGISTER_FAQ_HINT = /\b(how to register|registration link|sign ?up|enrol|enroll|buy (a )?(ticket|pass))\b/i;
|
|
const FAQ_ANSWER_MAX = 600;
|
|
|
|
const AUTH_REQUIRED_TICKET_INTENTS = new Set([
|
|
'ticket_qr_display',
|
|
'ticket_email_qr',
|
|
'ticket_status_lookup',
|
|
'ticket_document_receipt',
|
|
]);
|
|
|
|
const REFUSE_TICKET_INTENTS = new Set(['ticket_transfer_request', 'ticket_upgrade']);
|
|
|
|
function isTicketIntent(intent) {
|
|
return TICKET_INTENTS.has(String(intent || ''));
|
|
}
|
|
|
|
function isAuthRequiredTicketIntent(intent) {
|
|
return AUTH_REQUIRED_TICKET_INTENTS.has(String(intent || ''));
|
|
}
|
|
|
|
function isRefuseTicketIntent(intent) {
|
|
return REFUSE_TICKET_INTENTS.has(String(intent || ''));
|
|
}
|
|
|
|
function statusLabel(status) {
|
|
const s = String(status || '').toLowerCase();
|
|
if (s === 'pending') return 'pending approval';
|
|
if (s === 'confirmed') return 'confirmed';
|
|
if (s === 'checked_in') return 'checked in';
|
|
if (s === 'cancelled') return 'cancelled';
|
|
if (s === 'rejected') return 'rejected';
|
|
return s || 'registered';
|
|
}
|
|
|
|
function attendeeName(p) {
|
|
if (!p) return 'Attendee';
|
|
const display = String(p.displayName || '').trim();
|
|
if (display) return display;
|
|
const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
|
return name || p.email || 'Attendee';
|
|
}
|
|
|
|
function formatMoney(minor, currency) {
|
|
if (minor == null || !Number.isFinite(Number(minor))) return null;
|
|
const major = Math.round(Number(minor)) / 100;
|
|
const cur = String(currency || 'INR').toUpperCase();
|
|
return `${cur} ${major.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function serializeAttendees(list) {
|
|
if (!Array.isArray(list)) return [];
|
|
return list
|
|
.slice(0, 12)
|
|
.map((a) => {
|
|
if (!a || typeof a !== 'object') return null;
|
|
const name = [a.firstName, a.lastName].filter(Boolean).join(' ').trim()
|
|
|| String(a.displayName || '').trim()
|
|
|| null;
|
|
const email = String(a.email || '').trim().toLowerCase() || null;
|
|
if (!name && !email) return null;
|
|
return { name, email };
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function loadReceipt({ eventId, participantId, participantEmail, resolved }) {
|
|
if (resolved?.paymentTx) {
|
|
const tx = resolved.paymentTx;
|
|
const ctx = tx.registrationContext && typeof tx.registrationContext === 'object'
|
|
? tx.registrationContext
|
|
: {};
|
|
let ticketName = resolved.ticket?.name || null;
|
|
if (!ticketName && ctx.ticketId) {
|
|
const t = await EventTicket.findById(ctx.ticketId).select('name').lean();
|
|
ticketName = t?.name || null;
|
|
}
|
|
return {
|
|
kind: 'online',
|
|
status: tx.status || 'paid',
|
|
ticketName,
|
|
currency: tx.currency || ctx.quote?.currency || 'INR',
|
|
amountLabel: formatMoney(tx.amountGross ?? ctx.quote?.totalMinor, tx.currency),
|
|
orderId: tx.externalOrderId || String(tx._id),
|
|
paymentId: tx.externalPaymentId || null,
|
|
date: tx.createdAt ? new Date(tx.createdAt).toISOString() : null,
|
|
purchaserEmail: ctx.purchaserEmail || participantEmail || null,
|
|
attendees: serializeAttendees(ctx.attendees),
|
|
quote: ctx.quote
|
|
? {
|
|
base: formatMoney(ctx.quote.baseMinor, tx.currency),
|
|
tax: formatMoney(ctx.quote.taxMinor, tx.currency),
|
|
total: formatMoney(ctx.quote.totalMinor, tx.currency),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
if (resolved?.manualOrder) {
|
|
const order = resolved.manualOrder;
|
|
let ticketName = resolved.ticket?.name || null;
|
|
if (!ticketName && order.ticket) {
|
|
const t = await EventTicket.findById(order.ticket).select('name').lean();
|
|
ticketName = t?.name || null;
|
|
}
|
|
return {
|
|
kind: 'manual',
|
|
status: order.status || 'approved',
|
|
ticketName,
|
|
currency: order.currency || 'INR',
|
|
amountLabel: formatMoney(order.quote?.totalMinor, order.currency),
|
|
orderId: String(order._id),
|
|
paymentId: null,
|
|
date: order.createdAt ? new Date(order.createdAt).toISOString() : null,
|
|
purchaserEmail: order.purchaserEmail || participantEmail || null,
|
|
attendees: serializeAttendees(order.attendees),
|
|
quote: order.quote
|
|
? {
|
|
base: formatMoney(order.quote.baseMinor, order.currency),
|
|
tax: formatMoney(order.quote.taxMinor, order.currency),
|
|
total: formatMoney(order.quote.totalMinor, order.currency),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const pid = String(participantId);
|
|
const email = String(participantEmail || '').trim().toLowerCase();
|
|
|
|
const tx = await PaymentTransaction.findOne({
|
|
event: eventId,
|
|
purpose: 'ticket_purchase',
|
|
status: 'paid',
|
|
$or: [
|
|
{ participant: participantId },
|
|
{ 'registrationContext.fulfilledParticipantIds': pid },
|
|
...(email ? [{ 'registrationContext.purchaserEmail': email }] : []),
|
|
],
|
|
})
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
if (tx) {
|
|
return loadReceipt({
|
|
eventId,
|
|
participantId,
|
|
participantEmail,
|
|
resolved: { paymentTx: tx, ticket: resolved?.ticket },
|
|
});
|
|
}
|
|
|
|
const orderQuery = {
|
|
event: eventId,
|
|
$or: [
|
|
{ fulfillmentParticipantIds: pid },
|
|
...(email ? [{ purchaserEmail: email }] : []),
|
|
],
|
|
};
|
|
const order = await TicketManualOrder.findOne(orderQuery).sort({ createdAt: -1 }).lean();
|
|
if (order) {
|
|
return loadReceipt({
|
|
eventId,
|
|
participantId,
|
|
participantEmail,
|
|
resolved: { manualOrder: order, ticket: resolved?.ticket },
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function registrationFaqsFromBot(bot) {
|
|
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
|
|
return rows
|
|
.filter((f) => {
|
|
const cat = String(f?.category || '').trim();
|
|
const key = String(f?.key || '').trim();
|
|
const q = String(f?.question || '').trim();
|
|
if (!faqAnswerText(f)) return false;
|
|
if (REGISTER_FAQ_CATEGORIES.has(cat) || REGISTER_FAQ_CATEGORIES.has(key)) return true;
|
|
return REGISTER_FAQ_HINT.test(`${cat} ${key} ${q}`);
|
|
})
|
|
.slice(0, 6)
|
|
.map((f) => {
|
|
const q = String(f?.question || '').trim();
|
|
const a = faqAnswerText(f).trim();
|
|
const cat = String(f?.category || '').trim();
|
|
return {
|
|
q: q.length > 200 ? `${q.slice(0, 200)}…` : q,
|
|
a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a,
|
|
key: String(f?.key || cat || 'registration').trim() || null,
|
|
category: cat || null,
|
|
};
|
|
})
|
|
.filter((f) => f.a);
|
|
}
|
|
|
|
async function fetchHowToRegisterData({ bot, event, eventId, eventName }) {
|
|
const resolved = await loadEventRegistrationLinks(eventId || event?._id);
|
|
const eventDoc = resolved.event || (event && typeof event === 'object' ? event : null);
|
|
const registrationLinks = resolved.links;
|
|
|
|
const contactRow = emergencyContactFromBot(bot);
|
|
const hasContact = Boolean(contactRow?.text || contactRow?.email || contactRow?.phone);
|
|
const faqs = registrationFaqsFromBot(bot);
|
|
|
|
const hasHowTo = Boolean(registrationLinks.length || hasContact || faqs.length);
|
|
return {
|
|
intent: 'ticket_how_to_register',
|
|
found: hasHowTo,
|
|
eventName: eventName || eventDoc?.name || null,
|
|
registrationLinks,
|
|
contact: hasContact
|
|
? {
|
|
email: contactRow.email || null,
|
|
phone: contactRow.phone || null,
|
|
detail: contactRow.detail || null,
|
|
text: contactRow.text || null,
|
|
}
|
|
: undefined,
|
|
faqs,
|
|
note: hasHowTo
|
|
? 'Use DATA.registrationLinks only. Never invent a URL. Do not name the event.'
|
|
: 'No registration link on file. Share organizer contact only. Do not invent a URL.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {{ intent: string, eventId: object|string, participantId?: object|string|null, eventName?: string, bot?: object, event?: object }} opts
|
|
*/
|
|
async function fetchTicketData({ intent, eventId, participantId, eventName, bot = null, event = null } = {}) {
|
|
const intentKey = String(intent || '');
|
|
|
|
if (intentKey === 'ticket_how_to_register') {
|
|
return fetchHowToRegisterData({ bot, event, eventId, eventName });
|
|
}
|
|
|
|
if (isRefuseTicketIntent(intentKey)) {
|
|
return {
|
|
intent: intentKey,
|
|
policy: 'refuse_contact_organizer',
|
|
eventName: eventName || null,
|
|
note:
|
|
'Politely refuse. Ticket transfers and upgrades cannot be done in chat. Ask the attendee to contact the event organizer or on-site help desk. Offer to help with QR display, emailing their QR, status, or receipt instead.',
|
|
};
|
|
}
|
|
|
|
if (!participantId || !mongoose.Types.ObjectId.isValid(String(participantId))) {
|
|
return {
|
|
intent: intentKey,
|
|
eventName: eventName || null,
|
|
note: 'Participant identity is missing.',
|
|
found: false,
|
|
};
|
|
}
|
|
|
|
const participant = await Participant.findById(participantId).lean();
|
|
if (!participant) {
|
|
return {
|
|
intent: intentKey,
|
|
eventName: eventName || null,
|
|
found: false,
|
|
note: 'No participant record found.',
|
|
};
|
|
}
|
|
|
|
const membership = membershipForEvent(participant, eventId);
|
|
const resolved = await resolveParticipantTicket({
|
|
participantId,
|
|
eventId,
|
|
backfill: true,
|
|
});
|
|
|
|
let eventDoc = null;
|
|
if (!eventName && eventId) {
|
|
eventDoc = await Event.findById(eventId).select('name').lean();
|
|
}
|
|
|
|
const name = attendeeName(participant);
|
|
const email = String(participant.email || '').trim().toLowerCase() || null;
|
|
const membershipStatus = membership?.status || null;
|
|
const checkedIn = Boolean(membership?.checkedIn || membershipStatus === 'checked_in');
|
|
|
|
let qrImageUrl = null;
|
|
try {
|
|
qrImageUrl = await ensureParticipantCheckInQrUrl(participantId);
|
|
} catch (err) {
|
|
console.warn('[fetchTicketData] QR ensure failed', err?.message || err);
|
|
qrImageUrl = buildQrImageUrl(participantId);
|
|
}
|
|
|
|
const payment = participant.payment && typeof participant.payment === 'object'
|
|
? {
|
|
status: participant.payment.status || null,
|
|
amount: participant.payment.amount != null ? participant.payment.amount : null,
|
|
currency: participant.payment.currency || 'INR',
|
|
paidAt: participant.payment.paidAt
|
|
? new Date(participant.payment.paidAt).toISOString()
|
|
: null,
|
|
}
|
|
: null;
|
|
|
|
const base = {
|
|
intent: intentKey,
|
|
found: true,
|
|
eventName: eventName || eventDoc?.name || null,
|
|
attendee: {
|
|
id: String(participant._id),
|
|
name,
|
|
email,
|
|
},
|
|
membership: {
|
|
status: membershipStatus,
|
|
statusLabel: statusLabel(membershipStatus),
|
|
checkedIn,
|
|
registrationDate: membership?.registrationDate
|
|
? new Date(membership.registrationDate).toISOString()
|
|
: null,
|
|
},
|
|
ticket: resolved.ticket,
|
|
payment,
|
|
qr: {
|
|
imageUrl: qrImageUrl,
|
|
available: Boolean(qrImageUrl),
|
|
},
|
|
};
|
|
|
|
if (intentKey === 'ticket_document_receipt' || intentKey === 'ticket_email_qr') {
|
|
base.receipt = await loadReceipt({
|
|
eventId,
|
|
participantId,
|
|
participantEmail: email,
|
|
resolved,
|
|
});
|
|
}
|
|
|
|
if (intentKey === 'ticket_document_receipt' && !base.receipt) {
|
|
base.note = 'No paid ticket purchase or approved manual order was found for this attendee.';
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
module.exports = {
|
|
TICKET_INTENTS,
|
|
AUTH_REQUIRED_TICKET_INTENTS,
|
|
REFUSE_TICKET_INTENTS,
|
|
isTicketIntent,
|
|
isAuthRequiredTicketIntent,
|
|
isRefuseTicketIntent,
|
|
fetchTicketData,
|
|
buildQrImageUrl,
|
|
statusLabel,
|
|
attendeeName,
|
|
};
|