feat: add monthly card periods and apparatus lesson feedback

This commit is contained in:
richarjiang
2026-10-18 15:47:29 +08:00
parent b452de379d
commit a3d58fa8f2
40 changed files with 968 additions and 170 deletions

View File

@@ -230,7 +230,10 @@ describe('AuthService', () => {
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
where: {
userId: USER_ID,
status: MembershipStatus.ACTIVE,
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
startDate: { lte: expect.any(Date) },
expireDate: { gt: expect.any(Date) },
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }],
},
})
expect(result.user.activeMembershipCount).toBe(2)

View File

@@ -1,3 +1,4 @@
import { currentMembershipWhere } from '../membership/membership-query'
import { Inject, Injectable, Optional, UnauthorizedException, forwardRef } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
@@ -99,7 +100,7 @@ export class AuthService {
const activeMembershipCount = await this.prisma.membership.count({
where: {
userId: user.id,
status: MembershipStatus.ACTIVE,
...currentMembershipWhere(),
},
})

View File

@@ -84,6 +84,7 @@ const mockActiveMembership = {
const mockDurationMembership = {
...mockActiveMembership,
id: 'mem-duration-001',
expireDate: new Date('2100-01-01'),
cardTypeId: mockDurationCardType.id,
remainingTimes: null,
cardType: mockDurationCardType,
@@ -138,6 +139,7 @@ const mockStudioConfig = {
function buildTxMock(overrides: Record<string, unknown> = {}) {
return {
$queryRaw: jest.fn(),
timeSlot: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -255,6 +257,54 @@ describe('BookingService', () => {
afterEach(() => jest.clearAllMocks())
describe('monthly period booking boundaries', () => {
const planned = { ...mockLimitedDurationMembership, startDate:new Date('2099-12-30T00:00:00Z'),expireDate:new Date('2100-01-29T00:00:00Z'),status:MembershipStatus.SCHEDULED }
it('lets a paid future period reserve only its own dates and keeps its quota separate',async()=>{
const tx=buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(planned)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})
expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})}))
expect(tx.membership.update).not.toHaveBeenCalled()
})
it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{
const tx=buildTxMock()
const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')}
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(membership)
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
await expect(service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:membership.id})).rejects.toThrow('周期')
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('deducts a reserved future period without activating it ahead of time',async()=>{
const tx=buildTxMock()
tx.booking.findUnique.mockResolvedValue({...mockConfirmedBooking,status:BookingStatus.PENDING_CONFIRMATION,timeSlot:mockOpenSlot,membership:planned,membershipId:planned.id})
tx.booking.update.mockResolvedValue(mockConfirmedBooking)
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
await service.confirmBooking(MOCK_BOOKING_ID,MOCK_ADMIN_ID)
expect(tx.membership.update).toHaveBeenCalledWith({where:{id:planned.id},data:{remainingTimes:4,status:MembershipStatus.SCHEDULED}})
})
it('rejects administrative arrangement outside the selected period',async()=>{
const tx=buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({id:MOCK_USER_ID})
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({...planned,startDate:new Date('2100-01-01T00:00:00Z')})
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID,{userId:MOCK_USER_ID,timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})).rejects.toThrow('周期')
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
})
})
describe('confirmBooking', () => {
it('sends booking confirmed subscription message after admin confirmation', async () => {
const tx = buildTxMock({

View File

@@ -14,6 +14,8 @@ import {
TimeSlotStatus,
type TeachingScheduleSlot,
type PracticeActivity,
canUseMembershipForSlot,
getMembershipEffectiveState,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
@@ -76,6 +78,7 @@ export class BookingService {
dto: CreateBookingDto,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
// 1. Fetch and validate timeSlot
const timeSlot = await tx.timeSlot.findUnique({
where: { id: dto.timeSlotId },
@@ -117,7 +120,7 @@ export class BookingService {
if (membership.userId !== userId) {
throw new ForbiddenException('This membership does not belong to you')
}
if (membership.status !== MembershipStatus.ACTIVE) {
if (membership.status !== MembershipStatus.ACTIVE && !(membership.cardType.type === CardTypeCategory.DURATION && membership.status === MembershipStatus.SCHEDULED)) {
throw new BadRequestException(
`Membership is not active (status: ${membership.status})`,
)
@@ -127,6 +130,7 @@ export class BookingService {
if (membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
let originAssessmentId: string | undefined
if (dto.originAssessmentId) {
@@ -214,6 +218,7 @@ export class BookingService {
remark?: string,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT u.id FROM users u INNER JOIN bookings b ON b.user_id = u.id WHERE b.id = ${bookingId} FOR UPDATE`
// 1. Find booking with timeSlot and membership
const existing = await tx.booking.findUnique({
where: { id: bookingId },
@@ -237,6 +242,7 @@ export class BookingService {
if (existing.membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
if (existing.membership.status === MembershipStatus.EXPIRED) throw new BadRequestException('Membership has expired')
const isCountLimited = existing.membership.remainingTimes !== null
@@ -245,6 +251,7 @@ export class BookingService {
throw new BadRequestException('No remaining times on this membership')
}
}
if (existing.membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(existing.membership, existing.timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
// 3. Update booking status to CONFIRMED
const updated = await tx.booking.update({
@@ -273,8 +280,7 @@ export class BookingService {
// 5. Deduct membership times
if (isCountLimited) {
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
const newMembershipStatus =
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
const newMembershipStatus = getMembershipEffectiveState({ ...existing.membership, remainingTimes: newRemainingTimes, status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE }).status
await tx.membership.update({
where: { id: existing.membershipId },
@@ -309,6 +315,7 @@ export class BookingService {
dto: AdminArrangeBookingDto,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${dto.userId} FOR UPDATE`
const timeSlot = await tx.timeSlot.findUnique({
where: { id: dto.timeSlotId },
})
@@ -354,7 +361,7 @@ export class BookingService {
if (membership.userId !== dto.userId) {
throw new ForbiddenException('This membership does not belong to the member')
}
if (membership.status !== MembershipStatus.ACTIVE) {
if (membership.status !== MembershipStatus.ACTIVE && !(membership.cardType.type === CardTypeCategory.DURATION && membership.status === MembershipStatus.SCHEDULED)) {
throw new BadRequestException(
`Membership is not active (status: ${membership.status})`,
)
@@ -368,6 +375,7 @@ export class BookingService {
if (isCountLimited && (membership.remainingTimes ?? 0) <= 0) {
throw new BadRequestException('No remaining times on this membership')
}
if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
const occupied = await tx.timeSlot.updateMany({
where: {
@@ -423,7 +431,7 @@ export class BookingService {
where: { id: membership.id },
data: {
remainingTimes: newRemainingTimes,
status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
status: getMembershipEffectiveState({ ...membership, remainingTimes: newRemainingTimes, status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE }).status,
},
})
}
@@ -588,6 +596,7 @@ export class BookingService {
const withinLimit = slotStartMs >= deadlineMs
const updatedBooking = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${booking.userId} FOR UPDATE`
const cancelled = await tx.booking.update({
where: { id: bookingId },
data: {
@@ -625,10 +634,10 @@ export class BookingService {
// whether this booking earns a refund.
if (booking.membership.remainingTimes !== null) {
const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1
const newStatus =
booking.membership.status === MembershipStatus.USED_UP
const restoredStatus = booking.membership.status === MembershipStatus.USED_UP
? MembershipStatus.ACTIVE
: booking.membership.status
const newStatus = getMembershipEffectiveState({ ...booking.membership, remainingTimes: newRemainingTimes, status: restoredStatus }).status
await tx.membership.update({
where: { id: booking.membershipId },

View File

@@ -6,6 +6,7 @@ import {
computeMembershipGrant,
getMembershipRenewalHint,
pickRenewalTarget,
getMembershipEffectiveState,
} from '@mp-pilates/shared'
import type { RenewalHintMembership } from '@mp-pilates/shared'
@@ -143,7 +144,23 @@ describe('computeMembershipGrant', () => {
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
})
it('stacks sessions for an active count-limited DURATION card', () => {
it.each([
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.EXPIRED },
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.ACTIVE },
{ expireDate: now.toISOString(), status: MembershipStatus.ACTIVE },
{ expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED },
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.USED_UP },
])('resets an expired 10-session monthly plan with 6 unused sessions: %j', expiry => {
const result = computeMembershipGrant({ existing: { remainingTimes: 6, totalTimes: 10, ...expiry }, cardType: limitedDurationCard, now })
expect(result).toEqual({ startDate: now, isRenewal: true, remainingTimes: 10, totalTimes: 10, expireDate: new Date(now.getTime() + 30 * 86_400_000) })
})
it('does not extend an explicitly expired unlimited monthly plan from a future stored date', () => {
const result = computeMembershipGrant({ existing: { remainingTimes: null, totalTimes: null, expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED }, cardType: durationCard, now })
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
})
it('keeps a renewed monthly quota separate and schedules it after the current period', () => {
const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({
existing: {
@@ -158,13 +175,39 @@ describe('computeMembershipGrant', () => {
expect(result).toMatchObject({
isRenewal: true,
remainingTimes: 13,
totalTimes: 20,
remainingTimes: 10,
totalTimes: 10,
startDate: expireDate,
})
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
})
})
describe('Effective monthly-card benefits', () => {
it('expires unused monthly sessions immediately even before the scheduled status update', () => {
const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, status: MembershipStatus.ACTIVE, expireDate: now.toISOString(), cardType: { type: CardTypeCategory.DURATION } })
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 0 })
expect(original.remainingTimes).toBe(6)
expect(original.status).toBe(MembershipStatus.ACTIVE)
})
it('keeps unused monthly sessions available until the exact expiry instant', () => {
const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, expireDate: new Date(now.getTime() + 1).toISOString(), cardType: { type: CardTypeCategory.DURATION } })
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.ACTIVE, remainingTimes: 6 })
})
it('honors explicit EXPIRED status even when an administrator left a future date', () => {
const original = membership({ cardTypeId: 'monthly', remainingTimes: 6, status: MembershipStatus.EXPIRED, cardType: { type: CardTypeCategory.DURATION } })
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 0 })
})
it('keeps expired times-card balances for history without marking them usable', () => {
const original = membership({ cardTypeId: 'times', remainingTimes: 6, expireDate: now.toISOString() })
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: 6 })
})
it('preserves the unlimited-plan representation after expiry', () => {
const original = membership({ cardTypeId: 'monthly', remainingTimes: null, expireDate: now.toISOString(), cardType: { type: CardTypeCategory.DURATION } })
expect(getMembershipEffectiveState(original, now)).toEqual({ status: MembershipStatus.EXPIRED, remainingTimes: null })
})
})
describe('getMembershipRenewalHint', () => {
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
const hint = getMembershipRenewalHint(
@@ -303,6 +346,13 @@ describe('getMembershipRenewalHint', () => {
})
describe('pickRenewalTarget', () => {
it('queues monthly purchases after the live tail instead of an explicitly expired future-dated record', () => {
const target = pickRenewalTarget([
membership({cardTypeId:'monthly',expireDate:'2099-01-01T00:00:00Z',status:MembershipStatus.EXPIRED,cardType:{type:CardTypeCategory.DURATION}}),
membership({cardTypeId:'monthly',startDate:'2026-07-01T00:00:00Z',expireDate:'2026-07-31T00:00:00Z',status:MembershipStatus.SCHEDULED,cardType:{type:CardTypeCategory.DURATION}}),
],'monthly')
expect(target?.expireDate).toBe('2026-07-31T00:00:00Z')
})
it('picks the latest expiring membership of the same card type', () => {
const target = pickRenewalTarget(
[

View File

@@ -71,6 +71,7 @@ const mockDurationMembership = {
// ─── Mock Prisma ──────────────────────────────────────────────────────────
const mockPrismaService = {
$queryRaw: jest.fn(),
cardType: {
findMany: jest.fn(),
findUnique: jest.fn(),
@@ -133,6 +134,18 @@ describe('MembershipService', () => {
// ─── getValidMembership ─────────────────────────────────────────────────
describe('getUserMemberships()', () => {
it('returns zero effective monthly sessions at expiry without rewriting historical balances', async () => {
const expired = { ...mockDurationMembership, remainingTimes: 6, totalTimes: 10, expireDate: new Date('2020-01-01T00:00:00Z'), status: MembershipStatus.ACTIVE }
mockPrismaService.membership.findMany.mockResolvedValue([expired, mockActiveMembership])
const result = await service.getUserMemberships('user-001')
expect(result[0]).toMatchObject({ remainingTimes: 0, status: MembershipStatus.EXPIRED })
expect(result[1]).toMatchObject({ remainingTimes: mockActiveMembership.remainingTimes, status: MembershipStatus.ACTIVE })
expect(expired.remainingTimes).toBe(6)
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
})
})
describe('getValidMembership()', () => {
it('should return the earliest expiring valid membership', async () => {
const earlyExpiring = {
@@ -150,7 +163,8 @@ describe('MembershipService', () => {
expect.objectContaining({
where: expect.objectContaining({
userId: 'user-001',
status: MembershipStatus.ACTIVE,
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
startDate: { lte: expect.any(Date) },
}),
orderBy: { expireDate: 'asc' },
}),
@@ -422,31 +436,14 @@ describe('MembershipService', () => {
expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
})
it('extends a DURATION card from the current expireDate when still active', async () => {
const existing = {
...mockDurationMembership,
expireDate: new Date('2026-07-01T00:00:00Z'),
}
it('creates an independent scheduled monthly period without changing the current card', async () => {
const existing = { ...mockDurationMembership, expireDate: new Date('2026-07-01T00:00:00Z') }
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.update.mockResolvedValue(existing)
await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: null,
totalTimes: null,
status: MembershipStatus.ACTIVE,
}),
}),
)
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
expireDate: Date
}
expect(updateData.expireDate.getTime()).toBe(
existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
)
mockPrismaService.membership.create.mockImplementation(async ({data}) => ({id:'next-period',...data}))
const result = await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({data: {userId:'user-001',cardTypeId:mockDurationCardType.id,startDate:existing.expireDate,expireDate:new Date('2026-07-31T00:00:00Z'),remainingTimes:null,totalTimes:null,status:MembershipStatus.SCHEDULED}})
expect(result.id).toBe('next-period')
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
})
it('does not carry leftover times when renewing an expired TIMES card', async () => {
@@ -483,6 +480,15 @@ describe('MembershipService', () => {
)
})
it('resets the expired monthly balance from 6 to 10 when the status job has not run', async () => {
const existing = { ...mockDurationMembership, remainingTimes: 6, totalTimes: 10, status: MembershipStatus.ACTIVE, expireDate: new Date('2026-05-01T00:00:00Z') }
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.create.mockResolvedValue({ ...existing, id: 'new-period', remainingTimes: 10 })
await service.grantPurchasedCard(tx, 'user-001', { ...mockDurationCardType, totalTimes: 10 }, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({ data: { userId: 'user-001', cardTypeId: mockDurationCardType.id, startDate: now, remainingTimes: 10, totalTimes: 10, expireDate: new Date('2026-07-01T00:00:00Z'), status: MembershipStatus.ACTIVE } })
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
})
it('always creates a new membership for TRIAL cards', async () => {
const trialCardType = {
...mockTimesCardType,

View File

@@ -0,0 +1,106 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
import { MembershipService } from '../membership.service'
import { PrismaService } from '../../prisma/prisma.service'
import { canUseMembershipForSlot, getMembershipEffectiveState } from '@mp-pilates/shared'
const now = new Date('2026-06-10T00:00:00Z')
const cardType = { id: 'monthly', type: 'DURATION', totalTimes: 10, durationDays: 30 }
function period(id: string, start: string, end: string, remainingTimes = 10, status = 'SCHEDULED') {
return { id, userId: 'member', cardTypeId: 'monthly', remainingTimes, totalTimes: 10, startDate: new Date(start), expireDate: new Date(end), status, createdAt: new Date(start), cardType }
}
const current = () => period('current', '2026-06-01T00:00:00Z', '2026-07-01T00:00:00Z', 0, 'USED_UP')
const next = () => period('next', '2026-07-01T00:00:00Z', '2026-07-31T00:00:00Z')
const later = () => period('later', '2026-07-31T00:00:00Z', '2026-08-30T00:00:00Z')
describe('Independent monthly periods', () => {
function setup() {
const tx = { $queryRaw: jest.fn(), membership: { findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn() }, booking: { findMany: jest.fn().mockResolvedValue([]) } }
const db = { $transaction: jest.fn(async (callback: (value: typeof tx) => unknown) => callback(tx)) }
return { tx, service: new MembershipService(db as unknown as PrismaService) }
}
it('queues repeated purchases in distinct records and never changes the current 6-session balance', async () => {
const { tx, service } = setup()
const old = { ...current(), remainingTimes: 6, status: 'ACTIVE' }
const periods = [old]
tx.membership.findFirst.mockImplementation(async () => periods[periods.length - 1])
tx.membership.create.mockImplementation(async ({data}) => { const created = { ...data, id: 'purchase-' + periods.length, cardType, createdAt: now }; periods.push(created); return created })
await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
expect(periods.map(p => p.remainingTimes)).toEqual([6, 10, 10])
expect(periods[1]).toMatchObject({ startDate: old.expireDate, expireDate: new Date('2026-07-31T00:00:00Z'), status: 'SCHEDULED' })
expect(periods[2]).toMatchObject({ startDate: periods[1].expireDate, expireDate: new Date('2026-08-30T00:00:00Z'), status: 'SCHEDULED' })
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('activates the earliest next period and shifts later periods while preserving their full durations', async () => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue(next())
tx.membership.findMany.mockResolvedValue([current(), next(), later()])
tx.membership.update.mockImplementation(async ({where,data}) => ({ id:where.id,...data }))
const result = await service.activateNextPeriod('member', 'next', now)
expect(result).toMatchObject({ id: 'next', startDate: now, expireDate: new Date('2026-07-10T00:00:00Z'), status: 'ACTIVE' })
expect(tx.membership.update).toHaveBeenCalledWith({ where:{id:'current'},data:{expireDate:now,status:'EXPIRED'} })
expect(tx.membership.update).toHaveBeenCalledWith({ where:{id:'later'},data:{startDate:new Date('2026-07-10T00:00:00Z'),expireDate:new Date('2026-08-09T00:00:00Z'),status:'SCHEDULED'} })
})
it.each([6, null])('does not forfeit a current period with unused or unlimited benefits: %s', async remainingTimes => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue(next())
tx.membership.findMany.mockResolvedValue([{ ...current(), remainingTimes }, next()])
await expect(service.activateNextPeriod('member', 'next', now)).rejects.toBeInstanceOf(BadRequestException)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('prevents another member from activating a period', async () => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue(null)
await expect(service.activateNextPeriod('other', 'next', now)).rejects.toBeInstanceOf(NotFoundException)
expect(tx.membership.findFirst).toHaveBeenCalledWith(expect.objectContaining({where:{id:'next',userId:'other'}}))
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('cannot activate a later period out of order or activate the same period twice', async () => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue(later())
tx.membership.findMany.mockResolvedValue([current(), next(), later()])
await expect(service.activateNextPeriod('member', 'later', now)).rejects.toThrow('顺序')
tx.membership.findFirst.mockResolvedValue({ ...next(), startDate: now, status: 'ACTIVE' })
await expect(service.activateNextPeriod('member', 'next', now)).rejects.toThrow('待生效')
expect(tx.membership.update).not.toHaveBeenCalled()
})
it.each(['current', 'next'])('protects reservations that would be invalidated on %s', async membershipId => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue(next())
tx.membership.findMany.mockResolvedValue([current(), next()])
tx.booking.findMany.mockResolvedValue([{ membershipId, timeSlot:{date:new Date(membershipId === 'current' ? '2026-06-20' : '2026-07-20'),startTime:'09:00',endTime:'10:00'} }])
await expect(service.activateNextPeriod('member', 'next', now)).rejects.toBeInstanceOf(ConflictException)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('keeps valid next-period reservations attached to their original period record', async () => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue(next())
tx.membership.findMany.mockResolvedValue([current(), next()])
tx.booking.findMany.mockResolvedValue([{ membershipId:'next',timeSlot:{date:new Date('2026-07-02'),startTime:'09:00',endTime:'10:00'} }])
tx.membership.update.mockImplementation(async ({where,data}) => ({id:where.id,...data}))
await expect(service.activateNextPeriod('member','next',now)).resolves.toMatchObject({id:'next'})
})
})
describe('Period timestamps and slot eligibility', () => {
const planned = next()
it('automatically exposes a due scheduled period without waiting for the daily job', () => {
expect(getMembershipEffectiveState(planned, new Date('2026-07-01T00:00:00Z'))).toEqual({ status:'ACTIVE',remainingTimes:10 })
expect(getMembershipEffectiveState({...planned,remainingTimes:0},new Date('2026-07-01T00:00:00Z')).status).toBe('USED_UP')
expect(getMembershipEffectiveState(planned, now).status).toBe('SCHEDULED')
})
it.each([
['2026-06-30','23:00','23:50',false],
['2026-07-01','07:00','08:00',false],
['2026-07-01','08:00','09:00',true],
['2026-07-31','07:00','08:00',true],
['2026-07-31','08:00','09:00',false],
])('matches China-local course timestamps to one paid period: %s %s–%s', (date,startTime,endTime,eligible) => {
expect(canUseMembershipForSlot(planned,{date,startTime,endTime},now)).toBe(eligible)
})
it('does not borrow a remaining balance from another period or revive an expired one', () => {
const slot = {date:'2026-07-02',startTime:'09:00',endTime:'10:00'}
expect(canUseMembershipForSlot({...planned,remainingTimes:0},slot,now)).toBe(false)
expect(canUseMembershipForSlot({...planned,status:'EXPIRED'},slot,now)).toBe(false)
})
})

View File

@@ -0,0 +1,25 @@
import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import type { ExecutionContext } from '@nestjs/common'
import type { AddressInfo } from 'node:net'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { MembershipController } from '../membership.controller'
import { MembershipService } from '../membership.service'
describe('Explicit monthly-period activation', () => {
let app: INestApplication
const service = { activateNextPeriod: jest.fn().mockResolvedValue({id:'next'}) }
beforeAll(async () => {
const module = await Test.createTestingModule({controllers:[MembershipController],providers:[{provide:MembershipService,useValue:service}]})
.overrideGuard(JwtAuthGuard).useValue({canActivate(context:ExecutionContext){const req=context.switchToHttp().getRequest();if(!req.headers.authorization)throw new UnauthorizedException();req.user={sub:'owner',role:'MEMBER'};return true}}).compile()
app=module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({whitelist:true,forbidNonWhitelisted:true,transform:true}))
await app.listen(0,'127.0.0.1')
})
afterAll(async()=>{await app.close()})
beforeEach(()=>service.activateNextPeriod.mockClear())
async function request(body:unknown, authenticated=true){const address=app.getHttpServer().address() as AddressInfo;return fetch(`http://127.0.0.1:${address.port}/membership/next/activate`,{method:'POST',headers:{'Content-Type':'application/json',...(authenticated?{Authorization:'test'}:{})},body:JSON.stringify(body)})}
it('requires authentication',async()=>{expect((await request({confirmed:true},false)).status).toBe(401);expect(service.activateNextPeriod).not.toHaveBeenCalled()})
it.each([{}, {confirmed:false}, {confirmed:'true'}, {confirmed:true,userId:'other'}])('rejects missing consent and client-specified ownership: %j',async body=>{expect((await request(body)).status).toBe(400);expect(service.activateNextPeriod).not.toHaveBeenCalled()})
it('uses the authenticated owner only after explicit confirmation',async()=>{expect((await request({confirmed:true})).status).toBe(201);expect(service.activateNextPeriod).toHaveBeenCalledWith('owner','next')})
})

View File

@@ -0,0 +1,6 @@
import { Equals } from 'class-validator'
export class ActivatePeriodDto {
@Equals(true, { message: '请确认提前启用新周期' })
confirmed!: true
}

View File

@@ -0,0 +1,7 @@
import type { Prisma } from '@prisma/client'
import { MembershipStatus } from '@mp-pilates/shared'
/** A scheduled period becomes usable at its timestamp without waiting for cron. */
export function currentMembershipWhere(now = new Date()): Prisma.MembershipWhereInput {
return { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: now }, expireDate: { gt: now }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }
}

View File

@@ -16,6 +16,7 @@ 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'
import { ActivatePeriodDto } from './dto/activate-period.dto'
@Controller()
export class MembershipController {
@@ -36,6 +37,12 @@ export class MembershipController {
return this.membershipService.getUserMemberships(userId)
}
@Post('membership/:id/activate')
@UseGuards(JwtAuthGuard)
activate(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() _dto: ActivatePeriodDto) {
return this.membershipService.activateNextPeriod(userId, id)
}
// ─── Admin ─────────────────────────────────────────────────────────────────
@Get('admin/card-types')

View File

@@ -1,9 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { CardType, Membership, Prisma } from '@prisma/client'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, getMembershipEffectiveState, getStudioSlotRange } 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'
import { currentMembershipWhere } from './membership-query'
@Injectable()
export class MembershipService {
@@ -27,23 +28,22 @@ export class MembershipService {
include: { cardType: true },
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
})
return memberships.map((m) => ({ ...m, cardType: { ...m.cardType } }))
const now = new Date()
return memberships.map((m) => ({ ...m, ...getMembershipEffectiveState(m, now), 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 }],
...currentMembershipWhere(),
},
include: { cardType: true },
orderBy: { expireDate: 'asc' },
})
if (!membership) return null
return { ...membership, cardType: { ...membership.cardType } }
return { ...membership, ...getMembershipEffectiveState(membership), cardType: { ...membership.cardType } }
}
async deductMembership(membershipId: string): Promise<Membership & { cardType: CardType }> {
@@ -55,7 +55,7 @@ export class MembershipService {
if (!membership) {
throw new NotFoundException(`Membership ${membershipId} not found`)
}
if (membership.status !== MembershipStatus.ACTIVE) {
if (getMembershipEffectiveState(membership).status !== MembershipStatus.ACTIVE) {
throw new BadRequestException(`Membership ${membershipId} is not active`)
}
@@ -117,11 +117,13 @@ export class MembershipService {
cardType: Pick<CardType, 'id' | 'type' | 'totalTimes' | 'durationDays'>,
now = new Date(),
): Promise<Membership> {
// Serialize purchases and period activation for the same member.
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
const existing =
cardType.type === CardTypeCategory.TRIAL
? null
: await tx.membership.findFirst({
where: { userId, cardTypeId: cardType.id },
where: { userId, cardTypeId: cardType.id, ...(cardType.type === CardTypeCategory.DURATION ? { status: { not: MembershipStatus.EXPIRED } } : {}) },
orderBy: { expireDate: 'desc' },
})
@@ -135,16 +137,16 @@ export class MembershipService {
now,
})
if (!grant.isRenewal || !existing) {
if (cardType.type === CardTypeCategory.DURATION || !grant.isRenewal || !existing) {
return tx.membership.create({
data: {
userId,
cardTypeId: cardType.id,
startDate: now,
startDate: grant.startDate,
expireDate: grant.expireDate,
remainingTimes: grant.remainingTimes,
totalTimes: grant.totalTimes,
status: MembershipStatus.ACTIVE,
status: grant.startDate > now ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
},
})
}
@@ -160,6 +162,48 @@ export class MembershipService {
})
}
async activateNextPeriod(userId: string, membershipId: string, now = new Date()) {
return this.prisma.$transaction(async tx => {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
const target = await tx.membership.findFirst({ where: { id: membershipId, userId }, include: { cardType: true } })
if (!target) throw new NotFoundException('月卡周期不存在')
if (target.cardType.type !== CardTypeCategory.DURATION || target.startDate <= now || target.status === MembershipStatus.EXPIRED) throw new BadRequestException('只能提前启用待生效的月卡周期')
if (target.remainingTimes === 0) throw new BadRequestException('该周期课时已全部预约,请先检查预约记录')
const periods = await tx.membership.findMany({ where: { userId, cardTypeId: target.cardTypeId, status: { not: MembershipStatus.EXPIRED }, expireDate: { gt: now } }, orderBy: [{ startDate: 'asc' }, { createdAt: 'asc' }] })
const current = periods.filter(period => period.startDate <= now)
const queued = periods.filter(period => period.startDate > now)
if (queued[0]?.id !== membershipId) throw new BadRequestException('请按顺序启用最早的待生效周期')
if (current.some(period => period.remainingTimes !== 0)) throw new BadRequestException('本期课时尚未用完,请继续使用本期权益')
const reservations = await tx.booking.findMany({ where: { membershipId: { in: periods.map(period => period.id) }, status: { in: ['PENDING_CONFIRMATION', 'CONFIRMED'] } }, include: { timeSlot: true } })
const currentIds = new Set(current.map(period => period.id))
if (reservations.some(booking => currentIds.has(booking.membershipId) && getStudioSlotRange(booking.timeSlot.date, booking.timeSlot.startTime, booking.timeSlot.endTime).end > now)) throw new ConflictException('本期仍有未完成的预约,请上完或取消后再启用下一期')
let cursor = now
const shifted = queued.map(period => {
const duration = period.expireDate.getTime() - period.startDate.getTime()
if (duration <= 0) throw new BadRequestException('月卡周期日期无效,请联系馆主')
const dates = { startDate: cursor, expireDate: new Date(cursor.getTime() + duration) }
cursor = dates.expireDate
return { period, ...dates }
})
for (const change of shifted) {
if (reservations.some(booking => {
if (booking.membershipId !== change.period.id) return false
const slot = getStudioSlotRange(booking.timeSlot.date, booking.timeSlot.startTime, booking.timeSlot.endTime)
return slot.start < change.startDate || slot.end > change.expireDate
})) throw new ConflictException('提前启用会影响已约课程的有效期,请先调整相关预约')
}
for (const period of current) await tx.membership.update({ where: { id: period.id }, data: { expireDate: now, status: MembershipStatus.EXPIRED } })
let activated: Membership | undefined
for (const change of shifted) {
const updated = await tx.membership.update({ where: { id: change.period.id }, data: { startDate: change.startDate, expireDate: change.expireDate, status: change.period.id === membershipId ? MembershipStatus.ACTIVE : MembershipStatus.SCHEDULED } })
if (change.period.id === membershipId) activated = updated
}
return activated!
})
}
// ─── Admin ─────────────────────────────────────────────────────────────────
async getAllCardTypes(): Promise<CardType[]> {

View File

@@ -70,6 +70,7 @@ const mockPaymentParams = {
function buildPrismaMock() {
return {
$queryRaw: jest.fn(),
inviteReferral: { findUnique: jest.fn().mockResolvedValue(null) },
cardType: {
findUnique: jest.fn(),
@@ -379,6 +380,19 @@ describe('PaymentService', () => {
)
})
it('grants a fresh monthly period without restoring 6 expired sessions after payment confirmation', async () => {
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type: 'DURATION', durationDays: 30 })
const existing = { id: 'expired-monthly', userId: pendingOrder.userId, cardTypeId: pendingOrder.cardTypeId, remainingTimes: 6, totalTimes: 10, expireDate: new Date('2020-01-01T00:00:00Z'), status: MembershipStatus.ACTIVE }
prisma.membership.findFirst.mockResolvedValue(existing)
prisma.membership.create.mockImplementation(async ({data}) => ({ ...existing, ...data, id: 'new-monthly-period' }))
await service.handleWxNotify(headers, successBody)
const data = prisma.membership.create.mock.calls[0][0].data
expect(data).toMatchObject({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, startDate: expect.any(Date) })
expect(data.expireDate.getTime() - data.startDate.getTime()).toBe(30 * 86_400_000)
expect(prisma.membership.update).not.toHaveBeenCalled()
expect(prisma.order.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ membershipId: 'new-monthly-period' }) }))
})
it('is idempotent: duplicate callback for already-PAID order skips transaction', async () => {
const paidOrder = buildMockOrder({ status: OrderStatus.PAID })
prisma.order.findUnique.mockResolvedValue(paidOrder)

View File

@@ -283,6 +283,7 @@ describe('SlotGeneratorService', () => {
it('marks expired-date memberships as EXPIRED', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 2 }) // date-expired
.mockResolvedValueOnce({ count: 0 }) // activated
.mockResolvedValueOnce({ count: 1 }) // used-up
const total = await service.checkExpiredMemberships()
@@ -299,6 +300,7 @@ describe('SlotGeneratorService', () => {
it('marks zero-remaining memberships as USED_UP', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 4 })
@@ -307,7 +309,7 @@ describe('SlotGeneratorService', () => {
expect(total).toBe(4)
const secondCall = (
mockPrisma.membership.updateMany.mock.calls[1][0] as {
mockPrisma.membership.updateMany.mock.calls[2][0] as {
where: { remainingTimes: number }
data: { status: MembershipStatus }
}
@@ -316,15 +318,16 @@ describe('SlotGeneratorService', () => {
expect(secondCall.data.status).toBe(MembershipStatus.USED_UP)
})
it('runs both updates in parallel and sums results', async () => {
it('expires old periods before activation and sums all transitions', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 3 })
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 2 })
const total = await service.checkExpiredMemberships()
expect(total).toBe(5)
expect(mockPrisma.membership.updateMany).toHaveBeenCalledTimes(2)
expect(total).toBe(6)
expect(mockPrisma.membership.updateMany).toHaveBeenCalledTimes(3)
})
})

View File

@@ -169,24 +169,20 @@ export class SlotGeneratorService {
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 expired = await this.prisma.membership.updateMany({
where: { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED, MembershipStatus.USED_UP] }, expireDate: { lte: now } },
data: { status: MembershipStatus.EXPIRED },
})
const activated = await this.prisma.membership.updateMany({
where: { status: MembershipStatus.SCHEDULED, startDate: { lte: now }, expireDate: { gt: now } },
data: { status: MembershipStatus.ACTIVE },
})
const usedUp = await this.prisma.membership.updateMany({
where: { status: MembershipStatus.ACTIVE, remainingTimes: 0 },
data: { status: MembershipStatus.USED_UP },
})
const total = expired.count + usedUp.count
const total = expired.count + activated.count + usedUp.count
this.logger.log(
`Expired ${expired.count} memberships by date, ${usedUp.count} by sessions`,
)

View File

@@ -0,0 +1,72 @@
import 'reflect-metadata'
import { validate } from 'class-validator'
import { LESSON_APPARATUS } from '@mp-pilates/shared'
import { MemberNoteDto } from '../dto/member-progress.dto'
import { MemberProgressService } from '../member-progress.service'
import { PrismaService } from '../../prisma/prisma.service'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
describe('Coach lesson feedback', () => {
const db = {
user: { findUnique: jest.fn() },
booking: { findFirst: jest.fn() },
memberNote: { create: jest.fn() },
}
let service: MemberProgressService
beforeEach(() => {
jest.resetAllMocks()
db.user.findUnique.mockResolvedValue({ nickname: '学员' })
db.booking.findFirst.mockResolvedValue({ id: 'lesson' })
service = new MemberProgressService(db as unknown as PrismaService, {} as ProgressPhotoStorageService)
})
it('keeps legacy overall-only notes and their visibility', async () => {
await service.addNote('member', 'coach', { content: ' 今天控制力有进步 ', shared: false })
expect(db.memberNote.create).toHaveBeenCalledWith({ data: {
userId: 'member', operatorId: 'coach', content: '今天控制力有进步', apparatusNotes: {}, shared: false, bookingId: null,
} })
})
it.each(LESSON_APPARATUS)('allows %s on its own without an overall note or other apparatus', async apparatus => {
const dto = Object.assign(new MemberNoteDto(), { shared: true, apparatusNotes: { [apparatus]: ' 控制稳定,继续保持 ' }, bookingId: 'lesson' })
expect(await validate(dto)).toHaveLength(0)
await service.addNote('member', 'coach', dto)
expect(db.memberNote.create).toHaveBeenCalledWith({ data: {
userId: 'member', operatorId: 'coach', content: '', apparatusNotes: { [apparatus]: '控制稳定,继续保持' }, shared: true, bookingId: 'lesson',
} })
expect(db.booking.findFirst).toHaveBeenCalledWith({ where: { id: 'lesson', userId: 'member', status: 'COMPLETED' } })
})
it('stores all seven fields independently, preserving line breaks and removing blank fields', async () => {
const apparatusNotes = Object.fromEntries(LESSON_APPARATUS.map(apparatus => [apparatus, apparatus + ' 第一行\n第二行']))
await service.addNote('member', 'coach', { content: '整体反馈', shared: false, apparatusNotes })
expect(db.memberNote.create.mock.calls[0][0].data.apparatusNotes).toEqual(apparatusNotes)
await service.addNote('member', 'coach', { shared: false, apparatusNotes: { M: ' ', R: '稳定', Chair: '' } })
expect(db.memberNote.create.mock.calls[1][0].data.apparatusNotes).toEqual({ R: '稳定' })
})
it.each([{}, { content: '' }, { content: ' \n ', apparatusNotes: { M: ' ', LB: '' } }])('rejects an entirely empty record %j', async patch => {
await expect(service.addNote('member', 'coach', { shared: false, ...patch })).rejects.toThrow('请填写整体评价或至少一项器械记录')
expect(db.memberNote.create).not.toHaveBeenCalled()
})
it.each([null, [], 'M', { unknown: '内容' }, { m: '内容' }, { M: 5 }, { R: null }, { SC: '字'.repeat(1001) }])('rejects malformed apparatus payload %j before saving', async apparatusNotes => {
const dto = Object.assign(new MemberNoteDto(), { shared: false, content: '有效整体评价', apparatusNotes })
expect((await validate(dto)).length).toBeGreaterThan(0)
await expect(service.addNote('member', 'coach', dto)).rejects.toThrow('器械记录内容无效')
expect(db.memberNote.create).not.toHaveBeenCalled()
})
it('accepts the length limit and rejects oversized overall notes and invalid visibility', async () => {
const valid = { content: '字'.repeat(1000), shared: true, apparatusNotes: { TT: '字'.repeat(1000) } }
expect(await validate(Object.assign(new MemberNoteDto(), valid))).toHaveLength(0)
expect((await validate(Object.assign(new MemberNoteDto(), valid, { content: '字'.repeat(1001) }))).length).toBeGreaterThan(0)
expect((await validate(Object.assign(new MemberNoteDto(), valid, { shared: 'true' }))).length).toBeGreaterThan(0)
})
it('rejects foreign or unfinished lessons even for apparatus-only feedback', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.addNote('member', 'coach', { shared: true, bookingId: 'foreign', apparatusNotes: { M: '完成训练' } })).rejects.toThrow('只能批注该学员已完成的课程')
expect(db.memberNote.create).not.toHaveBeenCalled()
})
})

View File

@@ -93,6 +93,8 @@ const mockConfigService = {
// Tests
// ---------------------------------------------------------------------------
const usableWhere = { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }
describe('UserService', () => {
let service: UserService
@@ -125,7 +127,7 @@ describe('UserService', () => {
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
memberships: { where: usableWhere },
},
},
},
@@ -286,7 +288,7 @@ describe('UserService', () => {
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
memberships: { where: usableWhere },
},
},
},
@@ -321,7 +323,7 @@ describe('UserService', () => {
include: {
_count: {
select: {
memberships: { where: { status: MembershipStatus.ACTIVE } },
memberships: { where: usableWhere },
},
},
},
@@ -474,11 +476,11 @@ describe('UserService', () => {
describe('member filters', () => {
it.each([
['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
['ACTIVE', { memberships: { some: usableWhere } }],
['NONE', { NOT: { memberships: { some: usableWhere } } }],
['TIMES', { memberships: { some: { ...usableWhere, cardType: { type: CardTypeCategory.TIMES } } } }],
['DURATION', { memberships: { some: { ...usableWhere, cardType: { type: CardTypeCategory.DURATION } } } }],
['TRIAL', { memberships: { some: { ...usableWhere, cardType: { type: CardTypeCategory.TRIAL } } } }],
[undefined, {}],
])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
mockPrisma.user.findMany.mockResolvedValue([])
@@ -569,6 +571,17 @@ describe('UserService', () => {
])
})
it('shows expired monthly lessons as unavailable in the administrator archive before the daily status job', async () => {
const expired = { ...membership, expireDate: new Date('2020-01-01T00:00:00Z'), cardType: { ...cardType, name: '月卡', type: CardTypeCategory.DURATION, durationDays: 30 } }
mockPrisma.user.findUnique.mockResolvedValue({ ...makeUser(), memberships: [expired] })
mockPrisma.booking.groupBy.mockResolvedValue([])
mockPrisma.booking.findMany.mockResolvedValue([])
mockPrisma.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: null } })
const result = await service.getMemberDetail('user-1')
expect(result.memberships[0]).toMatchObject({ remainingTimes: 0, status: MembershipStatus.EXPIRED })
expect(expired.remainingTimes).toBe(6)
})
it('throws NotFoundException when member does not exist', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null)

View File

@@ -1,5 +1,5 @@
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy } from 'class-validator'
import { isPostureAssessment, PostureAssessment } from '@mp-pilates/shared'
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy, ValidateIf } from 'class-validator'
import { ApparatusNotes, isApparatusNotes, isPostureAssessment, LESSON_NOTE_MAX_LENGTH, PostureAssessment } from '@mp-pilates/shared'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsOptional() @IsNumber() @Min(30) @Max(250) height?: number | null
@@ -12,9 +12,12 @@ export class BodyMetricDto {
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {
@IsString() @MinLength(1) @MaxLength(1000) content!: string
@IsOptional() @IsString() @MaxLength(LESSON_NOTE_MAX_LENGTH) content?: string
@ValidateIf((_object, value) => value !== undefined)
@ValidateBy({ name: 'apparatusNotes', validator: { validate: isApparatusNotes, defaultMessage: () => '器械记录无效,每项最多填写1000字' } })
apparatusNotes?: ApparatusNotes
@IsBoolean() shared!: boolean
@IsOptional() @IsString() bookingId?: string
@IsOptional() @IsString() @MinLength(1) bookingId?: string
}
export class ProgressPhotoDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string

View File

@@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, filledApparatusNotes, isApparatusNotes, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
@Injectable()
export class MemberProgressService {
@@ -54,9 +54,12 @@ export class MemberProgressService {
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)
if (!dto.content.trim()) throw new BadRequestException('请填写笔记内容')
if (dto.apparatusNotes !== undefined && !isApparatusNotes(dto.apparatusNotes)) throw new BadRequestException('器械记录内容无效')
const content = dto.content?.trim() || ''
const apparatusNotes = Object.fromEntries(filledApparatusNotes(dto.apparatusNotes).map(note => [note.apparatus, note.content]))
if (!content && !Object.keys(apparatusNotes).length) throw new BadRequestException('请填写整体评价或至少一项器械记录')
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
return this.prisma.memberNote.create({ data: { userId, operatorId, content: dto.content.trim(), shared: dto.shared, bookingId: dto.bookingId || null } })
return this.prisma.memberNote.create({ data: { userId, operatorId, content, apparatusNotes, shared: dto.shared, bookingId: dto.bookingId || null } })
}
async remove(userId: string, kind: string, id: string) {
if (kind === 'metrics') {

View File

@@ -5,6 +5,7 @@ import {
UserRole,
CardTypeCategory,
SubscriptionMessageScene,
getMembershipEffectiveState,
} from '@mp-pilates/shared'
import type {
PaginatedData,
@@ -23,6 +24,8 @@ import type {
UpdateAdminMemberProfileDto,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import type { Prisma } from '@prisma/client'
import { currentMembershipWhere } from '../membership/membership-query'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
@@ -57,15 +60,16 @@ function serializeMembership(membership: {
}
}): MembershipWithCardType {
const { cardType, ...rest } = membership
const effective = getMembershipEffectiveState(membership)
return {
id: rest.id,
userId: rest.userId,
cardTypeId: rest.cardTypeId,
remainingTimes: rest.remainingTimes,
remainingTimes: effective.remainingTimes,
totalTimes: rest.totalTimes,
startDate: rest.startDate.toISOString(),
expireDate: rest.expireDate.toISOString(),
status: rest.status as MembershipStatus,
status: effective.status,
createdAt: rest.createdAt.toISOString(),
updatedAt: rest.updatedAt.toISOString(),
cardType: {
@@ -167,7 +171,7 @@ export class UserService {
_count: {
select: {
memberships: {
where: { status: MembershipStatus.ACTIVE },
where: currentMembershipWhere(),
},
},
},
@@ -195,7 +199,7 @@ export class UserService {
_count: {
select: {
memberships: {
where: { status: MembershipStatus.ACTIVE },
where: currentMembershipWhere(),
},
},
},
@@ -329,7 +333,7 @@ export class UserService {
_count: {
select: {
memberships: {
where: { status: MembershipStatus.ACTIVE },
where: currentMembershipWhere(),
},
},
},
@@ -355,7 +359,7 @@ export class UserService {
_count: {
select: {
memberships: {
where: { status: MembershipStatus.ACTIVE },
where: currentMembershipWhere(),
},
},
},
@@ -429,12 +433,9 @@ export class UserService {
const where: {
OR?: Array<{ [key: string]: unknown }>
memberships?: {
some: {
status: MembershipStatus
cardType?: { type: CardTypeCategory }
}
some: Prisma.MembershipWhereInput
}
NOT?: { memberships?: { some: { status: MembershipStatus } } }
NOT?: { memberships?: { some: Prisma.MembershipWhereInput } }
} = search
? {
OR: [
@@ -447,13 +448,13 @@ export class UserService {
// ACTIVE and NONE are complementary membership-status filters.
if (cardType === 'NONE') {
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
where.NOT = { memberships: { some: currentMembershipWhere() } }
} else if (cardType === 'ACTIVE') {
where.memberships = { some: { status: MembershipStatus.ACTIVE } }
where.memberships = { some: currentMembershipWhere() }
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
where.memberships = {
some: {
status: MembershipStatus.ACTIVE,
...currentMembershipWhere(),
cardType: { type: cardType as CardTypeCategory },
},
}
@@ -471,7 +472,7 @@ export class UserService {
createdAt: true,
lastLoginAt: true,
memberships: {
where: { status: MembershipStatus.ACTIVE },
where: currentMembershipWhere(),
include: { cardType: { select: { name: true, type: true } } },
orderBy: { expireDate: 'desc' },
take: 1,