From 60d797f184f1d29b1011377bcb44bba906e2a9de Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 10 Oct 2026 21:28:49 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A8=D8=A7=D8=B4=DA=AF=D8=A7=D9=87=20?= =?UTF-8?q?=D8=A7=D9=85=D8=AA=DB=8C=D8=A7=D8=B2=D8=A7=D8=AA=20=DA=A9=D8=A7?= =?UTF-8?q?=D8=B1=DA=A9=D9=86=D8=A7=D9=86=20(=D9=85=D9=88=D8=AA=D9=88?= =?UTF-8?q?=D8=B1=20=D8=A7=D9=85=D8=AA=DB=8C=D8=A7=D8=B2=D8=8C=20=D8=AC?= =?UTF-8?q?=D8=AF=D9=88=D9=84=20=D8=B1=D8=AA=D8=A8=D9=87=D8=8C=20=D9=81?= =?UTF-8?q?=D8=B1=D9=88=D8=B4=DA=AF=D8=A7=D9=87=20=D9=BE=D8=A7=D8=AF=D8=A7?= =?UTF-8?q?=D8=B4=D8=8C=20=D9=86=D8=B4=D8=A7=D9=86=E2=80=8C=D9=87=D8=A7?= =?UTF-8?q?=D8=8C=20=D9=BE=D9=86=D9=84=20=D9=85=D8=AF=DB=8C=D8=B1=DB=8C?= =?UTF-8?q?=D8=AA=20=D9=88=20=DA=AF=D8=B2=D8=A7=D8=B1=D8=B4=20ROI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرم‌ساز سرنخ به ابزارها Co-authored-by: Cursor --- apps/api/package.json | 2 +- .../20261008120000_gamification/migration.sql | 255 +++ apps/api/prisma/schema.prisma | 1772 +++++++++-------- apps/api/src/activities/activities.service.ts | 37 + apps/api/src/app.module.ts | 2 + apps/api/src/contacts/companies.service.ts | 16 +- apps/api/src/contacts/people.service.ts | 13 + apps/api/src/gamification/dto.ts | 341 ++++ .../gamification-admin.controller.ts | 200 ++ .../gamification-admin.service.ts | 790 ++++++++ .../gamification/gamification-cron.service.ts | 58 + .../src/gamification/gamification-defaults.ts | 90 + .../src/gamification/gamification-engine.ts | 312 +++ .../gamification/gamification.controller.ts | 74 + .../src/gamification/gamification.module.ts | 17 + .../src/gamification/gamification.service.ts | 1167 +++++++++++ apps/api/src/invoices/invoices.service.ts | 24 +- apps/api/src/leads/leads.service.ts | 67 +- apps/api/src/notes/notes.service.ts | 11 + apps/api/src/pipelines/deals.service.ts | 29 + apps/api/src/quotes/quotes.service.ts | 18 +- .../api/src/routines/routine-tasks.service.ts | 15 + apps/api/src/tickets/tickets.service.ts | 20 + apps/api/test/gamification-engine.test.ts | 196 ++ .../components/gamification/ClubBadges.vue | 164 ++ .../components/gamification/ClubHistory.vue | 293 +++ .../src/components/gamification/ClubHome.vue | 528 +++++ .../gamification/ClubLeaderboard.vue | 378 ++++ .../gamification/ClubRulesModal.vue | 176 ++ .../src/components/gamification/ClubStore.vue | 353 ++++ .../src/components/gamification/ClubTeams.vue | 172 ++ .../gamification/GamificationToaster.vue | 228 +++ .../gamification/admin/AdminAchievements.vue | 531 +++++ .../gamification/admin/AdminAdjust.vue | 160 ++ .../gamification/admin/AdminApprovals.vue | 313 +++ .../gamification/admin/AdminReports.vue | 306 +++ .../gamification/admin/AdminRewards.vue | 321 +++ .../gamification/admin/AdminRules.vue | 354 ++++ .../gamification/admin/AdminSettings.vue | 232 +++ .../gamification/admin/RuleBuilderModal.vue | 534 +++++ .../components/gamification/club-context.ts | 15 + .../src/components/gamification/rule-text.ts | 15 + apps/web/src/layouts/AppLayout.vue | 31 +- apps/web/src/lib/gamification.ts | 103 + apps/web/src/lib/nav-hubs.ts | 18 +- apps/web/src/router/index.ts | 10 + .../gamification/GamificationAdminView.vue | 187 ++ .../views/gamification/GamificationView.vue | 653 ++++++ e2e/smoke.spec.ts | 1 + packages/shared/src/gamification.ts | 491 +++++ packages/shared/src/index.ts | 11 +- 51 files changed, 11298 insertions(+), 806 deletions(-) create mode 100644 apps/api/prisma/migrations/20261008120000_gamification/migration.sql create mode 100644 apps/api/src/gamification/dto.ts create mode 100644 apps/api/src/gamification/gamification-admin.controller.ts create mode 100644 apps/api/src/gamification/gamification-admin.service.ts create mode 100644 apps/api/src/gamification/gamification-cron.service.ts create mode 100644 apps/api/src/gamification/gamification-defaults.ts create mode 100644 apps/api/src/gamification/gamification-engine.ts create mode 100644 apps/api/src/gamification/gamification.controller.ts create mode 100644 apps/api/src/gamification/gamification.module.ts create mode 100644 apps/api/src/gamification/gamification.service.ts create mode 100644 apps/api/test/gamification-engine.test.ts create mode 100644 apps/web/src/components/gamification/ClubBadges.vue create mode 100644 apps/web/src/components/gamification/ClubHistory.vue create mode 100644 apps/web/src/components/gamification/ClubHome.vue create mode 100644 apps/web/src/components/gamification/ClubLeaderboard.vue create mode 100644 apps/web/src/components/gamification/ClubRulesModal.vue create mode 100644 apps/web/src/components/gamification/ClubStore.vue create mode 100644 apps/web/src/components/gamification/ClubTeams.vue create mode 100644 apps/web/src/components/gamification/GamificationToaster.vue create mode 100644 apps/web/src/components/gamification/admin/AdminAchievements.vue create mode 100644 apps/web/src/components/gamification/admin/AdminAdjust.vue create mode 100644 apps/web/src/components/gamification/admin/AdminApprovals.vue create mode 100644 apps/web/src/components/gamification/admin/AdminReports.vue create mode 100644 apps/web/src/components/gamification/admin/AdminRewards.vue create mode 100644 apps/web/src/components/gamification/admin/AdminRules.vue create mode 100644 apps/web/src/components/gamification/admin/AdminSettings.vue create mode 100644 apps/web/src/components/gamification/admin/RuleBuilderModal.vue create mode 100644 apps/web/src/components/gamification/club-context.ts create mode 100644 apps/web/src/components/gamification/rule-text.ts create mode 100644 apps/web/src/lib/gamification.ts create mode 100644 apps/web/src/views/gamification/GamificationAdminView.vue create mode 100644 apps/web/src/views/gamification/GamificationView.vue create mode 100644 packages/shared/src/gamification.ts diff --git a/apps/api/package.json b/apps/api/package.json index 3f4f6ec..c49d4d1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": "*", diff --git a/apps/api/prisma/migrations/20261008120000_gamification/migration.sql b/apps/api/prisma/migrations/20261008120000_gamification/migration.sql new file mode 100644 index 0000000..72da01c --- /dev/null +++ b/apps/api/prisma/migrations/20261008120000_gamification/migration.sql @@ -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")); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 324af8b..027727c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -141,152 +141,161 @@ enum MemorySourceType { } model Organization { - id String @id @default(cuid()) - name String - slug String @unique + id String @id @default(cuid()) + name String + slug String @unique /// زمینه فعالیت سازمان — فهرست هم‌تراز صنایع دیدار - businessField String? - baseCurrency String @default("IRR") - enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) + businessField String? + baseCurrency String @default("IRR") + enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) /// ماژول‌های فعال منو برای این محیط - enabledModules String[] @default([]) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - users User[] - memberships Membership[] - orgRoles OrgRole[] - teams Team[] - companies Company[] - people Person[] - pipelines Pipeline[] - deals Deal[] - activities Activity[] - notes Note[] - noteRevisions NoteRevision[] - customFields CustomFieldDefinition[] - products Product[] - notifications Notification[] - noteAttachments NoteAttachment[] - leadForms LeadForm[] - quotes Quote[] - invoices Invoice[] - salesGoals SalesGoal[] - exchangeRates ExchangeRate[] - tickets Ticket[] - faqArticles FaqArticle[] - portalOtps PortalOtp[] - projectBoards ProjectBoard[] - projectCards ProjectCard[] - loyaltyTiers LoyaltyTier[] - loyaltySettings LoyaltySettings? - loyaltyLedgers LoyaltyLedger[] - loyaltyRewards LoyaltyReward[] - loyaltyRedemptions LoyaltyRedemption[] - automationRules AutomationRule[] - messageAutomationRules MessageAutomationRule[] - messageAutomationJobs MessageAutomationJob[] - memoryItems MemoryItem[] - noteAiSuggestions NoteAiSuggestion[] - accountingConnections AccountingConnection[] - accountingSyncJobs AccountingSyncJob[] - warehouses Warehouse[] - stockMovements StockMovement[] - partyOpenings PartyOpeningBalance[] - commissionEntries CommissionEntry[] - cheques Cheque[] - auditLogs AuditLog[] - savedViews SavedView[] - apiKeys OrganizationApiKey[] - webhookEndpoints WebhookEndpoint[] - smsCampaigns SmsCampaign[] - wooCommerceConnections WooCommerceConnection[] - montifyConnections MontifyConnection[] - voipConnections VoipConnection[] - reservationServices ReservationService[] - reservationAppointments ReservationAppointment[] - leads Lead[] - leadStages LeadStage[] - activityStages ActivityStage[] - activityTypeDefs ActivityTypeDef[] - leadEvents LeadEvent[] - leadImportJobs LeadImportJob[] - conversationSettings ConversationSettings? - conversationRooms ConversationRoom[] - conversationHiddenMessages ConversationHiddenMessage[] - conversationPinnedMessages ConversationPinnedMessage[] - conversationRoomUserPins ConversationRoomUserPin[] - conversationRoomMembers ConversationRoomMember[] - conversationRoomUserMutes ConversationRoomUserMute[] + enabledModules String[] @default([]) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] + memberships Membership[] + orgRoles OrgRole[] + teams Team[] + companies Company[] + people Person[] + pipelines Pipeline[] + deals Deal[] + activities Activity[] + notes Note[] + noteRevisions NoteRevision[] + customFields CustomFieldDefinition[] + products Product[] + notifications Notification[] + noteAttachments NoteAttachment[] + leadForms LeadForm[] + quotes Quote[] + invoices Invoice[] + salesGoals SalesGoal[] + exchangeRates ExchangeRate[] + tickets Ticket[] + faqArticles FaqArticle[] + portalOtps PortalOtp[] + projectBoards ProjectBoard[] + projectCards ProjectCard[] + loyaltyTiers LoyaltyTier[] + loyaltySettings LoyaltySettings? + loyaltyLedgers LoyaltyLedger[] + loyaltyRewards LoyaltyReward[] + loyaltyRedemptions LoyaltyRedemption[] + automationRules AutomationRule[] + messageAutomationRules MessageAutomationRule[] + messageAutomationJobs MessageAutomationJob[] + memoryItems MemoryItem[] + noteAiSuggestions NoteAiSuggestion[] + accountingConnections AccountingConnection[] + accountingSyncJobs AccountingSyncJob[] + warehouses Warehouse[] + stockMovements StockMovement[] + partyOpenings PartyOpeningBalance[] + commissionEntries CommissionEntry[] + cheques Cheque[] + auditLogs AuditLog[] + savedViews SavedView[] + apiKeys OrganizationApiKey[] + webhookEndpoints WebhookEndpoint[] + smsCampaigns SmsCampaign[] + wooCommerceConnections WooCommerceConnection[] + montifyConnections MontifyConnection[] + voipConnections VoipConnection[] + reservationServices ReservationService[] + reservationAppointments ReservationAppointment[] + leads Lead[] + leadStages LeadStage[] + activityStages ActivityStage[] + activityTypeDefs ActivityTypeDef[] + leadEvents LeadEvent[] + leadImportJobs LeadImportJob[] + conversationSettings ConversationSettings? + conversationRooms ConversationRoom[] + conversationHiddenMessages ConversationHiddenMessage[] + conversationPinnedMessages ConversationPinnedMessage[] + conversationRoomUserPins ConversationRoomUserPin[] + conversationRoomMembers ConversationRoomMember[] + conversationRoomUserMutes ConversationRoomUserMute[] conversationMessageReactions ConversationMessageReaction[] - trackingLinks TrackingLink[] - trackingEvents TrackingEvent[] - routineCategories RoutineCategory[] - routines Routine[] - routineTasks RoutineTask[] - routineTaskEvents RoutineTaskEvent[] - routineUserSettings RoutineUserSettings[] + trackingLinks TrackingLink[] + trackingEvents TrackingEvent[] + routineCategories RoutineCategory[] + routines Routine[] + routineTasks RoutineTask[] + routineTaskEvents RoutineTaskEvent[] + routineUserSettings RoutineUserSettings[] + gamificationSettings GamificationSettings? + gamificationRules GamificationRule[] + gamificationTransactions GamificationTransaction[] + gamificationWallets GamificationWallet[] + gamificationRewards GamificationReward[] + gamificationRedemptions GamificationRedemption[] + gamificationBadges GamificationBadge[] + gamificationUserBadges GamificationUserBadge[] + gamificationBonuses GamificationBonus[] } model User { - id String @id @default(cuid()) - email String + id String @id @default(cuid()) + email String /// موبایل نرمال‌شده (مثلاً 9121234567) — برای ورود با موبایل - mobile String? - passwordHash String - fullName String - role UserRole @default(sales) + mobile String? + passwordHash String + fullName String + role UserRole @default(sales) /// مسیر فایل آواتار در فضای آپلود - avatarStorageKey String? - isActive Boolean @default(true) - lastLoginAt DateTime? - orgRoleId String? - orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) - emailVerifiedAt DateTime? - mobileVerifiedAt DateTime? - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - ownedDeals Deal[] @relation("DealOwner") - assignedDeals Deal[] @relation("DealAssignees") - createdDeals Deal[] @relation("DealCreator") - ownedQuotes Quote[] @relation("QuoteOwner") - ownedInvoices Invoice[] @relation("InvoiceOwner") - ownedPeople Person[] @relation("PersonOwner") - ownedCompanies Company[] @relation("CompanyOwner") - ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") - activities Activity[] @relation("ActivityAssignee") - assignedActivities Activity[] @relation("ActivityAssignees") - createdActivities Activity[] @relation("ActivityCreator") - notes Note[] @relation("NoteAuthor") - noteRevisions NoteRevision[] @relation("NoteRevisionEditor") - noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") - notifications Notification[] - salesGoals SalesGoal[] @relation("SalesGoalUser") - commissionPercent Decimal @default(0) @db.Decimal(8, 2) - commissionEntries CommissionEntry[] - receivedCheques Cheque[] @relation("ChequeOwner") - assignedTickets Ticket[] @relation("TicketAssignee") - reportedTickets Ticket[] @relation("TicketReporter") - ticketMessages TicketMessage[] @relation("TicketMessageAuthor") - confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy") - memberships Membership[] - billingAccount BillingAccount? - auditLogs AuditLog[] @relation("AuditLogActor") - savedViews SavedView[] @relation("SavedViewOwner") - tablePreferences UserTablePreference[] - assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") - teamMemberships TeamMember[] + avatarStorageKey String? + isActive Boolean @default(true) + lastLoginAt DateTime? + orgRoleId String? + orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) + emailVerifiedAt DateTime? + mobileVerifiedAt DateTime? + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + ownedDeals Deal[] @relation("DealOwner") + assignedDeals Deal[] @relation("DealAssignees") + createdDeals Deal[] @relation("DealCreator") + ownedQuotes Quote[] @relation("QuoteOwner") + ownedInvoices Invoice[] @relation("InvoiceOwner") + ownedPeople Person[] @relation("PersonOwner") + ownedCompanies Company[] @relation("CompanyOwner") + ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") + activities Activity[] @relation("ActivityAssignee") + assignedActivities Activity[] @relation("ActivityAssignees") + createdActivities Activity[] @relation("ActivityCreator") + notes Note[] @relation("NoteAuthor") + noteRevisions NoteRevision[] @relation("NoteRevisionEditor") + noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") + notifications Notification[] + salesGoals SalesGoal[] @relation("SalesGoalUser") + commissionPercent Decimal @default(0) @db.Decimal(8, 2) + commissionEntries CommissionEntry[] + receivedCheques Cheque[] @relation("ChequeOwner") + assignedTickets Ticket[] @relation("TicketAssignee") + reportedTickets Ticket[] @relation("TicketReporter") + ticketMessages TicketMessage[] @relation("TicketMessageAuthor") + confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy") + memberships Membership[] + billingAccount BillingAccount? + auditLogs AuditLog[] @relation("AuditLogActor") + savedViews SavedView[] @relation("SavedViewOwner") + tablePreferences UserTablePreference[] + assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") + teamMemberships TeamMember[] reservationAssignments ReservationAppointment[] @relation("ReservationAssignee") - assignedLeads Lead[] @relation("LeadAssignee") - createdLeads Lead[] @relation("LeadCreator") - assignedRoutines Routine[] @relation("RoutineAssignee") - createdRoutines Routine[] @relation("RoutineCreator") - routineTasks RoutineTask[] @relation("RoutineTaskAssignee") - createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator") - routineTaskEvents RoutineTaskEvent[] - routineSettings RoutineUserSettings[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + assignedLeads Lead[] @relation("LeadAssignee") + createdLeads Lead[] @relation("LeadCreator") + assignedRoutines Routine[] @relation("RoutineAssignee") + createdRoutines Routine[] @relation("RoutineCreator") + routineTasks RoutineTask[] @relation("RoutineTaskAssignee") + createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator") + routineTaskEvents RoutineTaskEvent[] + routineSettings RoutineUserSettings[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, email]) @@unique([mobile]) @@ -321,22 +330,22 @@ model OrgRole { } model Membership { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - role UserRole @default(sales) - orgRoleId String? - orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) - isActive Boolean @default(true) - commissionPercent Decimal @default(0) @db.Decimal(8, 2) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + role UserRole @default(sales) + orgRoleId String? + orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) + isActive Boolean @default(true) + commissionPercent Decimal @default(0) @db.Decimal(8, 2) /// own | team | org — سطح دید داده‌ها برای نقش‌های غیرمالک/مدیر - dataVisibility String @default("own") + dataVisibility String @default("own") /// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility - dataVisibilityScopes Json @default("{}") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + dataVisibilityScopes Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([userId, organizationId]) @@index([organizationId, role]) @@ -412,47 +421,47 @@ model BillingAccount { } model Company { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - name String + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String /// کد داخلی شرکت (مثل «کد شرکت» در دیدار) - code String? + code String? /// منبع ورود: manual | didar | tahesab | excel | other - importSource String @default("manual") + importSource String @default("manual") /// شناسه در مبدأ (مثلاً کد دیدار شرکت) - sourceRef String? - nationalId String? - economicCode String? + sourceRef String? + nationalId String? + economicCode String? registrationNumber String? - phone String? - otherPhones String? - fax String? - email String? - otherEmails String? - website String? - province String? - city String? - postalCode String? - address String? - groupName String? - notes String? - registeredAt DateTime? - tags String[] @default([]) - customFields Json @default("{}") - people Person[] - deals Deal[] - activities Activity[] - smartNotes Note[] - quotes Quote[] - invoices Invoice[] - ownerId String? - owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull) - tickets Ticket[] - projectCards ProjectCard[] - routineTasks RoutineTask[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + phone String? + otherPhones String? + fax String? + email String? + otherEmails String? + website String? + province String? + city String? + postalCode String? + address String? + groupName String? + notes String? + registeredAt DateTime? + tags String[] @default([]) + customFields Json @default("{}") + people Person[] + deals Deal[] + activities Activity[] + smartNotes Note[] + quotes Quote[] + invoices Invoice[] + ownerId String? + owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull) + tickets Ticket[] + projectCards ProjectCard[] + routineTasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId]) @@index([organizationId, name]) @@ -464,64 +473,64 @@ model Company { } model Person { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - firstName String - lastName String + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + firstName String + lastName String /// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب) - previousFirstName String? - previousLastName String? + previousFirstName String? + previousLastName String? /// منبع ورود: manual | tahesab | didar | excel | phone | lead_form | other - importSource String @default("manual") + importSource String @default("manual") /// شناسه در مبدأ (کد دیدار، کد مشتری ته‌حساب، …) - sourceRef String? + sourceRef String? /// منبع و کد قبلی وقتی شخص از منبع دیگری دوباره وارد شده (مثلاً دیدار قبل از ته‌حساب) - previousImportSource String? - previousSourceRef String? - email String? - phone String? - mobile String? - position String? + previousImportSource String? + previousSourceRef String? + email String? + phone String? + mobile String? + position String? /// آدرس وب‌سایت - website String? + website String? /// پیج اینستاگرام - instagram String? + instagram String? /// نام معرف - referrer String? + referrer String? /// لیست آدرس‌ها (JSON array of strings) - addresses Json @default("[]") - notes String? - mood PersonMood @default(newcomer) - tags String[] @default([]) - customFields Json @default("{}") - loyaltyPoints Int @default(0) - loyaltyTierId String? - loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull) - loyaltyLedgers LoyaltyLedger[] - loyaltyRedemptions LoyaltyRedemption[] - deals Deal[] - activities Activity[] - smartNotes Note[] - quotes Quote[] - invoices Invoice[] + addresses Json @default("[]") + notes String? + mood PersonMood @default(newcomer) + tags String[] @default([]) + customFields Json @default("{}") + loyaltyPoints Int @default(0) + loyaltyTierId String? + loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull) + loyaltyLedgers LoyaltyLedger[] + loyaltyRedemptions LoyaltyRedemption[] + deals Deal[] + activities Activity[] + smartNotes Note[] + quotes Quote[] + invoices Invoice[] reservationAppointments ReservationAppointment[] - ownerId String? - owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull) - tickets Ticket[] - projectCards ProjectCard[] - memoryItems MemoryItem[] - accountingLinks PersonAccountingLink[] - accountingDocuments AccountingDocumentLog[] - openingBalances PartyOpeningBalance[] - cheques Cheque[] - trackingLinks TrackingLink[] - trackingEvents TrackingEvent[] - routineTasks RoutineTask[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + ownerId String? + owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull) + tickets Ticket[] + projectCards ProjectCard[] + memoryItems MemoryItem[] + accountingLinks PersonAccountingLink[] + accountingDocuments AccountingDocumentLog[] + openingBalances PartyOpeningBalance[] + cheques Cheque[] + trackingLinks TrackingLink[] + trackingEvents TrackingEvent[] + routineTasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId]) @@index([organizationId, email]) @@ -567,60 +576,60 @@ model PipelineStage { } model Deal { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - pipelineId String - pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) - stageId String - stage PipelineStage @relation(fields: [stageId], references: [id], onDelete: Restrict) - title String + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + pipelineId String + pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) + stageId String + stage PipelineStage @relation(fields: [stageId], references: [id], onDelete: Restrict) + title String /// کد دستی معامله - code String? + code String? /// منبع ورود: manual | didar | excel - importSource String @default("manual") + importSource String @default("manual") /// شناسه در مبدأ (کد دیدار معامله) - sourceRef String? - amount Decimal? @db.Decimal(18, 2) - currency String @default("IRR") - status DealStatus @default(open) + sourceRef String? + amount Decimal? @db.Decimal(18, 2) + currency String @default("IRR") + status DealStatus @default(open) /// احتمال موفقیت (۰ تا ۱۰۰) - probability Int? + probability Int? /// شیوه آشنایی - source String? + source String? /// شیوه آشنایی دلخواه - sourceDetail String? - referrer String? - lostReason String? - lostNote String? + sourceDetail String? + referrer String? + lostReason String? + lostNote String? /// تاریخ انجام (موفق/ناموفق شدن) - closedAt DateTime? - notes String? - tags String[] @default([]) - isStale Boolean @default(false) - isRotten Boolean @default(false) - customFields Json @default("{}") + closedAt DateTime? + notes String? + tags String[] @default([]) + isStale Boolean @default(false) + isRotten Boolean @default(false) + customFields Json @default("{}") expectedCloseAt DateTime? - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - ownerId String? - owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull) - createdById String? - createdBy User? @relation("DealCreator", fields: [createdById], references: [id], onDelete: SetNull) - assignees User[] @relation("DealAssignees") - position Int @default(0) - activities Activity[] - smartNotes Note[] - lineItems DealLineItem[] - quotes Quote[] - invoices Invoice[] - tickets Ticket[] - projectCards ProjectCard[] - routineTasks RoutineTask[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + ownerId String? + owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull) + createdById String? + createdBy User? @relation("DealCreator", fields: [createdById], references: [id], onDelete: SetNull) + assignees User[] @relation("DealAssignees") + position Int @default(0) + activities Activity[] + smartNotes Note[] + lineItems DealLineItem[] + quotes Quote[] + invoices Invoice[] + tickets Ticket[] + projectCards ProjectCard[] + routineTasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId]) @@index([pipelineId, stageId]) @@ -673,51 +682,51 @@ model ActivityTypeDef { } model Activity { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - type ActivityType @default(task) - status ActivityStatus @default(open) - priority ActivityPriority @default(medium) - title String - description String? - dueAt DateTime? - durationMinutes Int? - reminderAt DateTime? + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + type ActivityType @default(task) + status ActivityStatus @default(open) + priority ActivityPriority @default(medium) + title String + description String? + dueAt DateTime? + durationMinutes Int? + reminderAt DateTime? /// اعلان داخل پنل هنگام رسیدن یادآور - reminderNotifyApp Boolean @default(true) + reminderNotifyApp Boolean @default(true) /// پیامک به موبایل مسئول هنگام رسیدن یادآور - reminderNotifySms Boolean @default(false) + reminderNotifySms Boolean @default(false) /// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود - reminderSentAt DateTime? - completedAt DateTime? - tags String[] @default([]) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) - assigneeId String? - assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + reminderSentAt DateTime? + completedAt DateTime? + tags String[] @default([]) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + assigneeId String? + assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) /// مسئولان مشترک (علاوه بر assigneeId به‌عنوان مسئول اصلی) - assignees User[] @relation("ActivityAssignees") - createdById String? - createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) - notes Note[] - routineTasks RoutineTask[] + assignees User[] @relation("ActivityAssignees") + createdById String? + createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) + notes Note[] + routineTasks RoutineTask[] /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع - stageId String? - stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) + stageId String? + stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) /// نوع سفارشی؛ null → نوع اصلیِ همان type - typeDefId String? - typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull) + typeDefId String? + typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull) /// مقادیر فیلدهای اختصاصی نوع (کلید فیلد → مقدار) - fields Json? + fields Json? /// کاربران شرکت‌کننده؛ فعالیت در لیست آن‌ها هم دیده می‌شود - participantIds String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + participantIds String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([stageId]) @@index([typeDefId]) @@ -731,37 +740,37 @@ model Activity { } model Note { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) title String? body String - noteType String @default("general") - pinned Boolean @default(false) - color String? @default("default") - tags String[] @default([]) + noteType String @default("general") + pinned Boolean @default(false) + color String? @default("default") + tags String[] @default([]) reminderAt DateTime? /// [{ id: string, text: string, done: boolean }] checklist Json? locationText String? - analyzeEnabled Boolean @default(true) + analyzeEnabled Boolean @default(true) personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) activityId String? - activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull) + activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull) authorId String? - author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull) + author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull) /// team = قابل‌مشاهده برای تیم/سازمان در محدوده پرونده | personal = فقط نویسنده - visibility String @default("team") + visibility String @default("team") attachments NoteAttachment[] aiSuggestion NoteAiSuggestion? revisions NoteRevision[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, pinned, updatedAt]) @@index([organizationId, noteType, updatedAt]) @@ -870,31 +879,31 @@ model CustomFieldDefinition { } model Product { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) /// product | service - kind String @default("product") - name String - sku String? - description String? - category String? - unit String? @default("عدد") - stockQty Int? - brand String? - model String? - country String? - tags String[] @default([]) - unitPrice Decimal? @db.Decimal(18, 2) - currency String @default("IRR") - isActive Boolean @default(true) - lineItems DealLineItem[] - quoteLineItems QuoteLineItem[] - invoiceLineItems InvoiceLineItem[] - stockMovements StockMovement[] - accountingLinks ProductAccountingLink[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + kind String @default("product") + name String + sku String? + description String? + category String? + unit String? @default("عدد") + stockQty Int? + brand String? + model String? + country String? + tags String[] @default([]) + unitPrice Decimal? @db.Decimal(18, 2) + currency String @default("IRR") + isActive Boolean @default(true) + lineItems DealLineItem[] + quoteLineItems QuoteLineItem[] + invoiceLineItems InvoiceLineItem[] + stockMovements StockMovement[] + accountingLinks ProductAccountingLink[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, name]) @@index([organizationId, isActive]) @@ -940,39 +949,39 @@ model Notification { } model LeadForm { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String description String? - publicToken String @unique @default(cuid()) - isActive Boolean @default(true) + publicToken String @unique @default(cuid()) + isActive Boolean @default(true) /// ثبت به‌عنوان Lead در CRM (مسیر اصلی انتشار فرم) - createLead Boolean @default(true) - createPerson Boolean @default(false) - createDeal Boolean @default(false) + createLead Boolean @default(true) + createPerson Boolean @default(false) + createDeal Boolean @default(false) pipelineId String? /// مرحله اولیه لید (اختیاری؛ وگرنه اولین مرحله) leadStageId String? /// skip | update | activity - duplicateMode String @default("update") + duplicateMode String @default("update") source String? thankYouMessage String? redirectUrl String? - assigneeIds String[] @default([]) - roundRobinIndex Int @default(0) - submitLabel String @default("ارسال درخواست") - submitColor String @default("#14b8a6") + assigneeIds String[] @default([]) + roundRobinIndex Int @default(0) + submitLabel String @default("ارسال درخواست") + submitColor String @default("#14b8a6") coverStorageKey String? - viewCount Int @default(0) - startCount Int @default(0) - submitCount Int @default(0) - leadCreateCount Int @default(0) - duplicateCount Int @default(0) + viewCount Int @default(0) + startCount Int @default(0) + submitCount Int @default(0) + leadCreateCount Int @default(0) + duplicateCount Int @default(0) fields LeadFormField[] submissions LeadFormSubmission[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, updatedAt]) } @@ -1064,40 +1073,40 @@ model QuoteLineItem { } model Invoice { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - number String - title String - status InvoiceStatus @default(draft) - kind String @default("invoice") // invoice | credit - currency String @default("IRR") - notes String? - discountAmount Decimal @default(0) @db.Decimal(18, 2) - taxPercent Decimal @default(0) @db.Decimal(8, 2) - issuedAt DateTime? - dueDate DateTime? - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) - quoteId String? - quote Quote? @relation(fields: [quoteId], references: [id], onDelete: SetNull) - ownerId String? - owner User? @relation("InvoiceOwner", fields: [ownerId], references: [id], onDelete: SetNull) - lineItems InvoiceLineItem[] - payments Payment[] - installments InvoiceInstallment[] - stockMovements StockMovement[] - commissionPercent Decimal? @db.Decimal(8, 2) - commissionFixedAmount Decimal? @db.Decimal(18, 2) - commissionEntry CommissionEntry? - cheques Cheque[] - routineTasks RoutineTask[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + number String + title String + status InvoiceStatus @default(draft) + kind String @default("invoice") // invoice | credit + currency String @default("IRR") + notes String? + discountAmount Decimal @default(0) @db.Decimal(18, 2) + taxPercent Decimal @default(0) @db.Decimal(8, 2) + issuedAt DateTime? + dueDate DateTime? + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + quoteId String? + quote Quote? @relation(fields: [quoteId], references: [id], onDelete: SetNull) + ownerId String? + owner User? @relation("InvoiceOwner", fields: [ownerId], references: [id], onDelete: SetNull) + lineItems InvoiceLineItem[] + payments Payment[] + installments InvoiceInstallment[] + stockMovements StockMovement[] + commissionPercent Decimal? @db.Decimal(8, 2) + commissionFixedAmount Decimal? @db.Decimal(18, 2) + commissionEntry CommissionEntry? + cheques Cheque[] + routineTasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, number]) @@index([organizationId, status, updatedAt]) @@ -1127,19 +1136,19 @@ model InvoiceLineItem { } model Payment { - id String @id @default(cuid()) + id String @id @default(cuid()) invoiceId String - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) - amount Decimal @db.Decimal(18, 2) - currency String @default("IRR") - method PaymentMethod @default(transfer) - paidAt DateTime @default(now()) + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + amount Decimal @db.Decimal(18, 2) + currency String @default("IRR") + method PaymentMethod @default(transfer) + paidAt DateTime @default(now()) notes String? - installmentId String? @unique + installmentId String? @unique installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull) cheque Cheque? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([invoiceId, paidAt]) } @@ -1204,34 +1213,34 @@ model ExchangeRate { } model Ticket { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - number String - subject String - description String? - status TicketStatus @default(open) - priority TicketPriority @default(normal) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) - assigneeId String? - assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) - reporterId String? - reporter User? @relation("TicketReporter", fields: [reporterId], references: [id], onDelete: SetNull) - resolvedAt DateTime? + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + number String + subject String + description String? + status TicketStatus @default(open) + priority TicketPriority @default(normal) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + assigneeId String? + assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + reporterId String? + reporter User? @relation("TicketReporter", fields: [reporterId], references: [id], onDelete: SetNull) + resolvedAt DateTime? /// مهلت پاسخ اول بر اساس اولویت - slaDueAt DateTime? + slaDueAt DateTime? firstRespondedAt DateTime? - csatScore Int? - csatComment String? - csatAt DateTime? - messages TicketMessage[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + csatScore Int? + csatComment String? + csatAt DateTime? + messages TicketMessage[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, number]) @@index([organizationId, status, updatedAt]) @@ -1244,14 +1253,14 @@ model Ticket { } model TicketMessage { - id String @id @default(cuid()) - ticketId String - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - authorId String? - author User? @relation("TicketMessageAuthor", fields: [authorId], references: [id], onDelete: SetNull) - body String - isInternal Boolean @default(false) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + ticketId String + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + authorId String? + author User? @relation("TicketMessageAuthor", fields: [authorId], references: [id], onDelete: SetNull) + body String + isInternal Boolean @default(false) + createdAt DateTime @default(now()) @@index([ticketId, createdAt]) @@index([authorId]) @@ -1289,29 +1298,29 @@ model PortalOtp { /// برد کانبان برای کارهای داخلی / پس‌ازفروش (جدا از مسیر فروش) model ProjectBoard { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String - isDefault Boolean @default(false) + isDefault Boolean @default(false) stages ProjectBoardStage[] cards ProjectCard[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId]) @@index([organizationId, isDefault]) } model ProjectBoardStage { - id String @id @default(cuid()) + id String @id @default(cuid()) boardId String - board ProjectBoard @relation(fields: [boardId], references: [id], onDelete: Cascade) + board ProjectBoard @relation(fields: [boardId], references: [id], onDelete: Cascade) name String position Int cards ProjectCard[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([boardId, position]) } @@ -1559,33 +1568,33 @@ model AuthChallenge { /// اتصال به نرم‌افزار حسابداری خارجی (ته‌حساب) — چند اتصال/DB به‌ازای هر سازمان model AccountingConnection { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) /// فعلاً فقط tahesab - provider String @default("tahesab") - label String - baseUrl String - dbName String + provider String @default("tahesab") + label String + baseUrl String + dbName String /// AES-256-GCM ciphertext (base64) - tokenCiphertext String - tokenIv String - tokenTag String + tokenCiphertext String + tokenIv String + tokenTag String /// چهار کاراکتر آخر توکن برای نمایش ماسک‌شده - tokenLast4 String @default("") - enabled Boolean @default(true) - isDefault Boolean @default(false) + tokenLast4 String @default("") + enabled Boolean @default(true) + isDefault Boolean @default(false) /// اگر true باشد verify SSL نادیده گرفته می‌شود (فقط برای نصب‌های داخلی با گواهی خودامضا) - allowInsecureTls Boolean @default(false) - lastHealthAt DateTime? - lastHealthOk Boolean? - lastError String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - personLinks PersonAccountingLink[] - productLinks ProductAccountingLink[] - documentLogs AccountingDocumentLog[] - balanceCaches AccountingBalanceCache[] + allowInsecureTls Boolean @default(false) + lastHealthAt DateTime? + lastHealthOk Boolean? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + personLinks PersonAccountingLink[] + productLinks ProductAccountingLink[] + documentLogs AccountingDocumentLog[] + balanceCaches AccountingBalanceCache[] @@unique([organizationId, baseUrl, dbName]) @@index([organizationId, enabled]) @@ -1593,20 +1602,20 @@ model AccountingConnection { } model PersonAccountingLink { - id String @id @default(cuid()) - personId String - person Person @relation(fields: [personId], references: [id], onDelete: Cascade) - connectionId String - connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + personId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + connectionId String + connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) /// کد مشتری در ته‌حساب (Moshtari_Code) - moshtariCode Int + moshtariCode Int /// manual | mobile | name | created - matchMethod String - linkedAt DateTime @default(now()) - lastBalanceSyncedAt DateTime? - balanceCache AccountingBalanceCache? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + matchMethod String + linkedAt DateTime @default(now()) + lastBalanceSyncedAt DateTime? + balanceCache AccountingBalanceCache? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([personId, connectionId]) @@unique([connectionId, moshtariCode]) @@ -1638,24 +1647,24 @@ model ProductAccountingLink { } model AccountingDocumentLog { - id String @id @default(cuid()) - connectionId String - connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + id String @id @default(cuid()) + connectionId String + connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) /// مثلاً DoNewSanadBuySaleGOLD - docType String + docType String /// کد یکتای ≥۲۰ کاراکتر — idempotency - factorCode String - requestPayload Json - responsePayload Json? + factorCode String + requestPayload Json + responsePayload Json? /// pending | success | failed - status String @default("pending") - tahesabOk String? - tahesabFactorId Int? - errorMessage String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status String @default("pending") + tahesabOk String? + tahesabFactorId Int? + errorMessage String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([connectionId, factorCode]) @@index([connectionId, status]) @@ -1664,21 +1673,21 @@ model AccountingDocumentLog { } model AccountingBalanceCache { - id String @id @default(cuid()) - linkId String @unique - link PersonAccountingLink @relation(fields: [linkId], references: [id], onDelete: Cascade) - connectionId String - connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) - goldBalance Float? - rialBalance Float? + id String @id @default(cuid()) + linkId String @unique + link PersonAccountingLink @relation(fields: [linkId], references: [id], onDelete: Cascade) + connectionId String + connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + goldBalance Float? + rialBalance Float? currencyBalance Float? /// مانده هم‌ارز طلا (MandeyeKolBeTALA) در صورت وجود - totalGoldEquiv Float? + totalGoldEquiv Float? /// جزئیات گروه‌بندی‌شده برای UI - mandehDetail Json @default("[]") - rawResponse Json? - fetchedAt DateTime @default(now()) - updatedAt DateTime @updatedAt + mandehDetail Json @default("[]") + rawResponse Json? + fetchedAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([connectionId]) } @@ -1760,30 +1769,30 @@ model CommissionEntry { /// چک دریافتی؛ مستقل یا وصل به پرداخت فاکتور model Cheque { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - paymentId String? @unique - payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade) - invoiceId String? - invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Cascade) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - ownerId String? - owner User? @relation("ChequeOwner", fields: [ownerId], references: [id], onDelete: SetNull) - amount Decimal @db.Decimal(18, 2) - currency String @default("IRR") - number String - bank String - dueDate DateTime + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + paymentId String? @unique + payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + ownerId String? + owner User? @relation("ChequeOwner", fields: [ownerId], references: [id], onDelete: SetNull) + amount Decimal @db.Decimal(18, 2) + currency String @default("IRR") + number String + bank String + dueDate DateTime /// pending | cleared | bounced - status String @default("pending") + status String @default("pending") /// in = دریافتی، out = پرداختی - direction String @default("in") + direction String @default("in") reminderNotifiedAt DateTime? - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, status, dueDate]) @@index([organizationId, direction, dueDate]) @@ -1888,17 +1897,17 @@ model OrganizationApiKey { /// وب‌هوک خروجی سازمان model WebhookEndpoint { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String url String secret String - events String[] @default([]) - isActive Boolean @default(true) + events String[] @default([]) + isActive Boolean @default(true) deliveries WebhookDelivery[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, isActive]) } @@ -1926,39 +1935,39 @@ model WebhookDelivery { /// کمپین پیامک گروهی model SmsCampaign { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) name String body String /// draft | queued | sending | done | cancelled - status String @default("draft") + status String @default("draft") scheduledAt DateTime? - totalCount Int @default(0) - sentCount Int @default(0) - failedCount Int @default(0) - skippedCount Int @default(0) + totalCount Int @default(0) + sentCount Int @default(0) + failedCount Int @default(0) + skippedCount Int @default(0) createdById String? recipients SmsCampaignRecipient[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, status, scheduledAt]) } model SmsCampaignRecipient { - id String @id @default(cuid()) - campaignId String - campaign SmsCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) - personId String? + id String @id @default(cuid()) + campaignId String + campaign SmsCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + personId String? /// برای پشتیبانی آینده از Lead (فعلاً کمپین روی Person است) - leadId String? - mobile String? + leadId String? + mobile String? /// pending | sent | failed | skipped - status String @default("pending") - error String? - sentAt DateTime? - createdAt DateTime @default(now()) + status String @default("pending") + error String? + sentAt DateTime? + createdAt DateTime @default(now()) trackingLinks TrackingLink[] @@index([campaignId, status]) @@ -1968,14 +1977,14 @@ model SmsCampaignRecipient { /// لینک ردیابی یکتا — کانال‌خنثی (SMS / Email / …) model TrackingLink { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) /// توکن امن و غیرقابل‌حدس (بدون PII) - token String @unique + token String @unique originalUrl String /// sms | email | whatsapp | notification | other - channel String @default("sms") + channel String @default("sms") /// sms_campaign | … sourceType String sourceId String @@ -1985,13 +1994,13 @@ model TrackingLink { smsRecipientId String? smsRecipient SmsCampaignRecipient? @relation(fields: [smsRecipientId], references: [id], onDelete: SetNull) personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) leadId String? - lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) - clickCount Int @default(0) + lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) + clickCount Int @default(0) firstClickedAt DateTime? lastClickedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) events TrackingEvent[] @@index([organizationId, sourceType, sourceId]) @@ -2033,48 +2042,48 @@ model TrackingEvent { /// اتصال فروشگاه ووکامرس model WooCommerceConnection { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - label String - storeUrl String - consumerKeyCiphertext String - consumerKeyIv String - consumerKeyTag String - consumerKeyLast4 String @default("") + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + label String + storeUrl String + consumerKeyCiphertext String + consumerKeyIv String + consumerKeyTag String + consumerKeyLast4 String @default("") consumerSecretCiphertext String - consumerSecretIv String - consumerSecretTag String - webhookSecretCiphertext String? - webhookSecretIv String? - webhookSecretTag String? - enabled Boolean @default(true) - lastSyncAt DateTime? - lastHealthAt DateTime? - lastHealthOk Boolean? - lastError String? - orders WooCommerceOrderLink[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + consumerSecretIv String + consumerSecretTag String + webhookSecretCiphertext String? + webhookSecretIv String? + webhookSecretTag String? + enabled Boolean @default(true) + lastSyncAt DateTime? + lastHealthAt DateTime? + lastHealthOk Boolean? + lastError String? + orders WooCommerceOrderLink[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, enabled]) } model WooCommerceOrderLink { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String connectionId String connection WooCommerceConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) wooOrderId String wooOrderNumber String? invoiceId String - status String @default("") - total Decimal @default(0) @db.Decimal(18, 2) - currency String @default("IRR") + status String @default("") + total Decimal @default(0) @db.Decimal(18, 2) + currency String @default("IRR") rawPayload Json? - syncedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([connectionId, wooOrderId]) @@index([organizationId, syncedAt]) @@ -2083,17 +2092,17 @@ model WooCommerceOrderLink { /// اتصال فروشگاه Montify (هر فروشگاه: Base URL + توکن اختصاصی) model MontifyConnection { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) label String baseUrl String - storeId String @default("") + storeId String @default("") tokenCiphertext String tokenIv String tokenTag String - tokenLast4 String @default("") - enabled Boolean @default(true) + tokenLast4 String @default("") + enabled Boolean @default(true) webhookRemoteId String? webhookSecretCiphertext String? webhookSecretIv String? @@ -2101,7 +2110,7 @@ model MontifyConnection { /// بیشترین updated_at مشتریان دریافت‌شده (برای updated_after) customersCursor DateTime? /// idle | running | ok | error - syncStatus String @default("idle") + syncStatus String @default("idle") syncStartedAt DateTime? lastSyncAt DateTime? lastSyncResult Json? @@ -2113,15 +2122,15 @@ model MontifyConnection { orders MontifyOrder[] transactions MontifyTransaction[] deliveries MontifyWebhookDelivery[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, enabled]) } /// پیوند مشتری فروشگاه با شخص CRM (entityType = customer، entityId = Person.id) model MontifyLink { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String connectionId String connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) @@ -2133,9 +2142,9 @@ model MontifyLink { ordersSyncedAt DateTime? /// پروفایل فروشگاهی مشتری: wallet_balance، gold_wallet_balance، summary، engagement، … data Json? - syncedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([connectionId, entityType, externalId]) @@index([organizationId, entityType, entityId]) @@ -2143,7 +2152,7 @@ model MontifyLink { /// سفارش فروشگاه (فقط‌خواندنی، نگاشت مستند: code → order_reference، sts → order_status) model MontifyOrder { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String connectionId String connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) @@ -2154,29 +2163,29 @@ model MontifyOrder { orderStatus String? paymentStatus String? paymentType String? - totalPrice Decimal? @db.Decimal(20, 2) - discountTotalPrice Decimal? @db.Decimal(20, 2) - totalDiscounts Decimal? @db.Decimal(20, 2) - totalTax Decimal? @db.Decimal(20, 2) - totalQty Decimal? @db.Decimal(14, 3) - profit Decimal? @db.Decimal(20, 2) - percentProfit Decimal? @db.Decimal(10, 3) - sumGold Decimal? @db.Decimal(14, 3) - sumSilver Decimal? @db.Decimal(14, 3) + totalPrice Decimal? @db.Decimal(20, 2) + discountTotalPrice Decimal? @db.Decimal(20, 2) + totalDiscounts Decimal? @db.Decimal(20, 2) + totalTax Decimal? @db.Decimal(20, 2) + totalQty Decimal? @db.Decimal(14, 3) + profit Decimal? @db.Decimal(20, 2) + percentProfit Decimal? @db.Decimal(10, 3) + sumGold Decimal? @db.Decimal(14, 3) + sumSilver Decimal? @db.Decimal(14, 3) address Json? note String? cancelReason String? cancelledAt DateTime? closedAt DateTime? codeDiscount String? - items Json @default("[]") + items Json @default("[]") externalCreatedAt DateTime? externalUpdatedAt DateTime? deletedAt DateTime? raw Json? - syncedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([connectionId, externalId]) @@index([organizationId, personId]) @@ -2185,7 +2194,7 @@ model MontifyOrder { /// تراکنش مالی مشتری در فروشگاه (واریز/برداشت ریالی یا گرم طلا) model MontifyTransaction { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String connectionId String connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) @@ -2193,30 +2202,30 @@ model MontifyTransaction { customerExternalId String? personId String? type String? - amount Decimal? @db.Decimal(24, 4) + amount Decimal? @db.Decimal(24, 4) currency String? status String? referenceCode String? externalCreatedAt DateTime? raw Json? - syncedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([connectionId, externalId]) @@index([organizationId, personId]) } model MontifyWebhookDelivery { - id String @id @default(cuid()) + id String @id @default(cuid()) connectionId String connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) deliveryId String event String /// received | processed | failed - status String @default("received") + status String @default("received") error String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@unique([connectionId, deliveryId]) @@index([connectionId, createdAt]) @@ -2224,58 +2233,58 @@ model MontifyWebhookDelivery { /// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی) model VoipConnection { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - label String - enabled Boolean @default(true) - webhookSecretCiphertext String? - webhookSecretIv String? - webhookSecretTag String? + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + label String + enabled Boolean @default(true) + webhookSecretCiphertext String? + webhookSecretIv String? + webhookSecretTag String? /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند - clickToCallUrl String? - lastEventAt DateTime? - lastError String? - calls VoipCallLog[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + clickToCallUrl String? + lastEventAt DateTime? + lastError String? + calls VoipCallLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, enabled]) } model VoipCallLog { - id String @id @default(cuid()) - organizationId String - connectionId String - connection VoipConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) - externalCallId String + id String @id @default(cuid()) + organizationId String + connectionId String + connection VoipConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + externalCallId String /// inbound | outbound - direction String @default("inbound") + direction String @default("inbound") /// ringing | answered | missed | completed | failed - status String @default("completed") - fromNumber String? - toNumber String? - personId String? - activityId String? - durationSec Int? - startedAt DateTime @default(now()) - endedAt DateTime? - rawPayload Json? + status String @default("completed") + fromNumber String? + toNumber String? + personId String? + activityId String? + durationSec Int? + startedAt DateTime @default(now()) + endedAt DateTime? + rawPayload Json? /// لینک فایل ضبط از سانترال - recordingUrl String? + recordingUrl String? /// متن خام (از وب‌هوک یا STT) - transcript String? + transcript String? /// خلاصه فارسی تولیدشده - summary String? + summary String? /// none | pending | processing | done | failed | skipped - summaryStatus String @default("none") - summaryNoteId String? - summaryError String? - summaryAttempts Int @default(0) + summaryStatus String @default("none") + summaryNoteId String? + summaryError String? + summaryAttempts Int @default(0) summaryNextAttemptAt DateTime? - syncedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([connectionId, externalCallId]) @@index([organizationId, syncedAt]) @@ -2286,41 +2295,41 @@ model VoipCallLog { /// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای) model ReservationService { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - name String - description String @default("") - durationMinutes Int @default(30) + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String @default("") + durationMinutes Int @default(30) /// روزهای کار ۰=یکشنبه … ۶=شنبه - workDays Int[] @default([0, 1, 2, 3, 4, 5, 6]) - dayStartHour Int @default(9) - dayEndHour Int @default(17) - isActive Boolean @default(true) - appointments ReservationAppointment[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + workDays Int[] @default([0, 1, 2, 3, 4, 5, 6]) + dayStartHour Int @default(9) + dayEndHour Int @default(17) + isActive Boolean @default(true) + appointments ReservationAppointment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, isActive]) } model ReservationAppointment { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) serviceId String service ReservationService @relation(fields: [serviceId], references: [id], onDelete: Restrict) personId String - person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) assigneeId String? - assignee User? @relation("ReservationAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + assignee User? @relation("ReservationAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) startsAt DateTime endsAt DateTime /// pending | confirmed | canceled | done - status String @default("pending") + status String @default("pending") notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([organizationId, startsAt]) @@index([organizationId, status]) @@ -2346,53 +2355,53 @@ model LeadStage { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([organizationId, position]) @@unique([organizationId, name]) + @@index([organizationId, position]) } /// مخاطب بالقوه — جدا از Person/مشتری model Lead { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) /// نام فروشگاه / شخص / عنوان نمایشی - title String + title String /// نام مالک یا مسئول - contactName String? - mobile String - phone String? - city String? - region String? + contactName String? + mobile String + phone String? + city String? + region String? /// پیج اینستاگرام - instagram String? + instagram String? /// لیست آدرس‌ها (JSON array of strings) - addresses Json @default("[]") + addresses Json @default("[]") /// صنف - trade String? - source String? - notes String? + trade String? + source String? + notes String? /// لوگوی فروشگاه / شرکت - logoStorageKey String? - estimatedValue Decimal? @db.Decimal(18, 2) - stageId String - stage LeadStage @relation(fields: [stageId], references: [id], onDelete: Restrict) - position Int @default(0) - assigneeId String? - assignee User? @relation("LeadAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) - createdById String? - createdBy User? @relation("LeadCreator", fields: [createdById], references: [id], onDelete: SetNull) - lastContactAt DateTime? - nextFollowUpAt DateTime? + logoStorageKey String? + estimatedValue Decimal? @db.Decimal(18, 2) + stageId String + stage LeadStage @relation(fields: [stageId], references: [id], onDelete: Restrict) + position Int @default(0) + assigneeId String? + assignee User? @relation("LeadAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + createdById String? + createdBy User? @relation("LeadCreator", fields: [createdById], references: [id], onDelete: SetNull) + lastContactAt DateTime? + nextFollowUpAt DateTime? /// شخص مشتری پس از تبدیل convertedPersonId String? - convertedAt DateTime? - archivedAt DateTime? - events LeadEvent[] - trackingLinks TrackingLink[] - trackingEvents TrackingEvent[] - routineTasks RoutineTask[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + convertedAt DateTime? + archivedAt DateTime? + events LeadEvent[] + trackingLinks TrackingLink[] + trackingEvents TrackingEvent[] + routineTasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, mobile]) @@index([organizationId, stageId, position]) @@ -2503,15 +2512,15 @@ model ConversationHiddenMessage { /// یک پیام سنجاق‌شده در هر اتاق (برای همه اعضا) model ConversationPinnedMessage { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - externalRoomId String - messageId String - preview String @default("") - pinnedByUserId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + externalRoomId String + messageId String + preview String @default("") + pinnedByUserId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, externalRoomId]) @@index([organizationId, externalRoomId]) @@ -2672,48 +2681,48 @@ model Routine { /// نمونه روزانه یک کار (از روتین، دستی، پیگیری، لید یا مشتری) model RoutineTask { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - routineId String? - routine Routine? @relation(fields: [routineId], references: [id], onDelete: SetNull) - assigneeId String - assignee User @relation("RoutineTaskAssignee", fields: [assigneeId], references: [id], onDelete: Cascade) - title String - description String? - categoryId String? - category RoutineCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - priority ActivityPriority @default(medium) - status RoutineTaskStatus @default(todo) + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + routineId String? + routine Routine? @relation(fields: [routineId], references: [id], onDelete: SetNull) + assigneeId String + assignee User @relation("RoutineTaskAssignee", fields: [assigneeId], references: [id], onDelete: Cascade) + title String + description String? + categoryId String? + category RoutineCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + priority ActivityPriority @default(medium) + status RoutineTaskStatus @default(todo) /// روز برنامه به وقت تهران - scheduledFor DateTime @db.Date - dueAt DateTime? - startedAt DateTime? - completedAt DateTime? - resultNote String? - skipReason String? - source RoutineTaskSource @default(manual) - isMandatory Boolean @default(false) - leadId String? - lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - companyId String? - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - dealId String? - deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) - invoiceId String? - invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) - activityId String? - activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull) - reminderAt DateTime? - reminderSentAt DateTime? + scheduledFor DateTime @db.Date + dueAt DateTime? + startedAt DateTime? + completedAt DateTime? + resultNote String? + skipReason String? + source RoutineTaskSource @default(manual) + isMandatory Boolean @default(false) + leadId String? + lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + activityId String? + activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull) + reminderAt DateTime? + reminderSentAt DateTime? overdueNotifiedAt DateTime? - createdById String? - createdBy User? @relation("RoutineTaskCreator", fields: [createdById], references: [id], onDelete: SetNull) - events RoutineTaskEvent[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdById String? + createdBy User? @relation("RoutineTaskCreator", fields: [createdById], references: [id], onDelete: SetNull) + events RoutineTaskEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([routineId, assigneeId, scheduledFor]) @@index([organizationId, assigneeId, scheduledFor]) @@ -2764,3 +2773,196 @@ model RoutineUserSettings { @@unique([organizationId, userId]) } + +/// ───────── باشگاه امتیازات کارکنان (جدا از باشگاه مشتریان) ───────── + +model GamificationSettings { + id String @id @default(cuid()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + enabled Boolean @default(true) + /// [{ name, minScore }] + levels Json @default("[]") + monthlyGoal Int @default(1500) + showFullLeaderboard Boolean @default(true) + /// شروع استفاده — مبنای گزارش ROI + enabledAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model GamificationRule { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String? + event String + score Int @default(0) + coins Int @default(0) + /// [{ field, op, value }] + conditions Json @default("[]") + isActive Boolean @default(true) + maxPerDay Int? + maxCoinsPerDay Int? + maxPerUser Int? + cooldownMinutes Int? + oncePerEntity Boolean @default(false) + firstTimeOnly Boolean @default(false) + requiresApproval Boolean @default(false) + roles String[] @default([]) + teamIds String[] @default([]) + position Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, event, isActive]) +} + +/// دفتر کل امتیاز و سکه — هر تغییر کیف پول یک ردیف دارد +model GamificationTransaction { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + /// rule | bonus | achievement | adjust | redeem | refund + kind String + event String? + ruleId String? + bonusId String? + badgeId String? + score Int @default(0) + coins Int @default(0) + /// approved | pending | rejected + status String @default("approved") + title String + entityType String? + entityId String? + entityTitle String? + /// کلید یکتا برای جلوگیری از امتیاز تکراری + idempotencyKey String? + reason String? + createdById String? + decidedById String? + decidedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([organizationId, idempotencyKey]) + @@index([organizationId, createdAt]) + @@index([organizationId, userId, createdAt]) + @@index([organizationId, ruleId, userId, createdAt]) + @@index([organizationId, status]) +} + +/// موجودی تجمیعی هر کاربر — برای خواندن سریع +model GamificationWallet { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + score Int @default(0) + coins Int @default(0) + coinsEarned Int @default(0) + bestMonthScore Int @default(0) + updatedAt DateTime @updatedAt + + @@unique([organizationId, userId]) + @@index([organizationId, score]) +} + +model GamificationReward { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String? + cost Int + icon String @default("gift") + color String @default("#14b8a6") + stock Int? + isActive Boolean @default(true) + requiresApproval Boolean @default(true) + position Int @default(0) + redemptions GamificationRedemption[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, isActive]) +} + +model GamificationRedemption { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + rewardId String + reward GamificationReward @relation(fields: [rewardId], references: [id], onDelete: Cascade) + cost Int + /// pending | approved | rejected | delivered + status String @default("pending") + note String? + decisionNote String? + decidedById String? + decidedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, status]) + @@index([organizationId, userId]) +} + +model GamificationBadge { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String? + icon String @default("medal") + color String @default("#f59e0b") + /// event_count | score_total | routine_streak | single_deal_amount + metric String + event String? + threshold Int + coins Int @default(0) + isActive Boolean @default(true) + position Int @default(0) + earned GamificationUserBadge[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, isActive]) +} + +model GamificationUserBadge { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + badgeId String + badge GamificationBadge @relation(fields: [badgeId], references: [id], onDelete: Cascade) + userId String + earnedAt DateTime @default(now()) + + @@unique([badgeId, userId]) + @@index([organizationId, userId]) +} + +model GamificationBonus { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String? + /// event_count_period | routine_streak | top_of_month + kind String + event String? + threshold Int @default(1) + /// day | week | month + period String @default("month") + score Int @default(0) + coins Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, isActive]) +} diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index d8e4c42..ae3211a 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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 { @@ -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, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 09e31ee..f29fe66 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index 5ba2b3f..efdeb3f 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -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); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 975b391..94658fd 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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, diff --git a/apps/api/src/gamification/dto.ts b/apps/api/src/gamification/dto.ts new file mode 100644 index 0000000..f00e419 --- /dev/null +++ b/apps/api/src/gamification/dto.ts @@ -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; +} diff --git a/apps/api/src/gamification/gamification-admin.controller.ts b/apps/api/src/gamification/gamification-admin.controller.ts new file mode 100644 index 0000000..a7adb52 --- /dev/null +++ b/apps/api/src/gamification/gamification-admin.controller.ts @@ -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); + } +} diff --git a/apps/api/src/gamification/gamification-admin.service.ts b/apps/api/src/gamification/gamification-admin.service.ts new file mode 100644 index 0000000..e7eff1e --- /dev/null +++ b/apps/api/src/gamification/gamification-admin.service.ts @@ -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 { + await this.assertAdmin(actor); + const [rows, counts] = await Promise.all([ + this.prisma.gamificationRule.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + }), + this.prisma.gamificationTransaction.groupBy({ + by: ['ruleId'], + where: { organizationId: actor.organizationId, kind: 'rule', status: 'approved' }, + _count: { _all: true }, + }), + ]); + const countMap = new Map(counts.map((c) => [c.ruleId ?? '', c._count._all])); + return rows.map((r) => this.toRuleDto(r, countMap.get(r.id) ?? 0)); + } + + private async findRule(actor: Actor, id: string) { + const row = await this.prisma.gamificationRule.findFirst({ + where: { id, organizationId: actor.organizationId }, + }); + if (!row) throw new NotFoundException('قانون پیدا نشد'); + return row; + } + + async createRule(actor: Actor, dto: SaveRuleDto): Promise { + await this.assertAdmin(actor); + const max = await this.prisma.gamificationRule.aggregate({ + where: { organizationId: actor.organizationId }, + _max: { position: true }, + }); + const row = await this.prisma.gamificationRule.create({ + data: { + organizationId: actor.organizationId, + ...this.ruleData(dto), + position: (max._max.position ?? -1) + 1, + }, + }); + this.game.invalidate(actor.organizationId); + return this.toRuleDto(row); + } + + async updateRule(actor: Actor, id: string, dto: SaveRuleDto): Promise { + await this.assertAdmin(actor); + await this.findRule(actor, id); + const row = await this.prisma.gamificationRule.update({ where: { id }, data: this.ruleData(dto) }); + this.game.invalidate(actor.organizationId); + return this.toRuleDto(row); + } + + async toggleRule(actor: Actor, id: string, isActive: boolean): Promise { + await this.assertAdmin(actor); + await this.findRule(actor, id); + const row = await this.prisma.gamificationRule.update({ where: { id }, data: { isActive } }); + this.game.invalidate(actor.organizationId); + return this.toRuleDto(row); + } + + async duplicateRule(actor: Actor, id: string): Promise { + await this.assertAdmin(actor); + const src = await this.findRule(actor, id); + const { id: _id, createdAt: _c, updatedAt: _u, conditions, ...rest } = src; + const row = await this.prisma.gamificationRule.create({ + data: { + ...rest, + conditions: conditions as Prisma.InputJsonValue, + name: `${src.name} (کپی)`, + isActive: false, + position: src.position + 1, + }, + }); + this.game.invalidate(actor.organizationId); + return this.toRuleDto(row); + } + + async deleteRule(actor: Actor, id: string): Promise { + await this.assertAdmin(actor); + await this.findRule(actor, id); + await this.prisma.gamificationRule.delete({ where: { id } }); + this.game.invalidate(actor.organizationId); + } + + /* ───────── تنظیم دستی ───────── */ + + async adjust(actor: Actor, field: 'score' | 'coins', dto: AdjustDto): Promise { + await this.assertAdmin(actor); + const organizationId = actor.organizationId; + const amount = Math.trunc(dto.amount); + if (!amount) throw new BadRequestException('مقدار نمی‌تواند صفر باشد'); + const reason = dto.reason.trim(); + if (!reason) throw new BadRequestException('ثبت دلیل الزامی است'); + const member = (await this.game.members(organizationId)).find((m) => m.userId === dto.userId); + if (!member) throw new NotFoundException('کاربر پیدا نشد'); + + if (field === 'coins' && amount < 0) { + const wallet = await this.prisma.gamificationWallet.findUnique({ + where: { organizationId_userId: { organizationId, userId: dto.userId } }, + }); + if ((wallet?.coins ?? 0) + amount < 0) { + throw new BadRequestException('موجودی سکهٔ کاربر کافی نیست'); + } + } + + const label = field === 'score' ? 'امتیاز' : 'سکه'; + const tx = await this.game.record({ + organizationId, + userId: dto.userId, + kind: 'adjust', + score: field === 'score' ? amount : 0, + coins: field === 'coins' ? amount : 0, + title: `${amount > 0 ? 'افزایش' : 'کاهش'} دستی ${label}`, + reason, + createdById: actor.userId, + idempotencyKey: `adj:${randomUUID()}`, + }); + if (!tx) throw new BadRequestException('ثبت انجام نشد'); + if (amount > 0) await this.game.afterAward(organizationId, dto.userId, null); + await this.notifications.create({ + organizationId, + userId: dto.userId, + type: 'gamification_adjust', + title: `${amount > 0 ? '+' : ''}${amount} ${label}`, + body: reason, + link: '/club?tab=history', + entityType: 'gamification_tx', + entityId: tx.id, + }); + return (await this.game.toTxDtos(organizationId, [tx]))[0]!; + } + + /* ───────── تأیید امتیازهای معلق ───────── */ + + async pending(actor: Actor): Promise { + await this.assertAdmin(actor); + const rows = await this.prisma.gamificationTransaction.findMany({ + where: { organizationId: actor.organizationId, status: 'pending' }, + orderBy: { createdAt: 'asc' }, + take: 200, + }); + return this.game.toTxDtos(actor.organizationId, rows); + } + + async decideTx(actor: Actor, id: string, approve: boolean, dto: DecisionDto): Promise { + await this.assertAdmin(actor); + const organizationId = actor.organizationId; + const row = await this.prisma.$transaction(async (db) => { + const changed = await db.gamificationTransaction.updateMany({ + where: { id, organizationId, status: 'pending' }, + data: { + status: approve ? 'approved' : 'rejected', + decidedById: actor.userId, + decidedAt: new Date(), + ...(dto.note?.trim() ? { reason: dto.note.trim() } : {}), + }, + }); + if (!changed.count) throw new BadRequestException('این مورد قبلاً بررسی شده است'); + const tx = await db.gamificationTransaction.findUniqueOrThrow({ where: { id } }); + if (approve) await this.game.applyWallet(db, organizationId, tx.userId, tx.score, tx.coins, tx.kind); + return tx; + }); + this.game.invalidateBoard(organizationId); + if (approve) { + await this.game.afterAward(organizationId, row.userId, isGamificationEvent(row.event) ? row.event : null); + } + await this.notifications.create({ + organizationId, + userId: row.userId, + type: 'gamification_decision', + title: approve ? `امتیاز «${row.title}» تأیید شد` : `امتیاز «${row.title}» تأیید نشد`, + body: dto.note?.trim() || null, + link: '/club?tab=history', + entityType: 'gamification_tx', + entityId: row.id, + }); + return (await this.game.toTxDtos(organizationId, [row]))[0]!; + } + + /* ───────── درخواست‌های پاداش ───────── */ + + async redemptions(actor: Actor, status?: string): Promise { + await this.assertAdmin(actor); + const rows = await this.prisma.gamificationRedemption.findMany({ + where: { organizationId: actor.organizationId, ...(status ? { status } : {}) }, + include: { reward: true }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + return this.game.toRedemptionDtos(actor.organizationId, rows); + } + + async decideRedemption( + actor: Actor, + id: string, + action: 'approve' | 'reject' | 'deliver', + dto: DecisionDto, + ): Promise { + await this.assertAdmin(actor); + const organizationId = actor.organizationId; + const allowedFrom: Record = { + approve: ['pending'], + reject: ['pending', 'approved'], + deliver: ['pending', 'approved'], + }; + const next = action === 'approve' ? 'approved' : action === 'reject' ? 'rejected' : 'delivered'; + const row = await this.prisma.$transaction(async (db) => { + const changed = await db.gamificationRedemption.updateMany({ + where: { id, organizationId, status: { in: allowedFrom[action] } }, + data: { + status: next, + decidedById: actor.userId, + decidedAt: new Date(), + decisionNote: dto.note?.trim() || null, + }, + }); + if (!changed.count) throw new BadRequestException('وضعیت این درخواست قابل تغییر نیست'); + const red = await db.gamificationRedemption.findUniqueOrThrow({ + where: { id }, + include: { reward: true }, + }); + if (action === 'reject') { + await db.gamificationTransaction.create({ + data: { + organizationId, + userId: red.userId, + kind: 'refund', + coins: red.cost, + status: 'approved', + title: `بازگشت سکهٔ پاداش «${red.reward.name}»`, + entityType: 'redemption', + entityId: red.id, + idempotencyKey: `refund:${red.id}`, + reason: dto.note?.trim() || null, + createdById: actor.userId, + }, + }); + await this.game.applyWallet(db, organizationId, red.userId, 0, red.cost, 'refund'); + if (red.reward.stock != null) { + await db.gamificationReward.update({ + where: { id: red.rewardId }, + data: { stock: { increment: 1 } }, + }); + } + } + return red; + }); + const titles = { + approve: `درخواست «${row.reward.name}» تأیید شد`, + reject: `درخواست «${row.reward.name}» رد شد و سکه‌ها برگشت`, + deliver: `پاداش «${row.reward.name}» تحویل شد`, + }; + await this.notifications.create({ + organizationId, + userId: row.userId, + type: 'gamification_redemption', + title: titles[action], + body: dto.note?.trim() || null, + link: '/club?tab=store', + entityType: 'gamification_redemption', + entityId: row.id, + }); + return (await this.game.toRedemptionDtos(organizationId, [row]))[0]!; + } + + /* ───────── پاداش‌ها ───────── */ + + async listRewards(actor: Actor): Promise { + await this.assertAdmin(actor); + const rows = await this.prisma.gamificationReward.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: [{ position: 'asc' }, { cost: 'asc' }], + }); + return rows.map((r) => this.game.toRewardDto(r)); + } + + private rewardData(dto: SaveRewardDto) { + return { + name: dto.name.trim(), + description: dto.description?.trim() || null, + cost: Math.max(1, Math.trunc(dto.cost)), + icon: dto.icon?.trim() || 'gift', + color: dto.color?.trim() || '#14b8a6', + stock: dto.stock == null ? null : Math.max(0, Math.trunc(dto.stock)), + isActive: dto.isActive ?? true, + requiresApproval: dto.requiresApproval ?? true, + }; + } + + async createReward(actor: Actor, dto: SaveRewardDto) { + await this.assertAdmin(actor); + const count = await this.prisma.gamificationReward.count({ where: { organizationId: actor.organizationId } }); + const row = await this.prisma.gamificationReward.create({ + data: { organizationId: actor.organizationId, ...this.rewardData(dto), position: count }, + }); + return this.game.toRewardDto(row); + } + + async updateReward(actor: Actor, id: string, dto: SaveRewardDto) { + await this.assertAdmin(actor); + await this.ensureOwned('gamificationReward', actor, id); + const row = await this.prisma.gamificationReward.update({ where: { id }, data: this.rewardData(dto) }); + return this.game.toRewardDto(row); + } + + async deleteReward(actor: Actor, id: string) { + await this.assertAdmin(actor); + await this.ensureOwned('gamificationReward', actor, id); + const used = await this.prisma.gamificationRedemption.count({ where: { rewardId: id } }); + if (used) { + await this.prisma.gamificationReward.update({ where: { id }, data: { isActive: false } }); + return; + } + await this.prisma.gamificationReward.delete({ where: { id } }); + } + + /* ───────── نشان‌ها ───────── */ + + private badgeData(dto: SaveBadgeDto) { + if (!(dto.metric in GAMIFICATION_BADGE_METRICS)) throw new BadRequestException('شاخص نامعتبر است'); + if (dto.metric === 'event_count' && !isGamificationEvent(dto.event)) { + throw new BadRequestException('رویداد را انتخاب کنید'); + } + return { + name: dto.name.trim(), + description: dto.description?.trim() || null, + icon: dto.icon?.trim() || 'medal', + color: dto.color?.trim() || '#f59e0b', + metric: dto.metric, + event: dto.metric === 'event_count' ? dto.event! : null, + threshold: Math.max(1, Math.trunc(dto.threshold)), + coins: Math.max(0, Math.trunc(dto.coins ?? 0)), + isActive: dto.isActive ?? true, + }; + } + + async listBadges(actor: Actor): Promise { + await this.assertAdmin(actor); + const [rows, counts] = await Promise.all([ + this.prisma.gamificationBadge.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + }), + this.prisma.gamificationUserBadge.groupBy({ + by: ['badgeId'], + where: { organizationId: actor.organizationId }, + _count: { _all: true }, + }), + ]); + const countMap = new Map(counts.map((c) => [c.badgeId, c._count._all])); + return rows.map((b) => ({ + id: b.id, + name: b.name, + description: b.description, + icon: b.icon, + color: b.color, + metric: b.metric as GamificationBadgeMetric, + event: isGamificationEvent(b.event) ? b.event : null, + threshold: b.threshold, + coins: b.coins, + isActive: b.isActive, + earnedCount: countMap.get(b.id) ?? 0, + })); + } + + async createBadge(actor: Actor, dto: SaveBadgeDto) { + await this.assertAdmin(actor); + const count = await this.prisma.gamificationBadge.count({ where: { organizationId: actor.organizationId } }); + await this.prisma.gamificationBadge.create({ + data: { organizationId: actor.organizationId, ...this.badgeData(dto), position: count }, + }); + return this.listBadges(actor); + } + + async updateBadge(actor: Actor, id: string, dto: SaveBadgeDto) { + await this.assertAdmin(actor); + await this.ensureOwned('gamificationBadge', actor, id); + await this.prisma.gamificationBadge.update({ where: { id }, data: this.badgeData(dto) }); + return this.listBadges(actor); + } + + async deleteBadge(actor: Actor, id: string) { + await this.assertAdmin(actor); + await this.ensureOwned('gamificationBadge', actor, id); + await this.prisma.gamificationBadge.delete({ where: { id } }); + } + + /* ───────── بونوس‌ها ───────── */ + + private bonusData(dto: SaveBonusDto) { + if (!(dto.kind in GAMIFICATION_BONUS_KINDS)) throw new BadRequestException('نوع بونوس نامعتبر است'); + if (dto.kind === 'event_count_period' && !isGamificationEvent(dto.event)) { + throw new BadRequestException('رویداد را انتخاب کنید'); + } + if (!dto.score && !dto.coins) throw new BadRequestException('امتیاز یا سکه باید بیشتر از صفر باشد'); + const period = dto.period === 'day' || dto.period === 'week' ? dto.period : 'month'; + return { + name: dto.name.trim(), + description: dto.description?.trim() || null, + kind: dto.kind as GamificationBonusKind, + event: dto.kind === 'event_count_period' ? dto.event! : null, + threshold: Math.max(1, Math.trunc(dto.threshold)), + period: dto.kind === 'top_of_month' ? 'month' : period, + score: Math.max(0, Math.trunc(dto.score ?? 0)), + coins: Math.max(0, Math.trunc(dto.coins ?? 0)), + isActive: dto.isActive ?? true, + }; + } + + async listBonuses(actor: Actor): Promise { + await this.assertAdmin(actor); + const [rows, counts] = await Promise.all([ + this.prisma.gamificationBonus.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: { createdAt: 'asc' }, + }), + this.prisma.gamificationTransaction.groupBy({ + by: ['bonusId'], + where: { organizationId: actor.organizationId, kind: 'bonus', status: 'approved' }, + _count: { _all: true }, + }), + ]); + const countMap = new Map(counts.map((c) => [c.bonusId ?? '', c._count._all])); + return rows.map((b) => ({ + id: b.id, + name: b.name, + description: b.description, + kind: b.kind as GamificationBonusKind, + event: isGamificationEvent(b.event) ? b.event : null, + threshold: b.threshold, + period: (b.period === 'day' || b.period === 'week' ? b.period : 'month') as GamificationBonusDto['period'], + score: b.score, + coins: b.coins, + isActive: b.isActive, + awardedCount: countMap.get(b.id) ?? 0, + })); + } + + async createBonus(actor: Actor, dto: SaveBonusDto) { + await this.assertAdmin(actor); + await this.prisma.gamificationBonus.create({ + data: { organizationId: actor.organizationId, ...this.bonusData(dto) }, + }); + return this.listBonuses(actor); + } + + async updateBonus(actor: Actor, id: string, dto: SaveBonusDto) { + await this.assertAdmin(actor); + await this.ensureOwned('gamificationBonus', actor, id); + await this.prisma.gamificationBonus.update({ where: { id }, data: this.bonusData(dto) }); + return this.listBonuses(actor); + } + + async deleteBonus(actor: Actor, id: string) { + await this.assertAdmin(actor); + await this.ensureOwned('gamificationBonus', actor, id); + await this.prisma.gamificationBonus.delete({ where: { id } }); + } + + private async ensureOwned( + model: 'gamificationReward' | 'gamificationBadge' | 'gamificationBonus', + actor: Actor, + id: string, + ) { + const where = { id, organizationId: actor.organizationId }; + const found = + model === 'gamificationReward' + ? await this.prisma.gamificationReward.findFirst({ where, select: { id: true } }) + : model === 'gamificationBadge' + ? await this.prisma.gamificationBadge.findFirst({ where, select: { id: true } }) + : await this.prisma.gamificationBonus.findFirst({ where, select: { id: true } }); + if (!found) throw new NotFoundException('مورد پیدا نشد'); + } + + /* ───────── تنظیمات ───────── */ + + async settings(actor: Actor): Promise { + await this.assertAdmin(actor); + const row = await this.game.ensureDefaults(actor.organizationId); + return this.game.toSettingsDto(row); + } + + async updateSettings(actor: Actor, dto: UpdateSettingsDto): Promise { + await this.assertAdmin(actor); + await this.game.ensureDefaults(actor.organizationId); + const row = await this.prisma.gamificationSettings.update({ + where: { organizationId: actor.organizationId }, + data: { + ...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}), + ...(dto.levels !== undefined + ? { levels: normalizeLevels(dto.levels) as unknown as Prisma.InputJsonValue } + : {}), + ...(dto.monthlyGoal !== undefined ? { monthlyGoal: Math.max(1, Math.trunc(dto.monthlyGoal)) } : {}), + ...(dto.showFullLeaderboard !== undefined ? { showFullLeaderboard: dto.showFullLeaderboard } : {}), + }, + }); + this.game.invalidate(actor.organizationId); + return this.game.toSettingsDto(row); + } + + /* ───────── گزارش مدیریتی ───────── */ + + async report(actor: Actor, periodRaw: unknown): Promise { + await this.assertAdmin(actor); + const organizationId = actor.organizationId; + const period = parsePeriod(periodRaw); + const from = periodStart(period); + const createdAt = from ? { gte: from } : undefined; + const base: Prisma.GamificationTransactionWhereInput = { + organizationId, + status: 'approved', + ...(createdAt ? { createdAt } : {}), + }; + const members = await this.game.members(organizationId); + const names = new Map(members.map((m) => [m.userId, m.fullName])); + + const [earned, spent, byUser, byEvent, pendingApprovals, pendingRedemptions, perUserEvent, settings] = + await Promise.all([ + this.prisma.gamificationTransaction.aggregate({ + where: { ...base, kind: { notIn: ['redeem', 'refund'] } }, + _sum: { score: true, coins: true }, + _count: { _all: true }, + }), + this.prisma.gamificationTransaction.aggregate({ + where: { ...base, kind: { in: ['redeem', 'refund'] } }, + _sum: { coins: true }, + }), + this.prisma.gamificationTransaction.groupBy({ + by: ['userId'], + where: { ...base, kind: 'rule' }, + _count: { _all: true }, + _sum: { score: true }, + }), + this.prisma.gamificationTransaction.groupBy({ + by: ['event'], + where: { ...base, kind: 'rule' }, + _count: { _all: true }, + _sum: { score: true, coins: true }, + }), + this.prisma.gamificationTransaction.count({ where: { organizationId, status: 'pending' } }), + this.prisma.gamificationRedemption.count({ where: { organizationId, status: 'pending' } }), + this.prisma.gamificationTransaction.groupBy({ + by: ['userId', 'event'], + where: { + ...base, + kind: 'rule', + event: { in: ['DEAL_WON', 'LEAD_CREATED', 'TASK_COMPLETED', 'ROUTINE_COMPLETED'] }, + }, + _count: { _all: true }, + }), + this.game.ensureDefaults(organizationId), + ]); + + const top = (entries: Array<[string, number]>, n = 5): GamificationReportUserRow[] => + entries + .filter(([, v]) => v > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, n) + .map(([userId, value]) => ({ userId, fullName: names.get(userId) ?? '—', value })); + + const sumEvents = (events: string[]) => { + const map = new Map(); + for (const r of perUserEvent) { + if (r.event && events.includes(r.event)) { + map.set(r.userId, (map.get(r.userId) ?? 0) + r._count._all); + } + } + return [...map.entries()]; + }; + + let topGrowth: GamificationReportUserRow[] = []; + const prev = previousPeriod(period); + if (prev) { + const prevRows = await this.prisma.gamificationTransaction.groupBy({ + by: ['userId'], + where: { organizationId, status: 'approved', createdAt: { gte: prev.from, lt: prev.to } }, + _sum: { score: true }, + }); + const prevMap = new Map(prevRows.map((r) => [r.userId, r._sum.score ?? 0])); + topGrowth = top(byUser.map((r) => [r.userId, (r._sum.score ?? 0) - (prevMap.get(r.userId) ?? 0)])); + } + + const activeIds = new Set(byUser.map((r) => r.userId)); + const inactive = members + .filter((m) => !activeIds.has(m.userId)) + .slice(0, 20) + .map((m) => ({ userId: m.userId, fullName: m.fullName, value: 0 })); + + return { + period, + totals: { + score: earned._sum.score ?? 0, + coinsEarned: earned._sum.coins ?? 0, + coinsSpent: Math.abs(spent._sum.coins ?? 0), + activeUsers: activeIds.size, + totalUsers: members.length, + transactions: earned._count._all, + pendingApprovals, + pendingRedemptions, + }, + byEvent: byEvent + .filter((e) => isGamificationEvent(e.event)) + .map((e) => ({ + event: e.event as GamificationReportDto['byEvent'][number]['event'], + label: GAMIFICATION_EVENTS[e.event as keyof typeof GAMIFICATION_EVENTS].label, + count: e._count._all, + score: e._sum.score ?? 0, + coins: e._sum.coins ?? 0, + })) + .sort((a, b) => b.count - a.count), + topActive: top(byUser.map((r) => [r.userId, r._count._all])), + topGrowth, + topDeals: top(sumEvents(['DEAL_WON'])), + topLeads: top(sumEvents(['LEAD_CREATED'])), + topTasks: top(sumEvents(['TASK_COMPLETED', 'ROUTINE_COMPLETED'])), + inactive, + roi: await this.roi(organizationId, settings.enabledAt), + }; + } + + /** مقایسهٔ میانگین روزانهٔ شاخص‌ها در ۳۰ روز قبل و بعد از شروع باشگاه */ + private async roi(organizationId: string, enabledAt: Date): Promise { + const since = enabledAt; + const beforeFrom = new Date(since.getTime() - ROI_WINDOW_DAYS * 86_400_000); + const afterTo = new Date(Math.min(Date.now(), since.getTime() + ROI_WINDOW_DAYS * 86_400_000)); + const afterDays = Math.max(1, (afterTo.getTime() - since.getTime()) / 86_400_000); + const ranges = [ + { gte: beforeFrom, lt: since }, + { gte: since, lt: afterTo }, + ] as const; + + const count = async (fn: (r: { gte: Date; lt: Date }) => Promise) => + Promise.all(ranges.map((r) => fn(r))); + + const [activities, calls, leadCalls, leads, converted, customers, wonCount] = await Promise.all([ + count((r) => this.prisma.activity.count({ where: { organizationId, createdAt: r } })), + count((r) => this.prisma.activity.count({ where: { organizationId, type: 'call', createdAt: r } })), + count((r) => this.prisma.leadEvent.count({ where: { organizationId, type: 'call', createdAt: r } })), + count((r) => this.prisma.lead.count({ where: { organizationId, createdAt: r } })), + count((r) => this.prisma.lead.count({ where: { organizationId, convertedAt: r } })), + count((r) => this.prisma.person.count({ where: { organizationId, createdAt: r } })), + count((r) => this.prisma.deal.count({ where: { organizationId, status: 'won', closedAt: r } })), + ]); + const wonAmount = await Promise.all( + ranges.map(async (r) => { + const agg = await this.prisma.deal.aggregate({ + where: { organizationId, status: 'won', closedAt: r }, + _sum: { amount: true }, + }); + return Number(agg._sum.amount ?? 0); + }), + ); + const routineRate = await Promise.all( + ranges.map(async (r) => { + const fromKey = tehranDayKey(r.gte); + const toKey = addDays(tehranDayKey(r.lt), -1); + const where: Prisma.RoutineTaskWhereInput = { + organizationId, + status: { not: 'cancelled' }, + scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) }, + }; + const [total, done] = await Promise.all([ + this.prisma.routineTask.count({ where }), + this.prisma.routineTask.count({ where: { ...where, status: 'completed' } }), + ]); + return total ? Math.round((done / total) * 100) : 0; + }), + ); + + const perDay = (pair: number[]): [number, number] => [ + Math.round((pair[0]! / ROI_WINDOW_DAYS) * 10) / 10, + Math.round((pair[1]! / afterDays) * 10) / 10, + ]; + const metric = (key: string, label: string, [before, after]: [number, number]): GamificationRoiMetric => ({ + key, + label, + before, + after, + changePct: before > 0 ? Math.round(((after - before) / before) * 100) : null, + }); + + return { + since: since.toISOString(), + windowDays: ROI_WINDOW_DAYS, + metrics: [ + metric('activities', 'فعالیت ثبت‌شده در روز', perDay(activities)), + metric('calls', 'تماس ثبت‌شده در روز', perDay([calls[0]! + leadCalls[0]!, calls[1]! + leadCalls[1]!])), + metric('leads', 'لید جدید در روز', perDay(leads)), + metric('converted', 'تبدیل لید به مشتری در روز', perDay(converted)), + metric('customers', 'مشتری جدید در روز', perDay(customers)), + metric('won', 'معاملهٔ برنده در روز', perDay(wonCount)), + metric('wonAmount', 'مبلغ فروش در روز', perDay(wonAmount)), + metric('routine', 'درصد انجام کارهای روتین', [routineRate[0]!, routineRate[1]!]), + ], + }; + } +} diff --git a/apps/api/src/gamification/gamification-cron.service.ts b/apps/api/src/gamification/gamification-cron.service.ts new file mode 100644 index 0000000..b38030d --- /dev/null +++ b/apps/api/src/gamification/gamification-cron.service.ts @@ -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)}`); + } + } + } +} diff --git a/apps/api/src/gamification/gamification-defaults.ts b/apps/api/src/gamification/gamification-defaults.ts new file mode 100644 index 0000000..1473eed --- /dev/null +++ b/apps/api/src/gamification/gamification-defaults.ts @@ -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 }, +]; diff --git a/apps/api/src/gamification/gamification-engine.ts b/apps/api/src/gamification/gamification-engine.ts new file mode 100644 index 0000000..5d0c7e9 --- /dev/null +++ b/apps/api/src/gamification/gamification-engine.ts @@ -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; + +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; + 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, 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( + rows: T[], + tieBreak: (a: T, b: T) => number = () => 0, +): Array { + 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, + 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; +} diff --git a/apps/api/src/gamification/gamification.controller.ts b/apps/api/src/gamification/gamification.controller.ts new file mode 100644 index 0000000..247a5e6 --- /dev/null +++ b/apps/api/src/gamification/gamification.controller.ts @@ -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); + } +} diff --git a/apps/api/src/gamification/gamification.module.ts b/apps/api/src/gamification/gamification.module.ts new file mode 100644 index 0000000..d7ac168 --- /dev/null +++ b/apps/api/src/gamification/gamification.module.ts @@ -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 {} diff --git a/apps/api/src/gamification/gamification.service.ts b/apps/api/src/gamification/gamification.service.ts new file mode 100644 index 0000000..40f6ea1 --- /dev/null +++ b/apps/api/src/gamification/gamification.service.ts @@ -0,0 +1,1167 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + GamificationAchievementDto, + GamificationBadgeMetric, + GamificationEvent, + GamificationLeaderboardDto, + GamificationLeaderEntryDto, + GamificationMeDto, + GamificationPeriod, + GamificationPulseDto, + GamificationRedemptionDto, + GamificationRedemptionStatus, + GamificationRewardDto, + GamificationRuleDto, + GamificationSettingsDto, + GamificationTeamEntryDto, + GamificationTxDto, + GamificationTxKind, + GamificationTxStatus, +} from '@mento/shared'; +import { 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 { addDays, dateToDayKey, dayKeyToDate, tehranDayKey } from '../routines/routine-dates'; +import { + DEFAULT_LEVELS, + type Facts, + type ScoreRow, + commonFacts, + levelFor, + limitBlockReason, + matchesConditions, + needsUsage, + normalizeLevels, + periodStart, + rankRows, + routineStreak, + sanitizeConditions, +} from './gamification-engine'; +import { + DEFAULT_BADGES, + DEFAULT_BONUSES, + DEFAULT_REWARDS, + DEFAULT_RULES, +} from './gamification-defaults'; + +export type GamificationEmitInput = { + organizationId: string; + /** کاربری که امتیاز می‌گیرد */ + userId: string | null | undefined; + event: GamificationEvent; + entityType?: string; + entityId?: string | null; + entityTitle?: string | null; + /** شناسهٔ یکتای این رخداد؛ پیش‌فرض entityId */ + eventKey?: string; + facts?: Facts; +}; + +type Member = { + userId: string; + fullName: string; + hasAvatar: boolean; + role: string; + teamIds: string[]; +}; + +type RuleRow = Prisma.GamificationRuleGetPayload; +type SettingsRow = Prisma.GamificationSettingsGetPayload; +type TxRow = Prisma.GamificationTransactionGetPayload; +type Db = Prisma.TransactionClient | PrismaService; + +type RankedRow = ScoreRow & { fullName: string; rank: number }; + +export type RecordInput = { + organizationId: string; + userId: string; + kind: GamificationTxKind; + title: string; + score?: number; + coins?: number; + status?: GamificationTxStatus; + event?: GamificationEvent | null; + ruleId?: string | null; + bonusId?: string | null; + badgeId?: string | null; + entityType?: string | null; + entityId?: string | null; + entityTitle?: string | null; + idempotencyKey?: string | null; + reason?: string | null; + createdById?: string | null; +}; + +const SETTINGS_TTL = 60_000; +const RULES_TTL = 30_000; +const MEMBERS_TTL = 60_000; +const BOARD_TTL = 20_000; + +export const GAMIFICATION_PERIODS: GamificationPeriod[] = [ + 'today', + 'week', + 'month', + 'quarter', + 'year', + 'all', +]; + +export function parsePeriod(raw: unknown, fallback: GamificationPeriod = 'month'): GamificationPeriod { + return GAMIFICATION_PERIODS.includes(raw as GamificationPeriod) + ? (raw as GamificationPeriod) + : fallback; +} + +const ENTITY_LINKS: Record string> = { + lead: (id) => `/leads/${id}`, + person: (id) => `/contacts/people/${id}`, + company: (id) => `/contacts/companies/${id}`, + deal: (id) => `/deals/${id}`, + activity: (id) => `/activities/${id}`, + quote: (id) => `/quotes/${id}`, + invoice: (id) => `/invoices/${id}`, + ticket: (id) => `/tickets/${id}`, + note: () => '/notes', + routine_task: () => '/routines', +}; + +export function toRuleDto(r: RuleRow, awarded = 0): GamificationRuleDto { + return { + id: r.id, + name: r.name, + description: r.description, + event: isGamificationEvent(r.event) ? r.event : 'LEAD_CREATED', + score: r.score, + coins: r.coins, + conditions: sanitizeConditions(r.conditions), + isActive: r.isActive, + maxPerDay: r.maxPerDay, + maxCoinsPerDay: r.maxCoinsPerDay, + maxPerUser: r.maxPerUser, + cooldownMinutes: r.cooldownMinutes, + oncePerEntity: r.oncePerEntity, + firstTimeOnly: r.firstTimeOnly, + requiresApproval: r.requiresApproval, + roles: r.roles, + teamIds: r.teamIds, + position: r.position, + awardedCount: awarded, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + }; +} + +function isUniqueViolation(err: unknown): boolean { + return err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002'; +} + +@Injectable() +export class GamificationService { + private readonly logger = new Logger(GamificationService.name); + private readonly settingsCache = new Map(); + private readonly rulesCache = new Map(); + private readonly membersCache = new Map(); + private readonly boardCache = new Map< + string, + { at: number; rows: Array; prev: Array | null } + >(); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} + + /* ───────── دریافت رویداد ───────── */ + + /** هرگز عملیات اصلی را کند یا خراب نمی‌کند */ + emit(input: GamificationEmitInput): void { + if (!input.userId) return; + void this.process(input).catch((err: unknown) => { + this.logger.warn( + `gamification ${input.event} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } + + async process(input: GamificationEmitInput): Promise { + const { organizationId, event } = input; + const userId = input.userId; + if (!userId || !isGamificationEvent(event)) return 0; + const settings = await this.settingsFor(organizationId); + if (!settings?.enabled) return 0; + + const member = (await this.members(organizationId)).find((m) => m.userId === userId); + if (!member) return 0; + + const rules = (await this.rulesFor(organizationId)).filter((r) => r.event === event); + const facts: Facts = { ...commonFacts(), ...(input.facts ?? {}) }; + let awarded = 0; + + for (const rule of rules) { + if (rule.roles.length && !rule.roles.includes(member.role)) continue; + if (rule.teamIds.length && !rule.teamIds.some((t) => member.teamIds.includes(t))) continue; + if (!matchesConditions(sanitizeConditions(rule.conditions), facts)) continue; + if (needsUsage(rule)) { + const usage = await this.ruleUsage(organizationId, rule.id, userId); + if (limitBlockReason(rule, usage)) continue; + } + const occurrence = input.eventKey ?? input.entityId ?? randomUUID(); + const key = + rule.oncePerEntity && input.entityId + ? `r:${rule.id}:u:${userId}:e:${input.entityId}` + : `r:${rule.id}:u:${userId}:k:${occurrence}`; + const tx = await this.record({ + organizationId, + userId, + kind: 'rule', + event, + ruleId: rule.id, + score: rule.score, + coins: rule.coins, + status: rule.requiresApproval ? 'pending' : 'approved', + title: GAMIFICATION_EVENTS[event].feed, + entityType: input.entityType ?? null, + entityId: input.entityId ?? null, + entityTitle: input.entityTitle ?? null, + idempotencyKey: key, + }); + if (tx?.status === 'approved') awarded += 1; + } + + if (awarded > 0) await this.afterAward(organizationId, userId, event, facts); + return awarded; + } + + private async ruleUsage(organizationId: string, ruleId: string, userId: string) { + const base: Prisma.GamificationTransactionWhereInput = { + organizationId, + ruleId, + userId, + status: { not: 'rejected' }, + }; + const todayStart = periodStart('today')!; + const [totalCount, today, last] = await Promise.all([ + this.prisma.gamificationTransaction.count({ where: base }), + this.prisma.gamificationTransaction.aggregate({ + where: { ...base, createdAt: { gte: todayStart } }, + _count: { _all: true }, + _sum: { coins: true }, + }), + this.prisma.gamificationTransaction.findFirst({ + where: base, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }), + ]); + return { + totalCount, + todayCount: today._count._all, + todayCoins: today._sum.coins ?? 0, + lastAt: last?.createdAt ?? null, + }; + } + + /** ثبت تراکنش و به‌روزرسانی کیف پول؛ اگر کلید تکراری باشد null */ + async record(input: RecordInput): Promise { + try { + const tx = await this.prisma.$transaction(async (db) => { + const row = await db.gamificationTransaction.create({ + data: { + organizationId: input.organizationId, + userId: input.userId, + kind: input.kind, + event: input.event ?? null, + ruleId: input.ruleId ?? null, + bonusId: input.bonusId ?? null, + badgeId: input.badgeId ?? null, + score: input.score ?? 0, + coins: input.coins ?? 0, + status: input.status ?? 'approved', + title: input.title, + entityType: input.entityType ?? null, + entityId: input.entityId ?? null, + entityTitle: input.entityTitle?.slice(0, 200) ?? null, + idempotencyKey: input.idempotencyKey ?? null, + reason: input.reason ?? null, + createdById: input.createdById ?? null, + }, + }); + if (row.status === 'approved') { + await this.applyWallet(db, row.organizationId, row.userId, row.score, row.coins, row.kind); + } + return row; + }); + this.invalidateBoard(input.organizationId); + return tx; + } catch (err) { + if (isUniqueViolation(err)) return null; + throw err; + } + } + + async applyWallet( + db: Db, + organizationId: string, + userId: string, + score: number, + coins: number, + kind: string, + ) { + if (!score && !coins) return; + const earned = coins > 0 && kind !== 'refund' ? coins : 0; + await db.gamificationWallet.upsert({ + where: { organizationId_userId: { organizationId, userId } }, + create: { organizationId, userId, score, coins, coinsEarned: earned }, + update: { + score: { increment: score }, + coins: { increment: coins }, + coinsEarned: { increment: earned }, + }, + }); + } + + /* ───────── بونوس و نشان ───────── */ + + async afterAward(organizationId: string, userId: string, event: GamificationEvent | null, facts: Facts = {}) { + await this.updateBestMonth(organizationId, userId); + let streak: number | null = null; + const getStreak = async () => { + if (streak == null) streak = await this.routineStreakFor(organizationId, userId); + return streak; + }; + + if (event) { + const bonuses = await this.prisma.gamificationBonus.findMany({ + where: { organizationId, isActive: true, kind: { in: ['event_count_period', 'routine_streak'] } }, + }); + for (const bonus of bonuses) { + if (bonus.kind === 'event_count_period' && bonus.event === event) { + const period = bonus.period === 'day' ? 'today' : bonus.period === 'week' ? 'week' : 'month'; + const from = periodStart(period)!; + const count = await this.prisma.gamificationTransaction.count({ + where: { organizationId, userId, kind: 'rule', event, status: 'approved', createdAt: { gte: from } }, + }); + if (count >= bonus.threshold) { + await this.record({ + organizationId, + userId, + kind: 'bonus', + bonusId: bonus.id, + score: bonus.score, + coins: bonus.coins, + title: `پاداش ویژه: ${bonus.name}`, + idempotencyKey: `b:${bonus.id}:u:${userId}:p:${from.toISOString()}`, + }); + } + } + if (bonus.kind === 'routine_streak' && event === 'ROUTINE_COMPLETED' && bonus.threshold > 0) { + const s = await getStreak(); + if (s > 0 && s % bonus.threshold === 0) { + await this.record({ + organizationId, + userId, + kind: 'bonus', + bonusId: bonus.id, + score: bonus.score, + coins: bonus.coins, + title: `پاداش ویژه: ${bonus.name}`, + idempotencyKey: `b:${bonus.id}:u:${userId}:d:${tehranDayKey()}`, + }); + } + } + } + } + + const earned = await this.prisma.gamificationUserBadge.findMany({ + where: { organizationId, userId }, + select: { badgeId: true }, + }); + const earnedIds = new Set(earned.map((e) => e.badgeId)); + const badges = ( + await this.prisma.gamificationBadge.findMany({ where: { organizationId, isActive: true } }) + ).filter((b) => !earnedIds.has(b.id)); + if (!badges.length) return; + + let wallet: { score: number } | null | undefined; + for (const badge of badges) { + let current: number | null = null; + if (badge.metric === 'event_count' && badge.event && badge.event === event) { + current = await this.eventCount(organizationId, userId, badge.event); + } else if (badge.metric === 'score_total') { + wallet ??= await this.prisma.gamificationWallet.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + select: { score: true }, + }); + current = wallet?.score ?? 0; + } else if (badge.metric === 'routine_streak' && event === 'ROUTINE_COMPLETED') { + current = await getStreak(); + } else if (badge.metric === 'single_deal_amount' && event === 'DEAL_WON') { + current = Number(facts.amount ?? 0) || 0; + } + if (current != null && current >= badge.threshold) { + await this.grantBadge(organizationId, userId, badge); + } + } + } + + private async grantBadge( + organizationId: string, + userId: string, + badge: { id: string; name: string; coins: number }, + ) { + try { + await this.prisma.gamificationUserBadge.create({ + data: { organizationId, badgeId: badge.id, userId }, + }); + } catch (err) { + if (isUniqueViolation(err)) return; + throw err; + } + await this.record({ + organizationId, + userId, + kind: 'achievement', + badgeId: badge.id, + coins: badge.coins, + title: `نشان «${badge.name}» گرفتی`, + idempotencyKey: `a:${badge.id}:u:${userId}`, + }); + await this.notifications.create({ + organizationId, + userId, + type: 'gamification_badge', + title: 'نشان جدید گرفتی!', + body: `نشان «${badge.name}»${badge.coins ? ` و ${badge.coins} سکه` : ''}`, + link: '/club?tab=badges', + entityType: 'gamification_badge', + entityId: badge.id, + }); + } + + private async eventCount(organizationId: string, userId: string, event: string) { + return this.prisma.gamificationTransaction.count({ + where: { organizationId, userId, kind: 'rule', event, status: 'approved' }, + }); + } + + private async updateBestMonth(organizationId: string, userId: string) { + const from = periodStart('month')!; + const agg = await this.prisma.gamificationTransaction.aggregate({ + where: { organizationId, userId, status: 'approved', createdAt: { gte: from } }, + _sum: { score: true }, + }); + const monthScore = agg._sum.score ?? 0; + await this.prisma.gamificationWallet.updateMany({ + where: { organizationId, userId, bestMonthScore: { lt: monthScore } }, + data: { bestMonthScore: monthScore }, + }); + } + + async routineStreakFor(organizationId: string, userId: string): Promise { + const today = tehranDayKey(); + const rows = await this.prisma.routineTask.groupBy({ + by: ['scheduledFor', 'status'], + where: { + organizationId, + assigneeId: userId, + routineId: { not: null }, + status: { not: 'cancelled' }, + scheduledFor: { gte: dayKeyToDate(addDays(today, -120)), lte: dayKeyToDate(today) }, + }, + _count: { _all: true }, + }); + const days = new Map(); + for (const r of rows) { + const key = dateToDayKey(r.scheduledFor); + const d = days.get(key) ?? { total: 0, completed: 0 }; + d.total += r._count._all; + if (r.status === 'completed') d.completed += r._count._all; + days.set(key, d); + } + return routineStreak(days, today); + } + + /* ───────── تنظیمات، قوانین، اعضا (با کش) ───────── */ + + async ensureDefaults(organizationId: string): Promise { + const existing = await this.prisma.gamificationSettings.findUnique({ where: { organizationId } }); + if (existing) return existing; + try { + return await this.prisma.$transaction(async (db) => { + const settings = await db.gamificationSettings.create({ + data: { organizationId, levels: DEFAULT_LEVELS as unknown as Prisma.InputJsonValue }, + }); + await db.gamificationRule.createMany({ + data: DEFAULT_RULES.map((r, i) => ({ + organizationId, + name: r.name, + event: r.event, + score: r.score, + coins: r.coins, + conditions: (r.conditions ?? []) as unknown as Prisma.InputJsonValue, + maxPerDay: r.maxPerDay ?? null, + cooldownMinutes: r.cooldownMinutes ?? null, + oncePerEntity: r.oncePerEntity ?? false, + position: i, + })), + }); + await db.gamificationReward.createMany({ + data: DEFAULT_REWARDS.map((r, i) => ({ organizationId, ...r, position: i })), + }); + await db.gamificationBadge.createMany({ + data: DEFAULT_BADGES.map((b, i) => ({ organizationId, ...b, event: b.event ?? null, position: i })), + }); + await db.gamificationBonus.createMany({ + data: DEFAULT_BONUSES.map((b) => ({ organizationId, ...b, event: b.event ?? null })), + }); + return settings; + }); + } catch (err) { + if (!isUniqueViolation(err)) throw err; + const row = await this.prisma.gamificationSettings.findUnique({ where: { organizationId } }); + if (!row) throw err; + return row; + } + } + + /** null یعنی ماژول برای این سازمان خاموش است */ + async settingsFor(organizationId: string): Promise { + const hit = this.settingsCache.get(organizationId); + if (hit && Date.now() - hit.at < SETTINGS_TTL) return hit.value; + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { enabledModules: true }, + }); + const mods = org?.enabledModules ?? []; + const moduleOn = !!org && (!mods.length || mods.includes('gamification')); + const value = moduleOn ? await this.ensureDefaults(organizationId) : null; + this.settingsCache.set(organizationId, { at: Date.now(), value }); + return value; + } + + toSettingsDto(row: SettingsRow): GamificationSettingsDto { + return { + enabled: row.enabled, + levels: normalizeLevels(row.levels), + monthlyGoal: row.monthlyGoal, + showFullLeaderboard: row.showFullLeaderboard, + enabledAt: row.enabledAt.toISOString(), + }; + } + + async rulesFor(organizationId: string): Promise { + const hit = this.rulesCache.get(organizationId); + if (hit && Date.now() - hit.at < RULES_TTL) return hit.rules; + const rules = await this.prisma.gamificationRule.findMany({ + where: { organizationId, isActive: true }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + }); + this.rulesCache.set(organizationId, { at: Date.now(), rules }); + return rules; + } + + async members(organizationId: string): Promise { + const hit = this.membersCache.get(organizationId); + if (hit && Date.now() - hit.at < MEMBERS_TTL) return hit.members; + const [rows, teamRows] = await Promise.all([ + this.prisma.membership.findMany({ + where: { organizationId, isActive: true, user: { isActive: true } }, + select: { + role: true, + user: { select: { id: true, fullName: true, avatarStorageKey: true } }, + }, + }), + this.prisma.teamMember.findMany({ + where: { team: { organizationId } }, + select: { teamId: true, userId: true }, + }), + ]); + const teamsByUser = new Map(); + for (const t of teamRows) { + const list = teamsByUser.get(t.userId) ?? []; + list.push(t.teamId); + teamsByUser.set(t.userId, list); + } + const members = rows.map((r) => ({ + userId: r.user.id, + fullName: r.user.fullName, + hasAvatar: !!r.user.avatarStorageKey, + role: r.role, + teamIds: teamsByUser.get(r.user.id) ?? [], + })); + this.membersCache.set(organizationId, { at: Date.now(), members }); + return members; + } + + invalidate(organizationId: string) { + this.settingsCache.delete(organizationId); + this.rulesCache.delete(organizationId); + this.membersCache.delete(organizationId); + this.invalidateBoard(organizationId); + } + + invalidateBoard(organizationId: string) { + for (const key of this.boardCache.keys()) { + if (key.startsWith(`${organizationId}:`)) this.boardCache.delete(key); + } + } + + async publicRules(actor: Actor): Promise { + await this.requireEnabled(actor); + return (await this.rulesFor(actor.organizationId)).map((r) => toRuleDto(r)); + } + + async requireEnabled(actor: Actor): Promise { + const settings = await this.settingsFor(actor.organizationId); + if (!settings) throw new ForbiddenException('این ماژول برای این محیط فعال نیست'); + return settings; + } + + /* ───────── جدول امتیازات ───────── */ + + async scoreRows(organizationId: string, period: GamificationPeriod) { + const key = `${organizationId}:${period}`; + const hit = this.boardCache.get(key); + if (hit && Date.now() - hit.at < BOARD_TTL) return hit; + + const members = await this.members(organizationId); + const dayAgo = new Date(Date.now() - 86_400_000); + let scores: Map; + let prevScores: Map | null = null; + + if (period === 'all') { + const [wallets, recent] = await Promise.all([ + this.prisma.gamificationWallet.findMany({ + where: { organizationId }, + select: { userId: true, score: true }, + }), + this.prisma.gamificationTransaction.groupBy({ + by: ['userId'], + where: { organizationId, status: 'approved', createdAt: { gte: dayAgo } }, + _sum: { score: true }, + }), + ]); + scores = new Map(wallets.map((w) => [w.userId, w.score])); + const recentMap = new Map(recent.map((r) => [r.userId, r._sum.score ?? 0])); + prevScores = new Map( + members.map((m) => [m.userId, (scores.get(m.userId) ?? 0) - (recentMap.get(m.userId) ?? 0)]), + ); + } else { + const from = periodStart(period)!; + const grouped = await this.prisma.gamificationTransaction.groupBy({ + by: ['userId'], + where: { organizationId, status: 'approved', createdAt: { gte: from } }, + _sum: { score: true }, + }); + scores = new Map(grouped.map((g) => [g.userId, g._sum.score ?? 0])); + if (period !== 'today' && dayAgo > from) { + const prev = await this.prisma.gamificationTransaction.groupBy({ + by: ['userId'], + where: { organizationId, status: 'approved', createdAt: { gte: from, lt: dayAgo } }, + _sum: { score: true }, + }); + prevScores = new Map(prev.map((g) => [g.userId, g._sum.score ?? 0])); + } + } + + const rows = members.map((m) => ({ + userId: m.userId, + fullName: m.fullName, + score: scores.get(m.userId) ?? 0, + })); + const prev = prevScores + ? members.map((m) => ({ userId: m.userId, fullName: m.fullName, score: prevScores!.get(m.userId) ?? 0 })) + : null; + const entry = { at: Date.now(), rows, prev }; + this.boardCache.set(key, entry); + return entry; + } + + private rank(rows: T[]) { + return rankRows(rows, (a, b) => a.fullName.localeCompare(b.fullName, 'fa')); + } + + async ranked(organizationId: string, period: GamificationPeriod) { + const { rows, prev } = await this.scoreRows(organizationId, period); + const ranked: RankedRow[] = this.rank(rows); + const prevRanks = prev ? new Map(this.rank(prev).map((r) => [r.userId, r.rank])) : null; + return { ranked, prevRanks }; + } + + async leaderboard( + actor: Actor, + periodRaw: unknown, + limitRaw?: unknown, + ): Promise { + const settings = await this.requireEnabled(actor); + const period = parsePeriod(periodRaw); + const organizationId = actor.organizationId; + const [{ ranked, prevRanks }, all, members] = await Promise.all([ + this.ranked(organizationId, period), + this.scoreRows(organizationId, 'all'), + this.members(organizationId), + ]); + const levels = normalizeLevels(settings.levels); + const allScore = new Map(all.rows.map((r) => [r.userId, r.score])); + const memberMap = new Map(members.map((m) => [m.userId, m])); + + const toEntry = (r: RankedRow): GamificationLeaderEntryDto => { + const prevRank = prevRanks?.get(r.userId); + return { + rank: r.rank, + userId: r.userId, + fullName: r.fullName, + hasAvatar: memberMap.get(r.userId)?.hasAvatar ?? false, + score: r.score, + change: prevRank != null && r.score > 0 ? prevRank - r.rank : null, + levelName: levelFor(allScore.get(r.userId) ?? 0, levels).name, + isMe: r.userId === actor.userId, + }; + }; + + const full = settings.showFullLeaderboard || elevated(actor.role); + const requested = Number(limitRaw); + const limit = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 200) : 50; + const items = ranked.slice(0, full ? limit : Math.min(limit, 10)).map(toEntry); + const meRow = ranked.find((r) => r.userId === actor.userId); + return { + period, + from: periodStart(period)?.toISOString() ?? null, + totalUsers: ranked.length, + items, + me: meRow ? toEntry(meRow) : null, + }; + } + + async teams(actor: Actor, periodRaw: unknown): Promise { + await this.requireEnabled(actor); + const period = parsePeriod(periodRaw); + const [teams, { rows }] = await Promise.all([ + this.prisma.team.findMany({ + where: { organizationId: actor.organizationId }, + select: { id: true, name: true, members: { select: { userId: true } } }, + }), + this.scoreRows(actor.organizationId, period), + ]); + const scores = new Map(rows.map((r) => [r.userId, r.score])); + const list = teams + .filter((t) => t.members.length) + .map((t) => { + const score = t.members.reduce((s, m) => s + (scores.get(m.userId) ?? 0), 0); + return { + teamId: t.id, + name: t.name, + members: t.members.length, + score, + avgScore: Math.round(score / t.members.length), + isMine: t.members.some((m) => m.userId === actor.userId), + }; + }); + return rankRows( + list.map((t) => ({ ...t, userId: t.teamId })), + (a, b) => a.name.localeCompare(b.name, 'fa'), + ).map(({ userId: _u, ...t }) => t); + } + + /* ───────── خلاصهٔ من ───────── */ + + async me(actor: Actor, periodRaw?: unknown): Promise { + const settings = await this.requireEnabled(actor); + const period = parsePeriod(periodRaw); + const organizationId = actor.organizationId; + const userId = actor.userId; + const levels = normalizeLevels(settings.levels); + + const [wallet, board, todayBoard, monthBoard, rewards, badgesEarned, pendingRedemptions, recent, streakDays, members] = + await Promise.all([ + this.prisma.gamificationWallet.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + }), + this.ranked(organizationId, period), + this.scoreRows(organizationId, 'today'), + this.scoreRows(organizationId, 'month'), + this.prisma.gamificationReward.findMany({ + where: { organizationId, isActive: true }, + orderBy: { cost: 'asc' }, + }), + this.prisma.gamificationUserBadge.count({ where: { organizationId, userId } }), + this.prisma.gamificationRedemption.count({ where: { organizationId, userId, status: 'pending' } }), + this.prisma.gamificationTransaction.findMany({ + where: { organizationId, userId, status: { not: 'rejected' } }, + orderBy: { createdAt: 'desc' }, + take: 8, + }), + this.routineStreakFor(organizationId, userId), + this.members(organizationId), + ]); + + const score = wallet?.score ?? 0; + const coins = wallet?.coins ?? 0; + const meIdx = board.ranked.findIndex((r) => r.userId === userId); + const meRow = meIdx >= 0 ? board.ranked[meIdx]! : null; + const above = meRow ? board.ranked.filter((r) => r.score > meRow.score) : []; + const nextUp = above.length ? above[above.length - 1]! : null; + const first = board.ranked[0] ?? null; + + const monthScore = monthBoard.rows.find((r) => r.userId === userId)?.score ?? 0; + let target = Math.max(1, settings.monthlyGoal); + if (monthScore >= target) target = Math.ceil((monthScore + 1) / 500) * 500; + const nextReward = rewards.find((r) => r.cost > coins && (r.stock == null || r.stock > 0)) ?? null; + const me = members.find((m) => m.userId === userId); + + return { + user: { id: userId, fullName: me?.fullName ?? '', hasAvatar: me?.hasAvatar ?? false }, + isManager: elevated(actor.role), + settings: this.toSettingsDto(settings), + score, + coins, + period, + periodScore: meRow?.score ?? 0, + todayScore: todayBoard.rows.find((r) => r.userId === userId)?.score ?? 0, + rank: meRow?.rank ?? null, + totalUsers: board.ranked.length, + gapToNext: meRow && nextUp ? nextUp.score - meRow.score : null, + nextRankName: nextUp?.fullName ?? null, + gapToFirst: meRow && first && first.userId !== userId ? Math.max(0, first.score - meRow.score) : null, + level: levelFor(score, levels), + personalBest: Math.max(wallet?.bestMonthScore ?? 0, monthScore), + goal: { + target, + current: monthScore, + progress: Math.max(0, Math.min(100, Math.round((monthScore / target) * 100))), + }, + nextReward: nextReward + ? { + id: nextReward.id, + name: nextReward.name, + cost: nextReward.cost, + icon: nextReward.icon, + remaining: nextReward.cost - coins, + } + : null, + streakDays, + badgesEarned, + pendingRedemptions, + recent: await this.toTxDtos(organizationId, recent), + }; + } + + async pulse(actor: Actor, sinceRaw: unknown): Promise { + await this.requireEnabled(actor); + const organizationId = actor.organizationId; + const userId = actor.userId; + const parsed = typeof sinceRaw === 'string' ? new Date(sinceRaw) : null; + const since = + parsed && !Number.isNaN(parsed.getTime()) ? parsed : new Date(Date.now() - 60_000); + const now = new Date(); + const [wallet, rows, board] = await Promise.all([ + this.prisma.gamificationWallet.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + }), + this.prisma.gamificationTransaction.findMany({ + where: { + organizationId, + userId, + status: 'approved', + kind: { in: ['rule', 'bonus', 'achievement', 'adjust'] }, + createdAt: { gt: since, lte: now }, + }, + orderBy: { createdAt: 'asc' }, + take: 10, + }), + this.ranked(organizationId, 'month'), + ]); + return { + serverTime: now.toISOString(), + score: wallet?.score ?? 0, + coins: wallet?.coins ?? 0, + rank: board.ranked.find((r) => r.userId === userId)?.rank ?? null, + items: await this.toTxDtos(organizationId, rows), + }; + } + + /* ───────── تاریخچه و فید ───────── */ + + async transactions( + actor: Actor, + q: { userId?: string; kind?: string; event?: string; status?: string; page?: number; limit?: number }, + ) { + await this.requireEnabled(actor); + const manager = elevated(actor.role); + const userId = manager ? q.userId || undefined : actor.userId; + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 30)); + const where: Prisma.GamificationTransactionWhereInput = { + organizationId: actor.organizationId, + ...(userId ? { userId } : {}), + ...(q.kind ? { kind: q.kind } : {}), + ...(q.event ? { event: q.event } : {}), + ...(q.status ? { status: q.status } : {}), + }; + const [rows, total] = await Promise.all([ + this.prisma.gamificationTransaction.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.gamificationTransaction.count({ where }), + ]); + return { items: await this.toTxDtos(actor.organizationId, rows), total, page, limit }; + } + + async feed(actor: Actor): Promise { + await this.requireEnabled(actor); + const rows = await this.prisma.gamificationTransaction.findMany({ + where: { + organizationId: actor.organizationId, + status: 'approved', + kind: { in: ['rule', 'bonus', 'achievement'] }, + score: { gt: 0 }, + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + return this.toTxDtos(actor.organizationId, rows); + } + + async toTxDtos(organizationId: string, rows: TxRow[]): Promise { + if (!rows.length) return []; + const names = new Map((await this.members(organizationId)).map((m) => [m.userId, m.fullName])); + const missing = [ + ...new Set(rows.flatMap((r) => [r.userId, r.createdById]).filter((id): id is string => !!id && !names.has(id))), + ]; + if (missing.length) { + const users = await this.prisma.user.findMany({ + where: { id: { in: missing } }, + select: { id: true, fullName: true }, + }); + for (const u of users) names.set(u.id, u.fullName); + } + return rows.map((r) => ({ + id: r.id, + userId: r.userId, + userName: names.get(r.userId) ?? '—', + kind: r.kind as GamificationTxKind, + event: isGamificationEvent(r.event) ? r.event : null, + title: r.title, + score: r.score, + coins: r.coins, + status: r.status as GamificationTxStatus, + entityType: r.entityType, + entityId: r.entityId, + entityTitle: r.entityTitle, + link: r.entityType && r.entityId && ENTITY_LINKS[r.entityType] ? ENTITY_LINKS[r.entityType]!(r.entityId) : null, + reason: r.reason, + createdByName: r.createdById ? (names.get(r.createdById) ?? null) : null, + createdAt: r.createdAt.toISOString(), + })); + } + + /* ───────── نشان‌ها ───────── */ + + async achievements(actor: Actor, userIdRaw?: string): Promise { + await this.requireEnabled(actor); + const organizationId = actor.organizationId; + const userId = userIdRaw && elevated(actor.role) ? userIdRaw : actor.userId; + const [badges, earned, counts, wallet, streak, bestDeal, earnedCounts] = await Promise.all([ + this.prisma.gamificationBadge.findMany({ + where: { organizationId, isActive: true }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + }), + this.prisma.gamificationUserBadge.findMany({ where: { organizationId, userId } }), + this.prisma.gamificationTransaction.groupBy({ + by: ['event'], + where: { organizationId, userId, kind: 'rule', status: 'approved' }, + _count: { _all: true }, + }), + this.prisma.gamificationWallet.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + }), + this.routineStreakFor(organizationId, userId), + this.prisma.deal.aggregate({ + where: { organizationId, ownerId: userId, status: 'won' }, + _max: { amount: true }, + }), + this.prisma.gamificationUserBadge.groupBy({ + by: ['badgeId'], + where: { organizationId }, + _count: { _all: true }, + }), + ]); + const earnedMap = new Map(earned.map((e) => [e.badgeId, e.earnedAt])); + const countMap = new Map(counts.map((c) => [c.event ?? '', c._count._all])); + const earnedCountMap = new Map(earnedCounts.map((c) => [c.badgeId, c._count._all])); + const valueOf = (metric: string, event: string | null) => { + if (metric === 'event_count') return countMap.get(event ?? '') ?? 0; + if (metric === 'score_total') return wallet?.score ?? 0; + if (metric === 'routine_streak') return streak; + if (metric === 'single_deal_amount') return Number(bestDeal._max.amount ?? 0); + return 0; + }; + return badges.map((b) => { + const earnedAt = earnedMap.get(b.id) ?? null; + const current = valueOf(b.metric, b.event); + return { + 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: earnedCountMap.get(b.id) ?? 0, + earned: !!earnedAt, + earnedAt: earnedAt?.toISOString() ?? null, + current, + progress: earnedAt + ? 100 + : Math.max(0, Math.min(100, Math.round((current / Math.max(1, b.threshold)) * 100))), + }; + }); + } + + /* ───────── فروشگاه پاداش ───────── */ + + toRewardDto(r: Prisma.GamificationRewardGetPayload): GamificationRewardDto { + return { + id: r.id, + name: r.name, + description: r.description, + cost: r.cost, + icon: r.icon, + color: r.color, + stock: r.stock, + isActive: r.isActive, + requiresApproval: r.requiresApproval, + position: r.position, + }; + } + + async rewards(actor: Actor): Promise { + await this.requireEnabled(actor); + const rows = await this.prisma.gamificationReward.findMany({ + where: { organizationId: actor.organizationId, isActive: true }, + orderBy: [{ cost: 'asc' }, { position: 'asc' }], + }); + return rows.map((r) => this.toRewardDto(r)); + } + + async redeem(actor: Actor, rewardId: string, note?: string | null): Promise { + await this.requireEnabled(actor); + const organizationId = actor.organizationId; + const userId = actor.userId; + const reward = await this.prisma.gamificationReward.findFirst({ + where: { id: rewardId, organizationId, isActive: true }, + }); + if (!reward) throw new NotFoundException('پاداش پیدا نشد'); + if (reward.stock != null && reward.stock <= 0) { + throw new BadRequestException('موجودی این پاداش تمام شده است'); + } + + const redemption = await this.prisma.$transaction(async (db) => { + const paid = await db.gamificationWallet.updateMany({ + where: { organizationId, userId, coins: { gte: reward.cost } }, + data: { coins: { decrement: reward.cost } }, + }); + if (!paid.count) throw new BadRequestException('سکهٔ کافی برای این پاداش ندارید'); + if (reward.stock != null) { + const took = await db.gamificationReward.updateMany({ + where: { id: reward.id, stock: { gt: 0 } }, + data: { stock: { decrement: 1 } }, + }); + if (!took.count) throw new BadRequestException('موجودی این پاداش تمام شده است'); + } + const row = await db.gamificationRedemption.create({ + data: { + organizationId, + userId, + rewardId: reward.id, + cost: reward.cost, + status: reward.requiresApproval ? 'pending' : 'approved', + note: note?.trim().slice(0, 500) || null, + }, + include: { reward: true }, + }); + await db.gamificationTransaction.create({ + data: { + organizationId, + userId, + kind: 'redeem', + coins: -reward.cost, + status: 'approved', + title: `دریافت پاداش «${reward.name}»`, + entityType: 'redemption', + entityId: row.id, + idempotencyKey: `redeem:${row.id}`, + }, + }); + return row; + }); + + if (reward.requiresApproval) { + const me = (await this.members(organizationId)).find((m) => m.userId === userId); + const admins = (await this.members(organizationId)).filter( + (m) => elevated(m.role) && m.userId !== userId, + ); + for (const admin of admins) { + await this.notifications.create({ + organizationId, + userId: admin.userId, + type: 'gamification_redemption', + title: 'درخواست دریافت پاداش', + body: `${me?.fullName ?? 'یک همکار'} پاداش «${reward.name}» را درخواست کرد`, + link: '/club/admin?tab=redemptions', + entityType: 'gamification_redemption', + entityId: redemption.id, + }); + } + } + return (await this.toRedemptionDtos(organizationId, [redemption]))[0]!; + } + + async myRedemptions(actor: Actor): Promise { + await this.requireEnabled(actor); + const rows = await this.prisma.gamificationRedemption.findMany({ + where: { organizationId: actor.organizationId, userId: actor.userId }, + include: { reward: true }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + return this.toRedemptionDtos(actor.organizationId, rows); + } + + async toRedemptionDtos( + organizationId: string, + rows: Array>, + ): Promise { + const names = new Map((await this.members(organizationId)).map((m) => [m.userId, m.fullName])); + return rows.map((r) => ({ + id: r.id, + userId: r.userId, + userName: names.get(r.userId) ?? '—', + rewardId: r.rewardId, + rewardName: r.reward.name, + rewardIcon: r.reward.icon, + cost: r.cost, + status: r.status as GamificationRedemptionStatus, + note: r.note, + decisionNote: r.decisionNote, + decidedByName: r.decidedById ? (names.get(r.decidedById) ?? null) : null, + decidedAt: r.decidedAt?.toISOString() ?? null, + createdAt: r.createdAt.toISOString(), + })); + } +} diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index 76042c0..4d9570a 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -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; @@ -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( diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 2cfa6df..da8e937 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 = { @@ -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[0]['event'], + lead: { id: string; title: string }, + extra: { userId?: string | null; eventKey?: string; facts?: Record } = {}, + ) { + 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 { - 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 { - 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, diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index 8057b10..5bc668f 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -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); } diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 03f15ad..0328ec2 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -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); diff --git a/apps/api/src/quotes/quotes.service.ts b/apps/api/src/quotes/quotes.service.ts index 4bbbfca..3d67ab7 100644 --- a/apps/api/src/quotes/quotes.service.ts +++ b/apps/api/src/quotes/quotes.service.ts @@ -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; @@ -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( diff --git a/apps/api/src/routines/routine-tasks.service.ts b/apps/api/src/routines/routine-tasks.service.ts index 8e22c6f..103bdb1 100644 --- a/apps/api/src/routines/routine-tasks.service.ts +++ b/apps/api/src/routines/routine-tasks.service.ts @@ -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 { @@ -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); } diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index a3e8258..09aa1ca 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -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); diff --git a/apps/api/test/gamification-engine.test.ts b/apps/api/test/gamification-engine.test.ts new file mode 100644 index 0000000..182511f --- /dev/null +++ b/apps/api/test/gamification-engine.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/components/gamification/ClubBadges.vue b/apps/web/src/components/gamification/ClubBadges.vue new file mode 100644 index 0000000..e33249a --- /dev/null +++ b/apps/web/src/components/gamification/ClubBadges.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/apps/web/src/components/gamification/ClubHistory.vue b/apps/web/src/components/gamification/ClubHistory.vue new file mode 100644 index 0000000..fb31178 --- /dev/null +++ b/apps/web/src/components/gamification/ClubHistory.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/apps/web/src/components/gamification/ClubHome.vue b/apps/web/src/components/gamification/ClubHome.vue new file mode 100644 index 0000000..c7ad88e --- /dev/null +++ b/apps/web/src/components/gamification/ClubHome.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/apps/web/src/components/gamification/ClubLeaderboard.vue b/apps/web/src/components/gamification/ClubLeaderboard.vue new file mode 100644 index 0000000..c9383b0 --- /dev/null +++ b/apps/web/src/components/gamification/ClubLeaderboard.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/apps/web/src/components/gamification/ClubRulesModal.vue b/apps/web/src/components/gamification/ClubRulesModal.vue new file mode 100644 index 0000000..82a27ae --- /dev/null +++ b/apps/web/src/components/gamification/ClubRulesModal.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/apps/web/src/components/gamification/ClubStore.vue b/apps/web/src/components/gamification/ClubStore.vue new file mode 100644 index 0000000..48bc572 --- /dev/null +++ b/apps/web/src/components/gamification/ClubStore.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/apps/web/src/components/gamification/ClubTeams.vue b/apps/web/src/components/gamification/ClubTeams.vue new file mode 100644 index 0000000..f2c871a --- /dev/null +++ b/apps/web/src/components/gamification/ClubTeams.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/apps/web/src/components/gamification/GamificationToaster.vue b/apps/web/src/components/gamification/GamificationToaster.vue new file mode 100644 index 0000000..b07fb5c --- /dev/null +++ b/apps/web/src/components/gamification/GamificationToaster.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminAchievements.vue b/apps/web/src/components/gamification/admin/AdminAchievements.vue new file mode 100644 index 0000000..280d642 --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminAchievements.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminAdjust.vue b/apps/web/src/components/gamification/admin/AdminAdjust.vue new file mode 100644 index 0000000..86c4a9b --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminAdjust.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminApprovals.vue b/apps/web/src/components/gamification/admin/AdminApprovals.vue new file mode 100644 index 0000000..b219112 --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminApprovals.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminReports.vue b/apps/web/src/components/gamification/admin/AdminReports.vue new file mode 100644 index 0000000..e385870 --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminReports.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminRewards.vue b/apps/web/src/components/gamification/admin/AdminRewards.vue new file mode 100644 index 0000000..f46a6c4 --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminRewards.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminRules.vue b/apps/web/src/components/gamification/admin/AdminRules.vue new file mode 100644 index 0000000..755da63 --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminRules.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/AdminSettings.vue b/apps/web/src/components/gamification/admin/AdminSettings.vue new file mode 100644 index 0000000..5ec3364 --- /dev/null +++ b/apps/web/src/components/gamification/admin/AdminSettings.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/apps/web/src/components/gamification/admin/RuleBuilderModal.vue b/apps/web/src/components/gamification/admin/RuleBuilderModal.vue new file mode 100644 index 0000000..f5b5ca4 --- /dev/null +++ b/apps/web/src/components/gamification/admin/RuleBuilderModal.vue @@ -0,0 +1,534 @@ + + + + + diff --git a/apps/web/src/components/gamification/club-context.ts b/apps/web/src/components/gamification/club-context.ts new file mode 100644 index 0000000..7ab2a5e --- /dev/null +++ b/apps/web/src/components/gamification/club-context.ts @@ -0,0 +1,15 @@ +import { inject, type InjectionKey, type Ref } from 'vue'; +import type { GamificationMeDto } from '@mento/shared'; + +export type ClubContext = { + me: Ref; + reload: () => Promise; +}; + +export const CLUB_CTX: InjectionKey = Symbol('club'); + +export function useClub(): ClubContext { + const ctx = inject(CLUB_CTX); + if (!ctx) throw new Error('ClubContext not provided'); + return ctx; +} diff --git a/apps/web/src/components/gamification/rule-text.ts b/apps/web/src/components/gamification/rule-text.ts new file mode 100644 index 0000000..d025202 --- /dev/null +++ b/apps/web/src/components/gamification/rule-text.ts @@ -0,0 +1,15 @@ +import type { GamificationCondition } from '@mento/shared'; +import { GAMIFICATION_CONDITION_OPS, GAMIFICATION_FIELDS } from '@mento/shared'; +import { toFaDigits } from '@/lib/jalali'; + +/** متن خوانا برای یک شرط، مثل «مبلغ بیشتر از ۱۰۰٬۰۰۰» */ +export function conditionText(c: GamificationCondition): string { + const field = GAMIFICATION_FIELDS[c.field]; + const label = field?.label ?? c.field; + if (c.op === 'is_true') return `${label}: بله`; + if (c.op === 'is_false') return `${label}: خیر`; + const raw = c.value ?? ''; + const option = field?.options?.find((o) => o.value === String(raw)); + const value = option ? option.label : typeof raw === 'number' ? toFaDigits(raw.toLocaleString('en-US')) : String(raw); + return `${label} ${GAMIFICATION_CONDITION_OPS[c.op]} ${value}`; +} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index cc90b75..bcefa54 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -9,6 +9,7 @@ import MentoLogo from '@/components/brand/MentoLogo.vue'; import QuickCreate from '@/components/QuickCreate.vue'; import OrgSwitcher from '@/components/OrgSwitcher.vue'; import NavHubPanel from '@/components/nav/NavHubPanel.vue'; +import GamificationToaster from '@/components/gamification/GamificationToaster.vue'; import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs'; import type { ActivitySummaryDto, @@ -49,9 +50,8 @@ function pathIn(prefixes: string[]) { const dashboardActive = computed(() => route.path === '/'); const loyaltyActive = computed(() => pathIn(['/loyalty'])); const reportsActive = computed(() => pathIn(['/reports'])); -const leadsActive = computed(() => - pathIn(['/leads', '/settings/lead-stages', '/settings/lead-forms']), -); +const clubActive = computed(() => pathIn(['/club'])); +const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages'])); const customersActive = computed( () => pathIn(['/contacts']) || (isMobileNav.value && leadsActive.value), ); @@ -85,6 +85,7 @@ const toolsActive = computed(() => '/cheques', '/attachments', '/reservations', + '/settings/lead-forms', ]), ); const settingsActive = computed(() => @@ -104,6 +105,7 @@ const settingsActive = computed(() => const moreSectionActive = computed( () => routinesActive.value || + clubActive.value || notesActive.value || conversationsActive.value || supportActive.value || @@ -539,6 +541,18 @@ onUnmounted(() => { {{ toFaDigits(routineBadge) }} + + + باشگاه امتیازات + + {
+
@@ -899,6 +914,16 @@ onUnmounted(() => { کارهای روتین {{ toFaDigits(routineBadge) }}
+ + + باشگاه امتیازات + یادداشت‌ها diff --git a/apps/web/src/lib/gamification.ts b/apps/web/src/lib/gamification.ts new file mode 100644 index 0000000..49e6a44 --- /dev/null +++ b/apps/web/src/lib/gamification.ts @@ -0,0 +1,103 @@ +import type { GamificationEvent, GamificationTxDto } from '@mento/shared'; +import { GAMIFICATION_EVENTS } from '@mento/shared'; +import { toFaDigits } from '@/lib/jalali'; + +export { apiError } from '@/lib/routines'; + +type IconName = + | 'trophy' + | 'user-plus' + | 'funnel' + | 'briefcase' + | 'phone' + | 'calendar' + | 'document' + | 'check' + | 'refresh' + | 'dollar' + | 'chat' + | 'star' + | 'medal' + | 'gift' + | 'sliders' + | 'building' + | 'trend'; + +const EVENT_ICONS: Record = { + LEAD_CREATED: 'funnel', + LEAD_STAGE_CHANGED: 'trend', + LEAD_CONVERTED: 'user-plus', + CUSTOMER_CREATED: 'user-plus', + COMPANY_CREATED: 'building', + DEAL_CREATED: 'briefcase', + DEAL_STAGE_CHANGED: 'trend', + DEAL_WON: 'trophy', + DEAL_LOST: 'briefcase', + CALL_LOGGED: 'phone', + MEETING_CREATED: 'calendar', + FOLLOW_UP_CREATED: 'calendar', + NOTE_CREATED: 'document', + ACTIVITY_CREATED: 'check', + TASK_COMPLETED: 'check', + ROUTINE_COMPLETED: 'refresh', + QUOTE_CREATED: 'document', + INVOICE_ISSUED: 'dollar', + PAYMENT_REGISTERED: 'dollar', + TICKET_CREATED: 'chat', + TICKET_RESOLVED: 'chat', +}; + +export function txIcon(tx: Pick): IconName { + if (tx.kind === 'bonus') return 'star'; + if (tx.kind === 'achievement') return 'medal'; + if (tx.kind === 'redeem' || tx.kind === 'refund') return 'gift'; + if (tx.kind === 'adjust') return 'sliders'; + return tx.event ? EVENT_ICONS[tx.event] : 'star'; +} + +export function eventLabel(event: GamificationEvent | null | undefined): string { + return event ? GAMIFICATION_EVENTS[event]?.label ?? event : ''; +} + +/** عدد فارسی با جداکننده هزارگان */ +export function faNum(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return '—'; + return toFaDigits(Math.round(value).toLocaleString('en-US')); +} + +export function signedFa(value: number): string { + if (!value) return toFaDigits(0); + return `${value > 0 ? '+' : '−'}${faNum(Math.abs(value))}`; +} + +/** «۵ دقیقه پیش»، «دیروز ۱۴:۲۰» … */ +export function timeAgo(iso: string): string { + const d = new Date(iso); + const diff = Date.now() - d.getTime(); + const min = Math.floor(diff / 60_000); + if (min < 1) return 'همین الان'; + if (min < 60) return `${toFaDigits(min)} دقیقه پیش`; + const hours = Math.floor(min / 60); + if (hours < 24) return `${toFaDigits(hours)} ساعت پیش`; + const days = Math.floor(hours / 24); + if (days < 7) return `${toFaDigits(days)} روز پیش`; + return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium' }).format(d); +} + +export function formatDateTime(iso: string): string { + try { + return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso)); + } catch { + return iso; + } +} + +export const REWARD_ICONS = ['gift', 'star', 'trophy', 'medal', 'crown', 'calendar-sun', 'bag', 'target', 'fire'] as const; +export const BADGE_ICONS = ['medal', 'trophy', 'crown', 'star', 'fire', 'phone', 'users', 'funnel', 'target', 'check'] as const; + +export const ROLE_LABELS: Record = { + owner: 'مالک', + admin: 'مدیر', + sales: 'فروش', + support: 'پشتیبانی', +}; diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index a8162ac..8848d61 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -136,15 +136,6 @@ export const NAV_HUBS: NavHubDef[] = [ admin: true, module: 'leads', }, - { - to: '/settings/lead-forms', - title: 'فرم‌ساز سرنخ', - desc: 'لینک عمومی، iframe و QR برای جذب سرنخ', - icon: 'document', - tint: 'mint', - admin: true, - module: 'lead_forms', - }, ], }, { @@ -367,6 +358,15 @@ export const NAV_HUBS: NavHubDef[] = [ tint: 'amber', module: 'attachments', }, + { + to: '/settings/lead-forms', + title: 'فرم‌ساز سرنخ', + desc: 'لینک عمومی، iframe و QR برای جذب سرنخ', + icon: 'document', + tint: 'mint', + admin: true, + module: 'lead_forms', + }, ], }, { diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index c99dd7b..884c201 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -278,6 +278,16 @@ export const router = createRouter({ name: 'loyalty', component: () => import('@/views/LoyaltyClubView.vue'), }, + { + path: 'club', + name: 'gamification', + component: () => import('@/views/gamification/GamificationView.vue'), + }, + { + path: 'club/admin', + name: 'gamification-admin', + component: () => import('@/views/gamification/GamificationAdminView.vue'), + }, { path: 'deals/new', name: 'deal-new', diff --git a/apps/web/src/views/gamification/GamificationAdminView.vue b/apps/web/src/views/gamification/GamificationAdminView.vue new file mode 100644 index 0000000..021f91e --- /dev/null +++ b/apps/web/src/views/gamification/GamificationAdminView.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/apps/web/src/views/gamification/GamificationView.vue b/apps/web/src/views/gamification/GamificationView.vue new file mode 100644 index 0000000..a520df2 --- /dev/null +++ b/apps/web/src/views/gamification/GamificationView.vue @@ -0,0 +1,653 @@ + + + + + diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index f75b1ce..0f6dc8d 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -13,6 +13,7 @@ const PAGES: Array<[string, string]> = [ ['/notes', 'یادداشت‌ها'], ['/tickets', 'پشتیبانی'], ['/loyalty', 'باشگاه مشتریان'], + ['/club', 'باشگاه امتیازات'], ['/reports', 'گزارش‌ها'], ['/invoices', 'فاکتورها'], ['/quotes', 'پیش‌فاکتورها'], diff --git a/packages/shared/src/gamification.ts b/packages/shared/src/gamification.ts new file mode 100644 index 0000000..63c6212 --- /dev/null +++ b/packages/shared/src/gamification.ts @@ -0,0 +1,491 @@ +/** رویدادهای امتیازدهی — موتور پاداش فقط به همین رویدادها گوش می‌دهد */ +export type GamificationEvent = + | "LEAD_CREATED" + | "LEAD_STAGE_CHANGED" + | "LEAD_CONVERTED" + | "CUSTOMER_CREATED" + | "COMPANY_CREATED" + | "DEAL_CREATED" + | "DEAL_STAGE_CHANGED" + | "DEAL_WON" + | "DEAL_LOST" + | "CALL_LOGGED" + | "MEETING_CREATED" + | "FOLLOW_UP_CREATED" + | "NOTE_CREATED" + | "ACTIVITY_CREATED" + | "TASK_COMPLETED" + | "ROUTINE_COMPLETED" + | "QUOTE_CREATED" + | "INVOICE_ISSUED" + | "PAYMENT_REGISTERED" + | "TICKET_CREATED" + | "TICKET_RESOLVED"; + +export type GamificationFieldType = "number" | "string" | "boolean"; + +export interface GamificationFieldMeta { + label: string; + type: GamificationFieldType; + /** مقادیر پیشنهادی برای فیلدهای متنی */ + options?: Array<{ value: string; label: string }>; +} + +/** فیلدهای قابل استفاده در شرط‌های Rule Builder */ +export const GAMIFICATION_FIELDS: Record = { + amount: { label: "مبلغ", type: "number" }, + stageName: { label: "نام مرحله", type: "string" }, + source: { label: "منبع", type: "string" }, + activityType: { + label: "نوع فعالیت", + type: "string", + options: [ + { value: "call", label: "تماس" }, + { value: "meeting", label: "جلسه" }, + { value: "task", label: "کار" }, + { value: "note", label: "یادداشت" }, + ], + }, + typeName: { label: "عنوان نوع فعالیت", type: "string" }, + onTime: { label: "انجام به‌موقع", type: "boolean" }, + isMandatory: { label: "اجباری", type: "boolean" }, + priority: { + label: "اولویت", + type: "string", + options: [ + { value: "very_low", label: "خیلی کم" }, + { value: "low", label: "کم" }, + { value: "medium", label: "متوسط" }, + { value: "normal", label: "عادی (تیکت)" }, + { value: "high", label: "زیاد" }, + { value: "very_high", label: "خیلی زیاد" }, + { value: "urgent", label: "فوری (تیکت)" }, + ], + }, + hasDescription: { label: "توضیحات دارد", type: "boolean" }, + hour: { label: "ساعت انجام (۰ تا ۲۳)", type: "number" }, + weekday: { label: "روز هفته (۰=شنبه … ۶=جمعه)", type: "number" }, +}; + +const COMMON_FIELDS = ["hour", "weekday"]; + +export interface GamificationEventMeta { + label: string; + /** متن کوتاه برای فید امتیازات، مثل «معامله برنده شد» */ + feed: string; + group: "leads" | "customers" | "sales" | "activities" | "finance" | "support"; + fields: string[]; +} + +export const GAMIFICATION_EVENTS: Record = { + LEAD_CREATED: { label: "ثبت لید جدید", feed: "لید جدید ثبت شد", group: "leads", fields: ["source", "amount", ...COMMON_FIELDS] }, + LEAD_STAGE_CHANGED: { label: "تغییر مرحله لید", feed: "لید به مرحله بعد رفت", group: "leads", fields: ["stageName", "source", ...COMMON_FIELDS] }, + LEAD_CONVERTED: { label: "تبدیل لید به مشتری", feed: "لید به مشتری تبدیل شد", group: "leads", fields: ["source", "amount", ...COMMON_FIELDS] }, + CUSTOMER_CREATED: { label: "ثبت مشتری جدید", feed: "مشتری جدید ثبت شد", group: "customers", fields: ["source", ...COMMON_FIELDS] }, + COMPANY_CREATED: { label: "ثبت شرکت جدید", feed: "شرکت جدید ثبت شد", group: "customers", fields: [...COMMON_FIELDS] }, + DEAL_CREATED: { label: "ایجاد معامله", feed: "معامله جدید ایجاد شد", group: "sales", fields: ["amount", ...COMMON_FIELDS] }, + DEAL_STAGE_CHANGED: { label: "جابه‌جایی معامله در مسیر فروش", feed: "معامله به مرحله بعد رفت", group: "sales", fields: ["stageName", "amount", ...COMMON_FIELDS] }, + DEAL_WON: { label: "برنده شدن معامله", feed: "معامله برنده شد", group: "sales", fields: ["amount", "stageName", ...COMMON_FIELDS] }, + DEAL_LOST: { label: "از دست رفتن معامله", feed: "معامله بسته شد", group: "sales", fields: ["amount", ...COMMON_FIELDS] }, + CALL_LOGGED: { label: "ثبت تماس", feed: "تماس ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] }, + MEETING_CREATED: { label: "ثبت جلسه", feed: "جلسه ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] }, + FOLLOW_UP_CREATED: { label: "ثبت پیگیری", feed: "پیگیری ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] }, + NOTE_CREATED: { label: "ثبت یادداشت", feed: "یادداشت ثبت شد", group: "activities", fields: ["hasDescription", ...COMMON_FIELDS] }, + ACTIVITY_CREATED: { label: "ثبت فعالیت", feed: "فعالیت ثبت شد", group: "activities", fields: ["activityType", "typeName", "priority", "hasDescription", ...COMMON_FIELDS] }, + TASK_COMPLETED: { label: "انجام کار", feed: "کار انجام شد", group: "activities", fields: ["activityType", "typeName", "onTime", "priority", ...COMMON_FIELDS] }, + ROUTINE_COMPLETED: { label: "انجام کار روتین", feed: "کار روتین انجام شد", group: "activities", fields: ["onTime", "isMandatory", "priority", ...COMMON_FIELDS] }, + QUOTE_CREATED: { label: "ثبت پیش‌فاکتور", feed: "پیش‌فاکتور ثبت شد", group: "finance", fields: ["amount", ...COMMON_FIELDS] }, + INVOICE_ISSUED: { label: "صدور فاکتور", feed: "فاکتور صادر شد", group: "finance", fields: ["amount", ...COMMON_FIELDS] }, + PAYMENT_REGISTERED: { label: "ثبت پرداخت", feed: "پرداخت ثبت شد", group: "finance", fields: ["amount", ...COMMON_FIELDS] }, + TICKET_CREATED: { label: "ثبت تیکت", feed: "تیکت ثبت شد", group: "support", fields: ["priority", ...COMMON_FIELDS] }, + TICKET_RESOLVED: { label: "حل تیکت", feed: "تیکت حل شد", group: "support", fields: ["priority", ...COMMON_FIELDS] }, +}; + +export const GAMIFICATION_EVENT_KEYS = Object.keys(GAMIFICATION_EVENTS) as GamificationEvent[]; + +export const GAMIFICATION_EVENT_GROUP_LABELS: Record = { + leads: "لیدها", + customers: "مشتریان", + sales: "فروش", + activities: "فعالیت‌ها", + finance: "مالی", + support: "پشتیبانی", +}; + +export function isGamificationEvent(value: unknown): value is GamificationEvent { + return typeof value === "string" && value in GAMIFICATION_EVENTS; +} + +export type GamificationConditionOp = + | "eq" + | "neq" + | "gt" + | "gte" + | "lt" + | "lte" + | "contains" + | "in" + | "is_true" + | "is_false"; + +export const GAMIFICATION_CONDITION_OPS: Record = { + eq: "برابر", + neq: "مخالف", + gt: "بیشتر از", + gte: "حداقل", + lt: "کمتر از", + lte: "حداکثر", + contains: "شامل", + in: "یکی از (با ویرگول)", + is_true: "بله باشد", + is_false: "خیر باشد", +}; + +export interface GamificationCondition { + field: string; + op: GamificationConditionOp; + value?: string | number | null; +} + +export type GamificationPeriod = "today" | "week" | "month" | "quarter" | "year" | "all"; + +export const GAMIFICATION_PERIOD_LABELS: Record = { + today: "امروز", + week: "این هفته", + month: "این ماه", + quarter: "این فصل", + year: "امسال", + all: "همه زمان‌ها", +}; + +export type GamificationTxKind = "rule" | "bonus" | "achievement" | "adjust" | "redeem" | "refund"; +export type GamificationTxStatus = "approved" | "pending" | "rejected"; + +export interface GamificationLevelDef { + name: string; + minScore: number; +} + +export interface GamificationLevelDto { + level: number; + name: string; + minScore: number; + nextName: string | null; + nextMinScore: number | null; + /** درصد پیشرفت تا سطح بعد */ + progress: number; + toNext: number | null; +} + +export interface GamificationRuleDto { + id: string; + name: string; + description: string | null; + event: GamificationEvent; + score: number; + coins: number; + conditions: GamificationCondition[]; + isActive: boolean; + maxPerDay: number | null; + maxCoinsPerDay: number | null; + maxPerUser: number | null; + cooldownMinutes: number | null; + oncePerEntity: boolean; + firstTimeOnly: boolean; + requiresApproval: boolean; + /** نقش‌های مشمول؛ خالی = همه */ + roles: string[]; + /** تیم‌های مشمول؛ خالی = همه */ + teamIds: string[]; + position: number; + awardedCount: number; + createdAt: string; + updatedAt: string; +} + +export interface SaveGamificationRuleRequest { + name: string; + description?: string | null; + event: GamificationEvent; + score: number; + coins: number; + conditions?: GamificationCondition[]; + isActive?: boolean; + maxPerDay?: number | null; + maxCoinsPerDay?: number | null; + maxPerUser?: number | null; + cooldownMinutes?: number | null; + oncePerEntity?: boolean; + firstTimeOnly?: boolean; + requiresApproval?: boolean; + roles?: string[]; + teamIds?: string[]; +} + +export interface GamificationTxDto { + id: string; + userId: string; + userName: string; + kind: GamificationTxKind; + event: GamificationEvent | null; + title: string; + score: number; + coins: number; + status: GamificationTxStatus; + entityType: string | null; + entityId: string | null; + entityTitle: string | null; + /** لینک پرونده مرتبط در CRM */ + link: string | null; + reason: string | null; + createdByName: string | null; + createdAt: string; +} + +export interface GamificationLeaderEntryDto { + rank: number; + userId: string; + fullName: string; + hasAvatar: boolean; + score: number; + /** تغییر رتبه نسبت به ۲۴ ساعت قبل؛ مثبت = صعود */ + change: number | null; + levelName: string; + isMe: boolean; +} + +export interface GamificationLeaderboardDto { + period: GamificationPeriod; + from: string | null; + totalUsers: number; + items: GamificationLeaderEntryDto[]; + /** کاربر جاری همیشه برمی‌گردد، حتی اگر در فهرست نباشد */ + me: GamificationLeaderEntryDto | null; +} + +export interface GamificationTeamEntryDto { + rank: number; + teamId: string; + name: string; + members: number; + score: number; + avgScore: number; + isMine: boolean; +} + +export interface GamificationRewardDto { + id: string; + name: string; + description: string | null; + cost: number; + icon: string; + color: string; + stock: number | null; + isActive: boolean; + requiresApproval: boolean; + position: number; +} + +export interface SaveGamificationRewardRequest { + name: string; + description?: string | null; + cost: number; + icon?: string; + color?: string; + stock?: number | null; + isActive?: boolean; + requiresApproval?: boolean; +} + +export type GamificationRedemptionStatus = "pending" | "approved" | "rejected" | "delivered"; + +export const GAMIFICATION_REDEMPTION_LABELS: Record = { + pending: "در انتظار تأیید", + approved: "تأیید شده", + rejected: "رد شده", + delivered: "تحویل شده", +}; + +export interface GamificationRedemptionDto { + id: string; + userId: string; + userName: string; + rewardId: string; + rewardName: string; + rewardIcon: string; + cost: number; + status: GamificationRedemptionStatus; + note: string | null; + decisionNote: string | null; + decidedByName: string | null; + decidedAt: string | null; + createdAt: string; +} + +export type GamificationBadgeMetric = + | "event_count" + | "score_total" + | "routine_streak" + | "single_deal_amount"; + +export const GAMIFICATION_BADGE_METRICS: Record = { + event_count: "تعداد دفعات یک رویداد", + score_total: "مجموع امتیاز", + routine_streak: "روزهای پیاپی انجام کامل روتین‌ها", + single_deal_amount: "مبلغ یک معامله برنده", +}; + +export interface GamificationBadgeDto { + id: string; + name: string; + description: string | null; + icon: string; + color: string; + metric: GamificationBadgeMetric; + event: GamificationEvent | null; + threshold: number; + coins: number; + isActive: boolean; + earnedCount: number; +} + +export interface SaveGamificationBadgeRequest { + name: string; + description?: string | null; + icon?: string; + color?: string; + metric: GamificationBadgeMetric; + event?: GamificationEvent | null; + threshold: number; + coins?: number; + isActive?: boolean; +} + +export interface GamificationAchievementDto extends GamificationBadgeDto { + earned: boolean; + earnedAt: string | null; + /** مقدار فعلی کاربر برای این شاخص */ + current: number; + progress: number; +} + +export type GamificationBonusKind = "event_count_period" | "routine_streak" | "top_of_month"; + +export const GAMIFICATION_BONUS_KINDS: Record = { + event_count_period: "تعداد رویداد در بازه", + routine_streak: "روزهای پیاپی انجام کامل روتین", + top_of_month: "نفر اول امتیاز ماه", +}; + +export interface GamificationBonusDto { + id: string; + name: string; + description: string | null; + kind: GamificationBonusKind; + event: GamificationEvent | null; + threshold: number; + period: "day" | "week" | "month"; + score: number; + coins: number; + isActive: boolean; + awardedCount: number; +} + +export interface SaveGamificationBonusRequest { + name: string; + description?: string | null; + kind: GamificationBonusKind; + event?: GamificationEvent | null; + threshold: number; + period?: "day" | "week" | "month"; + score?: number; + coins?: number; + isActive?: boolean; +} + +export interface GamificationSettingsDto { + enabled: boolean; + levels: GamificationLevelDef[]; + /** هدف ماهانه امتیاز برای کارت «هدف بعدی» */ + monthlyGoal: number; + /** نمایش کامل جدول برای همه؛ اگر خاموش باشد فقط ۱۰ نفر اول و خود کاربر */ + showFullLeaderboard: boolean; + enabledAt: string | null; +} + +export interface GamificationMeDto { + user: { id: string; fullName: string; hasAvatar: boolean }; + isManager: boolean; + settings: GamificationSettingsDto; + score: number; + coins: number; + period: GamificationPeriod; + periodScore: number; + todayScore: number; + rank: number | null; + totalUsers: number; + /** فاصله تا نفر بالاتر */ + gapToNext: number | null; + nextRankName: string | null; + gapToFirst: number | null; + level: GamificationLevelDto; + /** بهترین امتیاز ماهانه کاربر تا امروز */ + personalBest: number; + goal: { target: number; current: number; progress: number }; + nextReward: { id: string; name: string; cost: number; icon: string; remaining: number } | null; + streakDays: number; + badgesEarned: number; + pendingRedemptions: number; + recent: GamificationTxDto[]; +} + +export interface GamificationPulseDto { + serverTime: string; + score: number; + coins: number; + rank: number | null; + items: GamificationTxDto[]; +} + +export interface GamificationAdjustRequest { + userId: string; + amount: number; + reason: string; +} + +export interface GamificationReportUserRow { + userId: string; + fullName: string; + value: number; +} + +export interface GamificationRoiMetric { + key: string; + label: string; + before: number; + after: number; + /** درصد تغییر؛ null وقتی مبنا صفر است */ + changePct: number | null; +} + +export interface GamificationReportDto { + period: GamificationPeriod; + totals: { + score: number; + coinsEarned: number; + coinsSpent: number; + activeUsers: number; + totalUsers: number; + transactions: number; + pendingApprovals: number; + pendingRedemptions: number; + }; + byEvent: Array<{ event: GamificationEvent; label: string; count: number; score: number; coins: number }>; + topActive: GamificationReportUserRow[]; + topGrowth: GamificationReportUserRow[]; + topDeals: GamificationReportUserRow[]; + topLeads: GamificationReportUserRow[]; + topTasks: GamificationReportUserRow[]; + inactive: GamificationReportUserRow[]; + roi: { since: string | null; windowDays: number; metrics: GamificationRoiMetric[] }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56760cf..b7fe5de 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -139,7 +139,8 @@ export type OrgModuleKey = | "reservations" | "leads" | "conversations" - | "routines"; + | "routines" + | "gamification"; export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "customers", @@ -178,6 +179,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "reservations", "conversations", "routines", + "gamification", ]; export const ORG_MODULE_LABELS: Record = { @@ -217,6 +219,7 @@ export const ORG_MODULE_LABELS: Record = { reservations: "رزرو و نوبت‌دهی", conversations: "گفتگو", routines: "کارهای روتین", + gamification: "باشگاه امتیازات", }; export const ORG_MODULE_GROUPS: Array<{ @@ -236,7 +239,7 @@ export const ORG_MODULE_GROUPS: Array<{ "sales_forecast", ], }, - { title: "فعالیت‌ها", keys: ["activities", "routines", "notes"] }, + { title: "فعالیت‌ها", keys: ["activities", "routines", "notes", "gamification"] }, { title: "پشتیبانی", keys: ["tickets", "faq", "conversations"] }, { title: "اتوماسیون", @@ -5211,3 +5214,7 @@ export interface RoutineUserSettingsDto { /** ساعت ارسال خلاصه پایان روز (۰ تا ۲۳) */ summaryHour: number; } + +/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */ + +export * from "./gamification";