feat: add monthly card periods and apparatus lesson feedback
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `memberships`
|
||||
MODIFY `status` ENUM('ACTIVE', 'EXPIRED', 'USED_UP', 'SCHEDULED') NOT NULL DEFAULT 'ACTIVE';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `member_notes` ADD COLUMN `apparatus_notes` JSON NULL;
|
||||
@@ -24,6 +24,7 @@ enum MembershipStatus {
|
||||
ACTIVE
|
||||
EXPIRED
|
||||
USED_UP
|
||||
SCHEDULED
|
||||
}
|
||||
|
||||
enum TimeSlotStatus {
|
||||
@@ -404,6 +405,7 @@ model MemberNote {
|
||||
userId String @map("user_id")
|
||||
bookingId String? @map("booking_id")
|
||||
content String @db.VarChar(1000)
|
||||
apparatusNotes Json? @map("apparatus_notes")
|
||||
shared Boolean @default(false)
|
||||
operatorId String @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
packages/server/src/membership/__tests__/monthly-periods.spec.ts
Normal file
106
packages/server/src/membership/__tests__/monthly-periods.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Equals } from 'class-validator'
|
||||
|
||||
export class ActivatePeriodDto {
|
||||
@Equals(true, { message: '请确认提前启用新周期' })
|
||||
confirmed!: true
|
||||
}
|
||||
7
packages/server/src/membership/membership-query.ts
Normal file
7
packages/server/src/membership/membership-query.ts
Normal 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 }] }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
|
||||
72
packages/server/src/user/__tests__/lesson-feedback.spec.ts
Normal file
72
packages/server/src/user/__tests__/lesson-feedback.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user