diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 119cbd5..df16e37 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -5,6 +5,8 @@ import { PrismaModule } from './prisma/prisma.module' import { UserModule } from './user/user.module' import { AuthModule } from './auth/auth.module' import { StudioModule } from './studio/studio.module' +import { TimeSlotModule } from './time-slot/time-slot.module' +import { MembershipModule } from './membership/membership.module' @Module({ imports: [ @@ -16,6 +18,8 @@ import { StudioModule } from './studio/studio.module' AuthModule, UserModule, StudioModule, + TimeSlotModule, + MembershipModule, ], controllers: [AppController], }) diff --git a/packages/server/src/membership/__tests__/membership.service.spec.ts b/packages/server/src/membership/__tests__/membership.service.spec.ts new file mode 100644 index 0000000..98065cd --- /dev/null +++ b/packages/server/src/membership/__tests__/membership.service.spec.ts @@ -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) + 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 }), + }), + ) + }) + }) +}) diff --git a/packages/server/src/membership/dto/create-card-type.dto.ts b/packages/server/src/membership/dto/create-card-type.dto.ts new file mode 100644 index 0000000..5d74202 --- /dev/null +++ b/packages/server/src/membership/dto/create-card-type.dto.ts @@ -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 +} diff --git a/packages/server/src/membership/dto/update-card-type.dto.ts b/packages/server/src/membership/dto/update-card-type.dto.ts new file mode 100644 index 0000000..d073e61 --- /dev/null +++ b/packages/server/src/membership/dto/update-card-type.dto.ts @@ -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 +} diff --git a/packages/server/src/membership/membership.controller.ts b/packages/server/src/membership/membership.controller.ts new file mode 100644 index 0000000..2d1df30 --- /dev/null +++ b/packages/server/src/membership/membership.controller.ts @@ -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) + } +} diff --git a/packages/server/src/membership/membership.module.ts b/packages/server/src/membership/membership.module.ts new file mode 100644 index 0000000..22f7bac --- /dev/null +++ b/packages/server/src/membership/membership.module.ts @@ -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 {} diff --git a/packages/server/src/membership/membership.service.ts b/packages/server/src/membership/membership.service.ts new file mode 100644 index 0000000..bd04fae --- /dev/null +++ b/packages/server/src/membership/membership.service.ts @@ -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 { + 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 { + 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 { + 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 { + const cardTypes = await this.prisma.cardType.findMany({ + orderBy: { sortOrder: 'asc' }, + }) + return cardTypes.map((ct) => ({ ...ct })) + } + + async createCardType(dto: CreateCardTypeDto): Promise { + 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 { + 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 { + 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 } + } +} diff --git a/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts new file mode 100644 index 0000000..c4bddf6 --- /dev/null +++ b/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts @@ -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 = {}) => ({ + 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) + }) + + // ------------------------------------------------------------------------- + // 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) + }) + }) +}) diff --git a/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts b/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts new file mode 100644 index 0000000..da71fff --- /dev/null +++ b/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts @@ -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 = {}) => ({ + 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) + }) + + // ------------------------------------------------------------------------- + // 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) + }) + }) +}) diff --git a/packages/server/src/time-slot/dto/create-manual-slot.dto.ts b/packages/server/src/time-slot/dto/create-manual-slot.dto.ts new file mode 100644 index 0000000..45425de --- /dev/null +++ b/packages/server/src/time-slot/dto/create-manual-slot.dto.ts @@ -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 +} diff --git a/packages/server/src/time-slot/dto/query-slots.dto.ts b/packages/server/src/time-slot/dto/query-slots.dto.ts new file mode 100644 index 0000000..471ceb1 --- /dev/null +++ b/packages/server/src/time-slot/dto/query-slots.dto.ts @@ -0,0 +1,6 @@ +import { IsDateString } from 'class-validator' + +export class QuerySlotsDto { + @IsDateString() + readonly date!: string +} diff --git a/packages/server/src/time-slot/dto/week-template.dto.ts b/packages/server/src/time-slot/dto/week-template.dto.ts new file mode 100644 index 0000000..174396f --- /dev/null +++ b/packages/server/src/time-slot/dto/week-template.dto.ts @@ -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[] +} diff --git a/packages/server/src/time-slot/slot-generator.service.ts b/packages/server/src/time-slot/slot-generator.service.ts new file mode 100644 index 0000000..ffe6f85 --- /dev/null +++ b/packages/server/src/time-slot/slot-generator.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/packages/server/src/time-slot/time-slot.controller.ts b/packages/server/src/time-slot/time-slot.controller.ts new file mode 100644 index 0000000..4b55d62 --- /dev/null +++ b/packages/server/src/time-slot/time-slot.controller.ts @@ -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() + } +} diff --git a/packages/server/src/time-slot/time-slot.module.ts b/packages/server/src/time-slot/time-slot.module.ts new file mode 100644 index 0000000..2f1e194 --- /dev/null +++ b/packages/server/src/time-slot/time-slot.module.ts @@ -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 {} diff --git a/packages/server/src/time-slot/time-slot.service.ts b/packages/server/src/time-slot/time-slot.service.ts new file mode 100644 index 0000000..a564da8 --- /dev/null +++ b/packages/server/src/time-slot/time-slot.service.ts @@ -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 { + 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 + }) + } +}