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

@@ -5,6 +5,8 @@ import { PrismaModule } from './prisma/prisma.module'
import { UserModule } from './user/user.module' import { UserModule } from './user/user.module'
import { AuthModule } from './auth/auth.module' import { AuthModule } from './auth/auth.module'
import { StudioModule } from './studio/studio.module' import { StudioModule } from './studio/studio.module'
import { TimeSlotModule } from './time-slot/time-slot.module'
import { MembershipModule } from './membership/membership.module'
@Module({ @Module({
imports: [ imports: [
@@ -16,6 +18,8 @@ import { StudioModule } from './studio/studio.module'
AuthModule, AuthModule,
UserModule, UserModule,
StudioModule, StudioModule,
TimeSlotModule,
MembershipModule,
], ],
controllers: [AppController], controllers: [AppController],
}) })

View File

@@ -0,0 +1,364 @@
import { Test, TestingModule } from '@nestjs/testing'
import { BadRequestException, NotFoundException } from '@nestjs/common'
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
import { MembershipService } from '../membership.service'
import { PrismaService } from '../../prisma/prisma.service'
import { CreateCardTypeDto } from '../dto/create-card-type.dto'
// ─── Fixtures ──────────────────────────────────────────────────────────────
const mockTimesCardType = {
id: 'ct-times-001',
name: '10次卡',
type: CardTypeCategory.TIMES,
totalTimes: 10,
durationDays: 180,
price: 150000,
originalPrice: null,
description: null,
isActive: true,
sortOrder: 0,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
const mockDurationCardType = {
id: 'ct-duration-001',
name: '月卡',
type: CardTypeCategory.DURATION,
totalTimes: null,
durationDays: 30,
price: 80000,
originalPrice: null,
description: null,
isActive: true,
sortOrder: 1,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
const mockInactiveCardType = {
...mockTimesCardType,
id: 'ct-inactive-001',
isActive: false,
}
const mockActiveMembership = {
id: 'mem-001',
userId: 'user-001',
cardTypeId: mockTimesCardType.id,
remainingTimes: 5,
startDate: new Date('2024-01-01T00:00:00Z'),
expireDate: new Date('2099-12-31T00:00:00Z'),
status: MembershipStatus.ACTIVE,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
cardType: mockTimesCardType,
}
const mockDurationMembership = {
...mockActiveMembership,
id: 'mem-duration-001',
cardTypeId: mockDurationCardType.id,
remainingTimes: null,
cardType: mockDurationCardType,
}
// ─── Mock Prisma ──────────────────────────────────────────────────────────
const mockPrismaService = {
cardType: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
membership: {
findMany: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
}
// ─── Tests ─────────────────────────────────────────────────────────────────
describe('MembershipService', () => {
let service: MembershipService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MembershipService,
{ provide: PrismaService, useValue: mockPrismaService },
],
}).compile()
service = module.get<MembershipService>(MembershipService)
jest.clearAllMocks()
})
// ─── getActiveCardTypes ─────────────────────────────────────────────────
describe('getActiveCardTypes()', () => {
it('should return only active card types ordered by sortOrder', async () => {
const activeCards = [mockTimesCardType, mockDurationCardType]
mockPrismaService.cardType.findMany.mockResolvedValue(activeCards)
const result = await service.getActiveCardTypes()
expect(result).toEqual(activeCards)
expect(mockPrismaService.cardType.findMany).toHaveBeenCalledWith({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
})
expect(result).not.toContainEqual(mockInactiveCardType)
})
it('should return new object references (immutable)', async () => {
const activeCards = [mockTimesCardType]
mockPrismaService.cardType.findMany.mockResolvedValue(activeCards)
const result = await service.getActiveCardTypes()
expect(result[0]).not.toBe(activeCards[0])
expect(result[0]).toEqual(activeCards[0])
})
})
// ─── getValidMembership ─────────────────────────────────────────────────
describe('getValidMembership()', () => {
it('should return the earliest expiring valid membership', async () => {
const earlyExpiring = {
...mockActiveMembership,
id: 'mem-early',
expireDate: new Date('2025-06-01T00:00:00Z'),
}
mockPrismaService.membership.findFirst.mockResolvedValue(earlyExpiring)
const result = await service.getValidMembership('user-001')
expect(result).not.toBeNull()
expect(result!.id).toBe('mem-early')
expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: 'user-001',
status: MembershipStatus.ACTIVE,
}),
orderBy: { expireDate: 'asc' },
}),
)
})
it('should return null when no valid membership exists', async () => {
mockPrismaService.membership.findFirst.mockResolvedValue(null)
const result = await service.getValidMembership('user-no-membership')
expect(result).toBeNull()
})
it('should include OR condition for remainingTimes > 0 or null', async () => {
mockPrismaService.membership.findFirst.mockResolvedValue(null)
await service.getValidMembership('user-001')
expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
}),
}),
)
})
})
// ─── deductMembership ───────────────────────────────────────────────────
describe('deductMembership()', () => {
it('should decrement remainingTimes for a TIMES card', async () => {
const membership = { ...mockActiveMembership, remainingTimes: 5 }
const updated = { ...membership, remainingTimes: 4 }
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
mockPrismaService.membership.update.mockResolvedValue(updated)
const result = await service.deductMembership('mem-001')
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'mem-001' },
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
expect(result.remainingTimes).toBe(4)
expect(result.status).toBe(MembershipStatus.ACTIVE)
})
it('should set status to USED_UP when remainingTimes reaches 0', async () => {
const membership = { ...mockActiveMembership, remainingTimes: 1 }
const updated = {
...membership,
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
mockPrismaService.membership.update.mockResolvedValue(updated)
const result = await service.deductMembership('mem-001')
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { remainingTimes: 0, status: MembershipStatus.USED_UP },
}),
)
expect(result.status).toBe(MembershipStatus.USED_UP)
expect(result.remainingTimes).toBe(0)
})
it('should not change times for a DURATION card', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
const result = await service.deductMembership('mem-duration-001')
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
expect(result.remainingTimes).toBeNull()
expect(result.status).toBe(MembershipStatus.ACTIVE)
})
it('should throw NotFoundException when membership does not exist', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(null)
await expect(service.deductMembership('non-existent')).rejects.toThrow(NotFoundException)
})
it('should throw BadRequestException when membership is not ACTIVE', async () => {
const usedUpMembership = {
...mockActiveMembership,
status: MembershipStatus.USED_UP,
}
mockPrismaService.membership.findUnique.mockResolvedValue(usedUpMembership)
await expect(service.deductMembership('mem-001')).rejects.toThrow(BadRequestException)
})
})
// ─── restoreMembership ──────────────────────────────────────────────────
describe('restoreMembership()', () => {
it('should increment remainingTimes and restore ACTIVE status from USED_UP', async () => {
const usedUpMembership = {
...mockActiveMembership,
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}
const restored = {
...usedUpMembership,
remainingTimes: 1,
status: MembershipStatus.ACTIVE,
}
mockPrismaService.membership.findUnique.mockResolvedValue(usedUpMembership)
mockPrismaService.membership.update.mockResolvedValue(restored)
const result = await service.restoreMembership('mem-001')
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'mem-001' },
data: { remainingTimes: 1, status: MembershipStatus.ACTIVE },
}),
)
expect(result.remainingTimes).toBe(1)
expect(result.status).toBe(MembershipStatus.ACTIVE)
})
it('should increment remainingTimes while keeping ACTIVE status', async () => {
const activeMembership = { ...mockActiveMembership, remainingTimes: 3 }
const updated = { ...activeMembership, remainingTimes: 4 }
mockPrismaService.membership.findUnique.mockResolvedValue(activeMembership)
mockPrismaService.membership.update.mockResolvedValue(updated)
const result = await service.restoreMembership('mem-001')
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
expect(result.remainingTimes).toBe(4)
})
it('should throw NotFoundException when membership does not exist', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(null)
await expect(service.restoreMembership('non-existent')).rejects.toThrow(NotFoundException)
})
})
// ─── createCardType ────────────────────────────────────────────────────
describe('createCardType()', () => {
it('should create a card type with correct data and return a new object', async () => {
const dto: CreateCardTypeDto = {
name: '体验卡',
type: CardTypeCategory.TRIAL,
totalTimes: 1,
durationDays: 7,
price: 9900,
sortOrder: 0,
}
const created = {
...mockTimesCardType,
id: 'ct-new-001',
name: dto.name,
type: dto.type,
totalTimes: dto.totalTimes!,
durationDays: dto.durationDays,
price: dto.price,
sortOrder: dto.sortOrder!,
}
mockPrismaService.cardType.create.mockResolvedValue(created)
const result = await service.createCardType(dto)
expect(mockPrismaService.cardType.create).toHaveBeenCalledWith({
data: {
name: dto.name,
type: dto.type,
totalTimes: dto.totalTimes,
durationDays: dto.durationDays,
price: dto.price,
originalPrice: null,
description: null,
sortOrder: 0,
},
})
expect(result).toEqual(created)
expect(result).not.toBe(created)
})
it('should default sortOrder to 0 when not provided', async () => {
const dto: CreateCardTypeDto = {
name: '月卡',
type: CardTypeCategory.DURATION,
durationDays: 30,
price: 80000,
}
mockPrismaService.cardType.create.mockResolvedValue({ ...mockDurationCardType })
await service.createCardType(dto)
expect(mockPrismaService.cardType.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ sortOrder: 0 }),
}),
)
})
})
})

View File

@@ -0,0 +1,44 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator'
export class CreateCardTypeDto {
@IsString()
name!: string
@IsEnum(CardTypeCategory)
type!: CardTypeCategory
@IsOptional()
@IsInt()
@Min(1)
totalTimes?: number
@IsInt()
@Min(1)
durationDays!: number
@IsNumber()
@Min(0)
price!: number
@IsOptional()
@IsNumber()
@Min(0)
originalPrice?: number
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number
}

View File

@@ -0,0 +1,48 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator'
export class UpdateCardTypeDto {
@IsOptional()
@IsString()
name?: string
@IsOptional()
@IsEnum(CardTypeCategory)
type?: CardTypeCategory
@IsOptional()
@IsInt()
@Min(1)
totalTimes?: number
@IsOptional()
@IsInt()
@Min(1)
durationDays?: number
@IsOptional()
@IsNumber()
@Min(0)
price?: number
@IsOptional()
@IsNumber()
@Min(0)
originalPrice?: number
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number
}

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
import { MembershipService } from './membership.service'
@Controller()
export class MembershipController {
constructor(private readonly membershipService: MembershipService) {}
// ─── Public ────────────────────────────────────────────────────────────────
@Get('membership/card-types')
getActiveCardTypes() {
return this.membershipService.getActiveCardTypes()
}
// ─── User ──────────────────────────────────────────────────────────────────
@Get('membership/my')
@UseGuards(JwtAuthGuard)
getUserMemberships(@CurrentUser('sub') userId: string) {
return this.membershipService.getUserMemberships(userId)
}
// ─── Admin ─────────────────────────────────────────────────────────────────
@Get('admin/card-types')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
getAllCardTypes() {
return this.membershipService.getAllCardTypes()
}
@Post('admin/card-types')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
createCardType(@Body() dto: CreateCardTypeDto) {
return this.membershipService.createCardType(dto)
}
@Put('admin/card-types/:id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
updateCardType(@Param('id') id: string, @Body() dto: UpdateCardTypeDto) {
return this.membershipService.updateCardType(id, dto)
}
@Delete('admin/card-types/:id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
deleteCardType(@Param('id') id: string) {
return this.membershipService.deleteCardType(id)
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { MembershipController } from './membership.controller'
import { MembershipService } from './membership.service'
@Module({
controllers: [MembershipController],
providers: [MembershipService],
exports: [MembershipService],
})
export class MembershipModule {}

View File

@@ -0,0 +1,172 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { CardType, Membership } from '@prisma/client'
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
@Injectable()
export class MembershipService {
constructor(private readonly prisma: PrismaService) {}
// ─── Public ────────────────────────────────────────────────────────────────
async getActiveCardTypes(): Promise<CardType[]> {
const cardTypes = await this.prisma.cardType.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
})
return cardTypes.map((ct) => ({ ...ct }))
}
// ─── User ──────────────────────────────────────────────────────────────────
async getUserMemberships(userId: string): Promise<(Membership & { cardType: CardType })[]> {
const memberships = await this.prisma.membership.findMany({
where: { userId },
include: { cardType: true },
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
})
return memberships.map((m) => ({ ...m, cardType: { ...m.cardType } }))
}
async getValidMembership(userId: string): Promise<(Membership & { cardType: CardType }) | null> {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
status: MembershipStatus.ACTIVE,
expireDate: { gt: new Date() },
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
},
include: { cardType: true },
orderBy: { expireDate: 'asc' },
})
if (!membership) return null
return { ...membership, cardType: { ...membership.cardType } }
}
async deductMembership(membershipId: string): Promise<Membership & { cardType: CardType }> {
const membership = await this.prisma.membership.findUnique({
where: { id: membershipId },
include: { cardType: true },
})
if (!membership) {
throw new NotFoundException(`Membership ${membershipId} not found`)
}
if (membership.status !== MembershipStatus.ACTIVE) {
throw new BadRequestException(`Membership ${membershipId} is not active`)
}
const isTimeBased =
membership.cardType.type === CardTypeCategory.TIMES ||
membership.cardType.type === CardTypeCategory.TRIAL
if (!isTimeBased) {
// DURATION card: validate expiry only, no times to deduct
return { ...membership, cardType: { ...membership.cardType } }
}
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
const newStatus = newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
const updated = await this.prisma.membership.update({
where: { id: membershipId },
data: {
remainingTimes: newRemainingTimes,
status: newStatus,
},
include: { cardType: true },
})
return { ...updated, cardType: { ...updated.cardType } }
}
async restoreMembership(membershipId: string): Promise<Membership & { cardType: CardType }> {
const membership = await this.prisma.membership.findUnique({
where: { id: membershipId },
include: { cardType: true },
})
if (!membership) {
throw new NotFoundException(`Membership ${membershipId} not found`)
}
const isTimeBased =
membership.cardType.type === CardTypeCategory.TIMES ||
membership.cardType.type === CardTypeCategory.TRIAL
if (!isTimeBased) {
return { ...membership, cardType: { ...membership.cardType } }
}
const newRemainingTimes = (membership.remainingTimes ?? 0) + 1
const newStatus =
membership.status === MembershipStatus.USED_UP
? MembershipStatus.ACTIVE
: membership.status
const updated = await this.prisma.membership.update({
where: { id: membershipId },
data: {
remainingTimes: newRemainingTimes,
status: newStatus,
},
include: { cardType: true },
})
return { ...updated, cardType: { ...updated.cardType } }
}
// ─── Admin ─────────────────────────────────────────────────────────────────
async getAllCardTypes(): Promise<CardType[]> {
const cardTypes = await this.prisma.cardType.findMany({
orderBy: { sortOrder: 'asc' },
})
return cardTypes.map((ct) => ({ ...ct }))
}
async createCardType(dto: CreateCardTypeDto): Promise<CardType> {
const created = await this.prisma.cardType.create({
data: {
name: dto.name,
type: dto.type,
totalTimes: dto.totalTimes ?? null,
durationDays: dto.durationDays,
price: dto.price,
originalPrice: dto.originalPrice ?? null,
description: dto.description ?? null,
sortOrder: dto.sortOrder ?? 0,
},
})
return { ...created }
}
async updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
const existing = await this.prisma.cardType.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException(`CardType ${id} not found`)
}
const updated = await this.prisma.cardType.update({
where: { id },
data: { ...dto },
})
return { ...updated }
}
async deleteCardType(id: string): Promise<CardType> {
const existing = await this.prisma.cardType.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException(`CardType ${id} not found`)
}
const updated = await this.prisma.cardType.update({
where: { id },
data: { isActive: false },
})
return { ...updated }
}
}

View File

@@ -0,0 +1,309 @@
import { Test, TestingModule } from '@nestjs/testing'
import { SlotGeneratorService } from '../slot-generator.service'
import { PrismaService } from '../../prisma/prisma.service'
import {
TimeSlotStatus,
TimeSlotSource,
MembershipStatus,
BookingStatus,
} from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return a Date whose JS getDay() maps to the given ISO weekday (1=Mon…7=Sun) */
function dateForIsoWeekday(isoWeekday: number): Date {
const base = new Date('2026-04-06T00:00:00Z') // Monday
const d = new Date(base)
d.setDate(base.getDate() + (isoWeekday - 1))
return d
}
const makeTemplate = (overrides: Record<string, unknown> = {}) => ({
id: 'tpl-1',
dayOfWeek: 1,
startTime: '09:00',
endTime: '10:00',
capacity: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
weekTemplate: {
findMany: jest.fn(),
},
timeSlot: {
createMany: jest.fn(),
updateMany: jest.fn(),
},
membership: {
updateMany: jest.fn(),
},
booking: {
updateMany: jest.fn(),
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SlotGeneratorService', () => {
let service: SlotGeneratorService
beforeEach(async () => {
jest.clearAllMocks()
const module: TestingModule = await Test.createTestingModule({
providers: [
SlotGeneratorService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile()
service = module.get<SlotGeneratorService>(SlotGeneratorService)
})
// -------------------------------------------------------------------------
// generateSlots
// -------------------------------------------------------------------------
describe('generateSlots', () => {
it('returns 0 when there are no active templates', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([])
const count = await service.generateSlots(7)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('creates correct number of slots from templates', async () => {
// 2 templates, both for Monday (ISO 1)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-1', dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }),
makeTemplate({ id: 'tpl-2', dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
// Use 7 days — will hit exactly one Monday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data, skipDuplicates } =
mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: unknown[]
skipDuplicates: boolean
}
expect(skipDuplicates).toBe(true)
// Both templates should appear in the batch (may include more days)
const mondaySlots = (
data as Array<{ startTime: string; source: TimeSlotSource }>
).filter(
(s) => s.startTime === '09:00' || s.startTime === '10:00',
)
expect(mondaySlots.length).toBeGreaterThanOrEqual(2)
expect(count).toBe(2)
})
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ dayOfWeek: 1 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
await service.generateSlots(7)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
}
expect(call.skipDuplicates).toBe(true)
})
it('maps Sunday (JS getDay()=0) to ISO weekday 7', async () => {
// Template for Sunday (ISO 7)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-sun', dayOfWeek: 7, startTime: '08:00', endTime: '09:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
// 7 days will cover exactly one Sunday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; source: TimeSlotSource }>
}
const sundaySlots = data.filter((s) => s.startTime === '08:00')
expect(sundaySlots.length).toBeGreaterThanOrEqual(1)
expect(sundaySlots[0].source).toBe(TimeSlotSource.TEMPLATE)
expect(count).toBe(1)
})
it('maps Monday (JS getDay()=1) to ISO weekday 1', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-mon', dayOfWeek: 1, startTime: '07:00', endTime: '08:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string }>
}
const mondaySlots = data.filter((s) => s.startTime === '07:00')
expect(mondaySlots.length).toBeGreaterThanOrEqual(1)
})
it('attaches templateId from the matching template', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-xyz', dayOfWeek: 2 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ templateId: string }>
}
const tuesdaySlots = data.filter((s) => s.templateId === 'tpl-xyz')
expect(tuesdaySlots.length).toBeGreaterThanOrEqual(1)
})
})
// -------------------------------------------------------------------------
// cleanupExpiredSlots
// -------------------------------------------------------------------------
describe('cleanupExpiredSlots', () => {
it('marks past OPEN slots as CLOSED', async () => {
mockPrisma.timeSlot.updateMany.mockResolvedValueOnce({ count: 3 })
const count = await service.cleanupExpiredSlots()
expect(count).toBe(3)
expect(mockPrisma.timeSlot.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: TimeSlotStatus.OPEN,
}),
data: { status: TimeSlotStatus.CLOSED },
}),
)
})
it('queries slots with date lt today', async () => {
mockPrisma.timeSlot.updateMany.mockResolvedValueOnce({ count: 0 })
await service.cleanupExpiredSlots()
const where = (mockPrisma.timeSlot.updateMany.mock.calls[0][0] as {
where: { date: { lt: Date } }
}).where
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
// The lt date should be today at midnight (within 1 second of test run)
const diff = Math.abs(where.date.lt.getTime() - today.getTime())
expect(diff).toBeLessThan(1000)
})
})
// -------------------------------------------------------------------------
// checkExpiredMemberships
// -------------------------------------------------------------------------
describe('checkExpiredMemberships', () => {
it('marks expired-date memberships as EXPIRED', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 2 }) // date-expired
.mockResolvedValueOnce({ count: 1 }) // used-up
const total = await service.checkExpiredMemberships()
expect(total).toBe(3)
const firstCall = (
mockPrisma.membership.updateMany.mock.calls[0][0] as {
data: { status: MembershipStatus }
}
).data
expect(firstCall.status).toBe(MembershipStatus.EXPIRED)
})
it('marks zero-remaining memberships as USED_UP', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 4 })
const total = await service.checkExpiredMemberships()
expect(total).toBe(4)
const secondCall = (
mockPrisma.membership.updateMany.mock.calls[1][0] as {
where: { remainingTimes: number }
data: { status: MembershipStatus }
}
)
expect(secondCall.where.remainingTimes).toBe(0)
expect(secondCall.data.status).toBe(MembershipStatus.USED_UP)
})
it('runs both updates in parallel and sums results', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 3 })
.mockResolvedValueOnce({ count: 2 })
const total = await service.checkExpiredMemberships()
expect(total).toBe(5)
expect(mockPrisma.membership.updateMany).toHaveBeenCalledTimes(2)
})
})
// -------------------------------------------------------------------------
// completeBookings
// -------------------------------------------------------------------------
describe('completeBookings', () => {
it('marks past CONFIRMED bookings as COMPLETED', async () => {
mockPrisma.booking.updateMany.mockResolvedValueOnce({ count: 5 })
const count = await service.completeBookings()
expect(count).toBe(5)
expect(mockPrisma.booking.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: BookingStatus.CONFIRMED,
}),
data: { status: BookingStatus.COMPLETED },
}),
)
})
it('filters by timeSlot.date lt today', async () => {
mockPrisma.booking.updateMany.mockResolvedValueOnce({ count: 0 })
await service.completeBookings()
const where = (mockPrisma.booking.updateMany.mock.calls[0][0] as {
where: { timeSlot: { date: { lt: Date } } }
}).where
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const diff = Math.abs(where.timeSlot.date.lt.getTime() - today.getTime())
expect(diff).toBeLessThan(1000)
})
})
})

View File

@@ -0,0 +1,245 @@
import { Test, TestingModule } from '@nestjs/testing'
import { NotFoundException } from '@nestjs/common'
import { TimeSlotService } from '../time-slot.service'
import { PrismaService } from '../../prisma/prisma.service'
import {
TimeSlotStatus,
TimeSlotSource,
BookingStatus,
DEFAULT_SLOT_CAPACITY,
} from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeSlot = (overrides: Record<string, unknown> = {}) => ({
id: 'slot-1',
date: new Date('2026-04-07T00:00:00Z'),
startTime: '09:00',
endTime: '10:00',
capacity: 2,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.TEMPLATE,
templateId: 'tpl-1',
createdAt: new Date('2026-04-01T00:00:00Z'),
updatedAt: new Date('2026-04-01T00:00:00Z'),
bookings: [],
...overrides,
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
timeSlot: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
weekTemplate: {
findMany: jest.fn(),
deleteMany: jest.fn(),
createMany: jest.fn(),
},
$transaction: jest.fn(),
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TimeSlotService', () => {
let service: TimeSlotService
beforeEach(async () => {
jest.clearAllMocks()
const module: TestingModule = await Test.createTestingModule({
providers: [
TimeSlotService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile()
service = module.get<TimeSlotService>(TimeSlotService)
})
// -------------------------------------------------------------------------
// getAvailableSlots
// -------------------------------------------------------------------------
describe('getAvailableSlots', () => {
it('returns slots with isBookedByMe=false when user has no booking', async () => {
const slot = makeSlot({ bookings: [] })
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
expect(result).toHaveLength(1)
expect(result[0].isBookedByMe).toBe(false)
expect(result[0].myBookingId).toBeNull()
})
it('marks isBookedByMe=true and sets myBookingId when user has a CONFIRMED booking', async () => {
const slot = makeSlot({ bookings: [{ id: 'booking-42' }] })
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
expect(result[0].isBookedByMe).toBe(true)
expect(result[0].myBookingId).toBe('booking-42')
})
it('excludes CLOSED slots from query', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
await service.getAvailableSlots('2026-04-07', 'user-1')
const where = (mockPrisma.timeSlot.findMany.mock.calls[0][0] as {
where: { status: { not: TimeSlotStatus } }
}).where
expect(where.status).toEqual({ not: TimeSlotStatus.CLOSED })
})
it('orders results by startTime ascending', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
await service.getAvailableSlots('2026-04-07', 'user-1')
const orderBy = (mockPrisma.timeSlot.findMany.mock.calls[0][0] as {
orderBy: { startTime: string }
}).orderBy
expect(orderBy).toEqual({ startTime: 'asc' })
})
it('returns correct date string (YYYY-MM-DD) in response', async () => {
const slot = makeSlot({ date: new Date('2026-04-07T00:00:00Z'), bookings: [] })
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
expect(result[0].date).toBe('2026-04-07')
})
it('sets isBookedByMe=false when no userId provided', async () => {
const slot = makeSlot({ bookings: [] })
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
// No userId passed
const result = await service.getAvailableSlots('2026-04-07')
expect(result[0].isBookedByMe).toBe(false)
expect(result[0].myBookingId).toBeNull()
})
it('maps multiple slots correctly', async () => {
const slots = [
makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1' }] }),
makeSlot({ id: 'slot-2', startTime: '10:00', bookings: [] }),
]
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(slots)
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
expect(result).toHaveLength(2)
expect(result[0].isBookedByMe).toBe(true)
expect(result[0].myBookingId).toBe('bk-1')
expect(result[1].isBookedByMe).toBe(false)
expect(result[1].myBookingId).toBeNull()
})
})
// -------------------------------------------------------------------------
// getSlotById
// -------------------------------------------------------------------------
describe('getSlotById', () => {
it('returns the slot with bookings when found', async () => {
const slot = makeSlot({ bookings: [] })
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(slot)
const result = await service.getSlotById('slot-1')
expect(result).toEqual(slot)
expect(mockPrisma.timeSlot.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'slot-1' } }),
)
})
it('throws NotFoundException when slot does not exist', async () => {
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(null)
await expect(service.getSlotById('no-such-slot')).rejects.toThrow(
NotFoundException,
)
})
})
// -------------------------------------------------------------------------
// createManualSlot
// -------------------------------------------------------------------------
describe('createManualSlot', () => {
it('creates a slot with MANUAL source', async () => {
const created = makeSlot({ source: TimeSlotSource.MANUAL })
mockPrisma.timeSlot.create.mockResolvedValueOnce(created)
await service.createManualSlot({
date: '2026-04-10',
startTime: '14:00',
endTime: '15:00',
})
const data = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
data: { source: TimeSlotSource }
}).data
expect(data.source).toBe(TimeSlotSource.MANUAL)
})
it('defaults capacity to DEFAULT_SLOT_CAPACITY when not provided', async () => {
mockPrisma.timeSlot.create.mockResolvedValueOnce(makeSlot())
await service.createManualSlot({
date: '2026-04-10',
startTime: '14:00',
endTime: '15:00',
})
const data = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
data: { capacity: number }
}).data
expect(data.capacity).toBe(DEFAULT_SLOT_CAPACITY)
})
})
// -------------------------------------------------------------------------
// closeSlot
// -------------------------------------------------------------------------
describe('closeSlot', () => {
it('sets status to CLOSED', async () => {
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(makeSlot())
mockPrisma.timeSlot.update.mockResolvedValueOnce(
makeSlot({ status: TimeSlotStatus.CLOSED }),
)
await service.closeSlot('slot-1')
expect(mockPrisma.timeSlot.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { status: TimeSlotStatus.CLOSED },
}),
)
})
it('throws NotFoundException when slot does not exist', async () => {
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(null)
await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException)
})
})
})

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsInt, Min, IsDateString } from 'class-validator'
import { Type } from 'class-transformer'
export class CreateManualSlotDto {
@IsDateString()
readonly date!: string
@IsString()
readonly startTime!: string
@IsString()
readonly endTime!: string
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
readonly capacity?: number
}

View File

@@ -0,0 +1,6 @@
import { IsDateString } from 'class-validator'
export class QuerySlotsDto {
@IsDateString()
readonly date!: string
}

View File

@@ -0,0 +1,42 @@
import {
IsInt,
IsString,
IsOptional,
IsBoolean,
Min,
Max,
ValidateNested,
ArrayNotEmpty,
} from 'class-validator'
import { Type } from 'class-transformer'
export class WeekTemplateItemDto {
/** 1 = Monday … 7 = Sunday */
@IsInt()
@Min(1)
@Max(7)
readonly dayOfWeek!: number
@IsString()
readonly startTime!: string
@IsString()
readonly endTime!: string
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
readonly capacity?: number
@IsOptional()
@IsBoolean()
readonly isActive?: boolean
}
export class UpdateWeekTemplateDto {
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => WeekTemplateItemDto)
readonly templates!: WeekTemplateItemDto[]
}

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

View File

@@ -0,0 +1,92 @@
import {
Controller,
Get,
Post,
Put,
Param,
Body,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { UserRole } from '@mp-pilates/shared'
import { TimeSlotService } from './time-slot.service'
import { SlotGeneratorService } from './slot-generator.service'
import { QuerySlotsDto } from './dto/query-slots.dto'
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
// ---------------------------------------------------------------------------
// Member endpoints
// ---------------------------------------------------------------------------
@UseGuards(JwtAuthGuard)
@Controller('time-slot')
export class TimeSlotController {
constructor(private readonly timeSlotService: TimeSlotService) {}
@Get('available')
getAvailableSlots(
@Query() query: QuerySlotsDto,
@CurrentUser('sub') userId: string,
) {
return this.timeSlotService.getAvailableSlots(query.date, userId)
}
@Get(':id')
getSlotById(@Param('id') id: string) {
return this.timeSlotService.getSlotById(id)
}
}
// ---------------------------------------------------------------------------
// Admin endpoints
// ---------------------------------------------------------------------------
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Controller('admin')
export class AdminTimeSlotController {
constructor(
private readonly timeSlotService: TimeSlotService,
private readonly slotGeneratorService: SlotGeneratorService,
) {}
// Week template management
@Get('week-template')
getWeekTemplates() {
return this.timeSlotService.getWeekTemplates()
}
@Put('week-template')
replaceWeekTemplates(@Body() dto: UpdateWeekTemplateDto) {
return this.timeSlotService.replaceWeekTemplates(dto.templates)
}
// Manual slot management
@Post('time-slot/manual')
createManualSlot(@Body() dto: CreateManualSlotDto) {
return this.timeSlotService.createManualSlot(dto)
}
@Put('time-slot/:id/close')
@HttpCode(HttpStatus.OK)
closeSlot(@Param('id') id: string) {
return this.timeSlotService.closeSlot(id)
}
// Slot generation trigger
@Post('generate-slots')
@HttpCode(HttpStatus.OK)
generateSlots() {
return this.slotGeneratorService.generateSlots()
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { TimeSlotController, AdminTimeSlotController } from './time-slot.controller'
import { TimeSlotService } from './time-slot.service'
import { SlotGeneratorService } from './slot-generator.service'
@Module({
controllers: [TimeSlotController, AdminTimeSlotController],
providers: [TimeSlotService, SlotGeneratorService],
exports: [TimeSlotService, SlotGeneratorService],
})
export class TimeSlotModule {}

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