import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common' import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client' import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared' import type { InviteActivitySummary } from '@mp-pilates/shared' import { PrismaService } from '../prisma/prisma.service' import { INVITE_REWARD_REQUIRED_COUNT, INVITE_REWARD_TIMES, } from './invite.constants' @Injectable() export class InviteService { constructor(private readonly prisma: PrismaService) {} private isTrialCardType(type: string): boolean { return type === 'TRIAL' } async bindInviterToUser(inviteeId: string, inviterId?: string | null): Promise { if (!inviterId || inviterId === inviteeId) { return } const [inviter, inviteeMembershipCount, existingReferral] = await Promise.all([ this.prisma.user.findUnique({ where: { id: inviterId }, select: { id: true } }), this.prisma.membership.count({ where: { userId: inviteeId } }), this.prisma.inviteReferral.findUnique({ where: { inviteeId } }), ]) if (!inviter || inviteeMembershipCount > 0 || existingReferral) { return } await this.prisma.inviteReferral.create({ data: { inviterId, inviteeId, }, }) } async recordTrialOrderPaid(orderId: string): Promise { const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { cardType: true, user: { select: { id: true } }, }, }) if (!order || order.status !== OrderStatus.PAID || !this.isTrialCardType(order.cardType.type)) { return } await this.prisma.inviteReferral.updateMany({ where: { inviteeId: order.user.id, status: InviteReferralStatus.REGISTERED, trialOrderId: null, }, data: { status: InviteReferralStatus.TRIAL_PURCHASED, trialOrderId: order.id, trialPurchasedAt: order.paidAt ?? new Date(), }, }) } async recordQualifiedTrialBooking(bookingId: string): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { membership: { include: { cardType: true } }, }, }) if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) { return } const referral = await this.prisma.inviteReferral.findFirst({ where: { inviteeId: booking.userId, status: { in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED], }, qualifiedBookingId: null, }, orderBy: { createdAt: 'asc' }, }) if (!referral) { return } await this.prisma.inviteReferral.update({ where: { id: referral.id }, data: { status: InviteReferralStatus.QUALIFIED, qualifiedBookingId: booking.id, qualifiedAt: booking.completedAt ?? new Date(), }, }) await this.grantRewardsIfEligible(referral.inviterId) } async getInviteActivitySummary(userId: string): Promise { const memberships = await this.prisma.membership.findMany({ where: { userId }, orderBy: [{ status: 'asc' }, { expireDate: 'desc' }], }) const referrals = await this.prisma.inviteReferral.findMany({ where: { inviterId: userId }, include: { invitee: { select: { id: true, nickname: true, avatarUrl: true, }, }, }, orderBy: { createdAt: 'desc' }, }) const rewardGrants = await this.prisma.inviteRewardGrant.findMany({ where: { inviterId: userId }, orderBy: { grantedAt: 'desc' }, }) const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE) const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0) const pendingRewardGrantCount = Math.max( 0, qualifiedInviteCount - rewardGrants.length * INVITE_REWARD_REQUIRED_COUNT, ) const currentCycleQualifiedCount = qualifiedInviteCount % INVITE_REWARD_REQUIRED_COUNT return { inviterId: userId, canInvite, sharePath: `/pages/profile/invite?inviterId=${userId}`, rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT, rewardRuleTimes: INVITE_REWARD_TIMES, qualifiedInviteCount, rewardedTimes, pendingRewardGrantCount, pendingInviteCount: referrals.filter((item: InviteReferral) => item.status !== InviteReferralStatus.QUALIFIED).length, currentCycleQualifiedCount, nextRewardRemainingCount: currentCycleQualifiedCount === 0 ? INVITE_REWARD_REQUIRED_COUNT : INVITE_REWARD_REQUIRED_COUNT - currentCycleQualifiedCount, referrals: referrals.map((item: InviteReferral & { invitee: { nickname: string; avatarUrl: string | null } }) => ({ id: item.id, inviteeId: item.inviteeId, inviteeNickname: item.invitee.nickname, inviteeAvatarUrl: item.invitee.avatarUrl, status: item.status as InviteReferralStatus, invitedAt: item.invitedAt.toISOString(), trialPurchasedAt: item.trialPurchasedAt?.toISOString() ?? null, qualifiedAt: item.qualifiedAt?.toISOString() ?? null, })), rewardGrants: rewardGrants.map((item: InviteRewardGrant) => ({ id: item.id, membershipId: item.membershipId, qualifiedReferralCount: item.qualifiedReferralCount, rewardTimes: item.rewardTimes, grantedAt: item.grantedAt.toISOString(), })), } } async validateInviterForTrialOrder(userId: string, inviterId?: string): Promise { if (!inviterId) { return } if (inviterId === userId) { throw new BadRequestException('不能邀请自己购买体验课') } const referral = await this.prisma.inviteReferral.findFirst({ where: { inviterId, inviteeId: userId, }, }) if (!referral) { throw new NotFoundException('邀请关系不存在或已失效') } } private async grantRewardsIfEligible(inviterId: string): Promise { const [qualifiedCount, rewardGrantCount] = await Promise.all([ this.prisma.inviteReferral.count({ where: { inviterId, status: InviteReferralStatus.QUALIFIED, }, }), this.prisma.inviteRewardGrant.count({ where: { inviterId } }), ]) const shouldGrantCount = Math.floor(qualifiedCount / INVITE_REWARD_REQUIRED_COUNT) const missingGrantCount = shouldGrantCount - rewardGrantCount if (missingGrantCount <= 0) { return } for (let index = 0; index < missingGrantCount; index += 1) { const targetQualifiedCount = (rewardGrantCount + index + 1) * INVITE_REWARD_REQUIRED_COUNT await this.prisma.$transaction(async (tx) => { const membership = await tx.membership.findFirst({ where: { userId: inviterId, status: MembershipStatus.ACTIVE, }, orderBy: [{ expireDate: 'desc' }, { createdAt: 'desc' }], }) if (!membership) { throw new BadRequestException('邀请人当前没有有效会员卡,无法发放奖励') } await tx.membership.update({ where: { id: membership.id }, data: { remainingTimes: membership.remainingTimes === null ? null : membership.remainingTimes + INVITE_REWARD_TIMES, status: MembershipStatus.ACTIVE, }, }) await tx.inviteRewardGrant.create({ data: { inviterId, membershipId: membership.id, qualifiedReferralCount: targetQualifiedCount, rewardTimes: INVITE_REWARD_TIMES, }, }) }) } } }