import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common' import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared' import { TimeSlotSource } from '@mp-pilates/shared' import { PrismaService } from '../prisma/prisma.service' import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared' import type { CreateManualSlotDto } from './dto/create-manual-slot.dto' import type { PublishDaySlotsDto } from './dto/publish-day-slots.dto' @Injectable() export class TimeSlotService { constructor(private readonly prisma: PrismaService) {} private toDateOfDay(date: Date): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)) } private toEndOfDay(date: Date): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999)) } private mapToWithBookingStatus( slot: { id: string date: Date startTime: string endTime: string capacity: number bookedCount: number status: string source: string templateId: string | null createdAt: Date updatedAt: Date }, myBooking: { id: string; status: string } | null, ): TimeSlotWithBookingStatus { return { id: slot.id, date: slot.date.toISOString().split('T')[0], startTime: slot.startTime, endTime: slot.endTime, capacity: slot.capacity, bookedCount: slot.bookedCount, status: slot.status as TimeSlotStatus, source: slot.source as TimeSlotSource, templateId: slot.templateId, createdAt: slot.createdAt.toISOString(), updatedAt: slot.updatedAt.toISOString(), isBookedByMe: myBooking !== null, myBookingId: myBooking?.id ?? null, myBookingStatus: (myBooking?.status as BookingStatus | undefined) ?? null, } } async getAvailableSlots( date: string, userId?: string, ): Promise { const parsedDate = new Date(date) const slots = await this.prisma.timeSlot.findMany({ where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate), }, status: { not: TimeSlotStatus.CLOSED }, }, orderBy: { startTime: 'asc' }, include: { bookings: userId ? { where: { userId, status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, }, select: { id: true, status: true }, } : false, }, }) return slots.map((slot) => { const myBooking = userId && Array.isArray(slot.bookings) && slot.bookings.length > 0 ? slot.bookings[0] : null return this.mapToWithBookingStatus(slot, myBooking) }) } async getSlotById(id: string, userId?: string): Promise { const slot = await this.prisma.timeSlot.findUnique({ where: { id }, include: { bookings: userId ? { where: { userId, status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, }, select: { id: true, status: true }, } : false, }, }) if (!slot) { throw new NotFoundException(`TimeSlot ${id} not found`) } const myBooking = userId && Array.isArray(slot.bookings) && slot.bookings.length > 0 ? slot.bookings[0] : null return this.mapToWithBookingStatus(slot, myBooking) } async createManualSlot(dto: CreateManualSlotDto) { const parsedDate = new Date(dto.date + 'T00:00:00Z') return this.prisma.timeSlot.create({ data: { date: parsedDate, startTime: dto.startTime, endTime: dto.endTime, capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY, source: TimeSlotSource.MANUAL, }, }) } async closeSlot(id: string) { const slot = await this.prisma.timeSlot.findUnique({ where: { id } }) if (!slot) { throw new NotFoundException(`TimeSlot ${id} not found`) } return this.prisma.timeSlot.update({ where: { id }, data: { status: TimeSlotStatus.CLOSED }, }) } async getWeekTemplates() { return this.prisma.weekTemplate.findMany({ orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }) } async replaceWeekTemplates( items: Array<{ dayOfWeek: number startTime: string endTime: string capacity?: number isActive?: boolean }>, ) { return this.prisma.$transaction(async (tx) => { await tx.weekTemplate.deleteMany() await tx.weekTemplate.createMany({ data: items.map((item) => ({ dayOfWeek: item.dayOfWeek, startTime: item.startTime, endTime: item.endTime, capacity: item.capacity ?? DEFAULT_SLOT_CAPACITY, isActive: item.isActive ?? true, })), }) return tx.weekTemplate.findMany({ orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }) }) } // ── Schedule preview & publish ────────────────────────────── /** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */ private toIsoWeekday(jsDay: number): number { return jsDay === 0 ? 7 : jsDay } /** * Return a schedule preview for a given date. * If TimeSlot records already exist → return them (isPublished: true). * Otherwise → derive from active WeekTemplates (isPublished: false). */ async getSchedulePreview(date: string): Promise { const parsedDate = new Date(date) // 1. Check for existing TimeSlot records (all statuses) const existingSlots = await this.prisma.timeSlot.findMany({ where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) }, }, orderBy: { startTime: 'asc' }, }) if (existingSlots.length > 0) { return existingSlots.map((slot) => ({ id: slot.id, date: date, startTime: slot.startTime, endTime: slot.endTime, capacity: slot.capacity, bookedCount: slot.bookedCount, status: slot.status as TimeSlotStatus, source: slot.source as TimeSlotSource, templateId: slot.templateId, isPublished: true, })) } // 2. No existing slots — derive from WeekTemplate const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay()) const templates = await this.prisma.weekTemplate.findMany({ where: { dayOfWeek: isoWeekday, isActive: true }, orderBy: { startTime: 'asc' }, }) return templates.map((tpl) => ({ id: null, date: date, startTime: tpl.startTime, endTime: tpl.endTime, capacity: tpl.capacity, bookedCount: 0, status: TimeSlotStatus.OPEN, source: TimeSlotSource.TEMPLATE, templateId: tpl.id, isPublished: false, })) } /** * Publish (create/update/remove) time slots for a specific date. * - Slots with existingSlotId → update * - New slots → create * - Existing DB slots not referenced → delete (or CLOSE if they have bookings) */ async publishDaySlots(dto: PublishDaySlotsDto) { const parsedDate = new Date(dto.date + 'T00:00:00Z') return this.prisma.$transaction(async (tx) => { // 1. Get existing slots for this date const existing = await tx.timeSlot.findMany({ where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } }, }) const existingMap = new Map(existing.map((s) => [s.id, s])) const keptIds = new Set() const results: Array<{ id: string date: Date startTime: string endTime: string capacity: number bookedCount: number status: string source: string }> = [] // 2. Process each slot in the request for (const item of dto.slots) { if (item.existingSlotId && existingMap.has(item.existingSlotId)) { // Update existing slot const existingSlot = existingMap.get(item.existingSlotId)! const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount) const updated = await tx.timeSlot.update({ where: { id: item.existingSlotId }, data: { startTime: item.startTime, endTime: item.endTime, capacity: safeCapacity, }, }) keptIds.add(item.existingSlotId) results.push(updated) } else { // Create new slot const created = await tx.timeSlot.create({ data: { date: parsedDate, startTime: item.startTime, endTime: item.endTime, capacity: item.capacity, source: TimeSlotSource.MANUAL, status: TimeSlotStatus.OPEN, }, }) results.push(created) } } // 3. Handle orphaned existing slots (not in request) for (const slot of existing) { if (!keptIds.has(slot.id)) { if (slot.bookedCount > 0) { // Has bookings → close instead of delete await tx.timeSlot.update({ where: { id: slot.id }, data: { status: TimeSlotStatus.CLOSED }, }) } else { await tx.timeSlot.delete({ where: { id: slot.id } }) } } } return results.map((slot) => ({ id: slot.id, date: slot.date.toISOString().split('T')[0], startTime: slot.startTime, endTime: slot.endTime, capacity: slot.capacity, bookedCount: slot.bookedCount, status: slot.status, source: slot.source, })) }) } }