Files
edr-platform/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
2026-06-29 12:42:37 +03:00

690 lines
27 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
bookingRef: string;
validatorId: string;
gateId?: string;
validatedAt: string;
leg?: string;
}
@Injectable()
export class TicketsService {
private readonly logger = new Logger(TicketsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ passengerName: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.status) {
where.status = filters.status;
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
}
if (filters.destinationStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
}
if (filters.arrivalDate) {
const start = new Date(filters.arrivalDate);
const end = new Date(filters.arrivalDate);
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}),
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
if (filters.coachId) {
where.seat = {
coachId: filters.coachId
};
}
const [tickets, total] = await Promise.all([
this.prisma.ticket.findMany({
where,
include: {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
passenger: { include: { travelerProfiles: true } },
seats: { include: { seat: { include: { coach: true } } } },
},
},
seat: { include: { coach: { include: { coachType: true } } } },
} as any,
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
}) as any,
this.prisma.ticket.count({ where }),
]);
const iamUserIds = tickets.map((t: any) => t.booking?.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
return {
items: tickets.map((t: any) => {
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
// Extract phone from TravelerProfile notes JSON
let guestPhone = null;
let guestEmail = null;
const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName);
// DEBUG: Log to see what we're getting
this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`);
if (matchingProfile) {
this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`);
}
if (matchingProfile?.notes) {
try {
const notesData = JSON.parse(matchingProfile.notes);
guestPhone = notesData.phone || null;
guestEmail = notesData.email || null;
this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`);
} catch (err) {
this.logger.error(`Failed to parse notes JSON: ${err}`);
}
}
// Fallback to booking contact info if no match in TravelerProfile
if (!guestPhone) guestPhone = t.booking?.contactPhone;
if (!guestEmail) guestEmail = t.booking?.contactEmail;
this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`);
const passengerInfo = iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`);
return {
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
passengerName: t.passengerName,
leg: t.leg,
booking: {
id: t.booking?.id,
bookingRef: t.booking?.bookingRef,
status: t.booking?.status,
bookingType: t.booking?.bookingType,
returnLegStatus: t.booking?.returnLegStatus ?? null,
outboundBoardedAt: t.booking?.outboundBoardedAt ?? null,
returnBoardedAt: t.booking?.returnBoardedAt ?? null,
totalMinor: t.booking?.totalMinor,
currency: t.booking?.currency,
displayCurrency: t.booking?.displayCurrency,
displayTotalMinor: t.booking?.displayTotalMinor,
passenger: passengerInfo,
contactEmail: t.booking?.contactEmail,
contactPhone: t.booking?.contactPhone,
returnSchedule: t.booking?.returnSchedule ?? null,
seats: t.booking?.seats ?? [],
},
schedule: t.booking?.schedule,
seat: t.seat ? {
id: t.seat.id,
seatNumber: t.seat.seatNumber,
coach: t.seat.coach ? {
id: t.seat.coach.id,
number: t.seat.coach.number,
coachType: t.seat.coach.coachType ? {
id: t.seat.coach.coachType.id,
name: t.seat.coach.coachType.name,
type: t.seat.coach.coachType.type,
} : null,
} : null,
} : null,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
};
}),
total,
skip: filters.skip,
take: filters.take,
};
}
async generate(bookingId: string) {
if (!bookingId) throw new BadRequestException('Booking ID is required');
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
paymentIntent: true,
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// No payment intent record at all
if (!(booking as any).paymentIntent) {
throw new HttpException(
{ status: 'error', message: 'Payment not completed', code: 400 },
HttpStatus.BAD_REQUEST,
);
}
// Payment intent exists but not yet succeeded
if ((booking as any).paymentIntent.status !== 'SUCCEEDED') {
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Payment status: ${(booking as any).paymentIntent.status}`,
},
HttpStatus.BAD_REQUEST,
);
}
// Booking not in CONFIRMED state — could be a webhook delivery failure.
if (booking.status !== 'CONFIRMED') {
if (booking.status === 'PENDING_PAYMENT') {
this.logger.warn(
`Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`,
);
await this.prisma.booking.update({
where: { id: bookingId },
data: { status: 'CONFIRMED' },
});
} else {
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Booking status: ${booking.status}`,
},
HttpStatus.BAD_REQUEST,
);
}
}
// Delete existing tickets if any
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Generate one ticket per unique passenger (grouped by passengerName)
const tickets = [];
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
// Group seats by passenger
const passengerSeatsMap = new Map<string, any[]>();
for (const bookingSeat of (booking as any).seats) {
const key = bookingSeat.passengerName;
if (!passengerSeatsMap.has(key)) {
passengerSeatsMap.set(key, []);
}
passengerSeatsMap.get(key)!.push(bookingSeat);
}
// Create one ticket per passenger
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
// Use first seat for primary data
const primarySeat = passengerSeats[0];
// Build passenger QR data with all legs included
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
seat: ps.seat.seatNumber,
coach: ps.seat.coach.number,
leg: ps.leg || 1,
scheduleId: ps.scheduleId || booking.scheduleId,
})),
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
const ticket = await this.prisma.ticket.create({
data: {
bookingId,
bookingRef: booking.bookingRef,
passengerName,
seatId: primarySeat.seatId,
leg: primarySeat.leg || 1,
scheduleId: primarySeat.scheduleId || booking.scheduleId,
qrPayload,
barcodePayload,
} as any,
});
tickets.push(ticket);
}
// Block all seats across all legs
for (const seatId of seatIds) {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
}).catch(() => null);
}
return { tickets, totalTickets: tickets.length };
}
async updateSeats(bookingId: string, seatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { tickets: true, seats: true } as any
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Remove old seat blocks
for (const ticket of (booking as any).tickets) {
await this.prisma.seatBlock.deleteMany({
where: { reason: { contains: ticket.id } }
});
}
// Delete existing tickets
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Update booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat assignments (simplified)
for (let i = 0; i < seatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: seatIds[i],
passengerName: `Passenger ${i + 1}`,
leg: 1
} as any
});
}
// Generate new tickets
return this.generate(bookingId);
}
async getByMerchantOrderId(merchantOrderId: string) {
const paymentIntent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
include: { booking: { include: { tickets: true } as any } } as any
});
if (!paymentIntent) throw new NotFoundException('Payment not found');
const booking = (paymentIntent as any).booking;
if (!booking) throw new NotFoundException('Booking not found');
return this.getByRef(booking.bookingRef);
}
async getByRef(ref: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: ref },
include: {
tickets: true,
schedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: true } } } }
} as any
});
if (!booking) throw new NotFoundException('Booking not found');
return {
booking: {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
bookingType: booking.bookingType,
totalMinor: booking.totalMinor,
currency: booking.currency
},
tickets: (booking as any).tickets,
schedule: (booking as any).schedule,
returnSchedule: (booking as any).returnSchedule,
seats: (booking as any).seats
};
}
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
try {
// Extract booking reference from QR code if it's JSON
let bookingRef = qrCodeOrRef;
try {
const qrData = JSON.parse(qrCodeOrRef);
if (qrData.ref) {
bookingRef = qrData.ref;
}
} catch {
// Not JSON, treat as booking reference
}
// Get booking and ticket info
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) {
throw new NotFoundException('Ticket not found');
}
if (booking.status !== 'CONFIRMED') {
throw new BadRequestException('Ticket is not confirmed');
}
const ticket = (booking as any).tickets[0];
if (!ticket) {
throw new NotFoundException('No ticket found for this booking');
}
// Check if ticket date matches today
const today = new Date();
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
if ((booking as any).schedule?.departureAt) {
const departureDate = new Date((booking as any).schedule.departureAt);
const departureDateStr = departureDate.toISOString().split('T')[0];
// Check if ticket is for today
if (departureDateStr !== todayDateStr) {
if (departureDateStr < todayDateStr) {
throw new BadRequestException('Ticket has expired - departure date has passed');
} else {
throw new BadRequestException('Ticket is for a future date - cannot board early');
}
}
// Additional check: ticket expires 4 hours after departure time
const departureTime = new Date((booking as any).schedule.departureAt);
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
if (today > expiryTime) {
throw new BadRequestException('Ticket has expired - boarding window closed');
}
}
// Use existing validation logic to handle round trips properly
const result = await this.validate(bookingRef, validatorId, gateId);
// Get seat information
const seatInfo = (booking as any).seats[0];
const seatNumber = seatInfo?.seat?.seatNumber || 'N/A';
const coachNumber = seatInfo?.seat?.coach?.number || 'N/A';
// Send notifications after successful boarding
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
return {
success: true,
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
boarding: {
ticketId: ticket.id,
bookingRef: booking.bookingRef,
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
route: `${(booking as any).schedule?.originStation?.name || 'N/A'}${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
seat: seatNumber,
coach: coachNumber,
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
departureTime: (booking as any).schedule?.departureAt,
boardedAt: result.validatedAt,
leg: result.leg || 'OUTBOUND',
bookingType: booking.bookingType,
isRoundTrip: booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'ROUND_TRIP_TRANSIT',
},
};
} catch (error) {
// Return structured error for the UI
const errorMessage = error instanceof Error ? error.message : 'Boarding failed';
const errorCode = error instanceof BadRequestException ? 'VALIDATION_ERROR'
: error instanceof NotFoundException ? 'NOT_FOUND'
: 'SYSTEM_ERROR';
return {
success: false,
error: errorMessage,
errorCode,
};
}
}
private async sendBoardingNotifications(booking: any, ticket: any, leg: string) {
try {
const passengerName = booking.seats?.[0]?.passengerName || ticket.passengerName || 'Passenger';
const contactEmail = booking.contactEmail;
const contactPhone = booking.contactPhone;
if (!contactEmail && !contactPhone) {
this.logger.warn(`No contact details found for booking ${booking.bookingRef}`);
return;
}
const routeInfo = `${booking.schedule?.originStation?.name}${booking.schedule?.destinationStation?.name}`;
const trainName = booking.schedule?.train?.name || booking.schedule?.train?.number;
const departureTime = booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : 'N/A';
const legText = leg === 'RETURN' ? 'Return' : 'Outbound';
// Use the existing sendBoardingPassNotification method
await this.notifications.sendBoardingPassNotification({
passengerId: booking.passengerId || null,
contactEmail,
contactPhone,
bookingRef: booking.bookingRef,
leg,
booking,
ticket,
});
} catch (error: any) {
this.logger.error('Error sending boarding notifications:', error);
}
}
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
// Accept either a ticket UUID or a bookingRef
let bookingRef = ticketIdOrRef;
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
if (isUuid) {
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
if (!ticket) throw new NotFoundException('Ticket not found');
bookingRef = ticket.bookingRef;
}
const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
const type = booking.bookingType;
const now = new Date();
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') {
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
// ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
if (type === 'TRANSIT') {
const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
if (type === 'ROUND_TRIP') {
let resolvedLeg = (leg ?? '').toUpperCase();
// Auto-detect next unused leg when called from backoffice without a leg param
if (!resolvedLeg) {
resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
}
const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
throw new BadRequestException('Outbound leg already validated');
}
bookingData.outboundBoardedAt = now;
} else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
throw new BadRequestException('Return leg already validated');
}
bookingData.returnBoardedAt = now;
} else {
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
}
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
throw new BadRequestException(`Unsupported booking type: ${type}`);
}
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
// TODO: Implement notification logic
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },
orderBy: { validatedAt: 'desc' }
});
}
async exportOfflineData(tripId: string) {
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
passenger: { select: { id: true, iamUserId: true } },
} as any,
});
return bookings.map((b) => ({
bookingRef: b.bookingRef,
ticketId: (b as any).tickets?.[0]?.id,
passengerName: (b as any).seats[0]?.passengerName,
seatLabel: (b as any).seats[0]?.seat.seatNumber,
coachLabel: (b as any).seats[0]?.seat.coach.number,
qrPayload: (b as any).tickets?.[0]?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: (b as any).tickets?.[0]?.validatedAt,
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
const results = [];
for (const validation of validations) {
try {
const result = await this.validate(
validation.bookingRef,
validation.validatorId,
validation.gateId,
validation.leg
);
results.push({
bookingRef: validation.bookingRef,
success: true,
result
});
} catch (error) {
results.push({
bookingRef: validation.bookingRef,
success: false,
error: error instanceof Error ? error.message : 'Validation failed'
});
}
}
return {
processed: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results
};
}
async delete(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } });
// Remove seat blocks associated with this ticket
await this.prisma.seatBlock.deleteMany({
where: {
reason: { contains: id }
}
});
await this.prisma.ticket.delete({ where: { id } });
return { deleted: true, ticketId: id };
}
async restore(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
}
}