import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreateLoyaltyRewardRequest, CreateLoyaltyTierRequest, LoyaltyLedgerDto, LoyaltyMemberDto, LoyaltyMessageResult, LoyaltyRewardDto, LoyaltySettingsDto, LoyaltyStatsDto, LoyaltyTierDto, UpdateLoyaltyRewardRequest, UpdateLoyaltySettingsRequest, UpdateLoyaltyTierRequest, } from '@mento/shared'; import ExcelJS from 'exceljs'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { buildSearchWhere, ci } from '../common/search'; import { type Actor, ownerWhere } from '../common/record-scope'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; const DEFAULT_TIERS = [ { name: 'برنزی', minPoints: 0, color: '#c9843a' }, { name: 'نقره‌ای', minPoints: 500, color: '#8b95a5' }, { name: 'طلایی', minPoints: 2000, color: '#d4a017' }, ] as const; const DEFAULT_REWARDS = [ { name: 'تخفیف ویژه', description: 'اعمال روی فاکتور بعدی', pointsCost: 500, color: '#14b8a6', }, { name: 'مشاوره رایگان', description: 'یک جلسه مشاوره اختصاصی', pointsCost: 1000, color: '#8b5cf6', }, { name: 'هدیه باشگاه', description: 'هدیه منتخب باشگاه مشتریان', pointsCost: 2000, color: '#eab308', }, ] as const; const DAY_MS = 86_400_000; @Injectable() export class LoyaltyService { constructor( private readonly prisma: PrismaService, private readonly sms: SmsGatewayService, ) {} async ensureDefaults(organizationId: string): Promise { await Promise.all([ this.ensureDefaultTiers(organizationId), this.ensureDefaultRewards(organizationId), this.ensureSettings(organizationId), ]); const unassigned = await this.prisma.person.count({ where: { organizationId, loyaltyTierId: null }, }); if (unassigned > 0) await this.recomputeAllTiers(organizationId); } async ensureDefaultTiers(organizationId: string): Promise { const count = await this.prisma.loyaltyTier.count({ where: { organizationId }, }); if (count > 0) return; await this.prisma.loyaltyTier.createMany({ data: DEFAULT_TIERS.map((t, position) => ({ organizationId, name: t.name, minPoints: t.minPoints, color: t.color, position, })), }); } async ensureDefaultRewards(organizationId: string): Promise { const count = await this.prisma.loyaltyReward.count({ where: { organizationId }, }); if (count > 0) return; await this.prisma.loyaltyReward.createMany({ data: DEFAULT_REWARDS.map((r, position) => ({ organizationId, name: r.name, description: r.description, pointsCost: r.pointsCost, color: r.color, position, })), }); } async ensureSettings(organizationId: string) { const existing = await this.prisma.loyaltySettings.findUnique({ where: { organizationId }, }); if (existing) return existing; return this.prisma.loyaltySettings.create({ data: { organizationId }, }); } async getSettings(organizationId: string): Promise { const row = await this.ensureSettings(organizationId); return { enabled: row.enabled, welcomePoints: row.welcomePoints, notifyOnAdjust: row.notifyOnAdjust, }; } async updateSettings( organizationId: string, dto: UpdateLoyaltySettingsRequest, ): Promise { await this.ensureSettings(organizationId); const row = await this.prisma.loyaltySettings.update({ where: { organizationId }, data: { ...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}), ...(dto.welcomePoints !== undefined ? { welcomePoints: Math.max(0, Math.floor(dto.welcomePoints)) } : {}), ...(dto.notifyOnAdjust !== undefined ? { notifyOnAdjust: dto.notifyOnAdjust } : {}), }, }); return { enabled: row.enabled, welcomePoints: row.welcomePoints, notifyOnAdjust: row.notifyOnAdjust, }; } async getStats(actor: Actor): Promise { await this.ensureDefaults(actor.organizationId); const scope = { organizationId: actor.organizationId, ...ownerWhere(actor), }; const now = new Date(); const monthStart = startOfMonth(now); const prevMonthStart = startOfMonth(addMonths(monthStart, -1)); const prevMonthEnd = monthStart; const retentionFrom = new Date(now.getTime() - 90 * DAY_MS); const prevRetentionEnd = monthStart; const prevRetentionFrom = new Date(prevRetentionEnd.getTime() - 90 * DAY_MS); const [ memberCount, membersAtMonthStart, pointsThis, pointsPrev, rewardsThis, rewardsPrev, people, ] = await Promise.all([ this.prisma.person.count({ where: scope }), this.prisma.person.count({ where: { ...scope, createdAt: { lt: monthStart } }, }), this.prisma.loyaltyLedger.aggregate({ where: { organizationId: actor.organizationId, delta: { gt: 0 }, createdAt: { gte: monthStart }, }, _sum: { delta: true }, }), this.prisma.loyaltyLedger.aggregate({ where: { organizationId: actor.organizationId, delta: { gt: 0 }, createdAt: { gte: prevMonthStart, lt: prevMonthEnd }, }, _sum: { delta: true }, }), this.prisma.loyaltyRedemption.count({ where: { organizationId: actor.organizationId, createdAt: { gte: monthStart }, }, }), this.prisma.loyaltyRedemption.count({ where: { organizationId: actor.organizationId, createdAt: { gte: prevMonthStart, lt: prevMonthEnd }, }, }), this.prisma.person.findMany({ where: scope, select: { id: true, updatedAt: true }, }), ]); const lastMap = await this.lastActivityMap( actor.organizationId, people.map((p) => p.id), ); const activeNow = people.filter((p) => { const at = lastMap.get(p.id) ?? p.updatedAt; return at >= retentionFrom; }).length; const activePrev = people.filter((p) => { const at = lastMap.get(p.id) ?? p.updatedAt; return at >= prevRetentionFrom && at < prevRetentionEnd; }).length; const retentionRate = memberCount ? Math.round((activeNow / memberCount) * 100) : 0; const prevRetention = membersAtMonthStart ? Math.round((activePrev / membersAtMonthStart) * 100) : 0; const pointsIssued = pointsThis._sum.delta ?? 0; const pointsPrevVal = pointsPrev._sum.delta ?? 0; return { memberCount, memberDeltaPct: pctChange(memberCount, membersAtMonthStart), pointsIssued, pointsIssuedDeltaPct: pctChange(pointsIssued, pointsPrevVal), rewardsUsed: rewardsThis, rewardsUsedDeltaPct: pctChange(rewardsThis, rewardsPrev), retentionRate, retentionDeltaPct: pctChange(retentionRate, prevRetention), }; } async listTiers(organizationId: string): Promise { await this.ensureDefaults(organizationId); const rows = await this.prisma.loyaltyTier.findMany({ where: { organizationId }, orderBy: [{ minPoints: 'desc' }, { position: 'asc' }], include: { _count: { select: { people: true } } }, }); return rows.map((r) => ({ id: r.id, name: r.name, minPoints: r.minPoints, position: r.position, color: r.color, memberCount: r._count.people, })); } async createTier( organizationId: string, dto: CreateLoyaltyTierRequest, ): Promise { await this.ensureDefaults(organizationId); const maxPos = await this.prisma.loyaltyTier.aggregate({ where: { organizationId }, _max: { position: true }, }); const row = await this.prisma.loyaltyTier.create({ data: { organizationId, name: dto.name.trim(), minPoints: Math.max(0, Math.floor(dto.minPoints)), color: emptyToNull(dto.color), position: (maxPos._max.position ?? -1) + 1, }, }); await this.recomputeAllTiers(organizationId); return { id: row.id, name: row.name, minPoints: row.minPoints, position: row.position, color: row.color, memberCount: 0, }; } async updateTier( organizationId: string, id: string, dto: UpdateLoyaltyTierRequest, ): Promise { const existing = await this.prisma.loyaltyTier.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('سطح پیدا نشد'); const row = await this.prisma.loyaltyTier.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), ...(dto.minPoints !== undefined ? { minPoints: Math.max(0, Math.floor(dto.minPoints)) } : {}), ...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}), }, include: { _count: { select: { people: true } } }, }); await this.recomputeAllTiers(organizationId); return { id: row.id, name: row.name, minPoints: row.minPoints, position: row.position, color: row.color, memberCount: row._count.people, }; } async deleteTier(organizationId: string, id: string): Promise { const existing = await this.prisma.loyaltyTier.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('سطح پیدا نشد'); await this.prisma.person.updateMany({ where: { organizationId, loyaltyTierId: id }, data: { loyaltyTierId: null }, }); await this.prisma.loyaltyTier.delete({ where: { id } }); await this.recomputeAllTiers(organizationId); } async listMembers( actor: Actor, q?: string, tierId?: string, ): Promise { await this.ensureDefaults(actor.organizationId); const rows = await this.prisma.person.findMany({ where: this.memberWhere(actor, q, tierId), include: { company: { select: { id: true, name: true } }, loyaltyTier: { select: { id: true, name: true, minPoints: true, color: true }, }, }, orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }], take: 400, }); const lastMap = await this.lastActivityMap( actor.organizationId, rows.map((r) => r.id), ); return rows.map((r) => this.toMemberDto(r, lastMap.get(r.id) ?? r.updatedAt)); } async exportMembers( actor: Actor, q?: string, tierId?: string, ): Promise { const members = await this.listMembers(actor, q, tierId); const wb = new ExcelJS.Workbook(); const sheet = wb.addWorksheet('اعضای باشگاه'); sheet.columns = [ { header: 'نام', key: 'fullName', width: 24 }, { header: 'شرکت', key: 'company', width: 24 }, { header: 'موبایل', key: 'mobile', width: 16 }, { header: 'ایمیل', key: 'email', width: 28 }, { header: 'سطح', key: 'tier', width: 14 }, { header: 'امتیاز', key: 'points', width: 12 }, { header: 'آخرین فعالیت', key: 'lastActivity', width: 22 }, ]; for (const m of members) { sheet.addRow({ fullName: m.fullName, company: m.company?.name ?? '', mobile: m.mobile ?? '', email: m.email ?? '', tier: m.loyaltyTier?.name ?? '', points: m.loyaltyPoints, lastActivity: m.lastActivityAt ?? '', }); } const buf = await wb.xlsx.writeBuffer(); return Buffer.from(buf); } async listLedger( actor: Actor, personId: string, ): Promise { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor), }, select: { id: true }, }); if (!person) throw new NotFoundException('شخص پیدا نشد'); const rows = await this.prisma.loyaltyLedger.findMany({ where: { personId, organizationId: actor.organizationId }, orderBy: { createdAt: 'desc' }, take: 80, }); return rows.map((r) => ({ id: r.id, delta: r.delta, reason: r.reason, source: r.source, createdAt: r.createdAt.toISOString(), })); } async adjustPoints( actor: Actor, personId: string, delta: number, reason?: string, source = 'manual', ): Promise { if (!Number.isFinite(delta) || delta === 0) { throw new BadRequestException('مقدار امتیاز نامعتبر است'); } const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor), }, }); if (!person) throw new NotFoundException('شخص پیدا نشد'); const updated = await this.applyDelta( actor.organizationId, personId, Math.trunc(delta), reason, source, ); const settings = await this.ensureSettings(actor.organizationId); if (settings.notifyOnAdjust && updated.mobile) { const sign = delta > 0 ? '+' : ''; void this.sms.send({ organizationId: actor.organizationId, phone: updated.mobile, text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`, source: 'loyalty', }); } return updated; } async bulkAdjust( actor: Actor, personIds: string[], delta: number, reason?: string, ): Promise<{ updated: number }> { const unique = [...new Set(personIds.filter(Boolean))]; if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده'); if (!Number.isFinite(delta) || delta === 0) { throw new BadRequestException('مقدار امتیاز نامعتبر است'); } let updated = 0; for (const id of unique) { try { await this.adjustPoints(actor, id, delta, reason, 'bulk'); updated += 1; } catch { /* skip inaccessible */ } } return { updated }; } async sendMessage( actor: Actor, personIds: string[], text: string, ): Promise { const unique = [...new Set(personIds.filter(Boolean))]; if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده'); const body = text.trim(); if (body.length < 2) throw new BadRequestException('متن پیام کوتاه است'); const people = await this.prisma.person.findMany({ where: { id: { in: unique }, organizationId: actor.organizationId, ...ownerWhere(actor), }, select: { id: true, mobile: true }, }); let sent = 0; let skipped = 0; let failed = 0; for (const p of people) { if (!p.mobile) { skipped += 1; continue; } const result = await this.sms.send({ organizationId: actor.organizationId, phone: p.mobile, text: body, source: 'loyalty', }); if (result.ok) sent += 1; else failed += 1; } skipped += Math.max(0, unique.length - people.length); return { sent, skipped, failed }; } /** برای اتوماسیون — بدون ACL مالکیت */ async addPointsByOrg( organizationId: string, personId: string, points: number, reason = 'اتوماسیون', ): Promise { if (!personId || !points) return; const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, }); if (!person) return; await this.applyDelta(organizationId, personId, Math.trunc(points), reason, 'automation'); } async applyWelcomePoints(organizationId: string, personId: string) { const settings = await this.ensureSettings(organizationId); if (!settings.enabled || settings.welcomePoints <= 0) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, select: { loyaltyPoints: true }, }); if (!person) return; const tierId = await this.resolveTierId(organizationId, person.loyaltyPoints); await this.prisma.person.update({ where: { id: personId }, data: { loyaltyTierId: tierId }, }); return; } await this.applyDelta( organizationId, personId, settings.welcomePoints, 'امتیاز خوش‌آمدگویی', 'welcome', ); } async resolveTierId( organizationId: string, points: number, ): Promise { await this.ensureDefaultTiers(organizationId); const tier = await this.prisma.loyaltyTier.findFirst({ where: { organizationId, minPoints: { lte: points } }, orderBy: { minPoints: 'desc' }, }); return tier?.id ?? null; } async listRewards(organizationId: string): Promise { await this.ensureDefaults(organizationId); const rows = await this.prisma.loyaltyReward.findMany({ where: { organizationId }, orderBy: [{ position: 'asc' }, { pointsCost: 'asc' }], include: { _count: { select: { redemptions: true } } }, }); return rows.map((r) => this.toRewardDto(r)); } async createReward( organizationId: string, dto: CreateLoyaltyRewardRequest, ): Promise { await this.ensureDefaults(organizationId); const maxPos = await this.prisma.loyaltyReward.aggregate({ where: { organizationId }, _max: { position: true }, }); const row = await this.prisma.loyaltyReward.create({ data: { organizationId, name: dto.name.trim(), description: emptyToNull(dto.description), pointsCost: Math.max(1, Math.floor(dto.pointsCost)), isActive: dto.isActive ?? true, color: emptyToNull(dto.color), position: (maxPos._max.position ?? -1) + 1, }, include: { _count: { select: { redemptions: true } } }, }); return this.toRewardDto(row); } async updateReward( organizationId: string, id: string, dto: UpdateLoyaltyRewardRequest, ): Promise { const existing = await this.prisma.loyaltyReward.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('پاداش پیدا نشد'); const row = await this.prisma.loyaltyReward.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), ...('description' in dto ? { description: emptyToNull(dto.description ?? null) } : {}), ...(dto.pointsCost !== undefined ? { pointsCost: Math.max(1, Math.floor(dto.pointsCost)) } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), ...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}), }, include: { _count: { select: { redemptions: true } } }, }); return this.toRewardDto(row); } async deleteReward(organizationId: string, id: string): Promise { const existing = await this.prisma.loyaltyReward.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('پاداش پیدا نشد'); const used = await this.prisma.loyaltyRedemption.count({ where: { rewardId: id }, }); if (used > 0) { await this.prisma.loyaltyReward.update({ where: { id }, data: { isActive: false }, }); return; } await this.prisma.loyaltyReward.delete({ where: { id } }); } async redeemReward( actor: Actor, rewardId: string, personId: string, ): Promise { const reward = await this.prisma.loyaltyReward.findFirst({ where: { id: rewardId, organizationId: actor.organizationId }, }); if (!reward || !reward.isActive) { throw new NotFoundException('پاداش پیدا نشد'); } const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor), }, }); if (!person) throw new NotFoundException('شخص پیدا نشد'); if (person.loyaltyPoints < reward.pointsCost) { throw new BadRequestException('امتیاز عضو برای این پاداش کافی نیست'); } await this.prisma.loyaltyRedemption.create({ data: { organizationId: actor.organizationId, personId, rewardId, pointsSpent: reward.pointsCost, }, }); return this.applyDelta( actor.organizationId, personId, -reward.pointsCost, `پاداش: ${reward.name}`, 'reward', ); } private async applyDelta( organizationId: string, personId: string, delta: number, reason: string | undefined, source: string, ): Promise { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, }); if (!person) throw new NotFoundException('شخص پیدا نشد'); const next = Math.max(0, person.loyaltyPoints + delta); const tierId = await this.resolveTierId(organizationId, next); const [updated] = await this.prisma.$transaction([ this.prisma.person.update({ where: { id: personId }, data: { loyaltyPoints: next, loyaltyTierId: tierId }, include: { company: { select: { id: true, name: true } }, loyaltyTier: { select: { id: true, name: true, minPoints: true, color: true }, }, }, }), this.prisma.loyaltyLedger.create({ data: { organizationId, personId, delta, reason: emptyToNull(reason), source, }, }), ]); return this.toMemberDto(updated, updated.updatedAt); } private memberWhere( actor: Actor, q?: string, tierId?: string, ): Prisma.PersonWhereInput { const search = buildSearchWhere(q, (t) => [ { firstName: ci(t) }, { lastName: ci(t) }, { mobile: { contains: t } }, { phone: { contains: t } }, { email: ci(t) }, { sourceRef: ci(t) }, { company: { name: ci(t) } }, ]); return { organizationId: actor.organizationId, ...ownerWhere(actor), ...(tierId ? { loyaltyTierId: tierId } : {}), ...(search ? { AND: [search] } : {}), }; } private async lastActivityMap(organizationId: string, personIds: string[]) { const map = new Map(); if (!personIds.length) return map; const rows = await this.prisma.activity.groupBy({ by: ['personId'], where: { organizationId, personId: { in: personIds }, }, _max: { updatedAt: true, completedAt: true, createdAt: true }, }); for (const r of rows) { if (!r.personId) continue; const dates = [r._max.updatedAt, r._max.completedAt, r._max.createdAt].filter( (d): d is Date => !!d, ); if (!dates.length) continue; map.set(r.personId, new Date(Math.max(...dates.map((d) => d.getTime())))); } return map; } private async recomputeAllTiers(organizationId: string) { const people = await this.prisma.person.findMany({ where: { organizationId }, select: { id: true, loyaltyPoints: true }, }); const tiers = await this.prisma.loyaltyTier.findMany({ where: { organizationId }, orderBy: { minPoints: 'desc' }, }); for (const p of people) { const tier = tiers.find((t) => t.minPoints <= p.loyaltyPoints) ?? null; await this.prisma.person.update({ where: { id: p.id }, data: { loyaltyTierId: tier?.id ?? null }, }); } } private toMemberDto( r: { id: string; firstName: string; lastName: string; mobile: string | null; email: string | null; loyaltyPoints: number; loyaltyTier?: LoyaltyMemberDto['loyaltyTier']; company?: LoyaltyMemberDto['company']; }, lastActivity: Date, ): LoyaltyMemberDto { return { id: r.id, fullName: `${r.firstName} ${r.lastName}`.trim(), mobile: r.mobile, email: r.email, loyaltyPoints: r.loyaltyPoints, loyaltyTier: r.loyaltyTier ?? null, company: r.company ?? null, lastActivityAt: lastActivity.toISOString(), }; } private toRewardDto(r: { id: string; name: string; description: string | null; pointsCost: number; isActive: boolean; position: number; color: string | null; _count: { redemptions: number }; }): LoyaltyRewardDto { return { id: r.id, name: r.name, description: r.description, pointsCost: r.pointsCost, isActive: r.isActive, position: r.position, color: r.color, redemptionCount: r._count.redemptions, }; } } function emptyToNull(v?: string | null) { if (v == null) return null; const t = v.trim(); return t ? t : null; } function startOfMonth(d: Date) { return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0); } function addMonths(d: Date, n: number) { return new Date(d.getFullYear(), d.getMonth() + n, 1, 0, 0, 0, 0); } function pctChange(current: number, previous: number): number | null { if (previous <= 0 && current <= 0) return 0; if (previous <= 0) return current > 0 ? 100 : 0; return Math.round(((current - previous) / previous) * 100); }