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:
171
packages/server/src/time-slot/slot-generator.service.ts
Normal file
171
packages/server/src/time-slot/slot-generator.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user