import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import type { GamificationBadgeDto, GamificationBadgeMetric, GamificationBonusDto, GamificationBonusKind, GamificationRedemptionDto, GamificationReportDto, GamificationReportUserRow, GamificationRewardDto, GamificationRoiMetric, GamificationRuleDto, GamificationSettingsDto, GamificationTxDto, } from '@mento/shared'; import { GAMIFICATION_BADGE_METRICS, GAMIFICATION_BONUS_KINDS, GAMIFICATION_EVENTS, isGamificationEvent, } from '@mento/shared'; import { randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { type Actor, elevated } from '../common/record-scope'; import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates'; import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine'; import { GamificationService, parsePeriod, toRuleDto } from './gamification.service'; import type { AdjustDto, DecisionDto, SaveBadgeDto, SaveBonusDto, SaveRewardDto, SaveRuleDto, UpdateSettingsDto, } from './dto'; const ROLES = ['owner', 'admin', 'sales', 'support']; const ROI_WINDOW_DAYS = 30; function optInt(v: number | null | undefined): number | null { if (v == null) return null; const n = Math.floor(Number(v)); return Number.isFinite(n) && n > 0 ? n : null; } @Injectable() export class GamificationAdminService { constructor( private readonly prisma: PrismaService, private readonly game: GamificationService, private readonly notifications: NotificationsService, ) {} async assertAdmin(actor: Actor) { if (!elevated(actor.role)) { throw new ForbiddenException('فقط مدیر سیستم به این بخش دسترسی دارد'); } await this.game.requireEnabled(actor); } /* ───────── قوانین ───────── */ private toRuleDto = toRuleDto; private ruleData(dto: SaveRuleDto) { if (!isGamificationEvent(dto.event)) throw new BadRequestException('رویداد نامعتبر است'); if (!dto.score && !dto.coins) throw new BadRequestException('امتیاز یا سکه باید بیشتر از صفر باشد'); return { name: dto.name.trim(), description: dto.description?.trim() || null, event: dto.event, score: Math.trunc(dto.score), coins: Math.trunc(dto.coins), conditions: sanitizeConditions(dto.conditions) as unknown as Prisma.InputJsonValue, isActive: dto.isActive ?? true, maxPerDay: optInt(dto.maxPerDay), maxCoinsPerDay: optInt(dto.maxCoinsPerDay), maxPerUser: optInt(dto.maxPerUser), cooldownMinutes: optInt(dto.cooldownMinutes), oncePerEntity: dto.oncePerEntity ?? false, firstTimeOnly: dto.firstTimeOnly ?? false, requiresApproval: dto.requiresApproval ?? false, roles: (dto.roles ?? []).filter((r) => ROLES.includes(r)), teamIds: (dto.teamIds ?? []).filter((t) => typeof t === 'string' && t).slice(0, 50), }; } async listRules(actor: Actor): Promise { await this.assertAdmin(actor); const [rows, counts] = await Promise.all([ this.prisma.gamificationRule.findMany({ where: { organizationId: actor.organizationId }, orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], }), this.prisma.gamificationTransaction.groupBy({ by: ['ruleId'], where: { organizationId: actor.organizationId, kind: 'rule', status: 'approved' }, _count: { _all: true }, }), ]); const countMap = new Map(counts.map((c) => [c.ruleId ?? '', c._count._all])); return rows.map((r) => this.toRuleDto(r, countMap.get(r.id) ?? 0)); } private async findRule(actor: Actor, id: string) { const row = await this.prisma.gamificationRule.findFirst({ where: { id, organizationId: actor.organizationId }, }); if (!row) throw new NotFoundException('قانون پیدا نشد'); return row; } async createRule(actor: Actor, dto: SaveRuleDto): Promise { await this.assertAdmin(actor); const max = await this.prisma.gamificationRule.aggregate({ where: { organizationId: actor.organizationId }, _max: { position: true }, }); const row = await this.prisma.gamificationRule.create({ data: { organizationId: actor.organizationId, ...this.ruleData(dto), position: (max._max.position ?? -1) + 1, }, }); this.game.invalidate(actor.organizationId); return this.toRuleDto(row); } async updateRule(actor: Actor, id: string, dto: SaveRuleDto): Promise { await this.assertAdmin(actor); await this.findRule(actor, id); const row = await this.prisma.gamificationRule.update({ where: { id }, data: this.ruleData(dto) }); this.game.invalidate(actor.organizationId); return this.toRuleDto(row); } async toggleRule(actor: Actor, id: string, isActive: boolean): Promise { await this.assertAdmin(actor); await this.findRule(actor, id); const row = await this.prisma.gamificationRule.update({ where: { id }, data: { isActive } }); this.game.invalidate(actor.organizationId); return this.toRuleDto(row); } async duplicateRule(actor: Actor, id: string): Promise { await this.assertAdmin(actor); const src = await this.findRule(actor, id); const { id: _id, createdAt: _c, updatedAt: _u, conditions, ...rest } = src; const row = await this.prisma.gamificationRule.create({ data: { ...rest, conditions: conditions as Prisma.InputJsonValue, name: `${src.name} (کپی)`, isActive: false, position: src.position + 1, }, }); this.game.invalidate(actor.organizationId); return this.toRuleDto(row); } async deleteRule(actor: Actor, id: string): Promise { await this.assertAdmin(actor); await this.findRule(actor, id); await this.prisma.gamificationRule.delete({ where: { id } }); this.game.invalidate(actor.organizationId); } /* ───────── تنظیم دستی ───────── */ async adjust(actor: Actor, field: 'score' | 'coins', dto: AdjustDto): Promise { await this.assertAdmin(actor); const organizationId = actor.organizationId; const amount = Math.trunc(dto.amount); if (!amount) throw new BadRequestException('مقدار نمی‌تواند صفر باشد'); const reason = dto.reason.trim(); if (!reason) throw new BadRequestException('ثبت دلیل الزامی است'); const member = (await this.game.members(organizationId)).find((m) => m.userId === dto.userId); if (!member) throw new NotFoundException('کاربر پیدا نشد'); if (field === 'coins' && amount < 0) { const wallet = await this.prisma.gamificationWallet.findUnique({ where: { organizationId_userId: { organizationId, userId: dto.userId } }, }); if ((wallet?.coins ?? 0) + amount < 0) { throw new BadRequestException('موجودی سکهٔ کاربر کافی نیست'); } } const label = field === 'score' ? 'امتیاز' : 'سکه'; const tx = await this.game.record({ organizationId, userId: dto.userId, kind: 'adjust', score: field === 'score' ? amount : 0, coins: field === 'coins' ? amount : 0, title: `${amount > 0 ? 'افزایش' : 'کاهش'} دستی ${label}`, reason, createdById: actor.userId, idempotencyKey: `adj:${randomUUID()}`, }); if (!tx) throw new BadRequestException('ثبت انجام نشد'); if (amount > 0) await this.game.afterAward(organizationId, dto.userId, null); await this.notifications.create({ organizationId, userId: dto.userId, type: 'gamification_adjust', title: `${amount > 0 ? '+' : ''}${amount} ${label}`, body: reason, link: '/club?tab=history', entityType: 'gamification_tx', entityId: tx.id, }); return (await this.game.toTxDtos(organizationId, [tx]))[0]!; } /* ───────── تأیید امتیازهای معلق ───────── */ async pending(actor: Actor): Promise { await this.assertAdmin(actor); const rows = await this.prisma.gamificationTransaction.findMany({ where: { organizationId: actor.organizationId, status: 'pending' }, orderBy: { createdAt: 'asc' }, take: 200, }); return this.game.toTxDtos(actor.organizationId, rows); } async decideTx(actor: Actor, id: string, approve: boolean, dto: DecisionDto): Promise { await this.assertAdmin(actor); const organizationId = actor.organizationId; const row = await this.prisma.$transaction(async (db) => { const changed = await db.gamificationTransaction.updateMany({ where: { id, organizationId, status: 'pending' }, data: { status: approve ? 'approved' : 'rejected', decidedById: actor.userId, decidedAt: new Date(), ...(dto.note?.trim() ? { reason: dto.note.trim() } : {}), }, }); if (!changed.count) throw new BadRequestException('این مورد قبلاً بررسی شده است'); const tx = await db.gamificationTransaction.findUniqueOrThrow({ where: { id } }); if (approve) await this.game.applyWallet(db, organizationId, tx.userId, tx.score, tx.coins, tx.kind); return tx; }); this.game.invalidateBoard(organizationId); if (approve) { await this.game.afterAward(organizationId, row.userId, isGamificationEvent(row.event) ? row.event : null); } await this.notifications.create({ organizationId, userId: row.userId, type: 'gamification_decision', title: approve ? `امتیاز «${row.title}» تأیید شد` : `امتیاز «${row.title}» تأیید نشد`, body: dto.note?.trim() || null, link: '/club?tab=history', entityType: 'gamification_tx', entityId: row.id, }); return (await this.game.toTxDtos(organizationId, [row]))[0]!; } /* ───────── درخواست‌های پاداش ───────── */ async redemptions(actor: Actor, status?: string): Promise { await this.assertAdmin(actor); const rows = await this.prisma.gamificationRedemption.findMany({ where: { organizationId: actor.organizationId, ...(status ? { status } : {}) }, include: { reward: true }, orderBy: { createdAt: 'desc' }, take: 200, }); return this.game.toRedemptionDtos(actor.organizationId, rows); } async decideRedemption( actor: Actor, id: string, action: 'approve' | 'reject' | 'deliver', dto: DecisionDto, ): Promise { await this.assertAdmin(actor); const organizationId = actor.organizationId; const allowedFrom: Record = { approve: ['pending'], reject: ['pending', 'approved'], deliver: ['pending', 'approved'], }; const next = action === 'approve' ? 'approved' : action === 'reject' ? 'rejected' : 'delivered'; const row = await this.prisma.$transaction(async (db) => { const changed = await db.gamificationRedemption.updateMany({ where: { id, organizationId, status: { in: allowedFrom[action] } }, data: { status: next, decidedById: actor.userId, decidedAt: new Date(), decisionNote: dto.note?.trim() || null, }, }); if (!changed.count) throw new BadRequestException('وضعیت این درخواست قابل تغییر نیست'); const red = await db.gamificationRedemption.findUniqueOrThrow({ where: { id }, include: { reward: true }, }); if (action === 'reject') { await db.gamificationTransaction.create({ data: { organizationId, userId: red.userId, kind: 'refund', coins: red.cost, status: 'approved', title: `بازگشت سکهٔ پاداش «${red.reward.name}»`, entityType: 'redemption', entityId: red.id, idempotencyKey: `refund:${red.id}`, reason: dto.note?.trim() || null, createdById: actor.userId, }, }); await this.game.applyWallet(db, organizationId, red.userId, 0, red.cost, 'refund'); if (red.reward.stock != null) { await db.gamificationReward.update({ where: { id: red.rewardId }, data: { stock: { increment: 1 } }, }); } } return red; }); const titles = { approve: `درخواست «${row.reward.name}» تأیید شد`, reject: `درخواست «${row.reward.name}» رد شد و سکه‌ها برگشت`, deliver: `پاداش «${row.reward.name}» تحویل شد`, }; await this.notifications.create({ organizationId, userId: row.userId, type: 'gamification_redemption', title: titles[action], body: dto.note?.trim() || null, link: '/club?tab=store', entityType: 'gamification_redemption', entityId: row.id, }); return (await this.game.toRedemptionDtos(organizationId, [row]))[0]!; } /* ───────── پاداش‌ها ───────── */ async listRewards(actor: Actor): Promise { await this.assertAdmin(actor); const rows = await this.prisma.gamificationReward.findMany({ where: { organizationId: actor.organizationId }, orderBy: [{ position: 'asc' }, { cost: 'asc' }], }); return rows.map((r) => this.game.toRewardDto(r)); } private rewardData(dto: SaveRewardDto) { return { name: dto.name.trim(), description: dto.description?.trim() || null, cost: Math.max(1, Math.trunc(dto.cost)), icon: dto.icon?.trim() || 'gift', color: dto.color?.trim() || '#14b8a6', stock: dto.stock == null ? null : Math.max(0, Math.trunc(dto.stock)), isActive: dto.isActive ?? true, requiresApproval: dto.requiresApproval ?? true, }; } async createReward(actor: Actor, dto: SaveRewardDto) { await this.assertAdmin(actor); const count = await this.prisma.gamificationReward.count({ where: { organizationId: actor.organizationId } }); const row = await this.prisma.gamificationReward.create({ data: { organizationId: actor.organizationId, ...this.rewardData(dto), position: count }, }); return this.game.toRewardDto(row); } async updateReward(actor: Actor, id: string, dto: SaveRewardDto) { await this.assertAdmin(actor); await this.ensureOwned('gamificationReward', actor, id); const row = await this.prisma.gamificationReward.update({ where: { id }, data: this.rewardData(dto) }); return this.game.toRewardDto(row); } async deleteReward(actor: Actor, id: string) { await this.assertAdmin(actor); await this.ensureOwned('gamificationReward', actor, id); const used = await this.prisma.gamificationRedemption.count({ where: { rewardId: id } }); if (used) { await this.prisma.gamificationReward.update({ where: { id }, data: { isActive: false } }); return; } await this.prisma.gamificationReward.delete({ where: { id } }); } /* ───────── نشان‌ها ───────── */ private badgeData(dto: SaveBadgeDto) { if (!(dto.metric in GAMIFICATION_BADGE_METRICS)) throw new BadRequestException('شاخص نامعتبر است'); if (dto.metric === 'event_count' && !isGamificationEvent(dto.event)) { throw new BadRequestException('رویداد را انتخاب کنید'); } return { name: dto.name.trim(), description: dto.description?.trim() || null, icon: dto.icon?.trim() || 'medal', color: dto.color?.trim() || '#f59e0b', metric: dto.metric, event: dto.metric === 'event_count' ? dto.event! : null, threshold: Math.max(1, Math.trunc(dto.threshold)), coins: Math.max(0, Math.trunc(dto.coins ?? 0)), isActive: dto.isActive ?? true, }; } async listBadges(actor: Actor): Promise { await this.assertAdmin(actor); const [rows, counts] = await Promise.all([ this.prisma.gamificationBadge.findMany({ where: { organizationId: actor.organizationId }, orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], }), this.prisma.gamificationUserBadge.groupBy({ by: ['badgeId'], where: { organizationId: actor.organizationId }, _count: { _all: true }, }), ]); const countMap = new Map(counts.map((c) => [c.badgeId, c._count._all])); return rows.map((b) => ({ id: b.id, name: b.name, description: b.description, icon: b.icon, color: b.color, metric: b.metric as GamificationBadgeMetric, event: isGamificationEvent(b.event) ? b.event : null, threshold: b.threshold, coins: b.coins, isActive: b.isActive, earnedCount: countMap.get(b.id) ?? 0, })); } async createBadge(actor: Actor, dto: SaveBadgeDto) { await this.assertAdmin(actor); const count = await this.prisma.gamificationBadge.count({ where: { organizationId: actor.organizationId } }); await this.prisma.gamificationBadge.create({ data: { organizationId: actor.organizationId, ...this.badgeData(dto), position: count }, }); return this.listBadges(actor); } async updateBadge(actor: Actor, id: string, dto: SaveBadgeDto) { await this.assertAdmin(actor); await this.ensureOwned('gamificationBadge', actor, id); await this.prisma.gamificationBadge.update({ where: { id }, data: this.badgeData(dto) }); return this.listBadges(actor); } async deleteBadge(actor: Actor, id: string) { await this.assertAdmin(actor); await this.ensureOwned('gamificationBadge', actor, id); await this.prisma.gamificationBadge.delete({ where: { id } }); } /* ───────── بونوس‌ها ───────── */ private bonusData(dto: SaveBonusDto) { if (!(dto.kind in GAMIFICATION_BONUS_KINDS)) throw new BadRequestException('نوع بونوس نامعتبر است'); if (dto.kind === 'event_count_period' && !isGamificationEvent(dto.event)) { throw new BadRequestException('رویداد را انتخاب کنید'); } if (!dto.score && !dto.coins) throw new BadRequestException('امتیاز یا سکه باید بیشتر از صفر باشد'); const period = dto.period === 'day' || dto.period === 'week' ? dto.period : 'month'; return { name: dto.name.trim(), description: dto.description?.trim() || null, kind: dto.kind as GamificationBonusKind, event: dto.kind === 'event_count_period' ? dto.event! : null, threshold: Math.max(1, Math.trunc(dto.threshold)), period: dto.kind === 'top_of_month' ? 'month' : period, score: Math.max(0, Math.trunc(dto.score ?? 0)), coins: Math.max(0, Math.trunc(dto.coins ?? 0)), isActive: dto.isActive ?? true, }; } async listBonuses(actor: Actor): Promise { await this.assertAdmin(actor); const [rows, counts] = await Promise.all([ this.prisma.gamificationBonus.findMany({ where: { organizationId: actor.organizationId }, orderBy: { createdAt: 'asc' }, }), this.prisma.gamificationTransaction.groupBy({ by: ['bonusId'], where: { organizationId: actor.organizationId, kind: 'bonus', status: 'approved' }, _count: { _all: true }, }), ]); const countMap = new Map(counts.map((c) => [c.bonusId ?? '', c._count._all])); return rows.map((b) => ({ id: b.id, name: b.name, description: b.description, kind: b.kind as GamificationBonusKind, event: isGamificationEvent(b.event) ? b.event : null, threshold: b.threshold, period: (b.period === 'day' || b.period === 'week' ? b.period : 'month') as GamificationBonusDto['period'], score: b.score, coins: b.coins, isActive: b.isActive, awardedCount: countMap.get(b.id) ?? 0, })); } async createBonus(actor: Actor, dto: SaveBonusDto) { await this.assertAdmin(actor); await this.prisma.gamificationBonus.create({ data: { organizationId: actor.organizationId, ...this.bonusData(dto) }, }); return this.listBonuses(actor); } async updateBonus(actor: Actor, id: string, dto: SaveBonusDto) { await this.assertAdmin(actor); await this.ensureOwned('gamificationBonus', actor, id); await this.prisma.gamificationBonus.update({ where: { id }, data: this.bonusData(dto) }); return this.listBonuses(actor); } async deleteBonus(actor: Actor, id: string) { await this.assertAdmin(actor); await this.ensureOwned('gamificationBonus', actor, id); await this.prisma.gamificationBonus.delete({ where: { id } }); } private async ensureOwned( model: 'gamificationReward' | 'gamificationBadge' | 'gamificationBonus', actor: Actor, id: string, ) { const where = { id, organizationId: actor.organizationId }; const found = model === 'gamificationReward' ? await this.prisma.gamificationReward.findFirst({ where, select: { id: true } }) : model === 'gamificationBadge' ? await this.prisma.gamificationBadge.findFirst({ where, select: { id: true } }) : await this.prisma.gamificationBonus.findFirst({ where, select: { id: true } }); if (!found) throw new NotFoundException('مورد پیدا نشد'); } /* ───────── تنظیمات ───────── */ async settings(actor: Actor): Promise { await this.assertAdmin(actor); const row = await this.game.ensureDefaults(actor.organizationId); return this.game.toSettingsDto(row); } async updateSettings(actor: Actor, dto: UpdateSettingsDto): Promise { await this.assertAdmin(actor); await this.game.ensureDefaults(actor.organizationId); const row = await this.prisma.gamificationSettings.update({ where: { organizationId: actor.organizationId }, data: { ...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}), ...(dto.levels !== undefined ? { levels: normalizeLevels(dto.levels) as unknown as Prisma.InputJsonValue } : {}), ...(dto.monthlyGoal !== undefined ? { monthlyGoal: Math.max(1, Math.trunc(dto.monthlyGoal)) } : {}), ...(dto.showFullLeaderboard !== undefined ? { showFullLeaderboard: dto.showFullLeaderboard } : {}), }, }); this.game.invalidate(actor.organizationId); return this.game.toSettingsDto(row); } /* ───────── گزارش مدیریتی ───────── */ async report(actor: Actor, periodRaw: unknown): Promise { await this.assertAdmin(actor); const organizationId = actor.organizationId; const period = parsePeriod(periodRaw); const from = periodStart(period); const createdAt = from ? { gte: from } : undefined; const base: Prisma.GamificationTransactionWhereInput = { organizationId, status: 'approved', ...(createdAt ? { createdAt } : {}), }; const members = await this.game.members(organizationId); const names = new Map(members.map((m) => [m.userId, m.fullName])); const [earned, spent, byUser, byEvent, pendingApprovals, pendingRedemptions, perUserEvent, settings] = await Promise.all([ this.prisma.gamificationTransaction.aggregate({ where: { ...base, kind: { notIn: ['redeem', 'refund'] } }, _sum: { score: true, coins: true }, _count: { _all: true }, }), this.prisma.gamificationTransaction.aggregate({ where: { ...base, kind: { in: ['redeem', 'refund'] } }, _sum: { coins: true }, }), this.prisma.gamificationTransaction.groupBy({ by: ['userId'], where: { ...base, kind: 'rule' }, _count: { _all: true }, _sum: { score: true }, }), this.prisma.gamificationTransaction.groupBy({ by: ['event'], where: { ...base, kind: 'rule' }, _count: { _all: true }, _sum: { score: true, coins: true }, }), this.prisma.gamificationTransaction.count({ where: { organizationId, status: 'pending' } }), this.prisma.gamificationRedemption.count({ where: { organizationId, status: 'pending' } }), this.prisma.gamificationTransaction.groupBy({ by: ['userId', 'event'], where: { ...base, kind: 'rule', event: { in: ['DEAL_WON', 'LEAD_CREATED', 'TASK_COMPLETED', 'ROUTINE_COMPLETED'] }, }, _count: { _all: true }, }), this.game.ensureDefaults(organizationId), ]); const top = (entries: Array<[string, number]>, n = 5): GamificationReportUserRow[] => entries .filter(([, v]) => v > 0) .sort((a, b) => b[1] - a[1]) .slice(0, n) .map(([userId, value]) => ({ userId, fullName: names.get(userId) ?? '—', value })); const sumEvents = (events: string[]) => { const map = new Map(); for (const r of perUserEvent) { if (r.event && events.includes(r.event)) { map.set(r.userId, (map.get(r.userId) ?? 0) + r._count._all); } } return [...map.entries()]; }; let topGrowth: GamificationReportUserRow[] = []; const prev = previousPeriod(period); if (prev) { const prevRows = await this.prisma.gamificationTransaction.groupBy({ by: ['userId'], where: { organizationId, status: 'approved', createdAt: { gte: prev.from, lt: prev.to } }, _sum: { score: true }, }); const prevMap = new Map(prevRows.map((r) => [r.userId, r._sum.score ?? 0])); topGrowth = top(byUser.map((r) => [r.userId, (r._sum.score ?? 0) - (prevMap.get(r.userId) ?? 0)])); } const activeIds = new Set(byUser.map((r) => r.userId)); const inactive = members .filter((m) => !activeIds.has(m.userId)) .slice(0, 20) .map((m) => ({ userId: m.userId, fullName: m.fullName, value: 0 })); return { period, totals: { score: earned._sum.score ?? 0, coinsEarned: earned._sum.coins ?? 0, coinsSpent: Math.abs(spent._sum.coins ?? 0), activeUsers: activeIds.size, totalUsers: members.length, transactions: earned._count._all, pendingApprovals, pendingRedemptions, }, byEvent: byEvent .filter((e) => isGamificationEvent(e.event)) .map((e) => ({ event: e.event as GamificationReportDto['byEvent'][number]['event'], label: GAMIFICATION_EVENTS[e.event as keyof typeof GAMIFICATION_EVENTS].label, count: e._count._all, score: e._sum.score ?? 0, coins: e._sum.coins ?? 0, })) .sort((a, b) => b.count - a.count), topActive: top(byUser.map((r) => [r.userId, r._count._all])), topGrowth, topDeals: top(sumEvents(['DEAL_WON'])), topLeads: top(sumEvents(['LEAD_CREATED'])), topTasks: top(sumEvents(['TASK_COMPLETED', 'ROUTINE_COMPLETED'])), inactive, roi: await this.roi(organizationId, settings.enabledAt), }; } /** مقایسهٔ میانگین روزانهٔ شاخص‌ها در ۳۰ روز قبل و بعد از شروع باشگاه */ private async roi(organizationId: string, enabledAt: Date): Promise { const since = enabledAt; const beforeFrom = new Date(since.getTime() - ROI_WINDOW_DAYS * 86_400_000); const afterTo = new Date(Math.min(Date.now(), since.getTime() + ROI_WINDOW_DAYS * 86_400_000)); const afterDays = Math.max(1, (afterTo.getTime() - since.getTime()) / 86_400_000); const ranges = [ { gte: beforeFrom, lt: since }, { gte: since, lt: afterTo }, ] as const; const count = async (fn: (r: { gte: Date; lt: Date }) => Promise) => Promise.all(ranges.map((r) => fn(r))); const [activities, calls, leadCalls, leads, converted, customers, wonCount] = await Promise.all([ count((r) => this.prisma.activity.count({ where: { organizationId, createdAt: r } })), count((r) => this.prisma.activity.count({ where: { organizationId, type: 'call', createdAt: r } })), count((r) => this.prisma.leadEvent.count({ where: { organizationId, type: 'call', createdAt: r } })), count((r) => this.prisma.lead.count({ where: { organizationId, createdAt: r } })), count((r) => this.prisma.lead.count({ where: { organizationId, convertedAt: r } })), count((r) => this.prisma.person.count({ where: { organizationId, createdAt: r } })), count((r) => this.prisma.deal.count({ where: { organizationId, status: 'won', closedAt: r } })), ]); const wonAmount = await Promise.all( ranges.map(async (r) => { const agg = await this.prisma.deal.aggregate({ where: { organizationId, status: 'won', closedAt: r }, _sum: { amount: true }, }); return Number(agg._sum.amount ?? 0); }), ); const routineRate = await Promise.all( ranges.map(async (r) => { const fromKey = tehranDayKey(r.gte); const toKey = addDays(tehranDayKey(r.lt), -1); const where: Prisma.RoutineTaskWhereInput = { organizationId, status: { not: 'cancelled' }, scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) }, }; const [total, done] = await Promise.all([ this.prisma.routineTask.count({ where }), this.prisma.routineTask.count({ where: { ...where, status: 'completed' } }), ]); return total ? Math.round((done / total) * 100) : 0; }), ); const perDay = (pair: number[]): [number, number] => [ Math.round((pair[0]! / ROI_WINDOW_DAYS) * 10) / 10, Math.round((pair[1]! / afterDays) * 10) / 10, ]; const metric = (key: string, label: string, [before, after]: [number, number]): GamificationRoiMetric => ({ key, label, before, after, changePct: before > 0 ? Math.round(((after - before) / before) * 100) : null, }); return { since: since.toISOString(), windowDays: ROI_WINDOW_DAYS, metrics: [ metric('activities', 'فعالیت ثبت‌شده در روز', perDay(activities)), metric('calls', 'تماس ثبت‌شده در روز', perDay([calls[0]! + leadCalls[0]!, calls[1]! + leadCalls[1]!])), metric('leads', 'لید جدید در روز', perDay(leads)), metric('converted', 'تبدیل لید به مشتری در روز', perDay(converted)), metric('customers', 'مشتری جدید در روز', perDay(customers)), metric('won', 'معاملهٔ برنده در روز', perDay(wonCount)), metric('wonAmount', 'مبلغ فروش در روز', perDay(wonAmount)), metric('routine', 'درصد انجام کارهای روتین', [routineRate[0]!, routineRate[1]!]), ], }; } }