feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import type { GamificationCondition, GamificationLevelDef } from '@mento/shared';
|
||||
import { GAMIFICATION_EVENT_KEYS } from '@mento/shared';
|
||||
|
||||
const nullable = () => ValidateIf((_, v) => v !== '' && v != null);
|
||||
|
||||
export class SaveRuleDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsIn(GAMIFICATION_EVENT_KEYS)
|
||||
event!: (typeof GAMIFICATION_EVENT_KEYS)[number];
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(-10000)
|
||||
@Max(100000)
|
||||
score!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
coins!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
conditions?: GamificationCondition[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxPerDay?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxCoinsPerDay?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxPerUser?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100000)
|
||||
cooldownMinutes?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
oncePerEntity?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
firstTimeOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresApproval?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
roles?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
teamIds?: string[];
|
||||
}
|
||||
|
||||
export class ToggleDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class AdjustDto {
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(-1000000)
|
||||
@Max(1000000)
|
||||
amount!: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecisionDto {
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class RedeemDto {
|
||||
@IsString()
|
||||
rewardId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class SaveRewardDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10000000)
|
||||
cost!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stock?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresApproval?: boolean;
|
||||
}
|
||||
|
||||
export class SaveBadgeDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsIn(['event_count', 'score_total', 'routine_streak', 'single_deal_amount'])
|
||||
metric!: 'event_count' | 'score_total' | 'routine_streak' | 'single_deal_amount';
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsIn(GAMIFICATION_EVENT_KEYS)
|
||||
event?: (typeof GAMIFICATION_EVENT_KEYS)[number] | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
threshold!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1000000)
|
||||
coins?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SaveBonusDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsIn(['event_count_period', 'routine_streak', 'top_of_month'])
|
||||
kind!: 'event_count_period' | 'routine_streak' | 'top_of_month';
|
||||
|
||||
@IsOptional()
|
||||
@nullable()
|
||||
@IsIn(GAMIFICATION_EVENT_KEYS)
|
||||
event?: (typeof GAMIFICATION_EVENT_KEYS)[number] | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
threshold!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'week', 'month'])
|
||||
period?: 'day' | 'week' | 'month';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1000000)
|
||||
score?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1000000)
|
||||
coins?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
levels?: GamificationLevelDef[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10000000)
|
||||
monthlyGoal?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showFullLeaderboard?: boolean;
|
||||
}
|
||||
|
||||
export class TxQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
kind?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
event?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { GamificationAdminService } from './gamification-admin.service';
|
||||
import {
|
||||
AdjustDto,
|
||||
DecisionDto,
|
||||
SaveBadgeDto,
|
||||
SaveBonusDto,
|
||||
SaveRewardDto,
|
||||
SaveRuleDto,
|
||||
ToggleDto,
|
||||
UpdateSettingsDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('admin/gamification')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('gamification')
|
||||
export class GamificationAdminController {
|
||||
constructor(private readonly admin: GamificationAdminService) {}
|
||||
|
||||
/* قوانین */
|
||||
|
||||
@Get('rules')
|
||||
rules(@CurrentUser() user: Actor) {
|
||||
return this.admin.listRules(user);
|
||||
}
|
||||
|
||||
@Post('rules')
|
||||
createRule(@CurrentUser() user: Actor, @Body() dto: SaveRuleDto) {
|
||||
return this.admin.createRule(user, dto);
|
||||
}
|
||||
|
||||
@Patch('rules/:id')
|
||||
updateRule(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRuleDto) {
|
||||
return this.admin.updateRule(user, id, dto);
|
||||
}
|
||||
|
||||
@Patch('rules/:id/toggle')
|
||||
toggleRule(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: ToggleDto) {
|
||||
return this.admin.toggleRule(user, id, dto.isActive);
|
||||
}
|
||||
|
||||
@Post('rules/:id/duplicate')
|
||||
duplicateRule(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.duplicateRule(user, id);
|
||||
}
|
||||
|
||||
@Delete('rules/:id')
|
||||
deleteRule(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteRule(user, id);
|
||||
}
|
||||
|
||||
/* تنظیم دستی */
|
||||
|
||||
@Post('adjust-score')
|
||||
adjustScore(@CurrentUser() user: Actor, @Body() dto: AdjustDto) {
|
||||
return this.admin.adjust(user, 'score', dto);
|
||||
}
|
||||
|
||||
@Post('adjust-coins')
|
||||
adjustCoins(@CurrentUser() user: Actor, @Body() dto: AdjustDto) {
|
||||
return this.admin.adjust(user, 'coins', dto);
|
||||
}
|
||||
|
||||
/* تأیید امتیاز */
|
||||
|
||||
@Get('pending')
|
||||
pending(@CurrentUser() user: Actor) {
|
||||
return this.admin.pending(user);
|
||||
}
|
||||
|
||||
@Post('transactions/:id/approve')
|
||||
approveTx(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideTx(user, id, true, dto);
|
||||
}
|
||||
|
||||
@Post('transactions/:id/reject')
|
||||
rejectTx(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideTx(user, id, false, dto);
|
||||
}
|
||||
|
||||
/* درخواستهای پاداش */
|
||||
|
||||
@Get('redemptions')
|
||||
redemptions(@CurrentUser() user: Actor, @Query('status') status?: string) {
|
||||
return this.admin.redemptions(user, status);
|
||||
}
|
||||
|
||||
@Post('redemptions/:id/approve')
|
||||
approveRedemption(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideRedemption(user, id, 'approve', dto);
|
||||
}
|
||||
|
||||
@Post('redemptions/:id/reject')
|
||||
rejectRedemption(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideRedemption(user, id, 'reject', dto);
|
||||
}
|
||||
|
||||
@Post('redemptions/:id/deliver')
|
||||
deliverRedemption(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: DecisionDto) {
|
||||
return this.admin.decideRedemption(user, id, 'deliver', dto);
|
||||
}
|
||||
|
||||
/* پاداشها */
|
||||
|
||||
@Get('rewards')
|
||||
rewards(@CurrentUser() user: Actor) {
|
||||
return this.admin.listRewards(user);
|
||||
}
|
||||
|
||||
@Post('rewards')
|
||||
createReward(@CurrentUser() user: Actor, @Body() dto: SaveRewardDto) {
|
||||
return this.admin.createReward(user, dto);
|
||||
}
|
||||
|
||||
@Patch('rewards/:id')
|
||||
updateReward(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRewardDto) {
|
||||
return this.admin.updateReward(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('rewards/:id')
|
||||
deleteReward(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteReward(user, id);
|
||||
}
|
||||
|
||||
/* نشانها */
|
||||
|
||||
@Get('badges')
|
||||
badges(@CurrentUser() user: Actor) {
|
||||
return this.admin.listBadges(user);
|
||||
}
|
||||
|
||||
@Post('badges')
|
||||
createBadge(@CurrentUser() user: Actor, @Body() dto: SaveBadgeDto) {
|
||||
return this.admin.createBadge(user, dto);
|
||||
}
|
||||
|
||||
@Patch('badges/:id')
|
||||
updateBadge(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveBadgeDto) {
|
||||
return this.admin.updateBadge(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('badges/:id')
|
||||
deleteBadge(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteBadge(user, id);
|
||||
}
|
||||
|
||||
/* بونوسها */
|
||||
|
||||
@Get('bonuses')
|
||||
bonuses(@CurrentUser() user: Actor) {
|
||||
return this.admin.listBonuses(user);
|
||||
}
|
||||
|
||||
@Post('bonuses')
|
||||
createBonus(@CurrentUser() user: Actor, @Body() dto: SaveBonusDto) {
|
||||
return this.admin.createBonus(user, dto);
|
||||
}
|
||||
|
||||
@Patch('bonuses/:id')
|
||||
updateBonus(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveBonusDto) {
|
||||
return this.admin.updateBonus(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete('bonuses/:id')
|
||||
deleteBonus(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.admin.deleteBonus(user, id);
|
||||
}
|
||||
|
||||
/* تنظیمات و گزارش */
|
||||
|
||||
@Get('settings')
|
||||
settings(@CurrentUser() user: Actor) {
|
||||
return this.admin.settings(user);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
updateSettings(@CurrentUser() user: Actor, @Body() dto: UpdateSettingsDto) {
|
||||
return this.admin.updateSettings(user, dto);
|
||||
}
|
||||
|
||||
@Get('reports')
|
||||
reports(@CurrentUser() user: Actor, @Query('period') period?: string) {
|
||||
return this.admin.report(user, period);
|
||||
}
|
||||
}
|
||||
@@ -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]!]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { jalaliMonthKeyOf, previousPeriod } from './gamification-engine';
|
||||
import { GamificationService } from './gamification.service';
|
||||
|
||||
@Injectable()
|
||||
export class GamificationCronService {
|
||||
private readonly logger = new Logger(GamificationCronService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
/** پاداش «نفر اول ماه» برای ماه شمسی گذشته؛ روزانه اجرا میشود و تکراری ثبت نمیکند */
|
||||
@Cron('20 0 * * *', { timeZone: 'Asia/Tehran' })
|
||||
async awardTopOfMonth() {
|
||||
const range = previousPeriod('month');
|
||||
if (!range) return;
|
||||
const monthKey = jalaliMonthKeyOf(range.from);
|
||||
const bonuses = await this.prisma.gamificationBonus.findMany({
|
||||
where: { kind: 'top_of_month', isActive: true },
|
||||
});
|
||||
for (const bonus of bonuses) {
|
||||
try {
|
||||
const settings = await this.game.settingsFor(bonus.organizationId);
|
||||
if (!settings?.enabled) continue;
|
||||
const memberIds = new Set((await this.game.members(bonus.organizationId)).map((m) => m.userId));
|
||||
const rows = await this.prisma.gamificationTransaction.groupBy({
|
||||
by: ['userId'],
|
||||
where: {
|
||||
organizationId: bonus.organizationId,
|
||||
status: 'approved',
|
||||
createdAt: { gte: range.from, lt: range.to },
|
||||
},
|
||||
_sum: { score: true },
|
||||
});
|
||||
const winner = rows
|
||||
.filter((r) => memberIds.has(r.userId) && (r._sum.score ?? 0) > 0)
|
||||
.sort((a, b) => (b._sum.score ?? 0) - (a._sum.score ?? 0))[0];
|
||||
if (!winner) continue;
|
||||
await this.game.record({
|
||||
organizationId: bonus.organizationId,
|
||||
userId: winner.userId,
|
||||
kind: 'bonus',
|
||||
bonusId: bonus.id,
|
||||
score: bonus.score,
|
||||
coins: bonus.coins,
|
||||
title: `پاداش ویژه: ${bonus.name}`,
|
||||
idempotencyKey: `b:${bonus.id}:m:${monthKey}`,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`top-of-month ${bonus.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
GamificationBadgeMetric,
|
||||
GamificationBonusKind,
|
||||
GamificationCondition,
|
||||
GamificationEvent,
|
||||
} from '@mento/shared';
|
||||
|
||||
type RuleSeed = {
|
||||
name: string;
|
||||
event: GamificationEvent;
|
||||
score: number;
|
||||
coins: number;
|
||||
conditions?: GamificationCondition[];
|
||||
maxPerDay?: number;
|
||||
cooldownMinutes?: number;
|
||||
oncePerEntity?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES: RuleSeed[] = [
|
||||
{ name: 'ثبت لید جدید', event: 'LEAD_CREATED', score: 5, coins: 1, maxPerDay: 40 },
|
||||
{ name: 'پیشرفت لید به مرحله بعد', event: 'LEAD_STAGE_CHANGED', score: 10, coins: 2, maxPerDay: 60 },
|
||||
{ name: 'تبدیل لید به مشتری', event: 'LEAD_CONVERTED', score: 30, coins: 10, oncePerEntity: true },
|
||||
{ name: 'ثبت مشتری جدید', event: 'CUSTOMER_CREATED', score: 20, coins: 5, maxPerDay: 40 },
|
||||
{ name: 'ایجاد معامله', event: 'DEAL_CREATED', score: 10, coins: 2, maxPerDay: 30 },
|
||||
{ name: 'برنده شدن معامله', event: 'DEAL_WON', score: 50, coins: 20, oncePerEntity: true },
|
||||
{ name: 'ثبت تماس', event: 'CALL_LOGGED', score: 2, coins: 0, maxPerDay: 80, cooldownMinutes: 1 },
|
||||
{ name: 'ثبت جلسه', event: 'MEETING_CREATED', score: 10, coins: 3, maxPerDay: 10 },
|
||||
{ name: 'ثبت پیگیری', event: 'FOLLOW_UP_CREATED', score: 3, coins: 1, maxPerDay: 40 },
|
||||
{ name: 'ثبت یادداشت', event: 'NOTE_CREATED', score: 1, coins: 0, maxPerDay: 30 },
|
||||
{ name: 'انجام کار', event: 'TASK_COMPLETED', score: 5, coins: 1, maxPerDay: 40 },
|
||||
{
|
||||
name: 'انجام کار روتین',
|
||||
event: 'ROUTINE_COMPLETED',
|
||||
score: 2,
|
||||
coins: 0,
|
||||
conditions: [{ field: 'onTime', op: 'is_false' }],
|
||||
},
|
||||
{
|
||||
name: 'انجام بهموقع کار روتین',
|
||||
event: 'ROUTINE_COMPLETED',
|
||||
score: 5,
|
||||
coins: 1,
|
||||
conditions: [{ field: 'onTime', op: 'is_true' }],
|
||||
},
|
||||
{ name: 'ثبت پیشفاکتور', event: 'QUOTE_CREATED', score: 10, coins: 3, maxPerDay: 20 },
|
||||
{ name: 'صدور فاکتور', event: 'INVOICE_ISSUED', score: 15, coins: 5, oncePerEntity: true },
|
||||
{ name: 'ثبت پرداخت', event: 'PAYMENT_REGISTERED', score: 20, coins: 5 },
|
||||
{ name: 'حل تیکت', event: 'TICKET_RESOLVED', score: 10, coins: 3, oncePerEntity: true },
|
||||
];
|
||||
|
||||
export const DEFAULT_REWARDS = [
|
||||
{ name: 'کارت هدیه', description: 'کارت هدیه خرید', cost: 500, icon: 'gift', color: '#14b8a6' },
|
||||
{ name: 'ناهار تیمی', description: 'ناهار مهمان شرکت', cost: 800, icon: 'star', color: '#f59e0b' },
|
||||
{ name: 'مرخصی تشویقی', description: 'یک روز مرخصی تشویقی', cost: 2000, icon: 'calendar-sun', color: '#8b5cf6' },
|
||||
{ name: 'هدفون', description: 'هدفون بیسیم', cost: 3000, icon: 'trophy', color: '#ef4444' },
|
||||
];
|
||||
|
||||
export const DEFAULT_BADGES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
metric: GamificationBadgeMetric;
|
||||
event?: GamificationEvent;
|
||||
threshold: number;
|
||||
coins: number;
|
||||
}> = [
|
||||
{ name: 'اولین فروش', description: 'اولین معامله برنده', icon: 'trophy', color: '#f59e0b', metric: 'event_count', event: 'DEAL_WON', threshold: 1, coins: 20 },
|
||||
{ name: '۱۰ فروش موفق', description: '۱۰ معامله برنده', icon: 'crown', color: '#eab308', metric: 'event_count', event: 'DEAL_WON', threshold: 10, coins: 100 },
|
||||
{ name: '۱۰۰ تماس', description: 'ثبت ۱۰۰ تماس', icon: 'phone', color: '#0ea5e9', metric: 'event_count', event: 'CALL_LOGGED', threshold: 100, coins: 50 },
|
||||
{ name: '۵۰ مشتری جدید', description: 'ثبت ۵۰ مشتری', icon: 'users', color: '#22c55e', metric: 'event_count', event: 'CUSTOMER_CREATED', threshold: 50, coins: 80 },
|
||||
{ name: '۱۰۰ لید', description: 'ثبت ۱۰۰ لید', icon: 'funnel', color: '#14b8a6', metric: 'event_count', event: 'LEAD_CREATED', threshold: 100, coins: 60 },
|
||||
{ name: '۳۰ روز روتین کامل', description: '۳۰ روز پیاپی انجام کامل کارهای روتین', icon: 'fire', color: '#f97316', metric: 'routine_streak', threshold: 30, coins: 150 },
|
||||
{ name: 'باشگاه هزار امتیازی', description: 'رسیدن به ۱۰۰۰ امتیاز', icon: 'medal', color: '#8b5cf6', metric: 'score_total', threshold: 1000, coins: 50 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BONUSES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
kind: GamificationBonusKind;
|
||||
event?: GamificationEvent;
|
||||
threshold: number;
|
||||
period: 'day' | 'week' | 'month';
|
||||
score: number;
|
||||
coins: number;
|
||||
}> = [
|
||||
{ name: '۱۰ فروش در ماه', description: '۱۰ معامله برنده در یک ماه', kind: 'event_count_period', event: 'DEAL_WON', threshold: 10, period: 'month', score: 100, coins: 200 },
|
||||
{ name: '۵ روز پیاپی روتین کامل', description: 'انجام کامل روتینها در ۵ روز پیاپی', kind: 'routine_streak', threshold: 5, period: 'day', score: 30, coins: 100 },
|
||||
{ name: 'نفر اول ماه', description: 'بیشترین امتیاز در ماه', kind: 'top_of_month', threshold: 1, period: 'month', score: 0, coins: 500 },
|
||||
];
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
GamificationCondition,
|
||||
GamificationConditionOp,
|
||||
GamificationLevelDef,
|
||||
GamificationLevelDto,
|
||||
GamificationPeriod,
|
||||
} from '@mento/shared';
|
||||
import { GAMIFICATION_CONDITION_OPS, GAMIFICATION_FIELDS } from '@mento/shared';
|
||||
import { toGregorian, toJalali } from '../common/jalali';
|
||||
import {
|
||||
addDays,
|
||||
persianWeekday,
|
||||
tehranDayKey,
|
||||
tehranDayStart,
|
||||
tehranHour,
|
||||
weekStartKey,
|
||||
} from '../routines/routine-dates';
|
||||
|
||||
export type Facts = Record<string, unknown>;
|
||||
|
||||
export const DEFAULT_LEVELS: GamificationLevelDef[] = [
|
||||
{ name: 'تازهکار', minScore: 0 },
|
||||
{ name: 'برنزی', minScore: 250 },
|
||||
{ name: 'نقرهای', minScore: 600 },
|
||||
{ name: 'حرفهای', minScore: 1200 },
|
||||
{ name: 'طلایی', minScore: 2000 },
|
||||
{ name: 'استاد', minScore: 3500 },
|
||||
];
|
||||
|
||||
/* ───────── شرطها ───────── */
|
||||
|
||||
function toNumber(v: unknown): number | null {
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v.replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
return String(v).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toBool(v: unknown): boolean {
|
||||
if (typeof v === 'string') return v === 'true' || v === '1';
|
||||
return Boolean(v);
|
||||
}
|
||||
|
||||
export function evaluateCondition(cond: GamificationCondition, facts: Facts): boolean {
|
||||
const actual = facts[cond.field];
|
||||
switch (cond.op) {
|
||||
case 'is_true':
|
||||
return toBool(actual);
|
||||
case 'is_false':
|
||||
return !toBool(actual);
|
||||
case 'gt':
|
||||
case 'gte':
|
||||
case 'lt':
|
||||
case 'lte': {
|
||||
const a = toNumber(actual);
|
||||
const b = toNumber(cond.value);
|
||||
if (a == null || b == null) return false;
|
||||
if (cond.op === 'gt') return a > b;
|
||||
if (cond.op === 'gte') return a >= b;
|
||||
if (cond.op === 'lt') return a < b;
|
||||
return a <= b;
|
||||
}
|
||||
case 'eq':
|
||||
case 'neq': {
|
||||
const a = toNumber(actual);
|
||||
const b = toNumber(cond.value);
|
||||
const same = a != null && b != null ? a === b : toText(actual) === toText(cond.value);
|
||||
return cond.op === 'eq' ? same : !same;
|
||||
}
|
||||
case 'contains':
|
||||
return toText(actual).includes(toText(cond.value));
|
||||
case 'in':
|
||||
return String(cond.value ?? '')
|
||||
.split(/[,،]/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.includes(toText(actual));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** همهٔ شرطها باید برقرار باشند (AND) */
|
||||
export function matchesConditions(conditions: GamificationCondition[], facts: Facts): boolean {
|
||||
return conditions.every((c) => evaluateCondition(c, facts));
|
||||
}
|
||||
|
||||
export function sanitizeConditions(raw: unknown): GamificationCondition[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: GamificationCondition[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const { field, op, value } = item as Record<string, unknown>;
|
||||
if (typeof field !== 'string' || !(field in GAMIFICATION_FIELDS)) continue;
|
||||
if (typeof op !== 'string' || !(op in GAMIFICATION_CONDITION_OPS)) continue;
|
||||
out.push({
|
||||
field,
|
||||
op: op as GamificationConditionOp,
|
||||
value: typeof value === 'number' || typeof value === 'string' ? value : null,
|
||||
});
|
||||
}
|
||||
return out.slice(0, 10);
|
||||
}
|
||||
|
||||
/** فیلدهای عمومی که برای همهٔ رویدادها محاسبه میشوند */
|
||||
export function commonFacts(now: Date = new Date()): Facts {
|
||||
return { hour: tehranHour(now), weekday: persianWeekday(tehranDayKey(now)) };
|
||||
}
|
||||
|
||||
/* ───────── محدودیتها ───────── */
|
||||
|
||||
export type RuleLimits = {
|
||||
maxPerDay: number | null;
|
||||
maxCoinsPerDay: number | null;
|
||||
maxPerUser: number | null;
|
||||
cooldownMinutes: number | null;
|
||||
firstTimeOnly: boolean;
|
||||
coins: number;
|
||||
};
|
||||
|
||||
export type RuleUsage = {
|
||||
todayCount: number;
|
||||
todayCoins: number;
|
||||
totalCount: number;
|
||||
lastAt: Date | null;
|
||||
};
|
||||
|
||||
/** دلیل رد امتیاز یا null اگر مجاز است */
|
||||
export function limitBlockReason(
|
||||
rule: RuleLimits,
|
||||
usage: RuleUsage,
|
||||
now: Date = new Date(),
|
||||
): string | null {
|
||||
if (rule.firstTimeOnly && usage.totalCount > 0) return 'first_time_only';
|
||||
if (rule.maxPerUser != null && usage.totalCount >= rule.maxPerUser) return 'max_per_user';
|
||||
if (rule.maxPerDay != null && usage.todayCount >= rule.maxPerDay) return 'max_per_day';
|
||||
if (
|
||||
rule.maxCoinsPerDay != null &&
|
||||
rule.coins > 0 &&
|
||||
usage.todayCoins + rule.coins > rule.maxCoinsPerDay
|
||||
) {
|
||||
return 'max_coins_per_day';
|
||||
}
|
||||
if (
|
||||
rule.cooldownMinutes != null &&
|
||||
rule.cooldownMinutes > 0 &&
|
||||
usage.lastAt &&
|
||||
now.getTime() - usage.lastAt.getTime() < rule.cooldownMinutes * 60_000
|
||||
) {
|
||||
return 'cooldown';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function needsUsage(rule: RuleLimits): boolean {
|
||||
return (
|
||||
rule.firstTimeOnly ||
|
||||
rule.maxPerUser != null ||
|
||||
rule.maxPerDay != null ||
|
||||
rule.maxCoinsPerDay != null ||
|
||||
(rule.cooldownMinutes != null && rule.cooldownMinutes > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── بازههای زمانی (تقویم شمسی، وقت تهران) ───────── */
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function jalaliOfKey(key: string) {
|
||||
const [gy, gm, gd] = key.split('-').map(Number) as [number, number, number];
|
||||
return toJalali(gy, gm, gd);
|
||||
}
|
||||
|
||||
function keyOfJalali(jy: number, jm: number, jd: number): string {
|
||||
const g = toGregorian(jy, jm, jd);
|
||||
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
|
||||
}
|
||||
|
||||
function shiftJalaliMonth(jy: number, jm: number, delta: number) {
|
||||
const idx = jy * 12 + (jm - 1) + delta;
|
||||
return { jy: Math.floor(idx / 12), jm: (idx % 12) + 1 };
|
||||
}
|
||||
|
||||
function periodStartKey(period: Exclude<GamificationPeriod, 'all'>, key: string): string {
|
||||
if (period === 'today') return key;
|
||||
if (period === 'week') return weekStartKey(key);
|
||||
const { jy, jm } = jalaliOfKey(key);
|
||||
if (period === 'month') return keyOfJalali(jy, jm, 1);
|
||||
if (period === 'quarter') return keyOfJalali(jy, Math.floor((jm - 1) / 3) * 3 + 1, 1);
|
||||
return keyOfJalali(jy, 1, 1);
|
||||
}
|
||||
|
||||
export function periodStart(period: GamificationPeriod, now: Date = new Date()): Date | null {
|
||||
if (period === 'all') return null;
|
||||
return tehranDayStart(periodStartKey(period, tehranDayKey(now)));
|
||||
}
|
||||
|
||||
/** بازهٔ هماندازهٔ قبلی، برای محاسبهٔ رشد */
|
||||
export function previousPeriod(
|
||||
period: GamificationPeriod,
|
||||
now: Date = new Date(),
|
||||
): { from: Date; to: Date } | null {
|
||||
if (period === 'all') return null;
|
||||
const startKey = periodStartKey(period, tehranDayKey(now));
|
||||
let prevKey: string;
|
||||
if (period === 'today') prevKey = addDays(startKey, -1);
|
||||
else if (period === 'week') prevKey = addDays(startKey, -7);
|
||||
else {
|
||||
const months = period === 'month' ? 1 : period === 'quarter' ? 3 : 12;
|
||||
const { jy, jm } = jalaliOfKey(startKey);
|
||||
const p = shiftJalaliMonth(jy, jm, -months);
|
||||
prevKey = keyOfJalali(p.jy, p.jm, 1);
|
||||
}
|
||||
return { from: tehranDayStart(prevKey), to: tehranDayStart(startKey) };
|
||||
}
|
||||
|
||||
/** کلید ماه شمسی، مثل 1405-07 */
|
||||
export function jalaliMonthKeyOf(now: Date = new Date()): string {
|
||||
const { jy, jm } = jalaliOfKey(tehranDayKey(now));
|
||||
return `${jy}-${pad(jm)}`;
|
||||
}
|
||||
|
||||
/* ───────── سطح و رتبه ───────── */
|
||||
|
||||
export function normalizeLevels(raw: unknown): GamificationLevelDef[] {
|
||||
if (!Array.isArray(raw)) return DEFAULT_LEVELS;
|
||||
const levels = raw
|
||||
.filter((l): l is { name: unknown; minScore: unknown } => !!l && typeof l === 'object')
|
||||
.map((l) => ({ name: String(l.name ?? '').trim(), minScore: Math.max(0, Math.floor(Number(l.minScore) || 0)) }))
|
||||
.filter((l) => l.name)
|
||||
.sort((a, b) => a.minScore - b.minScore);
|
||||
if (!levels.length) return DEFAULT_LEVELS;
|
||||
levels[0] = { ...levels[0]!, minScore: 0 };
|
||||
return levels.slice(0, 20);
|
||||
}
|
||||
|
||||
export function levelFor(score: number, levels: GamificationLevelDef[]): GamificationLevelDto {
|
||||
const list = levels.length ? levels : DEFAULT_LEVELS;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
if (score >= list[i]!.minScore) idx = i;
|
||||
}
|
||||
const cur = list[idx]!;
|
||||
const next = list[idx + 1] ?? null;
|
||||
const span = next ? next.minScore - cur.minScore : 0;
|
||||
const progress = next
|
||||
? Math.max(0, Math.min(100, Math.round(((score - cur.minScore) / Math.max(1, span)) * 100)))
|
||||
: 100;
|
||||
return {
|
||||
level: idx + 1,
|
||||
name: cur.name,
|
||||
minScore: cur.minScore,
|
||||
nextName: next?.name ?? null,
|
||||
nextMinScore: next?.minScore ?? null,
|
||||
progress,
|
||||
toNext: next ? Math.max(0, next.minScore - score) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ScoreRow = { userId: string; score: number };
|
||||
|
||||
/** رتبهبندی رقابتی (۱، ۲، ۲، ۴)؛ همامتیازها به ترتیب نام ثابت میمانند */
|
||||
export function rankRows<T extends ScoreRow>(
|
||||
rows: T[],
|
||||
tieBreak: (a: T, b: T) => number = () => 0,
|
||||
): Array<T & { rank: number }> {
|
||||
const sorted = [...rows].sort((a, b) => b.score - a.score || tieBreak(a, b));
|
||||
let lastScore: number | null = null;
|
||||
let lastRank = 0;
|
||||
return sorted.map((row, i) => {
|
||||
if (row.score !== lastScore) {
|
||||
lastRank = i + 1;
|
||||
lastScore = row.score;
|
||||
}
|
||||
return { ...row, rank: lastRank };
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────── روزهای پیاپی ───────── */
|
||||
|
||||
/**
|
||||
* روزهای پیاپی که همهٔ روتینها انجام شدهاند.
|
||||
* روزهای بدون روتین زنجیره را نمیشکنند؛ امروزِ ناتمام هم نادیده گرفته میشود.
|
||||
*/
|
||||
export function routineStreak(
|
||||
days: Map<string, { total: number; completed: number }>,
|
||||
today: string,
|
||||
maxDays = 120,
|
||||
): number {
|
||||
let streak = 0;
|
||||
for (let i = 0; i < maxDays; i += 1) {
|
||||
const key = addDays(today, -i);
|
||||
const d = days.get(key);
|
||||
if (!d || d.total === 0) continue;
|
||||
if (d.completed >= d.total) {
|
||||
streak += 1;
|
||||
continue;
|
||||
}
|
||||
if (i === 0) continue;
|
||||
break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { GamificationService } from './gamification.service';
|
||||
import { RedeemDto, TxQueryDto } from './dto';
|
||||
|
||||
@Controller('gamification')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('gamification')
|
||||
export class GamificationController {
|
||||
constructor(private readonly game: GamificationService) {}
|
||||
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: Actor, @Query('period') period?: string) {
|
||||
return this.game.me(user, period);
|
||||
}
|
||||
|
||||
@Get('pulse')
|
||||
pulse(@CurrentUser() user: Actor, @Query('since') since?: string) {
|
||||
return this.game.pulse(user, since);
|
||||
}
|
||||
|
||||
@Get('leaderboard')
|
||||
leaderboard(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('period') period?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.game.leaderboard(user, period, limit);
|
||||
}
|
||||
|
||||
@Get('leaderboard/teams')
|
||||
teams(@CurrentUser() user: Actor, @Query('period') period?: string) {
|
||||
return this.game.teams(user, period);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
transactions(@CurrentUser() user: Actor, @Query() q: TxQueryDto) {
|
||||
return this.game.transactions(user, q);
|
||||
}
|
||||
|
||||
@Get('rules')
|
||||
rules(@CurrentUser() user: Actor) {
|
||||
return this.game.publicRules(user);
|
||||
}
|
||||
|
||||
@Get('feed')
|
||||
feed(@CurrentUser() user: Actor) {
|
||||
return this.game.feed(user);
|
||||
}
|
||||
|
||||
@Get('achievements')
|
||||
achievements(@CurrentUser() user: Actor, @Query('userId') userId?: string) {
|
||||
return this.game.achievements(user, userId);
|
||||
}
|
||||
|
||||
@Get('rewards')
|
||||
rewards(@CurrentUser() user: Actor) {
|
||||
return this.game.rewards(user);
|
||||
}
|
||||
|
||||
@Post('rewards/redeem')
|
||||
redeem(@CurrentUser() user: Actor, @Body() dto: RedeemDto) {
|
||||
return this.game.redeem(user, dto.rewardId, dto.note);
|
||||
}
|
||||
|
||||
@Get('redemptions')
|
||||
redemptions(@CurrentUser() user: Actor) {
|
||||
return this.game.myRedemptions(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { GamificationController } from './gamification.controller';
|
||||
import { GamificationAdminController } from './gamification-admin.controller';
|
||||
import { GamificationService } from './gamification.service';
|
||||
import { GamificationAdminService } from './gamification-admin.service';
|
||||
import { GamificationCronService } from './gamification-cron.service';
|
||||
|
||||
/** سراسری است تا سرویسهای دیگر بدون import ماژول، رویداد امتیاز بفرستند */
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [GamificationController, GamificationAdminController],
|
||||
providers: [GamificationService, GamificationAdminService, GamificationCronService],
|
||||
exports: [GamificationService],
|
||||
})
|
||||
export class GamificationModule {}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user