feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشان‌ها، پنل مدیریت و گزارش ROI)

- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency
- اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت
- صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو
- انتقال فرم‌ساز سرنخ به ابزارها

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-10 21:28:49 +03:30
co-authored by Cursor
parent b87ebb22e0
commit 60d797f184
51 changed files with 11298 additions and 806 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts"
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts"
},
"dependencies": {
"@mento/shared": "*",
@@ -0,0 +1,255 @@
-- CreateTable
CREATE TABLE "GamificationSettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"levels" JSONB NOT NULL DEFAULT '[]',
"monthlyGoal" INTEGER NOT NULL DEFAULT 1500,
"showFullLeaderboard" BOOLEAN NOT NULL DEFAULT true,
"enabledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationRule" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"event" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"conditions" JSONB NOT NULL DEFAULT '[]',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"maxPerDay" INTEGER,
"maxCoinsPerDay" INTEGER,
"maxPerUser" INTEGER,
"cooldownMinutes" INTEGER,
"oncePerEntity" BOOLEAN NOT NULL DEFAULT false,
"firstTimeOnly" BOOLEAN NOT NULL DEFAULT false,
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
"roles" TEXT[] DEFAULT ARRAY[]::TEXT[],
"teamIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationTransaction" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"event" TEXT,
"ruleId" TEXT,
"bonusId" TEXT,
"badgeId" TEXT,
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'approved',
"title" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"entityTitle" TEXT,
"idempotencyKey" TEXT,
"reason" TEXT,
"createdById" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GamificationTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationWallet" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"coinsEarned" INTEGER NOT NULL DEFAULT 0,
"bestMonthScore" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationWallet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationReward" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"cost" INTEGER NOT NULL,
"icon" TEXT NOT NULL DEFAULT 'gift',
"color" TEXT NOT NULL DEFAULT '#14b8a6',
"stock" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"requiresApproval" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationRedemption" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rewardId" TEXT NOT NULL,
"cost" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"note" TEXT,
"decisionNote" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationBadge" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT NOT NULL DEFAULT 'medal',
"color" TEXT NOT NULL DEFAULT '#f59e0b',
"metric" TEXT NOT NULL,
"event" TEXT,
"threshold" INTEGER NOT NULL,
"coins" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationBadge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationUserBadge" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"badgeId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GamificationUserBadge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationBonus" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"kind" TEXT NOT NULL,
"event" TEXT,
"threshold" INTEGER NOT NULL DEFAULT 1,
"period" TEXT NOT NULL DEFAULT 'month',
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationBonus_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GamificationSettings_organizationId_key" ON "GamificationSettings"("organizationId");
-- CreateIndex
CREATE INDEX "GamificationRule_organizationId_event_isActive_idx" ON "GamificationRule"("organizationId", "event", "isActive");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_createdAt_idx" ON "GamificationTransaction"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_userId_createdAt_idx" ON "GamificationTransaction"("organizationId", "userId", "createdAt");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_ruleId_userId_create_idx" ON "GamificationTransaction"("organizationId", "ruleId", "userId", "createdAt");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_status_idx" ON "GamificationTransaction"("organizationId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "GamificationTransaction_organizationId_idempotencyKey_key" ON "GamificationTransaction"("organizationId", "idempotencyKey");
-- CreateIndex
CREATE INDEX "GamificationWallet_organizationId_score_idx" ON "GamificationWallet"("organizationId", "score");
-- CreateIndex
CREATE UNIQUE INDEX "GamificationWallet_organizationId_userId_key" ON "GamificationWallet"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "GamificationReward_organizationId_isActive_idx" ON "GamificationReward"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "GamificationRedemption_organizationId_status_idx" ON "GamificationRedemption"("organizationId", "status");
-- CreateIndex
CREATE INDEX "GamificationRedemption_organizationId_userId_idx" ON "GamificationRedemption"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "GamificationBadge_organizationId_isActive_idx" ON "GamificationBadge"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "GamificationUserBadge_organizationId_userId_idx" ON "GamificationUserBadge"("organizationId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "GamificationUserBadge_badgeId_userId_key" ON "GamificationUserBadge"("badgeId", "userId");
-- CreateIndex
CREATE INDEX "GamificationBonus_organizationId_isActive_idx" ON "GamificationBonus"("organizationId", "isActive");
-- AddForeignKey
ALTER TABLE "GamificationSettings" ADD CONSTRAINT "GamificationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationRule" ADD CONSTRAINT "GamificationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationTransaction" ADD CONSTRAINT "GamificationTransaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationWallet" ADD CONSTRAINT "GamificationWallet_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationReward" ADD CONSTRAINT "GamificationReward_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_rewardId_fkey" FOREIGN KEY ("rewardId") REFERENCES "GamificationReward"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationBadge" ADD CONSTRAINT "GamificationBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "GamificationBadge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationBonus" ADD CONSTRAINT "GamificationBonus_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Enable the new module for orgs with an explicit module list
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'gamification')
WHERE cardinality("enabledModules") > 0 AND NOT ('gamification' = ANY("enabledModules"));
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,8 @@ import {
ownerWhere,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import {
@@ -96,6 +98,7 @@ export class ActivitiesService {
private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
@@ -429,6 +432,22 @@ export class ActivitiesService {
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
const gameBase = {
organizationId,
userId,
entityType: 'activity',
entityId: row.id,
entityTitle: row.title,
facts: {
activityType: row.type,
typeName: row.typeDef?.name ?? null,
priority: row.priority,
hasDescription: !!row.description?.trim(),
},
};
this.game.emit({ ...gameBase, event: 'ACTIVITY_CREATED' });
if (row.type === 'call') this.game.emit({ ...gameBase, event: 'CALL_LOGGED' });
if (row.type === 'meeting') this.game.emit({ ...gameBase, event: 'MEETING_CREATED' });
if (initialStage?.kind === 'done') {
await this.fireDone(organizationId, row);
}
@@ -708,8 +727,26 @@ export class ActivitiesService {
company: { id: string } | null;
deal: { id: string } | null;
assignee: { id: string } | null;
type: string;
priority: string;
dueAt: Date | null;
typeDef?: { name: string } | null;
},
) {
this.game.emit({
organizationId,
userId: row.assignee?.id,
event: 'TASK_COMPLETED',
entityType: 'activity',
entityId: row.id,
entityTitle: row.title,
facts: {
activityType: row.type,
typeName: row.typeDef?.name ?? null,
priority: row.priority,
onTime: !row.dueAt || tehranDayKey() <= tehranDayKey(row.dueAt),
},
});
const ctx = {
organizationId,
entityType: 'activity' as const,
+2
View File
@@ -48,6 +48,7 @@ import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
import { ConversationsModule } from './conversations/conversations.module';
import { RoutinesModule } from './routines/routines.module';
import { GamificationModule } from './gamification/gamification.module';
@Module({
imports: [
@@ -103,6 +104,7 @@ import { RoutinesModule } from './routines/routines.module';
ReservationsModule,
ConversationsModule,
RoutinesModule,
GamificationModule,
],
})
export class AppModule {}
+15 -1
View File
@@ -26,6 +26,7 @@ import {
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null;
@@ -70,7 +71,10 @@ type CompanyRow = {
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly game: GamificationService,
) {}
async list(
actor: Actor,
@@ -157,6 +161,16 @@ export class CompaniesService {
data,
include: companyInclude,
});
if (row.importSource === 'manual') {
this.game.emit({
organizationId: actor.organizationId,
userId: actor.userId,
event: 'COMPANY_CREATED',
entityType: 'company',
entityId: row.id,
entityTitle: row.name,
});
}
return this.toDto(row);
}
+13
View File
@@ -31,6 +31,7 @@ import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import {
@@ -90,6 +91,7 @@ export class PeopleService {
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService,
private readonly game: GamificationService,
) {}
async list(
@@ -226,6 +228,17 @@ export class PeopleService {
};
await this.automations.runTrigger('person_created', createdCtx);
await this.messageAutomations.onEvent('person_created', createdCtx);
if (row.importSource === 'manual') {
this.game.emit({
organizationId: actor.organizationId,
userId: actor.userId,
event: 'CUSTOMER_CREATED',
entityType: 'person',
entityId: row.id,
entityTitle: createdCtx.title,
facts: { source: row.importSource },
});
}
this.webhooks.emit(actor.organizationId, 'person.created', {
id: row.id,
fullName: createdCtx.title,
+341
View File
@@ -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
+23 -1
View File
@@ -21,6 +21,7 @@ import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { InventoryService } from '../inventory/inventory.service';
import { CommissionsService } from '../finance/commissions.service';
import { GamificationService } from '../gamification/gamification.service';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
@@ -52,6 +53,7 @@ export class InvoicesService {
private readonly messageAutomations: MessageAutomationsService,
private readonly inventory: InventoryService,
private readonly commissions: CommissionsService,
private readonly game: GamificationService,
) {}
async list(
@@ -477,7 +479,7 @@ export class InvoicesService {
}
}
await this.prisma.$transaction(async (tx) => {
const paymentId = await this.prisma.$transaction(async (tx) => {
const payment = await tx.payment.create({
data: {
invoiceId,
@@ -525,6 +527,7 @@ export class InvoicesService {
data: { status: 'issued', issuedAt: new Date() },
});
}
return payment.id;
});
await this.recomputeStatus(invoiceId);
@@ -546,6 +549,16 @@ export class InvoicesService {
};
await this.automations.runTrigger('invoice_paid', paidCtx);
await this.messageAutomations.onEvent('invoice_paid', paidCtx);
this.game.emit({
organizationId,
userId: actor.userId,
event: 'PAYMENT_REGISTERED',
entityType: 'invoice',
entityId: row.id,
entityTitle: paidCtx.title,
eventKey: paymentId,
facts: { amount: dto.amount },
});
return row;
}
@@ -666,6 +679,15 @@ export class InvoicesService {
};
await this.automations.runTrigger('invoice_issued', ctx);
await this.messageAutomations.onEvent('invoice_issued', ctx);
this.game.emit({
organizationId,
userId: row.owner?.id,
event: 'INVOICE_ISSUED',
entityType: 'invoice',
entityId: row.id,
entityTitle: ctx.title,
facts: { amount: row.payableAmount },
});
}
private async ensureExists(
+65 -2
View File
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import type {
ConvertLeadResult,
LeadBoardColumnDto,
@@ -30,6 +31,7 @@ import { emptyToNull, normalizePhone } from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage';
import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = {
@@ -77,8 +79,27 @@ export class LeadsService {
private readonly prisma: PrismaService,
private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage,
private readonly game: GamificationService,
) {}
private gameEvent(
actor: Actor,
event: Parameters<GamificationService['emit']>[0]['event'],
lead: { id: string; title: string },
extra: { userId?: string | null; eventKey?: string; facts?: Record<string, unknown> } = {},
) {
this.game.emit({
organizationId: actor.organizationId,
userId: extra.userId ?? actor.userId,
event,
entityType: 'lead',
entityId: lead.id,
entityTitle: lead.title,
eventKey: extra.eventKey,
facts: extra.facts,
});
}
async list(
actor: Actor,
opts: {
@@ -325,6 +346,12 @@ export class LeadsService {
{ assigneeId },
);
}
this.gameEvent(actor, 'LEAD_CREATED', row, {
facts: {
source: row.source,
amount: row.estimatedValue == null ? null : Number(row.estimatedValue),
},
});
return this.toDto(row);
}
@@ -436,6 +463,10 @@ export class LeadsService {
'stage_changed',
`وضعیت به «${row.stage.name}» تغییر کرد`,
);
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${row.stageId}`,
facts: { stageName: row.stage.name, source: row.source },
});
}
return this.toDto(row);
}
@@ -446,7 +477,7 @@ export class LeadsService {
stageId: string,
position?: number,
): Promise<LeadDto> {
await this.findAccessible(actor, id);
const before = await this.findAccessible(actor, id);
const stage = await this.prisma.leadStage.findFirst({
where: { id: stageId, organizationId: actor.organizationId },
});
@@ -469,6 +500,12 @@ export class LeadsService {
'stage_changed',
`به مرحله «${stage.name}» منتقل شد`,
);
if (before.stageId !== stageId) {
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${stageId}`,
facts: { stageName: stage.name, source: row.source },
});
}
return this.toDto(row);
}
@@ -637,7 +674,7 @@ export class LeadsService {
stageId?: string;
},
): Promise<LeadDto> {
await this.findAccessible(actor, id);
const before = await this.findAccessible(actor, id);
const title = dto.outcome
? `تماس ثبت شد — ${dto.outcome}`
: 'تماس ثبت شد';
@@ -658,6 +695,16 @@ export class LeadsService {
data,
include: leadInclude,
});
this.gameEvent(actor, 'CALL_LOGGED', row, {
eventKey: randomUUID(),
facts: { hasDescription: !!dto.body?.trim() },
});
if (dto.stageId && dto.stageId !== before.stageId) {
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${dto.stageId}`,
facts: { stageName: row.stage.name, source: row.source },
});
}
return this.toDto(row);
}
@@ -665,6 +712,10 @@ export class LeadsService {
await this.findAccessible(actor, id);
await this.addEvent(actor, id, 'note', 'یادداشت', body);
const row = await this.findAccessible(actor, id);
this.gameEvent(actor, 'NOTE_CREATED', row, {
eventKey: randomUUID(),
facts: { hasDescription: body.trim().length > 0 },
});
return this.toDto(row);
}
@@ -689,6 +740,10 @@ export class LeadsService {
data: { nextFollowUpAt: at },
include: leadInclude,
});
this.gameEvent(actor, 'FOLLOW_UP_CREATED', row, {
eventKey: randomUUID(),
facts: { hasDescription: !!body?.trim() },
});
return this.toDto(row);
}
@@ -808,6 +863,14 @@ export class LeadsService {
})
.catch(() => undefined);
this.gameEvent(actor, 'LEAD_CONVERTED', row, {
userId: lead.assigneeId ?? actor.userId,
facts: {
source: lead.source,
amount: lead.estimatedValue == null ? null : Number(lead.estimatedValue),
},
});
return {
lead: this.toDto(row),
personId: person.id,
+11
View File
@@ -19,6 +19,7 @@ import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { LocalFileStorage } from '../storage/local-file.storage';
import { GamificationService } from '../gamification/gamification.service';
import {
type Actor,
noteAccessWhere,
@@ -110,6 +111,7 @@ export class NotesService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly game: GamificationService,
) {}
async list(
@@ -229,6 +231,15 @@ export class NotesService {
},
include,
});
this.game.emit({
organizationId,
userId,
event: 'NOTE_CREATED',
entityType: 'note',
entityId: row.id,
entityTitle: row.title ?? row.body.slice(0, 60),
facts: { hasDescription: row.body.trim().length >= 20 },
});
return this.toDto(row);
}
+29
View File
@@ -18,6 +18,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import {
type Actor,
canReassignOwner,
@@ -49,6 +50,7 @@ export class DealsService {
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService,
private readonly game: GamificationService,
) {}
async get(
@@ -188,6 +190,15 @@ export class DealsService {
};
await this.automations.runTrigger('deal_created', createdCtx);
await this.messageAutomations.onEvent('deal_created', createdCtx);
this.game.emit({
organizationId,
userId: actor.userId,
event: 'DEAL_CREATED',
entityType: 'deal',
entityId: created.id,
entityTitle: created.title,
facts: { amount: created.amount == null ? null : Number(created.amount) },
});
this.webhooks.emit(organizationId, 'deal.created', {
id: created.id,
title: created.title,
@@ -390,6 +401,24 @@ export class DealsService {
};
await this.automations.runTrigger('deal_stage_changed', ctx);
await this.messageAutomations.onEvent('deal_stage_changed', ctx);
const gameBase = {
organizationId,
userId: deal.ownerId ?? actor.userId,
entityType: 'deal',
entityId: deal.id,
entityTitle: deal.title,
facts: {
stageName: stage.name,
amount: row.amount == null ? null : Number(row.amount),
},
};
this.game.emit({ ...gameBase, event: 'DEAL_STAGE_CHANGED', eventKey: `${deal.id}:${stage.id}` });
if (status === 'won' && deal.status !== 'won') {
this.game.emit({ ...gameBase, event: 'DEAL_WON' });
}
if (status === 'lost' && deal.status !== 'lost') {
this.game.emit({ ...gameBase, event: 'DEAL_LOST' });
}
if (status === 'won' && deal.status !== 'won') {
await this.automations.runTrigger('deal_won', ctx);
await this.messageAutomations.onEvent('deal_won', ctx);
+16 -2
View File
@@ -14,6 +14,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { type Actor, ownerWhere } from '../common/record-scope';
import { GamificationService } from '../gamification/gamification.service';
type QuoteActor = Pick<Actor, 'userId' | 'role'>;
@@ -29,7 +30,10 @@ type QuoteRow = Prisma.QuoteGetPayload<{ include: typeof include }>;
@Injectable()
export class QuotesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly game: GamificationService,
) {}
async list(
organizationId: string,
@@ -183,7 +187,17 @@ export class QuotesService {
},
include,
});
return this.toDto(row);
const quote = this.toDto(row);
this.game.emit({
organizationId,
userId,
event: 'QUOTE_CREATED',
entityType: 'quote',
entityId: row.id,
entityTitle: row.title,
facts: { amount: quote.payableAmount },
});
return quote;
}
async update(
@@ -39,6 +39,7 @@ import {
} from './routine-mapper';
import { DEFAULT_REMIND_MINUTES } from './routine-generator.service';
import { RoutineStatsService, effectiveTotal } from './routine-stats.service';
import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
dayKeyToDate,
@@ -55,6 +56,7 @@ export class RoutineTasksService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly stats: RoutineStatsService,
private readonly game: GamificationService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
@@ -395,6 +397,19 @@ export class RoutineTasksService {
{ status: 'completed', completedAt: new Date(), resultNote: note?.trim() || row.resultNote },
[{ type: 'status_changed', note: note?.trim() || undefined }],
);
this.game.emit({
organizationId: actor.organizationId,
userId: row.assigneeId,
event: row.routineId ? 'ROUTINE_COMPLETED' : 'TASK_COMPLETED',
entityType: 'routine_task',
entityId: row.id,
entityTitle: row.title,
facts: {
onTime: row.status !== 'overdue' && (!row.dueAt || Date.now() <= row.dueAt.getTime()),
isMandatory: row.isMandatory,
priority: row.priority,
},
});
return toTaskDto(updated);
}
+20
View File
@@ -23,6 +23,7 @@ import {
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { GamificationService } from '../gamification/gamification.service';
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -45,6 +46,7 @@ export class TicketsService {
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly game: GamificationService,
) {}
async list(
@@ -200,6 +202,15 @@ export class TicketsService {
};
await this.automations.runTrigger('ticket_created', createdCtx);
await this.messageAutomations.onEvent('ticket_created', createdCtx);
this.game.emit({
organizationId: actor.organizationId,
userId: actor.userId,
event: 'TICKET_CREATED',
entityType: 'ticket',
entityId: row.id,
entityTitle: row.subject,
facts: { priority: row.priority },
});
return this.toDto(row);
}
@@ -319,6 +330,15 @@ export class TicketsService {
};
await this.automations.runTrigger('ticket_resolved', resolvedCtx);
await this.messageAutomations.onEvent('ticket_resolved', resolvedCtx);
this.game.emit({
organizationId: actor.organizationId,
userId: (data.assigneeId as string | null | undefined) ?? existing.assigneeId ?? actor.userId,
event: 'TICKET_RESOLVED',
entityType: 'ticket',
entityId: existing.id,
entityTitle: existing.subject,
facts: { priority: (dto.priority ?? existing.priority) as string },
});
}
return this.get(actor, id);
+196
View File
@@ -0,0 +1,196 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_LEVELS,
evaluateCondition,
jalaliMonthKeyOf,
levelFor,
limitBlockReason,
matchesConditions,
needsUsage,
normalizeLevels,
periodStart,
previousPeriod,
rankRows,
routineStreak,
sanitizeConditions,
} from '../src/gamification/gamification-engine';
/** پنجشنبه ۱۶ مهر ۱۴۰۵، ساعت ۱۲:۳۰ تهران */
const NOW = new Date('2026-10-08T09:00:00Z');
describe('gamification conditions', () => {
it('compares numbers (also numeric strings with commas)', () => {
assert.equal(evaluateCondition({ field: 'amount', op: 'gt', value: 1000 }, { amount: 1500 }), true);
assert.equal(evaluateCondition({ field: 'amount', op: 'gte', value: '1,500' }, { amount: 1500 }), true);
assert.equal(evaluateCondition({ field: 'amount', op: 'lt', value: 1000 }, { amount: 1500 }), false);
assert.equal(evaluateCondition({ field: 'amount', op: 'gt', value: 10 }, {}), false);
});
it('handles text, in-lists and booleans', () => {
assert.equal(evaluateCondition({ field: 'source', op: 'eq', value: 'Instagram' }, { source: 'instagram ' }), true);
assert.equal(evaluateCondition({ field: 'source', op: 'neq', value: 'site' }, { source: 'instagram' }), true);
assert.equal(evaluateCondition({ field: 'stageName', op: 'contains', value: 'مذاکره' }, { stageName: 'در حال مذاکره' }), true);
assert.equal(evaluateCondition({ field: 'priority', op: 'in', value: 'high، urgent' }, { priority: 'urgent' }), true);
assert.equal(evaluateCondition({ field: 'priority', op: 'in', value: 'high,urgent' }, { priority: 'low' }), false);
assert.equal(evaluateCondition({ field: 'onTime', op: 'is_true' }, { onTime: true }), true);
assert.equal(evaluateCondition({ field: 'onTime', op: 'is_false' }, { onTime: false }), true);
assert.equal(evaluateCondition({ field: 'onTime', op: 'is_false' }, {}), true);
});
it('requires all conditions (AND)', () => {
const conds = [
{ field: 'amount', op: 'gte' as const, value: 100 },
{ field: 'onTime', op: 'is_true' as const },
];
assert.equal(matchesConditions(conds, { amount: 200, onTime: true }), true);
assert.equal(matchesConditions(conds, { amount: 200, onTime: false }), false);
assert.equal(matchesConditions([], {}), true);
});
it('sanitizes unknown fields/ops and caps the list', () => {
const raw = [
{ field: 'amount', op: 'gt', value: 5 },
{ field: 'hack', op: 'gt', value: 5 },
{ field: 'amount', op: 'drop', value: 5 },
{ field: 'onTime', op: 'is_true', value: { x: 1 } },
...Array.from({ length: 20 }, () => ({ field: 'hour', op: 'lt', value: 10 })),
];
const out = sanitizeConditions(raw);
assert.equal(out.length, 10);
assert.deepEqual(out[0], { field: 'amount', op: 'gt', value: 5 });
assert.deepEqual(out[1], { field: 'onTime', op: 'is_true', value: null });
assert.deepEqual(sanitizeConditions('nope'), []);
});
});
describe('gamification limits', () => {
const base = {
maxPerDay: null,
maxCoinsPerDay: null,
maxPerUser: null,
cooldownMinutes: null,
firstTimeOnly: false,
coins: 0,
};
const usage = { todayCount: 0, todayCoins: 0, totalCount: 0, lastAt: null };
it('allows when no limit is set', () => {
assert.equal(limitBlockReason(base, { ...usage, todayCount: 99, totalCount: 999 }, NOW), null);
assert.equal(needsUsage(base), false);
});
it('blocks per day, per user, first time and coin caps', () => {
assert.equal(limitBlockReason({ ...base, maxPerDay: 3 }, { ...usage, todayCount: 3 }, NOW), 'max_per_day');
assert.equal(limitBlockReason({ ...base, maxPerDay: 3 }, { ...usage, todayCount: 2 }, NOW), null);
assert.equal(limitBlockReason({ ...base, maxPerUser: 1 }, { ...usage, totalCount: 1 }, NOW), 'max_per_user');
assert.equal(limitBlockReason({ ...base, firstTimeOnly: true }, { ...usage, totalCount: 1 }, NOW), 'first_time_only');
assert.equal(
limitBlockReason({ ...base, coins: 10, maxCoinsPerDay: 50 }, { ...usage, todayCoins: 45 }, NOW),
'max_coins_per_day',
);
assert.equal(limitBlockReason({ ...base, coins: 5, maxCoinsPerDay: 50 }, { ...usage, todayCoins: 45 }, NOW), null);
assert.equal(needsUsage({ ...base, maxPerDay: 1 }), true);
});
it('respects the cooldown window', () => {
const rule = { ...base, cooldownMinutes: 30 };
const recent = new Date(NOW.getTime() - 10 * 60_000);
const old = new Date(NOW.getTime() - 31 * 60_000);
assert.equal(limitBlockReason(rule, { ...usage, lastAt: recent }, NOW), 'cooldown');
assert.equal(limitBlockReason(rule, { ...usage, lastAt: old }, NOW), null);
});
});
describe('gamification periods (Jalali, Tehran)', () => {
it('computes period starts at Tehran midnight', () => {
assert.equal(periodStart('today', NOW)?.toISOString(), '2026-10-07T20:30:00.000Z');
assert.equal(periodStart('week', NOW)?.toISOString(), '2026-10-02T20:30:00.000Z');
assert.equal(periodStart('month', NOW)?.toISOString(), '2026-09-22T20:30:00.000Z');
assert.equal(periodStart('quarter', NOW)?.toISOString(), '2026-09-22T20:30:00.000Z');
assert.equal(periodStart('year', NOW)?.toISOString(), '2026-03-20T20:30:00.000Z');
assert.equal(periodStart('all', NOW), null);
});
it('computes the previous equal-sized period', () => {
const month = previousPeriod('month', NOW);
assert.equal(month?.from.toISOString(), '2026-08-22T20:30:00.000Z');
assert.equal(month?.to.toISOString(), '2026-09-22T20:30:00.000Z');
const week = previousPeriod('week', NOW);
assert.equal(week?.from.toISOString(), '2026-09-25T20:30:00.000Z');
assert.equal(previousPeriod('all', NOW), null);
});
it('builds the Jalali month key', () => {
assert.equal(jalaliMonthKeyOf(NOW), '1405-07');
});
});
describe('gamification levels and ranking', () => {
it('maps score to level with progress', () => {
const l0 = levelFor(0, DEFAULT_LEVELS);
assert.equal(l0.level, 1);
assert.equal(l0.toNext, 250);
const mid = levelFor(900, DEFAULT_LEVELS);
assert.equal(mid.name, 'نقره‌ای');
assert.equal(mid.progress, 50);
const top = levelFor(10_000, DEFAULT_LEVELS);
assert.equal(top.level, DEFAULT_LEVELS.length);
assert.equal(top.progress, 100);
assert.equal(top.nextName, null);
});
it('normalizes custom levels', () => {
const levels = normalizeLevels([
{ name: 'B', minScore: 100 },
{ name: ' ', minScore: 5 },
{ name: 'A', minScore: 40 },
]);
assert.deepEqual(levels, [
{ name: 'A', minScore: 0 },
{ name: 'B', minScore: 100 },
]);
assert.equal(normalizeLevels(null), DEFAULT_LEVELS);
});
it('uses competition ranking for ties', () => {
const ranked = rankRows([
{ userId: 'a', score: 50 },
{ userId: 'b', score: 80 },
{ userId: 'c', score: 50 },
{ userId: 'd', score: 10 },
]);
assert.deepEqual(
ranked.map((r) => [r.userId, r.rank]),
[
['b', 1],
['a', 2],
['c', 2],
['d', 4],
],
);
});
});
describe('routine streak', () => {
const today = '2026-10-08';
it('counts consecutive full days, skipping empty days and an unfinished today', () => {
const days = new Map([
['2026-10-08', { total: 3, completed: 1 }],
['2026-10-07', { total: 2, completed: 2 }],
['2026-10-06', { total: 0, completed: 0 }],
['2026-10-05', { total: 1, completed: 1 }],
['2026-10-04', { total: 2, completed: 1 }],
['2026-10-03', { total: 2, completed: 2 }],
]);
assert.equal(routineStreak(days, today), 2);
});
it('includes today when finished', () => {
const days = new Map([
['2026-10-08', { total: 1, completed: 1 }],
['2026-10-07', { total: 1, completed: 1 }],
]);
assert.equal(routineStreak(days, today), 2);
});
});