Files
mp-pilates/packages/server/src/time-slot/time-slot.service.ts
2026-04-12 22:18:34 +08:00

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