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,171 @@
import { Injectable, Logger } from '@nestjs/common'
import {
TimeSlotStatus,
TimeSlotSource,
MembershipStatus,
BookingStatus,
SLOT_GENERATION_DAYS,
DEFAULT_SLOT_CAPACITY,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
function toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/** Build a UTC Date for midnight of a local calendar date */
function toUtcMidnight(date: Date): Date {
const d = new Date(date)
d.setUTCHours(0, 0, 0, 0)
return d
}
@Injectable()
export class SlotGeneratorService {
private readonly logger = new Logger(SlotGeneratorService.name)
constructor(private readonly prisma: PrismaService) {}
/**
* Generate time slots for the next `daysAhead` days based on active
* WeekTemplates. Uses `createMany` with `skipDuplicates` so re-runs are safe.
*
* @returns Number of newly created slots
*/
async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> {
const templates = await this.prisma.weekTemplate.findMany({
where: { isActive: true },
})
if (templates.length === 0) {
this.logger.log('No active week templates found skipping slot generation')
return 0
}
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
const slotsToCreate: Array<{
date: Date
startTime: string
endTime: string
capacity: number
source: TimeSlotSource
templateId: string
}> = []
for (let offset = 0; offset < daysAhead; offset++) {
const target = new Date(tomorrow)
target.setDate(target.getDate() + offset)
const isoWeekday = toIsoWeekday(target.getDay())
const matchingTemplates = templates.filter(
(t) => t.dayOfWeek === isoWeekday,
)
for (const template of matchingTemplates) {
slotsToCreate.push({
date: toUtcMidnight(target),
startTime: template.startTime,
endTime: template.endTime,
capacity: template.capacity ?? DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.TEMPLATE,
templateId: template.id,
})
}
}
if (slotsToCreate.length === 0) {
return 0
}
const result = await this.prisma.timeSlot.createMany({
data: slotsToCreate,
skipDuplicates: true,
})
this.logger.log(`Generated ${result.count} new time slots`)
return result.count
}
/**
* Mark all OPEN slots whose date is strictly before today as CLOSED.
*
* @returns Number of slots updated
*/
async cleanupExpiredSlots(): Promise<number> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const result = await this.prisma.timeSlot.updateMany({
where: {
status: TimeSlotStatus.OPEN,
date: { lt: today },
},
data: { status: TimeSlotStatus.CLOSED },
})
this.logger.log(`Closed ${result.count} expired time slots`)
return result.count
}
/**
* Expire memberships whose end date has passed or whose remaining sessions
* have been exhausted.
*
* @returns Total number of memberships updated
*/
async checkExpiredMemberships(): Promise<number> {
const now = new Date()
const [expired, usedUp] = await Promise.all([
this.prisma.membership.updateMany({
where: {
status: MembershipStatus.ACTIVE,
expireDate: { lt: now },
},
data: { status: MembershipStatus.EXPIRED },
}),
this.prisma.membership.updateMany({
where: {
status: MembershipStatus.ACTIVE,
remainingTimes: 0,
},
data: { status: MembershipStatus.USED_UP },
}),
])
const total = expired.count + usedUp.count
this.logger.log(
`Expired ${expired.count} memberships by date, ${usedUp.count} by sessions`,
)
return total
}
/**
* Mark CONFIRMED bookings whose associated time slot is in the past as
* COMPLETED.
*
* @returns Number of bookings updated
*/
async completeBookings(): Promise<number> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const result = await this.prisma.booking.updateMany({
where: {
status: BookingStatus.CONFIRMED,
timeSlot: {
date: { lt: today },
},
},
data: { status: BookingStatus.COMPLETED },
})
this.logger.log(`Completed ${result.count} past bookings`)
return result.count
}
}