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