feat(server): add membership and time-slot modules

Membership: card type CRUD, deduction/restore logic, valid card lookup (15 tests)
TimeSlot: slot generation from week templates, availability query with booking
status, admin management, cleanup tasks (26 tests)
65 total tests passing
This commit is contained in:
richarjiang
2026-04-02 12:24:07 +08:00
parent a1a91f96d8
commit 593a6e5453
16 changed files with 1746 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
import { Injectable, NotFoundException } 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 } from '@mp-pilates/shared'
import type { CreateManualSlotDto } from './dto/create-manual-slot.dto'
@Injectable()
export class TimeSlotService {
constructor(private readonly prisma: PrismaService) {}
async getAvailableSlots(
date: string,
userId?: string,
): Promise<TimeSlotWithBookingStatus[]> {
const parsedDate = new Date(date)
// Build start/end of day boundaries for the query
const startOfDay = new Date(parsedDate)
startOfDay.setUTCHours(0, 0, 0, 0)
const endOfDay = new Date(parsedDate)
endOfDay.setUTCHours(23, 59, 59, 999)
const slots = await this.prisma.timeSlot.findMany({
where: {
date: {
gte: startOfDay,
lte: endOfDay,
},
status: { not: TimeSlotStatus.CLOSED },
},
orderBy: { startTime: 'asc' },
include: {
bookings: userId
? {
where: {
userId,
status: BookingStatus.CONFIRMED,
},
select: { id: true },
}
: false,
},
})
return slots.map((slot) => {
const myBooking =
userId && Array.isArray(slot.bookings) && slot.bookings.length > 0
? slot.bookings[0]
: null
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,
} satisfies TimeSlotWithBookingStatus
})
}
async getSlotById(id: string) {
const slot = await this.prisma.timeSlot.findUnique({
where: { id },
include: { bookings: true },
})
if (!slot) {
throw new NotFoundException(`TimeSlot ${id} not found`)
}
return slot
}
async createManualSlot(dto: CreateManualSlotDto) {
const date = new Date(dto.date)
date.setUTCHours(0, 0, 0, 0)
return this.prisma.timeSlot.create({
data: {
date,
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()
const created = 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 created
})
}
}