Lets staff redeem a registration's Main Tickets by quantity at the door (via the Payment/registration flow) instead of scanning each QR code, and automatically emails/WhatsApps a check-in confirmation to the attendee. Also routes the At The Door "Open" button and post-payment flow dynamically: paid registrations jump straight to Check-In instead of a forced ticket print, since tickets are already sent automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
804 lines
29 KiB
JavaScript
804 lines
29 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
|
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
|
|
|
// @desc Generate tickets for a registration
|
|
// @route POST /api/tickets/generate
|
|
// @access Private/Admin
|
|
const generateTickets = async (req, res) => {
|
|
try {
|
|
const { registrationId } = req.body;
|
|
|
|
if (!registrationId) {
|
|
res.status(400);
|
|
throw new Error('registrationId is required');
|
|
}
|
|
|
|
await assertRegistrationEventOpen(registrationId, res);
|
|
|
|
// Delegate fully to the shared utility which handles deduplication/consolidation
|
|
const newTickets = await generateTicketsForRegistration(registrationId);
|
|
|
|
// Fetch the final canonical ticket set (one per option)
|
|
const options = await prisma.registrationOption.findMany({ where: { registrationId } });
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: { registrationOptionId: { in: options.map(o => o.id) } },
|
|
include: {
|
|
registrationOption: { include: { eventOption: true } },
|
|
user: true,
|
|
event: true
|
|
},
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
|
|
res.status(201).json({
|
|
message: `${newTickets.length} ticket(s) generated, ${tickets.length} total`,
|
|
tickets
|
|
});
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Get all tickets
|
|
// @route GET /api/tickets
|
|
// @access Private/Admin
|
|
const getTickets = async (req, res) => {
|
|
try {
|
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
|
const skip = (page - 1) * limit;
|
|
|
|
const include = {
|
|
registrationOption: {
|
|
include: {
|
|
eventOption: true,
|
|
registration: {
|
|
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } }
|
|
}
|
|
}
|
|
},
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
|
};
|
|
|
|
const [tickets, total] = await prisma.$transaction([
|
|
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
|
prisma.ticket.count()
|
|
]);
|
|
|
|
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Get user tickets
|
|
// @route GET /api/tickets/mytickets
|
|
// @access Private
|
|
const getUserTickets = async (req, res) => {
|
|
try {
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: {
|
|
userId: req.user.id,
|
|
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
|
},
|
|
include: {
|
|
registrationOption: {
|
|
include: {
|
|
eventOption: true,
|
|
variant: true,
|
|
registration: true
|
|
}
|
|
},
|
|
event: true,
|
|
usages: {
|
|
include: {
|
|
scannedBy: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
res.json(tickets);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Get ticket by ID
|
|
// @route GET /api/tickets/:id
|
|
// @access Private
|
|
const getTicketById = async (req, res) => {
|
|
try {
|
|
const ticket = await prisma.ticket.findUnique({
|
|
where: { id: req.params.id },
|
|
include: {
|
|
registrationOption: {
|
|
include: {
|
|
eventOption: true,
|
|
registration: {
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
event: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
},
|
|
usages: {
|
|
include: {
|
|
scannedBy: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!ticket) {
|
|
res.status(404);
|
|
throw new Error('Ticket not found');
|
|
}
|
|
|
|
// Check if user is authorized to view this ticket
|
|
if (ticket.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
|
|
res.status(403);
|
|
throw new Error('Not authorized to view this ticket');
|
|
}
|
|
|
|
res.json(ticket);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Get ticket by QR code
|
|
// @route GET /api/tickets/qr/:qrCode
|
|
// @access Private/Staff
|
|
const getTicketByQrCode = async (req, res) => {
|
|
try {
|
|
const ticket = await prisma.ticket.findUnique({
|
|
where: { qrCode: req.params.qrCode },
|
|
include: {
|
|
registrationOption: {
|
|
include: {
|
|
eventOption: true,
|
|
registration: {
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
event: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
},
|
|
usages: {
|
|
include: {
|
|
scannedBy: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!ticket) {
|
|
res.status(404);
|
|
throw new Error('Ticket not found');
|
|
}
|
|
|
|
res.json(ticket);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Lightweight ticket preview for the scan-confirm flow (only fields the confirm modal needs)
|
|
// @route GET /api/tickets/scan-preview/:qrCode
|
|
// @access Private/Staff
|
|
const getScanPreview = async (req, res) => {
|
|
try {
|
|
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
|
const rows = await prisma.$queryRaw`
|
|
SELECT
|
|
t.id,
|
|
t."qrCode",
|
|
t.quantity,
|
|
t."isUsed",
|
|
t."eventId",
|
|
ro.id AS "registrationOptionId",
|
|
eo.id AS "eventOptionId",
|
|
eo.name AS "eventOptionName",
|
|
ov.id AS "variantId",
|
|
ov.name AS "variantName",
|
|
e.id AS "evId",
|
|
e.title AS "eventTitle",
|
|
u.id AS "userId",
|
|
u.name AS "userName",
|
|
COALESCE(
|
|
json_agg(
|
|
json_build_object(
|
|
'quantityRedeemed', tu."quantityRedeemed",
|
|
'scannedAt', tu."scannedAt"
|
|
)
|
|
) FILTER (WHERE tu.id IS NOT NULL),
|
|
'[]'::json
|
|
) AS usages
|
|
FROM "Ticket" t
|
|
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
|
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
|
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
|
LEFT JOIN "Event" e ON e.id = t."eventId"
|
|
LEFT JOIN "User" u ON u.id = t."userId"
|
|
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
|
WHERE t."qrCode" = ${req.params.qrCode}
|
|
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
|
|
`;
|
|
|
|
if (!rows || rows.length === 0) {
|
|
res.status(404);
|
|
throw new Error('Ticket not found');
|
|
}
|
|
|
|
const r = rows[0];
|
|
res.json({
|
|
id: r.id,
|
|
quantity: Number(r.quantity),
|
|
isUsed: r.isUsed,
|
|
eventId: r.eventId,
|
|
registrationOption: {
|
|
id: r.registrationOptionId,
|
|
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
|
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
|
},
|
|
event: { id: r.evId, title: r.eventTitle },
|
|
user: { id: r.userId, name: r.userName },
|
|
usages: r.usages || []
|
|
});
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Scan ticket (mark as used, with optional partial quantity redemption)
|
|
// @route POST /api/tickets/scan/:qrCode
|
|
// @access Private/Staff
|
|
const scanTicket = async (req, res) => {
|
|
try {
|
|
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
|
const rows = await prisma.$queryRaw`
|
|
SELECT
|
|
t.id,
|
|
t.quantity,
|
|
t."isUsed",
|
|
t."eventId",
|
|
e.title AS "eventTitle",
|
|
eo.id AS "eventOptionId",
|
|
eo.name AS "eventOptionName",
|
|
eo."isMainTicket" AS "isMainTicket",
|
|
COALESCE(
|
|
json_agg(
|
|
json_build_object(
|
|
'id', tu.id,
|
|
'quantityRedeemed', tu."quantityRedeemed",
|
|
'scannedAt', tu."scannedAt"
|
|
)
|
|
) FILTER (WHERE tu.id IS NOT NULL),
|
|
'[]'::json
|
|
) AS usages
|
|
FROM "Ticket" t
|
|
LEFT JOIN "Event" e ON e.id = t."eventId"
|
|
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
|
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
|
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
|
WHERE t."qrCode" = ${req.params.qrCode}
|
|
GROUP BY t.id, e.id, eo.id
|
|
`;
|
|
|
|
if (!rows || rows.length === 0) {
|
|
res.status(404);
|
|
throw new Error('Ticket not found');
|
|
}
|
|
|
|
const r = rows[0];
|
|
const ticket = {
|
|
id: r.id,
|
|
quantity: Number(r.quantity),
|
|
isUsed: r.isUsed,
|
|
eventId: r.eventId,
|
|
event: { title: r.eventTitle },
|
|
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
|
usages: r.usages || []
|
|
};
|
|
|
|
// Optional server-side guard: ensure ticket belongs to the requested event when provided
|
|
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
|
|
if (providedEventId && ticket.eventId !== providedEventId) {
|
|
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
|
}
|
|
|
|
// Compute how many have already been redeemed
|
|
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
|
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
|
|
|
if (remaining <= 0) {
|
|
return res.status(403).json({
|
|
message: 'Ticket has already been fully used',
|
|
ticket,
|
|
remaining: 0
|
|
});
|
|
}
|
|
|
|
// Determine how many to redeem this scan (defaults to all remaining)
|
|
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
|
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
|
|
|
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
|
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
|
const fullyUsed = newRemaining <= 0;
|
|
|
|
// Run the usage insert and ticket status update in parallel — they don't depend on each other
|
|
const [ticketUsage] = await Promise.all([
|
|
prisma.ticketUsage.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
ticketId: ticket.id,
|
|
scannedById: req.user.id,
|
|
quantityRedeemed: qtyToRedeem,
|
|
}
|
|
}),
|
|
prisma.ticket.update({
|
|
where: { id: ticket.id },
|
|
data: { isUsed: fullyUsed, updatedAt: new Date() }
|
|
})
|
|
]);
|
|
|
|
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
|
|
// the QR camera flow are unaffected)
|
|
if (ticket.registrationOption?.eventOption?.isMainTicket) {
|
|
require('../utils/notifications')
|
|
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
|
.catch(e => console.error('Failed to send check-in notification:', e));
|
|
}
|
|
|
|
res.json({
|
|
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
|
ticketUsage,
|
|
ticket,
|
|
qtyRedeemed: qtyToRedeem,
|
|
totalRedeemed: newTotalRedeemed,
|
|
remaining: newRemaining,
|
|
});
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Get tickets by event
|
|
// @route GET /api/tickets/event/:eventId
|
|
// @access Private/Staff
|
|
const getTicketsByEvent = async (req, res) => {
|
|
try {
|
|
const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000));
|
|
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: { eventId: req.params.eventId },
|
|
include: {
|
|
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: limit
|
|
});
|
|
|
|
res.json(tickets);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Mark tickets as email sent
|
|
// @route PUT /api/tickets/email-sent
|
|
// @access Private/Admin
|
|
const markTicketsAsEmailSent = async (req, res) => {
|
|
try {
|
|
const { ticketIds } = req.body;
|
|
|
|
if (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0) {
|
|
res.status(400);
|
|
throw new Error('Ticket IDs are required');
|
|
}
|
|
|
|
// Update tickets
|
|
await prisma.ticket.updateMany({
|
|
where: {
|
|
id: {
|
|
in: ticketIds
|
|
}
|
|
},
|
|
data: {
|
|
emailSent: true,
|
|
updatedAt: new Date()
|
|
}
|
|
});
|
|
|
|
res.json({ message: `${ticketIds.length} tickets marked as email sent` });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Email (and WhatsApp if preference set) tickets to user
|
|
// @route POST /api/tickets/email
|
|
// @access Private
|
|
// Optional body param: channel ('email'|'whatsapp'|'both') — overrides user's notification preference for this request
|
|
const emailTickets = async (req, res) => {
|
|
try {
|
|
// If caller specifies an explicit channel, build a minimal userOverride that forces that preference
|
|
const { channel } = req.body || {};
|
|
let userOverride = null;
|
|
if (channel && ['email', 'whatsapp', 'both'].includes(channel)) {
|
|
// Load base user data then override the preference
|
|
const userId = req.user?.id;
|
|
if (userId) {
|
|
const baseUser = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
|
|
});
|
|
if (baseUser) {
|
|
userOverride = { ...baseUser, notificationPreference: channel };
|
|
}
|
|
}
|
|
}
|
|
return await emailTicketsInternal(req, res, userOverride);
|
|
} catch (error) {
|
|
console.error('Error emailing tickets:', error);
|
|
res.status(error.statusCode || 400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get recent ticket scans
|
|
// @route GET /api/tickets/scans/recent
|
|
// @access Private/Staff
|
|
const getRecentScans = async (req, res) => {
|
|
try {
|
|
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
|
|
const eventId = req.query.eventId || undefined;
|
|
|
|
const scans = await prisma.ticketUsage.findMany({
|
|
where: eventId ? { ticket: { eventId } } : undefined,
|
|
orderBy: { scannedAt: 'desc' },
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
scannedAt: true,
|
|
quantityRedeemed: true,
|
|
scannedBy: { select: { id: true, name: true } },
|
|
ticket: {
|
|
select: {
|
|
id: true,
|
|
event: { select: { title: true } },
|
|
registrationOption: { select: { eventOption: { select: { name: true } } } }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
res.json(scans);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Get scan statistics
|
|
// @route GET /api/tickets/scans/stats
|
|
// @access Private/Staff
|
|
const getScanStats = async (req, res) => {
|
|
try {
|
|
const eventId = req.query.eventId || undefined;
|
|
const startOfDay = new Date();
|
|
startOfDay.setHours(0, 0, 0, 0);
|
|
|
|
// Total scans today (optionally by event)
|
|
const whereBase = {
|
|
scannedAt: { gte: startOfDay },
|
|
...(eventId ? { ticket: { eventId } } : {})
|
|
};
|
|
|
|
const [totalToday, myToday, lastHour] = await Promise.all([
|
|
prisma.ticketUsage.count({ where: whereBase }),
|
|
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
|
|
prisma.ticketUsage.count({
|
|
where: {
|
|
...(eventId ? { ticket: { eventId } } : {}),
|
|
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
|
|
}
|
|
})
|
|
]);
|
|
|
|
// Per-scanner breakdown today
|
|
const byStaff = await prisma.ticketUsage.groupBy({
|
|
by: ['scannedById'],
|
|
where: whereBase,
|
|
_count: { _all: true }
|
|
});
|
|
const staffIds = byStaff.map(b => b.scannedById);
|
|
const staffUsers = staffIds.length > 0 ? await prisma.user.findMany({
|
|
where: { id: { in: staffIds } },
|
|
select: { id: true, name: true }
|
|
}) : [];
|
|
const nameMap = Object.fromEntries(staffUsers.map(u => [u.id, u.name]));
|
|
|
|
res.json({
|
|
totalToday,
|
|
myToday,
|
|
lastHour,
|
|
byStaff: byStaff.map(b => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all }))
|
|
});
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Send tickets to a specific phone/email (staff override for at-the-door)
|
|
// @route POST /api/tickets/send-to
|
|
// @access Private/Staff
|
|
const sendTicketsTo = async (req, res) => {
|
|
try {
|
|
const { registrationId, ticketIds, channel, overridePhone, overrideEmail } = req.body;
|
|
if (!registrationId && (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0)) {
|
|
res.status(400); throw new Error('registrationId or ticketIds required');
|
|
}
|
|
if (!channel || !['email','whatsapp','both'].includes(channel)) {
|
|
res.status(400); throw new Error('channel must be email, whatsapp, or both');
|
|
}
|
|
|
|
// Determine which user owns the tickets to get their default contact info
|
|
let ownerUserId;
|
|
if (registrationId) {
|
|
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true } });
|
|
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
|
ownerUserId = reg.userId;
|
|
} else {
|
|
const firstTicket = await prisma.ticket.findUnique({ where: { id: ticketIds[0] }, select: { userId: true } });
|
|
if (!firstTicket) { res.status(404); throw new Error('Ticket not found'); }
|
|
ownerUserId = firstTicket.userId;
|
|
}
|
|
|
|
// Use a mock req that targets the ticket owner; override contact details in user object via closure
|
|
const originalUser = await prisma.user.findUnique({
|
|
where: { id: ownerUserId },
|
|
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
|
|
});
|
|
if (!originalUser) { res.status(404); throw new Error('Ticket owner not found'); }
|
|
|
|
// Build a virtual user with override contact details
|
|
const virtualUser = {
|
|
...originalUser,
|
|
email: overrideEmail || originalUser.email,
|
|
phoneNumber: overridePhone || originalUser.phoneNumber,
|
|
// Force the preference to match the requested channel
|
|
notificationPreference: channel,
|
|
};
|
|
|
|
// Reuse emailTickets logic via mock request but with virtual user
|
|
// We build a minimal mock and call the internal flow directly
|
|
const mockBody = registrationId ? { registrationId } : { ticketIds };
|
|
const mockReq = { user: { id: ownerUserId }, body: mockBody, _virtualUser: virtualUser };
|
|
const mockRes = { status: () => mockRes, json: (body) => { res.json(body); } };
|
|
|
|
// Call emailTickets with the virtual user override
|
|
await emailTicketsInternal(mockReq, mockRes, virtualUser);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// Internal helper used by both emailTickets and sendTicketsTo
|
|
async function emailTicketsInternal(req, res, userOverride) {
|
|
const { ticketIds, registrationId } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
const user = userOverride || await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { email: true, name: true, phoneNumber: true, notificationPreference: true }
|
|
});
|
|
if (!user) { res.status(404); throw new Error('User not found'); }
|
|
const pref = user.notificationPreference || 'email';
|
|
const noValidEmail = !user.email || user.email.endsWith('@guest.local');
|
|
// For users without a valid email, WhatsApp is used as fallback regardless of preference
|
|
const canSendWA = !!user.phoneNumber && ((pref === 'whatsapp' || pref === 'both') || noValidEmail);
|
|
// Only block if neither channel can deliver
|
|
if (noValidEmail && !canSendWA) {
|
|
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
|
}
|
|
|
|
let tickets;
|
|
if (registrationId) {
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId, userId }
|
|
});
|
|
if (!registration) { res.status(404); throw new Error('Registration not found or does not belong to you'); }
|
|
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
|
|
tickets = await prisma.ticket.findMany({
|
|
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
|
|
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
} else {
|
|
tickets = await prisma.ticket.findMany({
|
|
where: { id: { in: ticketIds }, userId },
|
|
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
}
|
|
|
|
// Dedup by registrationOptionId
|
|
{
|
|
const seen = new Map(); const deduped = [];
|
|
for (const t of tickets) {
|
|
const key = t.registrationOptionId;
|
|
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
|
|
else {
|
|
const existing = seen.get(key);
|
|
if ((existing.usages||[]).length === 0 && (t.usages||[]).length > 0) {
|
|
seen.set(key, t); deduped[deduped.indexOf(existing)] = t;
|
|
}
|
|
}
|
|
}
|
|
tickets = deduped;
|
|
}
|
|
if (tickets.length === 0) { res.status(404); throw new Error('No valid tickets found'); }
|
|
|
|
// Generate PDF
|
|
const PDFDocument = require('pdfkit');
|
|
const QRCode = require('qrcode');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const tempFilePath = path.join(__dirname, '..', '..', 'temp', `tickets-${userId}-${Date.now()}.pdf`);
|
|
const tempDir = path.join(__dirname, '..', '..', 'temp');
|
|
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
|
|
|
const doc = new PDFDocument({ size: 'A4', margin: 20 });
|
|
const writeStream = fs.createWriteStream(tempFilePath);
|
|
doc.pipe(writeStream);
|
|
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
|
|
const ticketsPerRow = 2; const ticketsPerColumn = 4;
|
|
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
|
|
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
|
|
QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
|
|
})));
|
|
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
|
|
let ticketIndex = 0;
|
|
for (const ticket of tickets) {
|
|
const row = Math.floor(ticketIndex % ticketsPerColumn);
|
|
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
|
|
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
|
|
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
|
|
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
|
|
doc.font('Helvetica').fontSize(10);
|
|
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
|
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
|
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
|
const eventDate = new Date(ticket.event.startDate);
|
|
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
|
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
|
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
|
|
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
|
|
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
|
|
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
|
|
ticketIndex++;
|
|
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
|
|
}
|
|
doc.end();
|
|
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
|
|
|
const eventTitle = tickets[0].event.title;
|
|
const eventDate = tickets[0].event.startDate
|
|
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
|
: '';
|
|
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
|
const sentChannels = [];
|
|
|
|
const { sendMail, emailWrapper } = require('../utils/email');
|
|
const { canWhatsApp, waPdf } = require('../utils/notify');
|
|
const { buildWATicketCaption } = require('../utils/waMessages');
|
|
const orgUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
|
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
|
const ticketHtml = emailWrapper(`
|
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">🎟️ Your tickets are here!</p>
|
|
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
|
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Your tickets for <strong>${eventTitle}</strong>${eventDate ? ` on <strong>${eventDate}</strong>` : ''} are attached.</p>
|
|
<p style="font-size:13px;color:#64748b;font-family:${ff}">Questions? Visit <a href="${orgUrl}" style="color:#2563eb">${orgUrl}</a>.</p>
|
|
`, { preheader: `Your tickets for ${eventTitle} are attached!` });
|
|
|
|
// Send email unless preference is whatsapp-only
|
|
if (pref !== 'whatsapp' && user.email && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@guest.local')) {
|
|
await sendMail({
|
|
to: user.email,
|
|
subject: `Your tickets for ${eventTitle}`,
|
|
html: ticketHtml,
|
|
text: `Hi ${user.name},\n\nYour tickets for ${eventTitle}${eventDate ? ' on ' + eventDate : ''} are attached.\n\nSee you there!`,
|
|
attachments: [{ filename: pdfFilename, path: tempFilePath, contentType: 'application/pdf' }],
|
|
});
|
|
sentChannels.push('email');
|
|
}
|
|
|
|
// Send WhatsApp if preference includes it, or as fallback when email isn't available
|
|
if (pref === 'whatsapp' || pref === 'both' || noValidEmail) {
|
|
const { normalizeZAPhone } = require('../utils/whatsapp');
|
|
const normalizedPhone = normalizeZAPhone(user.phoneNumber);
|
|
if (normalizedPhone) {
|
|
const caption = buildWATicketCaption({ name: user.name, eventTitle, eventDate });
|
|
await waPdf({ ...user, phoneNumber: normalizedPhone, notificationPreference: 'both' }, tempFilePath, pdfFilename, caption).catch(() => {});
|
|
sentChannels.push('whatsapp');
|
|
}
|
|
}
|
|
|
|
try { require('fs').unlinkSync(tempFilePath); } catch {}
|
|
await prisma.ticket.updateMany({ where: { id: { in: tickets.map(t => t.id) } }, data: { emailSent: true, updatedAt: new Date() } });
|
|
res.json({ message: `${tickets.length} ticket(s) sent via ${sentChannels.join(' & ') || 'no channel'}`, ticketIds: tickets.map(t => t.id) });
|
|
}
|
|
|
|
module.exports = {
|
|
generateTickets,
|
|
getTickets,
|
|
getUserTickets,
|
|
getTicketById,
|
|
getTicketByQrCode,
|
|
getScanPreview,
|
|
scanTicket,
|
|
getTicketsByEvent,
|
|
markTicketsAsEmailSent,
|
|
emailTickets,
|
|
sendTicketsTo,
|
|
getRecentScans,
|
|
getScanStats
|
|
}; |