feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,790 @@
|
||||
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<GamificationRuleDto[]> {
|
||||
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<GamificationRuleDto> {
|
||||
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<GamificationRuleDto> {
|
||||
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<GamificationRuleDto> {
|
||||
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<GamificationRuleDto> {
|
||||
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<void> {
|
||||
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<GamificationTxDto> {
|
||||
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<GamificationTxDto[]> {
|
||||
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<GamificationTxDto> {
|
||||
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<GamificationRedemptionDto[]> {
|
||||
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<GamificationRedemptionDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const organizationId = actor.organizationId;
|
||||
const allowedFrom: Record<typeof action, string[]> = {
|
||||
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<GamificationRewardDto[]> {
|
||||
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<GamificationBadgeDto[]> {
|
||||
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<GamificationBonusDto[]> {
|
||||
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<GamificationSettingsDto> {
|
||||
await this.assertAdmin(actor);
|
||||
const row = await this.game.ensureDefaults(actor.organizationId);
|
||||
return this.game.toSettingsDto(row);
|
||||
}
|
||||
|
||||
async updateSettings(actor: Actor, dto: UpdateSettingsDto): Promise<GamificationSettingsDto> {
|
||||
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<GamificationReportDto> {
|
||||
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<string, number>();
|
||||
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<GamificationReportDto['roi']> {
|
||||
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<number>) =>
|
||||
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]!]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user