perf: 支持课程补录

This commit is contained in:
richarjiang
2026-09-08 15:08:29 +08:00
parent d1f193e13e
commit b4b3caac70
24 changed files with 1074 additions and 945 deletions

View File

@@ -0,0 +1,205 @@
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { LessonSupplementController } from '../lesson-supplement.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { ConflictException, NotFoundException } from '@nestjs/common'
import { Prisma } from '@prisma/client'
import { validate } from 'class-validator'
import { LessonSupplementService } from '../lesson-supplement.service'
import { CreateLessonSupplementDto } from '../dto/create-lesson-supplement.dto'
import { PrismaService } from '../../prisma/prisma.service'
const dto = { requestId: 'supp_request_123456', quantity: 10 }
const makeRecord = (extra = {}) => ({
id: 'supp-1', userId: 'member', ...dto, membershipId: null,
deductedTimes: 0, cardName: null, remark: null, operatorId: 'teacher', operatorName: '老师',
createdAt: new Date('2026-09-08T04:00:00Z'), revokedAt: null, revokedBy: null, ...extra,
})
const makeCard = (extra = {}) => ({
id: 'card', userId: 'member', remainingTimes: 20, status: 'ACTIVE',
expireDate: new Date('2099-01-01'), updatedAt: new Date('2026-09-01'), cardType: { name: '私教次卡' }, ...extra,
})
describe('LessonSupplementService', () => {
let service: LessonSupplementService
let db: any
beforeEach(() => {
db = {
user: { findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: where.id, nickname: '老师' })) },
membership: { findUnique: jest.fn().mockResolvedValue(makeCard()), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
lessonSupplement: {
findUnique: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(makeRecord()),
findMany: jest.fn().mockResolvedValue([makeRecord()]),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve(makeRecord(data))),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
}
db.$transaction = jest.fn(callback => callback(db))
service = new LessonSupplementService(db as PrismaService)
})
it('records ten historical classes without a card, time slot or booking', async () => {
const result = await service.create('member', 'teacher', dto)
expect(result.quantity).toBe(10)
expect(result.deductedTimes).toBe(0)
expect(db.membership.updateMany).not.toHaveBeenCalled()
expect(db.lessonSupplement.create).toHaveBeenCalledWith({ data: expect.objectContaining({ operatorId: 'teacher', operatorName: '老师', membershipId: null }) })
})
it('atomically deducts selected card and preserves the actual deduction snapshot', async () => {
const result = await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
expect(result.deductedTimes).toBe(10)
expect(result.cardName).toBe('私教次卡')
expect(db.$transaction).toHaveBeenCalledTimes(1)
expect(db.membership.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ id: 'card', userId: 'member', remainingTimes: 20 }),
data: { remainingTimes: { decrement: 10 }, status: 'ACTIVE' },
})
})
it('marks an exhausted card used up', async () => {
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: 10 }))
await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('USED_UP')
})
it('allows historical deduction from an expired card without reactivating it', async () => {
db.membership.findUnique.mockResolvedValue(makeCard({ expireDate: new Date('2020-01-01') }))
await service.create('member', 'teacher', { ...dto, membershipId: 'card' })
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('EXPIRED')
})
it.each([
[makeCard({ userId: 'another-member' }), '请选择该学员的会员卡'],
[null, '请选择该学员的会员卡'],
[makeCard({ remainingTimes: 9 }), '会员卡剩余次数不足'],
[makeCard({ remainingTimes: null }), '不限次会员卡无需扣次'],
])('rejects invalid card %j before writing', async (card, message) => {
db.membership.findUnique.mockResolvedValue(card)
await expect(service.create('member', 'teacher', { ...dto, membershipId: 'card' })).rejects.toThrow(message)
expect(db.lessonSupplement.create).not.toHaveBeenCalled()
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('rejects nonexistent members', async () => {
db.user.findUnique.mockResolvedValue(null)
await expect(service.create('missing', 'teacher', dto)).rejects.toBeInstanceOf(NotFoundException)
})
it('fails the transaction if the membership changed concurrently', async () => {
db.membership.updateMany.mockResolvedValue({ count: 0 })
await expect(service.create('member', 'teacher', { ...dto, membershipId: 'card' })).rejects.toBeInstanceOf(ConflictException)
})
it('replays a lost success response without another write or deduction', async () => {
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord())
expect((await service.create('member', 'teacher', dto)).id).toBe('supp-1')
expect(db.$transaction).not.toHaveBeenCalled()
})
it('does not reuse a request identity for a different quantity', async () => {
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord())
await expect(service.create('member', 'teacher', { ...dto, quantity: 20 })).rejects.toBeInstanceOf(ConflictException)
})
it('does not replay a revoked supplement', async () => {
db.lessonSupplement.findUnique.mockResolvedValue(makeRecord({ revokedAt: new Date() }))
await expect(service.create('member', 'teacher', dto)).rejects.toBeInstanceOf(ConflictException)
})
it('recovers a simultaneous duplicate submit after unique-key rejection', async () => {
db.lessonSupplement.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(makeRecord())
db.lessonSupplement.create.mockRejectedValue(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
expect((await service.create('member', 'teacher', dto)).quantity).toBe(10)
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('refunds precisely the snapshot and reactivates an exhausted unexpired card', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: 0, status: 'USED_UP' }))
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany.mock.calls[0][0].data).toEqual({ remainingTimes: { increment: 10 }, status: 'ACTIVE' })
expect(db.lessonSupplement.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'supp-1', userId: 'member', revokedAt: null }, data: { revokedAt: expect.any(Date), revokedBy: 'teacher' } })
})
it('keeps an expired card expired on refund', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.findUnique.mockResolvedValue(makeCard({ expireDate: new Date('2020-01-01') }))
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany.mock.calls[0][0].data.status).toBe('EXPIRED')
})
it('does not refund a non-deducting supplement', async () => {
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('does not double-refund an already revoked record', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ revokedAt: new Date(), deductedTimes: 10, membershipId: 'card' }))
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('does not double-refund a concurrent revocation', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.lessonSupplement.updateMany.mockResolvedValue({ count: 0 })
await service.revoke('member', 'supp-1', 'teacher')
expect(db.membership.updateMany).not.toHaveBeenCalled()
})
it('rejects revocation of another member record', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(null)
await expect(service.revoke('other', 'supp-1', 'teacher')).rejects.toBeInstanceOf(NotFoundException)
expect(db.lessonSupplement.findFirst).toHaveBeenCalledWith({ where: { id: 'supp-1', userId: 'other' } })
})
it('blocks refund after a card is changed to unlimited', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.findUnique.mockResolvedValue(makeCard({ remainingTimes: null }))
await expect(service.revoke('member', 'supp-1', 'teacher')).rejects.toBeInstanceOf(ConflictException)
})
it('fails revocation transaction when a concurrent card update wins', async () => {
db.lessonSupplement.findFirst.mockResolvedValue(makeRecord({ deductedTimes: 10, membershipId: 'card' }))
db.membership.updateMany.mockResolvedValue({ count: 0 })
await expect(service.revoke('member', 'supp-1', 'teacher')).rejects.toBeInstanceOf(ConflictException)
})
it('limits member history to their non-revoked supplements', async () => {
await service.list('member')
expect(db.lessonSupplement.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null })
await service.list('member', true)
expect(db.lessonSupplement.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' })
})
})
describe('CreateLessonSupplementDto', () => {
it.each([0, -1, 1.5, 1000, '10', null, undefined])('rejects invalid quantity %s', async quantity => {
const errors = await validate(Object.assign(new CreateLessonSupplementDto(), dto, { quantity }))
expect(errors.some(error => error.property === 'quantity')).toBe(true)
})
it('accepts a quantity-only supplement', async () => {
expect(await validate(Object.assign(new CreateLessonSupplementDto(), dto))).toEqual([])
})
it('rejects oversized notes and missing retry identity', async () => {
const errors = await validate(Object.assign(new CreateLessonSupplementDto(), { quantity: 10, remark: '字'.repeat(201) }))
expect(errors.map(error => error.property)).toEqual(expect.arrayContaining(['requestId', 'remark']))
})
})
describe('LessonSupplement authorization', () => {
it('requires authentication for all supplement endpoints', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, LessonSupplementController)).toContain(JwtAuthGuard)
})
it.each(['list', 'create', 'revoke'] as const)('restricts %s to teachers/admins', method => {
const handler = LessonSupplementController.prototype[method]
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => LessonSupplementController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -53,6 +53,10 @@ const makeBooking = (
// ---------------------------------------------------------------------------
const mockPrisma = {
lessonSupplement: {
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }),
groupBy: jest.fn().mockResolvedValue([]),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
@@ -374,6 +378,13 @@ describe('UserService', () => {
expect(result.totalBookings).toBe(2)
})
it('adds ten active historical classes only to lifetime total', async () => {
mockPrisma.booking.findMany.mockResolvedValue([makeBooking(dateInMonth(1), '09:00', '10:00')])
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
expect(await service.getStats('user-1')).toEqual({ totalBookings: 11, totalDays: 1, monthBookings: 1, monthDays: 1, monthHours: 1 })
expect(mockPrisma.lessonSupplement.aggregate).toHaveBeenCalledWith({ where: { userId: 'user-1', revokedAt: null }, _sum: { quantity: true } })
})
it('counts distinct dates for totalDays', async () => {
mockPrisma.booking.findMany.mockResolvedValue([
makeBooking(dateInMonth(1), '09:00', '10:00'),
@@ -436,6 +447,16 @@ describe('UserService', () => {
})
})
it('batch-adds supplements to member-list completed counts without inflating reservations', async () => {
mockPrisma.user.findMany.mockResolvedValue([makeUser({ memberships: [] })])
mockPrisma.user.count.mockResolvedValue(1)
mockPrisma.booking.groupBy.mockResolvedValue([{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } }])
mockPrisma.lessonSupplement.groupBy.mockResolvedValueOnce([{ userId: 'user-1', _sum: { quantity: 10 } }])
const result = await service.getMembers(1, 20)
expect(result.items[0]).toMatchObject({ totalBookings: 3, completedBookings: 13 })
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
})
describe('getMemberDetail', () => {
const cardType = {
id: 'ct-1',
@@ -490,6 +511,7 @@ describe('UserService', () => {
},
])
mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } })
const result = await service.getMemberDetail('user-1')
expect(result.user.userId).toBe('user-1')
@@ -498,7 +520,7 @@ describe('UserService', () => {
expect(result.memberships[0].cardType.name).toBe('10次卡')
expect(result.stats).toEqual({
totalBookings: 5,
completedBookings: 3,
completedBookings: 13,
cancelledBookings: 1,
noShowBookings: 1,
})

View File

@@ -0,0 +1,23 @@
import { IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator'
export class CreateLessonSupplementDto {
@IsString()
@Matches(/^[a-zA-Z0-9_-]{16,80}$/)
readonly requestId!: string
@IsInt()
@Min(1)
@Max(999)
readonly quantity!: number
@IsOptional()
@IsString()
@Matches(/\S/)
@MaxLength(191)
readonly membershipId?: string
@IsOptional()
@IsString()
@MaxLength(200)
readonly remark?: string
}

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
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 { LessonSupplementService } from './lesson-supplement.service'
import { CreateLessonSupplementDto } from './dto/create-lesson-supplement.dto'
@Controller()
@UseGuards(JwtAuthGuard)
export class LessonSupplementController {
constructor(private readonly service: LessonSupplementService) {}
@Get('user/lesson-supplements')
listMine(@CurrentUser('sub') userId: string) { return this.service.list(userId) }
@Get('admin/members/:userId/lesson-supplements')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
list(@Param('userId') userId: string) { return this.service.list(userId, true) }
@Post('admin/members/:userId/lesson-supplements')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
create(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: CreateLessonSupplementDto) {
return this.service.create(userId, operatorId, dto)
}
@Post('admin/members/:userId/lesson-supplements/:id/revoke')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
async revoke(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string) {
await this.service.revoke(userId, id, operatorId)
return { revoked: true }
}
}

View File

@@ -0,0 +1,115 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { LessonSupplement, Prisma } from '@prisma/client'
import { LessonSupplementRecord, MembershipStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateLessonSupplementDto } from './dto/create-lesson-supplement.dto'
@Injectable()
export class LessonSupplementService {
constructor(private readonly prisma: PrismaService) {}
private serialize(row: LessonSupplement): LessonSupplementRecord {
return {
id: row.id, requestId: row.requestId, quantity: row.quantity,
deductedTimes: row.deductedTimes, cardName: row.cardName,
remark: row.remark, operatorName: row.operatorName,
createdAt: row.createdAt.toISOString(), revokedAt: row.revokedAt?.toISOString() ?? null,
}
}
async list(userId: string, includeRevoked = false): Promise<LessonSupplementRecord[]> {
const records = await this.prisma.lessonSupplement.findMany({
where: { userId, ...(includeRevoked ? {} : { revokedAt: null }) },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
})
return records.map(row => this.serialize(row))
}
private replay(row: LessonSupplement, dto: CreateLessonSupplementDto) {
if (row.quantity !== dto.quantity || row.membershipId !== (dto.membershipId ?? null)
|| row.remark !== (dto.remark?.trim() || null)) {
throw new ConflictException('这次补录已提交,请刷新记录后再操作')
}
if (row.revokedAt) throw new ConflictException('这次补录已撤销,请重新填写')
return this.serialize(row)
}
async create(userId: string, operatorId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
const key = { userId_requestId: { userId, requestId: dto.requestId } }
// Persisted request identity makes retries safe, including a lost success response.
const existing = await this.prisma.lessonSupplement.findUnique({ where: key })
if (existing) return this.replay(existing, dto)
try {
return await this.prisma.$transaction(async tx => {
const user = await tx.user.findUnique({ where: { id: userId } })
if (!user) throw new NotFoundException('会员不存在')
const operator = await tx.user.findUnique({ where: { id: operatorId } })
if (!operator) throw new NotFoundException('操作人不存在')
const card = dto.membershipId
? await tx.membership.findUnique({ where: { id: dto.membershipId }, include: { cardType: true } })
: null
if (dto.membershipId && (!card || card.userId !== userId)) {
throw new BadRequestException('请选择该学员的会员卡')
}
if (card && (card.remainingTimes === null || card.remainingTimes < dto.quantity)) {
throw new BadRequestException(card.remainingTimes === null ? '不限次会员卡无需扣次,请选择仅补记录' : '会员卡剩余次数不足')
}
// Historical use may belong to an expired card. Its expiry/status stays expired.
const row = await tx.lessonSupplement.create({ data: {
userId, requestId: dto.requestId, quantity: dto.quantity,
membershipId: card?.id ?? null, deductedTimes: card ? dto.quantity : 0,
cardName: card?.cardType.name ?? null, remark: dto.remark?.trim() || null,
operatorId, operatorName: operator.nickname || '老师',
} })
if (card) {
const remaining = card.remainingTimes! - dto.quantity
const changed = await tx.membership.updateMany({
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: {
remainingTimes: { decrement: dto.quantity },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
if (changed.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
}
return this.serialize(row)
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const row = await this.prisma.lessonSupplement.findUnique({ where: key })
if (row) return this.replay(row, dto)
}
throw error
}
}
async revoke(userId: string, id: string, operatorId: string): Promise<void> {
await this.prisma.$transaction(async tx => {
const row = await tx.lessonSupplement.findFirst({ where: { id, userId } })
if (!row) throw new NotFoundException('补录记录不存在')
if (row.revokedAt) return
// Claim once before refunding; concurrent revocations cannot refund twice.
const claimed = await tx.lessonSupplement.updateMany({
where: { id, userId, revokedAt: null }, data: { revokedAt: new Date(), revokedBy: operatorId },
})
if (!claimed.count) return
if (row.deductedTimes > 0) {
if (!row.membershipId) throw new ConflictException('原会员卡不存在,请先核对扣次记录')
const card = await tx.membership.findUnique({ where: { id: row.membershipId } })
if (!card || card.remainingTimes === null) {
throw new ConflictException('原会员卡已变更为不限次或不存在,请先核对会员卡后再撤销')
}
const restored = await tx.membership.updateMany({
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: {
remainingTimes: { increment: row.deductedTimes },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : MembershipStatus.ACTIVE,
},
})
if (restored.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
}
})
}
}

View File

@@ -5,10 +5,13 @@ import { UserController } from './user.controller'
import { UserService } from './user.service'
import { SubscriptionMessageService } from './subscription-message.service'
import { LessonSupplementService } from './lesson-supplement.service'
import { LessonSupplementController } from './lesson-supplement.controller'
@Module({
imports: [AuthModule, ConfigModule],
controllers: [UserController],
providers: [UserService, SubscriptionMessageService],
controllers: [UserController, LessonSupplementController],
providers: [LessonSupplementService, UserService, SubscriptionMessageService],
exports: [UserService, SubscriptionMessageService],
})
export class UserModule {}

View File

@@ -319,6 +319,9 @@ export class UserService {
},
})
const supplements = await this.prisma.lessonSupplement.aggregate({
where: { userId, revokedAt: null }, _sum: { quantity: true },
})
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
@@ -344,7 +347,7 @@ export class UserService {
}, 0)
return {
totalBookings: completedBookings.length,
totalBookings: completedBookings.length + (supplements._sum.quantity ?? 0),
totalDays,
monthBookings: monthBookings.length,
monthDays,
@@ -441,6 +444,14 @@ export class UserService {
statsMap.set(stat.userId, entry)
}
const supplements = userIds.length
? await this.prisma.lessonSupplement.groupBy({
by: ['userId'], where: { userId: { in: userIds }, revokedAt: null },
_sum: { quantity: true },
})
: []
const supplementMap = new Map(supplements.map(row => [row.userId, row._sum.quantity ?? 0]))
const items = users.map((u) => {
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
const active = u.memberships[0]
@@ -456,7 +467,7 @@ export class UserService {
? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory }
: null,
totalBookings: s.total,
completedBookings: s.completed,
completedBookings: s.completed + (supplementMap.get(u.id) ?? 0),
cancelledBookings: s.cancelled,
}
})
@@ -515,6 +526,10 @@ export class UserService {
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
}
const supplements = await this.prisma.lessonSupplement.aggregate({
where: { userId, revokedAt: null }, _sum: { quantity: true },
})
return {
user: {
userId: user.id,
@@ -528,7 +543,7 @@ export class UserService {
memberships: user.memberships.map((membership) => serializeMembership(membership)),
stats: {
totalBookings: stats.total,
completedBookings: stats.completed,
completedBookings: stats.completed + (supplements._sum.quantity ?? 0),
cancelledBookings: stats.cancelled,
noShowBookings: stats.noShow,
},