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