Eventbuzz_Concierge/src/services/ticketData.service.js

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