329 lines
9.8 KiB
TypeScript
329 lines
9.8 KiB
TypeScript
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<TimeSlotWithBookingStatus[]> {
|
|
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<TimeSlotWithBookingStatus> {
|
|
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<ScheduleSlotPreview[]> {
|
|
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<string>()
|
|
|
|
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,
|
|
}))
|
|
})
|
|
}
|
|
}
|