feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"));
|
||||
+987
-785
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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user