feat: routines module; super admin subscription badge, 7-day trial and lifetime plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-06 19:54:38 +03:30
co-authored by Cursor
parent 3de2d0fa67
commit b3784818fc
58 changed files with 13018 additions and 18 deletions
@@ -0,0 +1,247 @@
-- CreateEnum
CREATE TYPE "RoutineTaskStatus" AS ENUM ('todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled');
-- CreateEnum
CREATE TYPE "RoutineRecurrence" AS ENUM ('daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'every_n_days', 'specific_dates');
-- CreateEnum
CREATE TYPE "RoutineTaskSource" AS ENUM ('routine', 'manual', 'follow_up', 'lead', 'customer', 'deal');
-- CreateTable
CREATE TABLE "RoutineCategory" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT '#14b8a6',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoutineCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Routine" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"assigneeId" TEXT,
"teamId" TEXT,
"categoryId" TEXT,
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
"timeOfDay" TEXT NOT NULL DEFAULT '09:00',
"durationMinutes" INTEGER,
"startDate" DATE NOT NULL,
"endDate" DATE,
"recurrence" "RoutineRecurrence" NOT NULL DEFAULT 'daily',
"weekdays" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
"intervalDays" INTEGER,
"monthDay" INTEGER,
"specificDates" DATE[],
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
"allowSkip" BOOLEAN NOT NULL DEFAULT false,
"skipReasonRequired" BOOLEAN NOT NULL DEFAULT true,
"reportRequired" BOOLEAN NOT NULL DEFAULT false,
"allowAfterDeadline" BOOLEAN NOT NULL DEFAULT true,
"carryOverOverdue" BOOLEAN NOT NULL DEFAULT false,
"entityLink" TEXT NOT NULL DEFAULT 'none',
"reminderMinutesBefore" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Routine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutineTask" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"routineId" TEXT,
"assigneeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"categoryId" TEXT,
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
"status" "RoutineTaskStatus" NOT NULL DEFAULT 'todo',
"scheduledFor" DATE NOT NULL,
"dueAt" TIMESTAMP(3),
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"resultNote" TEXT,
"skipReason" TEXT,
"source" "RoutineTaskSource" NOT NULL DEFAULT 'manual',
"isMandatory" BOOLEAN NOT NULL DEFAULT false,
"leadId" TEXT,
"personId" TEXT,
"companyId" TEXT,
"dealId" TEXT,
"invoiceId" TEXT,
"activityId" TEXT,
"reminderAt" TIMESTAMP(3),
"reminderSentAt" TIMESTAMP(3),
"overdueNotifiedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoutineTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutineTaskEvent" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"actorId" TEXT,
"type" TEXT NOT NULL,
"fromStatus" "RoutineTaskStatus",
"toStatus" "RoutineTaskStatus",
"note" TEXT,
"meta" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RoutineTaskEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutineUserSettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"remindBeforeMinutes" INTEGER DEFAULT 30,
"endOfDaySummary" BOOLEAN NOT NULL DEFAULT true,
"overdueAlert" BOOLEAN NOT NULL DEFAULT true,
"summaryHour" INTEGER NOT NULL DEFAULT 16,
"lastSummaryOn" DATE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoutineUserSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RoutineCategory_organizationId_sortOrder_idx" ON "RoutineCategory"("organizationId", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "RoutineCategory_organizationId_name_key" ON "RoutineCategory"("organizationId", "name");
-- CreateIndex
CREATE INDEX "Routine_organizationId_isActive_deletedAt_idx" ON "Routine"("organizationId", "isActive", "deletedAt");
-- CreateIndex
CREATE INDEX "Routine_assigneeId_idx" ON "Routine"("assigneeId");
-- CreateIndex
CREATE INDEX "Routine_teamId_idx" ON "Routine"("teamId");
-- CreateIndex
CREATE INDEX "RoutineTask_organizationId_assigneeId_scheduledFor_idx" ON "RoutineTask"("organizationId", "assigneeId", "scheduledFor");
-- CreateIndex
CREATE INDEX "RoutineTask_organizationId_status_dueAt_idx" ON "RoutineTask"("organizationId", "status", "dueAt");
-- CreateIndex
CREATE INDEX "RoutineTask_reminderAt_reminderSentAt_idx" ON "RoutineTask"("reminderAt", "reminderSentAt");
-- CreateIndex
CREATE INDEX "RoutineTask_leadId_idx" ON "RoutineTask"("leadId");
-- CreateIndex
CREATE INDEX "RoutineTask_personId_idx" ON "RoutineTask"("personId");
-- CreateIndex
CREATE INDEX "RoutineTask_companyId_idx" ON "RoutineTask"("companyId");
-- CreateIndex
CREATE INDEX "RoutineTask_dealId_idx" ON "RoutineTask"("dealId");
-- CreateIndex
CREATE INDEX "RoutineTask_activityId_idx" ON "RoutineTask"("activityId");
-- CreateIndex
CREATE UNIQUE INDEX "RoutineTask_routineId_assigneeId_scheduledFor_key" ON "RoutineTask"("routineId", "assigneeId", "scheduledFor");
-- CreateIndex
CREATE INDEX "RoutineTaskEvent_taskId_createdAt_idx" ON "RoutineTaskEvent"("taskId", "createdAt");
-- CreateIndex
CREATE INDEX "RoutineTaskEvent_organizationId_createdAt_idx" ON "RoutineTaskEvent"("organizationId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "RoutineUserSettings_organizationId_userId_key" ON "RoutineUserSettings"("organizationId", "userId");
-- AddForeignKey
ALTER TABLE "RoutineCategory" ADD CONSTRAINT "RoutineCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_routineId_fkey" FOREIGN KEY ("routineId") REFERENCES "Routine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "RoutineTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Enable the new routines module for organizations with an explicit module list
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'routines')
WHERE cardinality("enabledModules") > 0 AND NOT ('routines' = ANY("enabledModules"));
+207 -1
View File
@@ -218,6 +218,11 @@ model Organization {
conversationMessageReactions ConversationMessageReaction[]
trackingLinks TrackingLink[]
trackingEvents TrackingEvent[]
routineCategories RoutineCategory[]
routines Routine[]
routineTasks RoutineTask[]
routineTaskEvents RoutineTaskEvent[]
routineUserSettings RoutineUserSettings[]
}
model User {
@@ -271,6 +276,12 @@ model User {
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
@@ -338,6 +349,7 @@ model Team {
name String
description String @default("")
members TeamMember[]
routines Routine[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -379,7 +391,7 @@ model BillingAccount {
id String @id @default(cuid())
ownerUserId String @unique
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
/// trial | monthly | quarterly | semiannual | yearly | custom
/// trial | monthly | quarterly | semiannual | yearly | lifetime | custom
planCode String @default("trial")
/// active | expired | suspended
status String @default("active")
@@ -435,6 +447,7 @@ model Company {
owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
routineTasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -503,6 +516,7 @@ model Person {
cheques Cheque[]
trackingLinks TrackingLink[]
trackingEvents TrackingEvent[]
routineTasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -601,6 +615,7 @@ model Deal {
invoices Invoice[]
tickets Ticket[]
projectCards ProjectCard[]
routineTasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -646,6 +661,7 @@ model Activity {
createdById String?
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
notes Note[]
routineTasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1023,6 +1039,7 @@ model Invoice {
commissionFixedAmount Decimal? @db.Decimal(18, 2)
commissionEntry CommissionEntry?
cheques Cheque[]
routineTasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2176,6 +2193,7 @@ model Lead {
events LeadEvent[]
trackingLinks TrackingLink[]
trackingEvents TrackingEvent[]
routineTasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2361,3 +2379,191 @@ model ConversationMessageReaction {
@@unique([organizationId, externalRoomId, messageId, userId, emoji])
@@index([organizationId, externalRoomId])
}
enum RoutineTaskStatus {
todo
in_progress
completed
overdue
skipped
cancelled
}
enum RoutineRecurrence {
daily
weekdays
weekly
biweekly
monthly
every_n_days
specific_dates
}
enum RoutineTaskSource {
routine
manual
follow_up
lead
customer
deal
}
/// دسته‌بندی کارهای روتین (فروش، پیگیری، لید، …)
model RoutineCategory {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
color String @default("#14b8a6")
sortOrder Int @default(0)
routines Routine[]
tasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, sortOrder])
}
/// الگوی کار تکرارشونده؛ هر روز Task مستقل (RoutineTask) تولید می‌کند
model Routine {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
title String
description String?
assigneeId String?
assignee User? @relation("RoutineAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
/// اگر ست باشد برای هر عضو تیم یک Task جدا ساخته می‌شود
teamId String?
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
categoryId String?
category RoutineCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
priority ActivityPriority @default(medium)
/// HH:mm به وقت تهران
timeOfDay String @default("09:00")
durationMinutes Int?
startDate DateTime @db.Date
endDate DateTime? @db.Date
recurrence RoutineRecurrence @default(daily)
/// ۰ = شنبه … ۶ = جمعه
weekdays Int[] @default([])
intervalDays Int?
monthDay Int?
specificDates DateTime[] @db.Date
isMandatory Boolean @default(true)
allowSkip Boolean @default(false)
skipReasonRequired Boolean @default(true)
reportRequired Boolean @default(false)
allowAfterDeadline Boolean @default(true)
carryOverOverdue Boolean @default(false)
/// none | optional | lead | customer
entityLink String @default("none")
reminderMinutesBefore Int?
isActive Boolean @default(true)
createdById String?
createdBy User? @relation("RoutineCreator", fields: [createdById], references: [id], onDelete: SetNull)
deletedAt DateTime?
tasks RoutineTask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive, deletedAt])
@@index([assigneeId])
@@index([teamId])
}
/// نمونه روزانه یک کار (از روتین، دستی، پیگیری، لید یا مشتری)
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)
/// روز برنامه به وقت تهران
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
@@unique([routineId, assigneeId, scheduledFor])
@@index([organizationId, assigneeId, scheduledFor])
@@index([organizationId, status, dueAt])
@@index([reminderAt, reminderSentAt])
@@index([leadId])
@@index([personId])
@@index([companyId])
@@index([dealId])
@@index([activityId])
}
/// تاریخچه تغییرات هر Task
model RoutineTaskEvent {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
taskId String
task RoutineTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
/// created | status_changed | rescheduled | note | reassigned | updated
type String
fromStatus RoutineTaskStatus?
toStatus RoutineTaskStatus?
note String?
meta Json @default("{}")
createdAt DateTime @default(now())
@@index([taskId, createdAt])
@@index([organizationId, createdAt])
}
/// تنظیمات اعلان کارهای روتین هر کاربر
model RoutineUserSettings {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
remindBeforeMinutes Int? @default(30)
endOfDaySummary Boolean @default(true)
overdueAlert Boolean @default(true)
summaryHour Int @default(16)
lastSummaryOn DateTime? @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, userId])
}
+2
View File
@@ -46,6 +46,7 @@ import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
import { ConversationsModule } from './conversations/conversations.module';
import { RoutinesModule } from './routines/routines.module';
@Module({
imports: [
@@ -99,6 +100,7 @@ import { ConversationsModule } from './conversations/conversations.module';
VoipModule,
ReservationsModule,
ConversationsModule,
RoutinesModule,
],
})
export class AppModule {}
+5 -3
View File
@@ -15,7 +15,9 @@ import type {
import {
BILLING_PLAN_DAYS,
BILLING_PLAN_DEFAULT_MAX_ORGS,
BILLING_TRIAL_DAYS,
defaultEnabledModules,
isPermanentBillingPlan,
isOrgModuleKey,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
@@ -25,7 +27,7 @@ function daysRemaining(expiresAt: Date, now = new Date()): number {
return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000)));
}
function toSummary(row: {
export function toSummary(row: {
planCode: string;
status: string;
startsAt: Date;
@@ -41,7 +43,7 @@ function toSummary(row: {
expiresAt: row.expiresAt.toISOString(),
maxOrganizations: row.maxOrganizations,
daysRemaining: remaining,
warn: remaining > 0 && remaining <= 3,
warn: !isPermanentBillingPlan(row.planCode) && remaining > 0 && remaining <= 3,
};
}
@@ -81,7 +83,7 @@ export class BillingService {
const existing = await this.getByOwnerUserId(ownerUserId);
if (existing) return existing;
const now = new Date();
const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000);
const expiresAt = new Date(now.getTime() + BILLING_TRIAL_DAYS * 24 * 60 * 60 * 1000);
return this.prisma.billingAccount.create({
data: {
ownerUserId,
+2 -2
View File
@@ -124,8 +124,8 @@ export class ResetPlatformUserPasswordDto {
}
export class ApplyBillingPlanDto {
@IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'custom'])
planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'custom';
@IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'lifetime', 'custom'])
planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'lifetime' | 'custom';
@IsOptional()
@IsInt()
+18 -2
View File
@@ -18,7 +18,7 @@ import { normalizeMobile } from '../auth/auth.service';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
import { BillingService, toSummary } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import type {
@@ -103,9 +103,21 @@ export class PlatformService {
deals: true,
},
},
memberships: {
where: { role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
take: 1,
select: { user: { select: { billingAccount: true } } },
},
},
});
return rows.map((row) => this.orgToDto(row));
return rows.map((row) => {
const account = row.memberships[0]?.user.billingAccount;
return {
...this.orgToDto(row),
subscription: account ? toSummary(account) : null,
};
});
}
async getOrganization(id: string): Promise<PlatformOrganizationDto> {
@@ -144,8 +156,12 @@ export class PlatformService {
},
});
if (!row) throw new NotFoundException('محیط پیدا نشد');
const ownerAccount = row.memberships.find(
(m) => m.role === 'owner' && m.isActive,
)?.user.billingAccount;
return {
...this.orgToDto(row),
subscription: ownerAccount ? toSummary(ownerAccount) : null,
members: row.memberships.map((m) => {
const isBillingOwner = m.role === 'owner';
return {
+311
View File
@@ -0,0 +1,311 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
const RECURRENCES = [
'daily',
'weekdays',
'weekly',
'biweekly',
'monthly',
'every_n_days',
'specific_dates',
] as const;
const SOURCES = ['routine', 'manual', 'follow_up', 'lead', 'customer', 'deal'] as const;
const STATUSES = ['todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled'] as const;
const ENTITY_LINKS = ['none', 'optional', 'lead', 'customer'] as const;
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
const nullable = () => ValidateIf((_, v) => v !== '' && v != null);
export class SaveRoutineCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
}
export class SaveRoutineDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional()
@nullable()
@IsString()
assigneeId?: string | null;
@IsOptional()
@nullable()
@IsString()
teamId?: string | null;
@IsOptional()
@nullable()
@IsString()
categoryId?: string | null;
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@Matches(TIME)
timeOfDay!: string;
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(24 * 60)
durationMinutes?: number | null;
@Matches(DAY_KEY)
startDate!: string;
@IsOptional()
@nullable()
@Matches(DAY_KEY)
endDate?: string | null;
@IsIn(RECURRENCES)
recurrence!: (typeof RECURRENCES)[number];
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
weekdays?: number[];
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
intervalDays?: number | null;
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(31)
monthDay?: number | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(366)
@Matches(DAY_KEY, { each: true })
specificDates?: string[];
@IsOptional() @IsBoolean() isMandatory?: boolean;
@IsOptional() @IsBoolean() allowSkip?: boolean;
@IsOptional() @IsBoolean() skipReasonRequired?: boolean;
@IsOptional() @IsBoolean() reportRequired?: boolean;
@IsOptional() @IsBoolean() allowAfterDeadline?: boolean;
@IsOptional() @IsBoolean() carryOverOverdue?: boolean;
@IsOptional()
@IsIn(ENTITY_LINKS)
entityLink?: (typeof ENTITY_LINKS)[number];
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(24 * 60)
reminderMinutesBefore?: number | null;
@IsOptional() @IsBoolean() isActive?: boolean;
}
export class ToggleRoutineDto {
@IsBoolean()
isActive!: boolean;
}
export class ChangeRoutineAssigneeDto {
@IsOptional()
@nullable()
@IsString()
assigneeId?: string | null;
@IsOptional()
@nullable()
@IsString()
teamId?: string | null;
}
export class CreateRoutineTaskDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional()
@nullable()
@IsDateString()
dueAt?: string | null;
@IsOptional()
@nullable()
@Matches(DAY_KEY)
scheduledFor?: string | null;
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@IsOptional() @nullable() @IsString() categoryId?: string | null;
@IsOptional() @nullable() @IsString() assigneeId?: string | null;
@IsOptional() @IsBoolean() isMandatory?: boolean;
@IsOptional()
@IsIn(SOURCES)
source?: (typeof SOURCES)[number];
@IsOptional() @nullable() @IsString() leadId?: string | null;
@IsOptional() @nullable() @IsString() personId?: string | null;
@IsOptional() @nullable() @IsString() companyId?: string | null;
@IsOptional() @nullable() @IsString() dealId?: string | null;
@IsOptional() @nullable() @IsString() invoiceId?: string | null;
@IsOptional() @nullable() @IsString() activityId?: string | null;
}
export class UpdateRoutineTaskDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional() @nullable() @IsDateString() dueAt?: string | null;
@IsOptional() @nullable() @Matches(DAY_KEY) scheduledFor?: string | null;
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
@IsOptional() @nullable() @IsString() categoryId?: string | null;
@IsOptional() @nullable() @IsString() assigneeId?: string | null;
@IsOptional() @IsBoolean() isMandatory?: boolean;
@IsOptional() @nullable() @IsString() leadId?: string | null;
@IsOptional() @nullable() @IsString() personId?: string | null;
@IsOptional() @nullable() @IsString() companyId?: string | null;
@IsOptional() @nullable() @IsString() dealId?: string | null;
@IsOptional() @nullable() @IsString() invoiceId?: string | null;
}
export class CompleteRoutineTaskDto {
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
note?: string | null;
}
export class SkipRoutineTaskDto {
@IsOptional()
@nullable()
@IsString()
@MaxLength(1000)
reason?: string | null;
}
export class RescheduleRoutineTaskDto {
@IsDateString()
dueAt!: string;
}
export class RoutineTaskQueryDto {
@IsOptional() @IsString() userId?: string;
@IsOptional() @Matches(DAY_KEY) date?: string;
}
export class RoutineRangeQueryDto {
@IsOptional() @Matches(DAY_KEY) from?: string;
@IsOptional() @Matches(DAY_KEY) to?: string;
@IsOptional() @IsString() userId?: string;
@IsOptional() @IsString() teamId?: string;
@IsOptional() @IsString() categoryId?: string;
@IsOptional() @IsIn(SOURCES) source?: (typeof SOURCES)[number];
@IsOptional() @IsIn(STATUSES) status?: (typeof STATUSES)[number];
@IsOptional() @IsIn(['me', 'team']) view?: 'me' | 'team';
}
export class RoutineEntityQueryDto {
@IsOptional() @IsString() leadId?: string;
@IsOptional() @IsString() personId?: string;
@IsOptional() @IsString() companyId?: string;
@IsOptional() @IsString() dealId?: string;
}
export class ListRoutinesQueryDto {
@IsOptional() @IsIn(['mine', 'all']) scope?: 'mine' | 'all';
@IsOptional() @IsString() q?: string;
}
export class RoutineUserSettingsUpdateDto {
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(24 * 60)
remindBeforeMinutes?: number | null;
@IsOptional() @IsBoolean() endOfDaySummary?: boolean;
@IsOptional() @IsBoolean() overdueAlert?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
summaryHour?: number;
}
+101
View File
@@ -0,0 +1,101 @@
import type { RoutineRecurrence } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import {
addDays,
dayDiff,
jalaliDayOfMonth,
persianWeekday,
toFaDigits,
weekStartKey,
} from './routine-dates';
export type RecurrenceRule = {
recurrence: RoutineRecurrence;
startDate: string;
endDate: string | null;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: string[];
};
function weeklyDays(rule: RecurrenceRule): number[] {
return rule.weekdays.length ? rule.weekdays : [persianWeekday(rule.startDate)];
}
export function occursOn(rule: RecurrenceRule, day: string): boolean {
if (day < rule.startDate) return false;
if (rule.endDate && day > rule.endDate) return false;
switch (rule.recurrence) {
case 'daily':
return true;
case 'weekdays':
case 'weekly':
return weeklyDays(rule).includes(persianWeekday(day));
case 'biweekly': {
if (!weeklyDays(rule).includes(persianWeekday(day))) return false;
const weeks = Math.floor(
dayDiff(weekStartKey(rule.startDate), weekStartKey(day)) / 7,
);
return weeks % 2 === 0;
}
case 'monthly': {
const target = rule.monthDay ?? jalaliDayOfMonth(rule.startDate);
const current = jalaliDayOfMonth(day);
if (current === target) return true;
// ماه‌هایی که روز هدف را ندارند (مثلاً ۳۱ در مهر): آخرین روز ماه
const isLastDay = jalaliDayOfMonth(addDays(day, 1)) === 1;
return isLastDay && target > current;
}
case 'every_n_days': {
const n = Math.max(1, rule.intervalDays ?? 1);
return dayDiff(rule.startDate, day) % n === 0;
}
case 'specific_dates':
return rule.specificDates.includes(day);
default:
return false;
}
}
function joinFa(items: string[]): string {
if (items.length <= 1) return items.join('');
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
}
function describeWeekdays(days: number[]): string {
const sorted = [...new Set(days)].sort((a, b) => a - b);
if (sorted.length === 7) return 'هر روز';
const contiguous =
sorted.length >= 3 &&
sorted.every((d, i) => i === 0 || d === sorted[i - 1]! + 1);
if (contiguous) {
return `${ROUTINE_WEEKDAY_LABELS[sorted[0]!]} تا ${ROUTINE_WEEKDAY_LABELS[sorted[sorted.length - 1]!]}`;
}
return `هر ${joinFa(sorted.map((d) => ROUTINE_WEEKDAY_LABELS[d]!))}`;
}
export function describeRecurrence(rule: RecurrenceRule): string {
switch (rule.recurrence) {
case 'daily':
return 'هر روز';
case 'weekdays':
case 'weekly':
return describeWeekdays(weeklyDays(rule));
case 'biweekly':
return `هر دو هفته، ${joinFa(weeklyDays(rule).map((d) => ROUTINE_WEEKDAY_LABELS[d]!))}`;
case 'monthly': {
const day = rule.monthDay ?? jalaliDayOfMonth(rule.startDate);
return day === 1 ? 'اول هر ماه' : `روز ${toFaDigits(day)} هر ماه`;
}
case 'every_n_days': {
const n = Math.max(1, rule.intervalDays ?? 1);
return n === 1 ? 'هر روز' : `هر ${toFaDigits(n)} روز یک‌بار`;
}
case 'specific_dates':
return `${toFaDigits(rule.specificDates.length)} تاریخ مشخص`;
default:
return '';
}
}
+100
View File
@@ -0,0 +1,100 @@
/** کلید روز (YYYY-MM-DD) و زمان‌ها همگی به وقت تهران (بدون ساعت تابستانی، +03:30). */
export const TEHRAN_OFFSET = '+03:30';
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const HOUR_FMT = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
hourCycle: 'h23',
});
const JALALI_DAY_FMT = new Intl.DateTimeFormat('en-US-u-ca-persian', {
timeZone: 'UTC',
day: 'numeric',
});
const DAY_KEY_RE = /^\d{4}-\d{2}-\d{2}$/;
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
export function isDayKey(value: unknown): value is string {
return typeof value === 'string' && DAY_KEY_RE.test(value);
}
export function isTimeOfDay(value: unknown): value is string {
return typeof value === 'string' && TIME_RE.test(value);
}
export function tehranDayKey(d: Date = new Date()): string {
return DAY_FMT.format(d);
}
export function tehranHour(d: Date = new Date()): number {
return Number(HOUR_FMT.format(d)) % 24;
}
/** مقدار مناسب برای ستون‌های @db.Date */
export function dayKeyToDate(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
}
/** خواندن ستون‌های @db.Date */
export function dateToDayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
export function addDays(key: string, n: number): string {
const d = dayKeyToDate(key);
d.setUTCDate(d.getUTCDate() + n);
return dateToDayKey(d);
}
/** b - a بر حسب روز */
export function dayDiff(a: string, b: string): number {
return Math.round(
(dayKeyToDate(b).getTime() - dayKeyToDate(a).getTime()) / 86_400_000,
);
}
/** ۰ = شنبه … ۶ = جمعه */
export function persianWeekday(key: string): number {
return (dayKeyToDate(key).getUTCDay() + 1) % 7;
}
/** شنبهٔ همان هفته */
export function weekStartKey(key: string): string {
return addDays(key, -persianWeekday(key));
}
export function jalaliDayOfMonth(key: string): number {
return Number(JALALI_DAY_FMT.format(dayKeyToDate(key)));
}
export function tehranDateTime(key: string, hhmm: string): Date {
return new Date(`${key}T${hhmm}:00${TEHRAN_OFFSET}`);
}
export function tehranDayStart(key: string): Date {
return tehranDateTime(key, '00:00');
}
export function eachDay(from: string, to: string): string[] {
const out: string[] = [];
let cur = from;
let guard = 0;
while (cur <= to && guard < 800) {
out.push(cur);
cur = addDays(cur, 1);
guard += 1;
}
return out;
}
export function toFaDigits(value: number | string): string {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
}
@@ -0,0 +1,167 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { occursOn } from './recurrence';
import { routineRule } from './routine-mapper';
import { dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates';
export const DEFAULT_REMIND_MINUTES = 30;
@Injectable()
export class RoutineGeneratorService implements OnApplicationBootstrap {
private readonly logger = new Logger(RoutineGeneratorService.name);
private overdueRunning = false;
constructor(private readonly prisma: PrismaService) {}
onApplicationBootstrap() {
// جبران cron از دست رفته (ری‌استارت سرور حوالی نیمه‌شب)
setTimeout(() => {
void this.generateForDay(tehranDayKey()).catch((err) =>
this.logger.warn(`bootstrap generate failed: ${err instanceof Error ? err.message : err}`),
);
void this.markOverdue();
}, 8000);
}
@Cron('5 0 * * *', { timeZone: 'Asia/Tehran' })
async generateToday(): Promise<void> {
try {
const count = await this.generateForDay(tehranDayKey());
if (count) this.logger.log(`generated ${count} routine tasks`);
} catch (err) {
this.logger.warn(`daily generate failed: ${err instanceof Error ? err.message : err}`);
}
}
/** ساخت Taskهای یک روز برای روتین‌های فعال (idempotent). */
async generateForDay(
day: string,
filter: { organizationId?: string; routineId?: string } = {},
): Promise<number> {
const date = dayKeyToDate(day);
const routines = await this.prisma.routine.findMany({
where: {
isActive: true,
deletedAt: null,
startDate: { lte: date },
OR: [{ endDate: null }, { endDate: { gte: date } }],
...(filter.organizationId ? { organizationId: filter.organizationId } : {}),
...(filter.routineId ? { id: filter.routineId } : {}),
},
include: { team: { select: { members: { select: { userId: true } } } } },
});
if (!routines.length) return 0;
const candidates = new Set<string>();
const planned: Array<{ routine: (typeof routines)[number]; userIds: string[] }> = [];
for (const routine of routines) {
if (!occursOn(routineRule(routine), day)) continue;
const userIds = routine.assigneeId
? [routine.assigneeId]
: [...new Set(routine.team?.members.map((m) => m.userId) ?? [])];
if (!userIds.length) continue;
userIds.forEach((id) => candidates.add(`${routine.organizationId}:${id}`));
planned.push({ routine, userIds });
}
if (!planned.length) return 0;
const memberships = await this.prisma.membership.findMany({
where: {
isActive: true,
userId: { in: [...candidates].map((c) => c.split(':')[1]!) },
organizationId: { in: [...new Set(planned.map((p) => p.routine.organizationId))] },
user: { isActive: true },
},
select: { userId: true, organizationId: true },
});
const active = new Set(memberships.map((m) => `${m.organizationId}:${m.userId}`));
const offsets = await this.reminderOffsets(memberships);
const rows: Prisma.RoutineTaskCreateManyInput[] = [];
for (const { routine, userIds } of planned) {
const dueAt = tehranDateTime(day, routine.timeOfDay);
for (const assigneeId of userIds) {
const key = `${routine.organizationId}:${assigneeId}`;
if (!active.has(key)) continue;
const minutes = routine.reminderMinutesBefore ?? offsets.get(key) ?? null;
const reminderAt = minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null;
rows.push({
organizationId: routine.organizationId,
routineId: routine.id,
assigneeId,
title: routine.title,
description: routine.description,
categoryId: routine.categoryId,
priority: routine.priority,
scheduledFor: date,
dueAt,
reminderAt,
source: 'routine',
isMandatory: routine.isMandatory,
createdById: routine.createdById,
});
}
}
if (!rows.length) return 0;
const result = await this.prisma.routineTask.createMany({ data: rows, skipDuplicates: true });
return result.count;
}
/** دقیقه‌های یادآور پیش‌فرض هر کاربر (تنظیمات شخصی؛ بدون تنظیم = ۳۰) */
async reminderOffsets(
pairs: Array<{ organizationId: string; userId: string }>,
): Promise<Map<string, number | null>> {
const map = new Map<string, number | null>();
for (const p of pairs) map.set(`${p.organizationId}:${p.userId}`, DEFAULT_REMIND_MINUTES);
if (!pairs.length) return map;
const settings = await this.prisma.routineUserSettings.findMany({
where: { userId: { in: [...new Set(pairs.map((p) => p.userId))] } },
select: { organizationId: true, userId: true, remindBeforeMinutes: true },
});
for (const s of settings) {
const key = `${s.organizationId}:${s.userId}`;
if (map.has(key)) map.set(key, s.remindBeforeMinutes);
}
return map;
}
@Cron('*/5 * * * *')
async markOverdue(): Promise<void> {
if (this.overdueRunning) return;
this.overdueRunning = true;
try {
const now = new Date();
for (let i = 0; i < 20; i += 1) {
const due = await this.prisma.routineTask.findMany({
where: { status: { in: ['todo', 'in_progress'] }, dueAt: { lt: now } },
select: { id: true, organizationId: true, status: true },
take: 500,
});
if (!due.length) break;
await this.prisma.$transaction([
this.prisma.routineTask.updateMany({
where: { id: { in: due.map((t) => t.id) }, status: { in: ['todo', 'in_progress'] } },
data: { status: 'overdue' },
}),
this.prisma.routineTaskEvent.createMany({
data: due.map((t) => ({
organizationId: t.organizationId,
taskId: t.id,
type: 'status_changed',
fromStatus: t.status,
toStatus: 'overdue' as const,
note: 'مهلت انجام گذشت',
})),
}),
]);
if (due.length < 500) break;
}
} catch (err) {
this.logger.warn(`overdue run failed: ${err instanceof Error ? err.message : err}`);
} finally {
this.overdueRunning = false;
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import type {
RoutineCategoryDto,
RoutineDto,
RoutineEntityLink,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskEventDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { describeRecurrence } from './recurrence';
import { dateToDayKey } from './routine-dates';
const userSelect = { select: { id: true, fullName: true } } as const;
export const routineTaskInclude = {
category: true,
assignee: userSelect,
createdBy: userSelect,
routine: {
select: {
id: true,
allowSkip: true,
skipReasonRequired: true,
reportRequired: true,
allowAfterDeadline: true,
entityLink: true,
},
},
lead: { select: { id: true, title: true, contactName: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
invoice: { select: { id: true, number: true, title: true } },
activity: { select: { id: true, title: true } },
} satisfies Prisma.RoutineTaskInclude;
export type RoutineTaskRow = Prisma.RoutineTaskGetPayload<{
include: typeof routineTaskInclude;
}>;
export const routineInclude = {
category: true,
assignee: userSelect,
team: { select: { id: true, name: true } },
} satisfies Prisma.RoutineInclude;
export type RoutineRow = Prisma.RoutineGetPayload<{ include: typeof routineInclude }>;
export function toCategoryDto(row: {
id: string;
name: string;
color: string;
sortOrder: number;
}): RoutineCategoryDto {
return { id: row.id, name: row.name, color: row.color, sortOrder: row.sortOrder };
}
function normalizeEntityLink(value: string | null | undefined): RoutineEntityLink {
return value === 'optional' || value === 'lead' || value === 'customer' ? value : 'none';
}
export function toTaskDto(row: RoutineTaskRow): RoutineTaskDto {
const entities: RoutineEntityRefDto[] = [];
if (row.lead) {
entities.push({ type: 'lead', id: row.lead.id, label: row.lead.title || row.lead.contactName || 'لید' });
}
if (row.person) {
entities.push({
type: 'person',
id: row.person.id,
label: `${row.person.firstName} ${row.person.lastName}`.trim(),
});
}
if (row.company) entities.push({ type: 'company', id: row.company.id, label: row.company.name });
if (row.deal) entities.push({ type: 'deal', id: row.deal.id, label: row.deal.title });
if (row.invoice) {
entities.push({
type: 'invoice',
id: row.invoice.id,
label: row.invoice.title || `فاکتور ${row.invoice.number}`,
});
}
if (row.activity) entities.push({ type: 'activity', id: row.activity.id, label: row.activity.title });
const r = row.routine;
return {
id: row.id,
routineId: row.routineId,
title: row.title,
description: row.description,
status: row.status,
priority: row.priority,
category: row.category ? toCategoryDto(row.category) : null,
scheduledFor: dateToDayKey(row.scheduledFor),
dueAt: row.dueAt?.toISOString() ?? null,
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
resultNote: row.resultNote,
skipReason: row.skipReason,
source: row.source,
isMandatory: row.isMandatory,
rules: {
allowSkip: r ? r.allowSkip : true,
skipReasonRequired: r ? r.skipReasonRequired : false,
reportRequired: r ? r.reportRequired : false,
allowAfterDeadline: r ? r.allowAfterDeadline : true,
entityLink: normalizeEntityLink(r?.entityLink ?? 'optional'),
},
assignee: row.assignee,
createdBy: row.createdBy,
entities,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function toEventDto(row: {
id: string;
type: string;
fromStatus: RoutineTaskDto['status'] | null;
toStatus: RoutineTaskDto['status'] | null;
note: string | null;
createdAt: Date;
actor: { id: string; fullName: string } | null;
}): RoutineTaskEventDto {
return {
id: row.id,
type: row.type as RoutineTaskEventDto['type'],
fromStatus: row.fromStatus,
toStatus: row.toStatus,
note: row.note,
actor: row.actor,
createdAt: row.createdAt.toISOString(),
};
}
export function routineRule(row: {
recurrence: RoutineDto['recurrence'];
startDate: Date;
endDate: Date | null;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: Date[];
}) {
return {
recurrence: row.recurrence,
startDate: dateToDayKey(row.startDate),
endDate: row.endDate ? dateToDayKey(row.endDate) : null,
weekdays: row.weekdays,
intervalDays: row.intervalDays,
monthDay: row.monthDay,
specificDates: row.specificDates.map(dateToDayKey),
};
}
export function toRoutineDto(
row: RoutineRow,
execution: { total: number; done: number } = { total: 0, done: 0 },
): RoutineDto {
const rule = routineRule(row);
return {
id: row.id,
title: row.title,
description: row.description,
assignee: row.assignee,
team: row.team,
category: row.category ? toCategoryDto(row.category) : null,
priority: row.priority,
timeOfDay: row.timeOfDay,
durationMinutes: row.durationMinutes,
startDate: rule.startDate,
endDate: rule.endDate,
recurrence: row.recurrence,
weekdays: row.weekdays,
intervalDays: row.intervalDays,
monthDay: row.monthDay,
specificDates: rule.specificDates,
isMandatory: row.isMandatory,
allowSkip: row.allowSkip,
skipReasonRequired: row.skipReasonRequired,
reportRequired: row.reportRequired,
allowAfterDeadline: row.allowAfterDeadline,
carryOverOverdue: row.carryOverOverdue,
entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
executionDone: execution.done,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { dayKeyToDate, tehranDayKey, tehranHour, toFaDigits } from './routine-dates';
const TIME_FMT = new Intl.DateTimeFormat('fa-IR', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
@Injectable()
export class RoutineRemindersService {
private readonly logger = new Logger(RoutineRemindersService.name);
private running = false;
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
@Cron('* * * * *')
async run(): Promise<void> {
if (this.running) return;
this.running = true;
try {
await this.sendDueReminders();
await this.sendOverdueAlerts();
await this.sendDaySummaries();
} catch (err) {
this.logger.warn(`routine reminders failed: ${err instanceof Error ? err.message : err}`);
} finally {
this.running = false;
}
}
/** یادآور قبل از مهلت («یک Task شما ۳۰ دقیقه دیگر Deadline دارد») */
private async sendDueReminders() {
const now = new Date();
const due = await this.prisma.routineTask.findMany({
where: {
status: { in: ['todo', 'in_progress'] },
reminderAt: { lte: now },
reminderSentAt: null,
},
select: { id: true, organizationId: true, assigneeId: true, title: true, dueAt: true, reminderAt: true },
take: 300,
});
for (const task of due) {
const claimed = await this.prisma.routineTask.updateMany({
where: { id: task.id, reminderSentAt: null },
data: { reminderSentAt: now },
});
if (!claimed.count) continue;
// یادآورهای خیلی قدیمی (قطعی سرور) ارسال نمی‌شوند
if (task.reminderAt && now.getTime() - task.reminderAt.getTime() > 2 * 60 * 60_000) continue;
const minutes = task.dueAt ? Math.max(0, Math.round((task.dueAt.getTime() - now.getTime()) / 60_000)) : null;
await this.notifications.create({
organizationId: task.organizationId,
userId: task.assigneeId,
type: 'routine_task_reminder',
title:
minutes != null && minutes > 0
? `یک کار شما ${toFaDigits(minutes)} دقیقه دیگر مهلت دارد`
: 'یادآوری کار روتین',
body: task.dueAt ? `${task.title} — ساعت ${TIME_FMT.format(task.dueAt)}` : task.title,
link: `/routines?task=${task.id}`,
entityType: 'routine_task',
entityId: task.id,
});
}
}
/** هشدار Task اجباری که مهلتش گذشته («… هنوز انجام نشده») */
private async sendOverdueAlerts() {
const rows = await this.prisma.routineTask.findMany({
where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null },
select: { id: true, organizationId: true, assigneeId: true, title: true },
take: 300,
});
if (!rows.length) return;
const settings = await this.prisma.routineUserSettings.findMany({
where: { userId: { in: [...new Set(rows.map((r) => r.assigneeId))] }, overdueAlert: false },
select: { organizationId: true, userId: true },
});
const muted = new Set(settings.map((s) => `${s.organizationId}:${s.userId}`));
const now = new Date();
for (const task of rows) {
const claimed = await this.prisma.routineTask.updateMany({
where: { id: task.id, overdueNotifiedAt: null },
data: { overdueNotifiedAt: now },
});
if (!claimed.count || muted.has(`${task.organizationId}:${task.assigneeId}`)) continue;
await this.notifications.create({
organizationId: task.organizationId,
userId: task.assigneeId,
type: 'routine_task_overdue',
title: `کار «${task.title}» هنوز انجام نشده`,
body: 'مهلت این کار اجباری گذشته است.',
link: `/routines/overdue?task=${task.id}`,
entityType: 'routine_task',
entityId: task.id,
});
}
}
/** خلاصه پایان روز («۳ کار تا پایان امروز باقی مانده است») */
private async sendDaySummaries() {
const hour = tehranHour();
const today = tehranDayKey();
const todayDate = dayKeyToDate(today);
const groups = await this.prisma.routineTask.groupBy({
by: ['organizationId', 'assigneeId'],
where: { scheduledFor: todayDate, status: { in: ['todo', 'in_progress'] } },
_count: { _all: true },
});
if (!groups.length) return;
const settings = await this.prisma.routineUserSettings.findMany({
where: { userId: { in: [...new Set(groups.map((g) => g.assigneeId))] } },
});
const byKey = new Map(settings.map((s) => [`${s.organizationId}:${s.userId}`, s]));
for (const g of groups) {
const key = `${g.organizationId}:${g.assigneeId}`;
const s = byKey.get(key);
if (s && !s.endOfDaySummary) continue;
if (hour < (s?.summaryHour ?? 16)) continue;
if (s?.lastSummaryOn && s.lastSummaryOn.getTime() >= todayDate.getTime()) continue;
await this.prisma.routineUserSettings.upsert({
where: { organizationId_userId: { organizationId: g.organizationId, userId: g.assigneeId } },
create: { organizationId: g.organizationId, userId: g.assigneeId, lastSummaryOn: todayDate },
update: { lastSummaryOn: todayDate },
});
await this.notifications.create({
organizationId: g.organizationId,
userId: g.assigneeId,
type: 'routine_day_summary',
title: `${toFaDigits(g._count._all)} کار تا پایان امروز باقی مانده است`,
body: 'برای تکمیل روز، کارهای باقی‌مانده را انجام دهید.',
link: '/routines',
entityType: 'routine_day',
entityId: `${g.assigneeId}:${today}`,
});
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ForbiddenException } from '@nestjs/common';
import { type Actor, elevated, scopedOwnerIds } from '../common/record-scope';
import type { PrismaService } from '../prisma/prisma.service';
export type RoutineScope = {
userId: string;
organizationId: string;
/** مالک/مدیر سازمان یا مدیر حداقل یک تیم */
isManager: boolean;
/** کاربرانی که می‌تواند برایشان روتین/Task تعریف کند؛ null = همه */
manageableUserIds: string[] | null;
/** کاربرانی که Taskهایشان را می‌بیند؛ null = همه */
viewableUserIds: string[] | null;
};
export async function resolveRoutineScope(
prisma: PrismaService,
actor: Actor,
): Promise<RoutineScope> {
const base = {
userId: actor.userId,
organizationId: actor.organizationId,
};
if (elevated(actor.role)) {
return { ...base, isManager: true, manageableUserIds: null, viewableUserIds: null };
}
const managed = await prisma.teamMember.findMany({
where: { userId: actor.userId, role: 'manager', team: { organizationId: actor.organizationId } },
select: { teamId: true },
});
let manageable: string[] = [actor.userId];
if (managed.length) {
const members = await prisma.teamMember.findMany({
where: { teamId: { in: managed.map((m) => m.teamId) } },
select: { userId: true },
});
manageable = [...new Set([actor.userId, ...members.map((m) => m.userId)])];
}
const visible = scopedOwnerIds(actor, 'routines');
const viewable = visible === null ? null : [...new Set([...visible, ...manageable])];
return {
...base,
isManager: managed.length > 0,
manageableUserIds: manageable,
viewableUserIds: viewable,
};
}
export function canView(scope: RoutineScope, userId: string): boolean {
return scope.viewableUserIds === null || scope.viewableUserIds.includes(userId);
}
export function canManage(scope: RoutineScope, userId: string): boolean {
return scope.manageableUserIds === null || scope.manageableUserIds.includes(userId);
}
export function assertManager(scope: RoutineScope): void {
if (!scope.isManager) {
throw new ForbiddenException('فقط مدیران به این بخش دسترسی دارند');
}
}
/** فیلتر Prisma روی assigneeId بر اساس دید کاربر و فیلتر کاربر درخواستی */
export function assigneeFilter(
scope: RoutineScope,
requestedUserId?: string | null,
): { assigneeId?: string | { in: string[] } } {
if (requestedUserId) {
if (!canView(scope, requestedUserId)) {
throw new ForbiddenException('دسترسی به کارهای این کاربر ندارید');
}
return { assigneeId: requestedUserId };
}
if (scope.viewableUserIds === null) return {};
return { assigneeId: { in: scope.viewableUserIds } };
}
@@ -0,0 +1,565 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type {
RoutineCalendarDayDto,
RoutineCategoryStatDto,
RoutineDayState,
RoutineExecutionRowDto,
RoutineHistoryDayDto,
RoutineMemberStatDto,
RoutineMonthlyReportDto,
RoutinePerformanceDto,
RoutinePeriodTotalsDto,
RoutineScoreDto,
RoutineStatusBreakdownDto,
RoutineTaskStatus,
RoutineTeamPerformanceDto,
RoutineTrendPointDto,
RoutineWeeklyReportDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { RoutineRangeQueryDto } from './dto';
import { describeRecurrence } from './recurrence';
import { routineRule } from './routine-mapper';
import { type RoutineScope, assertManager, canView } from './routine-scope';
import {
addDays,
dateToDayKey,
dayDiff,
dayKeyToDate,
eachDay,
jalaliDayOfMonth,
tehranDayKey,
weekStartKey,
} from './routine-dates';
type StatTask = {
assigneeId: string;
scheduledFor: Date;
status: RoutineTaskStatus;
isMandatory: boolean;
categoryId: string | null;
routineId: string | null;
source: string;
dueAt: Date | null;
completedAt: Date | null;
createdAt: Date;
};
type Counts = {
total: number;
completed: number;
overdue: number;
skipped: number;
cancelled: number;
pending: number;
};
const statSelect = {
assigneeId: true,
scheduledFor: true,
status: true,
isMandatory: true,
categoryId: true,
routineId: true,
source: true,
dueAt: true,
completedAt: true,
createdAt: true,
} satisfies Prisma.RoutineTaskSelect;
function emptyCounts(): Counts {
return { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 };
}
function addTo(c: Counts, status: RoutineTaskStatus, n = 1) {
c.total += n;
if (status === 'completed') c.completed += n;
else if (status === 'overdue') c.overdue += n;
else if (status === 'skipped') c.skipped += n;
else if (status === 'cancelled') c.cancelled += n;
else c.pending += n;
}
/** Taskهای لغو و رد شده در درصد حساب نمی‌شوند */
export function effectiveTotal(c: Counts): number {
return c.total - c.cancelled - c.skipped;
}
export function percentOf(c: Counts): number | null {
const eff = effectiveTotal(c);
return eff > 0 ? Math.round((c.completed / eff) * 100) : null;
}
export function dayState(c: Counts, day: string, today: string): RoutineDayState {
const eff = effectiveTotal(c);
if (eff <= 0 || day > today) return 'empty';
if (c.completed >= eff) return 'complete';
if (c.completed === 0 && day < today) return 'missed';
return 'partial';
}
export function jalaliMonthStart(day: string): string {
return addDays(day, -(jalaliDayOfMonth(day) - 1));
}
@Injectable()
export class RoutineStatsService {
constructor(private readonly prisma: PrismaService) {}
/* ───────── امتیاز روزانه و Streak ───────── */
async score(organizationId: string, userId: string, day = tehranDayKey()): Promise<RoutineScoreDto> {
const monthStart = jalaliMonthStart(day);
const weekStart = weekStartKey(day);
const from = [monthStart, weekStart, addDays(day, -1)].sort()[0]!;
const tasks = await this.prisma.routineTask.findMany({
where: {
organizationId,
assigneeId: userId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) },
},
select: { scheduledFor: true, status: true },
});
const range = (a: string, b: string) => {
const c = emptyCounts();
for (const t of tasks) {
const k = dateToDayKey(t.scheduledFor);
if (k >= a && k <= b) addTo(c, t.status);
}
return percentOf(c);
};
const yesterday = addDays(day, -1);
return {
today: range(day, day),
yesterday: range(yesterday, yesterday),
week: range(weekStart, day),
month: range(monthStart, day),
streak: (await this.streaks(organizationId, [userId], day)).get(userId) ?? 0,
};
}
/** روزهای متوالی که همه Taskهای اجباری انجام (یا مجاز رد) شده‌اند؛ روز بدون Task اجباری خنثی است */
async streaks(
organizationId: string,
userIds: string[],
today = tehranDayKey(),
): Promise<Map<string, number>> {
const out = new Map<string, number>();
if (!userIds.length) return out;
const groups = await this.prisma.routineTask.groupBy({
by: ['assigneeId', 'scheduledFor', 'status'],
where: {
organizationId,
assigneeId: { in: userIds },
isMandatory: true,
status: { not: 'cancelled' },
scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) },
},
_count: { _all: true },
});
const byUser = new Map<string, Map<string, { total: number; ok: number }>>();
for (const g of groups) {
const days = byUser.get(g.assigneeId) ?? new Map();
const key = dateToDayKey(g.scheduledFor);
const cur = days.get(key) ?? { total: 0, ok: 0 };
cur.total += g._count._all;
if (g.status === 'completed' || g.status === 'skipped') cur.ok += g._count._all;
days.set(key, cur);
byUser.set(g.assigneeId, days);
}
for (const userId of userIds) {
const days = byUser.get(userId);
let streak = 0;
if (days) {
const keys = [...days.keys()].sort().reverse();
for (const key of keys) {
const d = days.get(key)!;
const complete = d.ok >= d.total;
if (key === today) {
if (complete) streak += 1;
continue;
}
if (!complete) break;
streak += 1;
}
}
out.set(userId, streak);
}
return out;
}
/* ───────── خلاصه روزها (تقویم/تاریخچه) ───────── */
summarizeDays(tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, from: string, to: string) {
const today = tehranDayKey();
const map = new Map<string, Counts>();
for (const t of tasks) {
const k = dateToDayKey(t.scheduledFor);
const c = map.get(k) ?? emptyCounts();
addTo(c, t.status);
map.set(k, c);
}
return eachDay(from, to).map((date) => {
const c = map.get(date) ?? emptyCounts();
return { date, counts: c, state: dayState(c, date, today), percent: percentOf(c) };
});
}
toCalendarDays(
tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>,
from: string,
to: string,
): RoutineCalendarDayDto[] {
return this.summarizeDays(tasks, from, to).map(({ date, counts, state, percent }) => ({
date,
total: effectiveTotal(counts),
completed: counts.completed,
overdue: counts.overdue,
percent,
state,
}));
}
/* ───────── گزارش‌ها ───────── */
async performance(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutinePerformanceDto> {
const userId = q.userId || scope.userId;
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
const { from, to } = this.range(q, 29);
const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]);
const agg = await this.aggregate(scope.organizationId, tasks, from, to);
const streak = (await this.streaks(scope.organizationId, [userId])).get(userId) ?? 0;
return {
from,
to,
total: agg.effTotal,
completed: agg.counts.completed,
notDone: agg.status.notDone,
overdue: agg.counts.overdue,
percent: percentOf(agg.counts),
avgCompletionMinutes: this.avgCompletion(tasks),
completeDays: agg.days.complete,
partialDays: agg.days.partial,
missedDays: agg.days.missed,
streak,
status: agg.status,
trend: agg.trend,
byCategory: agg.byCategory,
};
}
async team(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineTeamPerformanceDto> {
assertManager(scope);
const { from, to } = this.range(q, 29);
const users = await this.teamUsers(scope, q);
const tasks = await this.fetch(scope.organizationId, from, to, q, users.map((u) => u.id));
const agg = await this.aggregate(scope.organizationId, tasks, from, to);
return {
from,
to,
total: agg.effTotal,
completed: agg.counts.completed,
overdue: agg.counts.overdue,
percent: percentOf(agg.counts),
status: agg.status,
trend: agg.trend,
byCategory: agg.byCategory,
members: await this.memberStats(scope.organizationId, users, tasks),
};
}
async weekly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineWeeklyReportDto> {
const from = weekStartKey(q.from || tehranDayKey());
const to = addDays(from, 6);
const prevFrom = addDays(from, -7);
const userIds = await this.viewUsers(scope, q);
const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds);
const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from);
const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from);
return {
from,
to,
days: this.toCalendarDays(current, from, to),
totals: this.periodTotals(current, from, to),
previous: this.periodTotals(previous, prevFrom, addDays(from, -1)),
};
}
async monthly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineMonthlyReportDto> {
const from = q.from || jalaliMonthStart(tehranDayKey());
const to = q.to || addDays(jalaliMonthStart(addDays(from, 32)), -1);
const length = dayDiff(from, to) + 1;
const prevTo = addDays(from, -1);
const prevFrom = addDays(from, -length);
const userIds = await this.viewUsers(scope, q);
const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds);
const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from);
const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from);
const status = this.statusBreakdown(current);
let byUser: RoutineMemberStatDto[] = [];
if (q.view === 'team') {
const users = await this.teamUsers(scope, q);
byUser = await this.memberStats(scope.organizationId, users, current);
}
return {
from,
to,
totals: this.periodTotals(current, from, to),
previous: this.periodTotals(previous, prevFrom, prevTo),
status,
days: this.toCalendarDays(current, from, to),
byUser,
};
}
async routinesExecution(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineExecutionRowDto[]> {
assertManager(scope);
const { from, to } = this.range(q, 29);
const users = await this.teamUsers(scope, q);
const where: Prisma.RoutineTaskWhereInput = {
organizationId: scope.organizationId,
routineId: { not: null },
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
assigneeId: { in: users.map((u) => u.id) },
status: { notIn: ['cancelled'] },
...(q.categoryId ? { categoryId: q.categoryId } : {}),
};
const groups = await this.prisma.routineTask.groupBy({
by: ['routineId', 'status'],
where,
_count: { _all: true },
});
const map = new Map<string, Counts>();
for (const g of groups) {
if (!g.routineId) continue;
const c = map.get(g.routineId) ?? emptyCounts();
addTo(c, g.status, g._count._all);
map.set(g.routineId, c);
}
if (!map.size) return [];
const routines = await this.prisma.routine.findMany({
where: { id: { in: [...map.keys()] } },
include: { assignee: { select: { fullName: true } }, team: { select: { name: true } } },
});
return routines
.map((r) => {
const c = map.get(r.id)!;
const resolved = c.completed + c.overdue + c.skipped;
return {
routineId: r.id,
title: r.deletedAt ? `${r.title} (حذف‌شده)` : r.title,
assigneeLabel: r.assignee?.fullName ?? r.team?.name ?? '—',
recurrenceLabel: describeRecurrence(routineRule(r)),
occurrences: c.total,
completed: c.completed,
notDone: c.overdue + c.pending,
skipped: c.skipped,
percent: resolved ? Math.round((c.completed / resolved) * 100) : null,
};
})
.sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101));
}
async history(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineHistoryDayDto[]> {
const userId = q.userId || scope.userId;
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
const { from, to } = this.range(q, 59);
const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]);
return this.summarizeDays(tasks, from, to)
.filter((d) => d.counts.total > 0)
.reverse()
.map(({ date, counts, state, percent }) => ({
date,
total: counts.total,
completed: counts.completed,
overdue: counts.overdue,
skipped: counts.skipped,
cancelled: counts.cancelled,
percent,
state,
}));
}
/* ───────── کمکی ───────── */
private range(q: { from?: string; to?: string }, defaultDays: number) {
const to = q.to || tehranDayKey();
let from = q.from || addDays(to, -defaultDays);
if (from > to) from = to;
if (dayDiff(from, to) > 400) from = addDays(to, -400);
return { from, to };
}
private async viewUsers(scope: RoutineScope, q: RoutineRangeQueryDto) {
if (q.view === 'team') return (await this.teamUsers(scope, q)).map((u) => u.id);
const userId = q.userId || scope.userId;
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
return [userId];
}
async teamUsers(scope: RoutineScope, q: { userId?: string; teamId?: string }) {
assertManager(scope);
let ids = scope.manageableUserIds;
if (q.teamId) {
const members = await this.prisma.teamMember.findMany({
where: { teamId: q.teamId, team: { organizationId: scope.organizationId } },
select: { userId: true },
});
const teamIds = members.map((m) => m.userId);
ids = ids ? ids.filter((id) => teamIds.includes(id)) : teamIds;
}
if (q.userId) {
ids = ids ? ids.filter((id) => id === q.userId) : [q.userId];
}
const rows = await this.prisma.membership.findMany({
where: {
organizationId: scope.organizationId,
isActive: true,
user: { isActive: true },
...(ids ? { userId: { in: ids } } : {}),
},
select: { user: { select: { id: true, fullName: true } } },
orderBy: { user: { fullName: 'asc' } },
});
return rows.map((r) => r.user);
}
private fetch(
organizationId: string,
from: string,
to: string,
q: RoutineRangeQueryDto,
userIds: string[],
): Promise<StatTask[]> {
return this.prisma.routineTask.findMany({
where: {
organizationId,
assigneeId: { in: userIds },
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.source ? { source: q.source } : {}),
...(q.status ? { status: q.status } : {}),
},
select: statSelect,
});
}
private statusBreakdown(tasks: StatTask[]): RoutineStatusBreakdownDto & { notDone: number } {
const c = emptyCounts();
for (const t of tasks) addTo(c, t.status);
return {
completed: c.completed,
notDone: c.pending + c.overdue,
overdue: c.overdue,
skipped: c.skipped,
cancelled: c.cancelled,
};
}
private periodTotals(tasks: StatTask[], from: string, to: string): RoutinePeriodTotalsDto {
const c = emptyCounts();
for (const t of tasks) addTo(c, t.status);
const days = this.summarizeDays(tasks, from, to);
const percents = days.map((d) => d.percent).filter((p): p is number => p != null);
return {
total: effectiveTotal(c),
completed: c.completed,
notDone: c.pending + c.overdue,
overdue: c.overdue,
percent: percentOf(c),
completeDays: days.filter((d) => d.state === 'complete').length,
partialDays: days.filter((d) => d.state === 'partial').length,
missedDays: days.filter((d) => d.state === 'missed').length,
avgPercent: percents.length
? Math.round(percents.reduce((s, p) => s + p, 0) / percents.length)
: null,
};
}
private async aggregate(organizationId: string, tasks: StatTask[], from: string, to: string) {
const counts = emptyCounts();
for (const t of tasks) addTo(counts, t.status);
const days = this.summarizeDays(tasks, from, to);
const trend: RoutineTrendPointDto[] = days.map((d) => ({
date: d.date,
total: effectiveTotal(d.counts),
completed: d.counts.completed,
percent: d.percent,
}));
const catCounts = new Map<string | null, Counts>();
for (const t of tasks) {
const c = catCounts.get(t.categoryId) ?? emptyCounts();
addTo(c, t.status);
catCounts.set(t.categoryId, c);
}
const catIds = [...catCounts.keys()].filter((id): id is string => !!id);
const cats = catIds.length
? await this.prisma.routineCategory.findMany({
where: { organizationId, id: { in: catIds } },
})
: [];
const byCategory: RoutineCategoryStatDto[] = [...catCounts.entries()]
.map(([id, c]) => {
const cat = cats.find((x) => x.id === id);
return {
categoryId: id,
name: cat?.name ?? 'بدون دسته',
color: cat?.color ?? '#94a3b8',
total: effectiveTotal(c),
completed: c.completed,
percent: percentOf(c),
};
})
.sort((a, b) => b.total - a.total);
return {
counts,
effTotal: effectiveTotal(counts),
status: this.statusBreakdown(tasks),
trend,
byCategory,
days: {
complete: days.filter((d) => d.state === 'complete').length,
partial: days.filter((d) => d.state === 'partial').length,
missed: days.filter((d) => d.state === 'missed').length,
},
};
}
private async memberStats(
organizationId: string,
users: Array<{ id: string; fullName: string }>,
tasks: StatTask[],
): Promise<RoutineMemberStatDto[]> {
const streaks = await this.streaks(organizationId, users.map((u) => u.id));
return users.map((user) => {
const own = tasks.filter((t) => t.assigneeId === user.id);
const c = emptyCounts();
for (const t of own) addTo(c, t.status);
return {
user,
total: effectiveTotal(c),
completed: c.completed,
overdue: c.overdue,
skipped: c.skipped,
percent: percentOf(c),
streak: streaks.get(user.id) ?? 0,
avgCompletionMinutes: this.avgCompletion(own),
};
});
}
/** میانگین فاصله انجام از زمان برنامه (روتین: ساعت برنامه، بقیه: زمان ثبت) */
private avgCompletion(tasks: StatTask[]): number | null {
const values: number[] = [];
for (const t of tasks) {
if (t.status !== 'completed' || !t.completedAt) continue;
const ref = t.source === 'routine' && t.dueAt ? t.dueAt : t.createdAt;
values.push(Math.max(0, (t.completedAt.getTime() - ref.getTime()) / 60_000));
}
if (!values.length) return null;
return Math.round(values.reduce((s, v) => s + v, 0) / values.length);
}
}
@@ -0,0 +1,129 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Put,
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 { RoutineTasksService } from './routine-tasks.service';
import {
CompleteRoutineTaskDto,
CreateRoutineTaskDto,
RescheduleRoutineTaskDto,
RoutineEntityQueryDto,
RoutineRangeQueryDto,
RoutineTaskQueryDto,
RoutineUserSettingsUpdateDto,
SkipRoutineTaskDto,
UpdateRoutineTaskDto,
} from './dto';
@Controller('routine-tasks')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('routines')
export class RoutineTasksController {
constructor(private readonly tasks: RoutineTasksService) {}
@Get('today')
today(@CurrentUser() user: Actor, @Query() q: RoutineTaskQueryDto) {
return this.tasks.today(user, q.userId, q.date);
}
@Get('badge')
badge(@CurrentUser() user: Actor) {
return this.tasks.badge(user);
}
@Get('overdue')
overdue(@CurrentUser() user: Actor, @Query() q: RoutineTaskQueryDto) {
return this.tasks.overdue(user, q.userId);
}
@Get('calendar')
calendar(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.tasks.calendar(user, q);
}
@Get('by-entity')
byEntity(@CurrentUser() user: Actor, @Query() q: RoutineEntityQueryDto) {
return this.tasks.byEntity(user, q);
}
@Get('settings')
settings(@CurrentUser() user: Actor) {
return this.tasks.getSettings(user);
}
@Put('settings')
updateSettings(@CurrentUser() user: Actor, @Body() dto: RoutineUserSettingsUpdateDto) {
return this.tasks.updateSettings(user, dto);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateRoutineTaskDto) {
return this.tasks.create(user, dto);
}
@Post('from-activity/:activityId')
fromActivity(@CurrentUser() user: Actor, @Param('activityId') activityId: string) {
return this.tasks.createFromActivity(user, activityId);
}
@Get(':id')
detail(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.detail(user, id);
}
@Patch(':id')
update(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: UpdateRoutineTaskDto) {
return this.tasks.update(user, id, dto);
}
@Post(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: CompleteRoutineTaskDto) {
return this.tasks.complete(user, id, dto.note);
}
@Post(':id/uncomplete')
uncomplete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.uncomplete(user, id);
}
@Post(':id/start')
start(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.start(user, id);
}
@Post(':id/skip')
skip(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SkipRoutineTaskDto) {
return this.tasks.skip(user, id, dto.reason);
}
@Post(':id/reschedule')
reschedule(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: RescheduleRoutineTaskDto,
) {
return this.tasks.reschedule(user, id, dto.dueAt);
}
@Post(':id/move-today')
moveToday(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.moveToToday(user, id);
}
@Post(':id/cancel')
cancel(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SkipRoutineTaskDto) {
return this.tasks.cancel(user, id, dto.reason);
}
}
@@ -0,0 +1,609 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
RoutineCalendarDto,
RoutineTaskDetailDto,
RoutineTaskDto,
RoutineTaskSource,
RoutineTaskStatus,
RoutineTodayDto,
RoutineUserSettingsDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import type { Actor } from '../common/record-scope';
import type {
CreateRoutineTaskDto,
RoutineEntityQueryDto,
RoutineRangeQueryDto,
RoutineUserSettingsUpdateDto,
UpdateRoutineTaskDto,
} from './dto';
import {
type RoutineScope,
assigneeFilter,
canManage,
canView,
resolveRoutineScope,
} from './routine-scope';
import {
type RoutineTaskRow,
routineTaskInclude,
toEventDto,
toTaskDto,
} from './routine-mapper';
import { DEFAULT_REMIND_MINUTES } from './routine-generator.service';
import { RoutineStatsService, effectiveTotal } from './routine-stats.service';
import {
addDays,
dayKeyToDate,
isDayKey,
tehranDateTime,
tehranDayKey,
} from './routine-dates';
const OPEN_STATUSES: RoutineTaskStatus[] = ['todo', 'in_progress', 'overdue'];
@Injectable()
export class RoutineTasksService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly stats: RoutineStatsService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── خواندن ───────── */
async today(actor: Actor, userId?: string, date?: string): Promise<RoutineTodayDto> {
const scope = await this.scope(actor);
const target = userId || actor.userId;
if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید');
const day = date && isDayKey(date) ? date : tehranDayKey();
const dayDate = dayKeyToDate(day);
const [todayRows, overdueRows, score] = await Promise.all([
this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
assigneeId: target,
scheduledFor: dayDate,
status: { not: 'cancelled' },
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }],
}),
this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
assigneeId: target,
status: 'overdue',
scheduledFor: { lt: dayDate },
},
include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } },
orderBy: [{ dueAt: 'asc' }],
take: 100,
}),
this.stats.score(actor.organizationId, target, day),
]);
const carried = overdueRows.filter((r) => r.routine?.carryOverOverdue);
const overdue = overdueRows.filter((r) => !r.routine?.carryOverOverdue);
const counts = { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 };
for (const r of todayRows) {
counts.total += 1;
if (r.status === 'completed') counts.completed += 1;
else if (r.status === 'skipped') counts.skipped += 1;
else if (r.status === 'overdue') counts.overdue += 1;
else counts.pending += 1;
}
const eff = effectiveTotal(counts);
return {
date: day,
tasks: [...todayRows, ...carried].map((r) => toTaskDto(r as RoutineTaskRow)),
overdue: overdue.map((r) => toTaskDto(r as RoutineTaskRow)),
stats: {
total: eff,
completed: counts.completed,
remaining: counts.pending,
overdue: counts.overdue + overdueRows.length,
percent: eff > 0 ? Math.round((counts.completed / eff) * 100) : null,
},
score,
};
}
/** شمارش کارهای باقی‌مانده امروز برای Badge منو */
async badge(actor: Actor): Promise<{ remaining: number; overdue: number }> {
const day = dayKeyToDate(tehranDayKey());
const [remaining, overdue] = await Promise.all([
this.prisma.routineTask.count({
where: {
organizationId: actor.organizationId,
assigneeId: actor.userId,
scheduledFor: day,
status: { in: ['todo', 'in_progress'] },
},
}),
this.prisma.routineTask.count({
where: { organizationId: actor.organizationId, assigneeId: actor.userId, status: 'overdue' },
}),
]);
return { remaining, overdue };
}
async overdue(actor: Actor, userId?: string): Promise<RoutineTaskDto[]> {
const scope = await this.scope(actor);
const filter =
userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId);
const rows = await this.prisma.routineTask.findMany({
where: { organizationId: actor.organizationId, status: 'overdue', ...filter },
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }],
take: 300,
});
return rows.map(toTaskDto);
}
async calendar(actor: Actor, q: RoutineRangeQueryDto): Promise<RoutineCalendarDto> {
const scope = await this.scope(actor);
const to = q.to || tehranDayKey();
let from = q.from || addDays(to, -30);
if (from > to) from = to;
const filter = assigneeFilter(scope, q.userId || actor.userId);
const where: Prisma.RoutineTaskWhereInput = {
organizationId: actor.organizationId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...filter,
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.status ? { status: q.status } : {}),
...(q.source ? { source: q.source } : {}),
};
const rows = await this.prisma.routineTask.findMany({
where,
include: routineTaskInclude,
orderBy: [{ scheduledFor: 'asc' }, { dueAt: 'asc' }],
take: 3000,
});
return {
days: this.stats.toCalendarDays(rows, from, to),
tasks: rows.map(toTaskDto),
};
}
async byEntity(actor: Actor, q: RoutineEntityQueryDto): Promise<RoutineTaskDto[]> {
const or: Prisma.RoutineTaskWhereInput[] = [];
if (q.leadId) or.push({ leadId: q.leadId });
if (q.personId) or.push({ personId: q.personId });
if (q.companyId) or.push({ companyId: q.companyId });
if (q.dealId) or.push({ dealId: q.dealId });
if (!or.length) return [];
const scope = await this.scope(actor);
const rows = await this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
OR: or,
status: { not: 'cancelled' },
...assigneeFilter(scope),
},
include: routineTaskInclude,
orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }],
take: 100,
});
return rows.map(toTaskDto);
}
async detail(actor: Actor, id: string): Promise<RoutineTaskDetailDto> {
const scope = await this.scope(actor);
const row = await this.findTask(actor.organizationId, id);
if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید');
const events = await this.prisma.routineTaskEvent.findMany({
where: { taskId: id },
include: { actor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'asc' },
});
return { ...toTaskDto(row), events: events.map(toEventDto) };
}
/* ───────── ایجاد / ویرایش ───────── */
async create(actor: Actor, dto: CreateRoutineTaskDto): Promise<RoutineTaskDto> {
const scope = await this.scope(actor);
const assigneeId = dto.assigneeId || actor.userId;
if (assigneeId !== actor.userId && !canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه ثبت کار برای این کاربر را ندارید');
}
await this.assertMember(actor.organizationId, assigneeId);
await this.assertRefs(actor.organizationId, dto);
const { scheduledFor, dueAt } = this.resolveWhen(dto.dueAt, dto.scheduledFor);
const source: RoutineTaskSource =
dto.source ??
(dto.activityId
? 'follow_up'
: dto.leadId
? 'lead'
: dto.personId || dto.companyId
? 'customer'
: dto.dealId
? 'deal'
: 'manual');
const reminderAt = dto.dueAt ? await this.defaultReminder(actor.organizationId, assigneeId, dueAt) : null;
const row = await this.prisma.routineTask.create({
data: {
organizationId: actor.organizationId,
assigneeId,
title: dto.title.trim(),
description: dto.description?.trim() || null,
priority: dto.priority ?? 'medium',
categoryId: dto.categoryId || null,
isMandatory: dto.isMandatory ?? false,
source,
scheduledFor: dayKeyToDate(scheduledFor),
dueAt,
reminderAt,
status: dueAt.getTime() < Date.now() ? 'overdue' : 'todo',
leadId: dto.leadId || null,
personId: dto.personId || null,
companyId: dto.companyId || null,
dealId: dto.dealId || null,
invoiceId: dto.invoiceId || null,
activityId: dto.activityId || null,
createdById: actor.userId,
events: {
create: {
organizationId: actor.organizationId,
actorId: actor.userId,
type: 'created',
toStatus: 'todo',
},
},
},
include: routineTaskInclude,
});
if (assigneeId !== actor.userId) {
await this.notifications.create({
organizationId: actor.organizationId,
userId: assigneeId,
type: 'routine_task_assigned',
title: 'کار جدید برای شما ثبت شد',
body: row.title,
link: `/routines?task=${row.id}`,
entityType: 'routine_task',
entityId: row.id,
});
}
return toTaskDto(row);
}
async createFromActivity(actor: Actor, activityId: string): Promise<RoutineTaskDto> {
const activity = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
description: true,
dueAt: true,
priority: true,
personId: true,
companyId: true,
dealId: true,
assigneeId: true,
},
});
if (!activity) throw new NotFoundException('پیگیری پیدا نشد');
const existing = await this.prisma.routineTask.findFirst({
where: { organizationId: actor.organizationId, activityId, status: { not: 'cancelled' } },
include: routineTaskInclude,
});
if (existing) return toTaskDto(existing);
const scope = await this.scope(actor);
const assigneeId =
activity.assigneeId && canManage(scope, activity.assigneeId)
? activity.assigneeId
: actor.userId;
return this.create(actor, {
title: activity.title,
description: activity.description,
dueAt: activity.dueAt?.toISOString() ?? null,
priority: activity.priority,
assigneeId,
personId: activity.personId,
companyId: activity.companyId,
dealId: activity.dealId,
activityId: activity.id,
source: 'follow_up',
});
}
async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise<RoutineTaskDto> {
const { scope, row } = await this.loadForEdit(actor, id);
await this.assertRefs(actor.organizationId, dto);
const data: Prisma.RoutineTaskUncheckedUpdateInput = {};
const events: Array<{ type: string; note?: string }> = [];
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
if (dto.priority) data.priority = dto.priority;
if (dto.categoryId !== undefined) data.categoryId = dto.categoryId || null;
if (dto.isMandatory !== undefined) data.isMandatory = dto.isMandatory;
for (const key of ['leadId', 'personId', 'companyId', 'dealId', 'invoiceId'] as const) {
if (dto[key] !== undefined) data[key] = dto[key] || null;
}
if (dto.assigneeId && dto.assigneeId !== row.assigneeId) {
if (!canManage(scope, dto.assigneeId)) {
throw new ForbiddenException('اجازه ارجاع به این کاربر را ندارید');
}
await this.assertMember(actor.organizationId, dto.assigneeId);
data.assigneeId = dto.assigneeId;
events.push({ type: 'reassigned' });
}
if (dto.dueAt !== undefined || dto.scheduledFor !== undefined) {
const when = this.resolveWhen(dto.dueAt, dto.scheduledFor);
data.scheduledFor = dayKeyToDate(when.scheduledFor);
data.dueAt = when.dueAt;
data.reminderAt = await this.defaultReminder(
actor.organizationId,
(data.assigneeId as string) ?? row.assigneeId,
when.dueAt,
);
data.reminderSentAt = null;
if (row.status === 'overdue' && when.dueAt.getTime() > Date.now()) data.status = 'todo';
events.push({ type: 'rescheduled' });
}
if (!events.length) events.push({ type: 'updated' });
const updated = await this.saveWithEvents(actor, row, data, events);
return toTaskDto(updated);
}
/* ───────── عملیات وضعیت ───────── */
async complete(actor: Actor, id: string, note?: string | null) {
const { row } = await this.loadForEdit(actor, id);
if (row.status === 'completed') {
if (!note?.trim()) return toTaskDto(row);
const updated = await this.saveWithEvents(actor, row, { resultNote: note.trim() }, [
{ type: 'note', note: note.trim() },
]);
return toTaskDto(updated);
}
const rules = toTaskDto(row).rules;
if (row.status === 'cancelled') throw new BadRequestException('کار لغو شده قابل انجام نیست');
if (row.status === 'overdue' && !rules.allowAfterDeadline) {
throw new BadRequestException('انجام این کار بعد از مهلت مجاز نیست');
}
if (rules.reportRequired && !note?.trim()) {
throw new BadRequestException('ثبت گزارش/نتیجه برای این کار الزامی است');
}
const updated = await this.saveWithEvents(
actor,
row,
{ status: 'completed', completedAt: new Date(), resultNote: note?.trim() || row.resultNote },
[{ type: 'status_changed', note: note?.trim() || undefined }],
);
return toTaskDto(updated);
}
async uncomplete(actor: Actor, id: string) {
const { row } = await this.loadForEdit(actor, id);
if (!['completed', 'skipped', 'cancelled'].includes(row.status)) return toTaskDto(row);
const status: RoutineTaskStatus =
row.dueAt && row.dueAt.getTime() < Date.now() ? 'overdue' : 'todo';
const updated = await this.saveWithEvents(
actor,
row,
{ status, completedAt: null, skipReason: null },
[{ type: 'status_changed' }],
);
return toTaskDto(updated);
}
async start(actor: Actor, id: string) {
const { row } = await this.loadForEdit(actor, id);
if (row.status !== 'todo') return toTaskDto(row);
const updated = await this.saveWithEvents(
actor,
row,
{ status: 'in_progress', startedAt: new Date() },
[{ type: 'status_changed' }],
);
return toTaskDto(updated);
}
async skip(actor: Actor, id: string, reason?: string | null) {
const { row } = await this.loadForEdit(actor, id);
const rules = toTaskDto(row).rules;
if (!OPEN_STATUSES.includes(row.status)) throw new BadRequestException('این کار باز نیست');
if (!rules.allowSkip) throw new BadRequestException('رد کردن این کار مجاز نیست');
if (rules.skipReasonRequired && !reason?.trim()) {
throw new BadRequestException('ثبت دلیل رد کردن الزامی است');
}
const updated = await this.saveWithEvents(
actor,
row,
{ status: 'skipped', skipReason: reason?.trim() || null },
[{ type: 'status_changed', note: reason?.trim() || undefined }],
);
return toTaskDto(updated);
}
async cancel(actor: Actor, id: string, reason?: string | null) {
const { row } = await this.loadForEdit(actor, id);
if (row.status === 'cancelled') return toTaskDto(row);
const updated = await this.saveWithEvents(actor, row, { status: 'cancelled' }, [
{ type: 'status_changed', note: reason?.trim() || undefined },
]);
return toTaskDto(updated);
}
async reschedule(actor: Actor, id: string, dueAtIso: string) {
return this.update(actor, id, { dueAt: dueAtIso });
}
async moveToToday(actor: Actor, id: string) {
const { row } = await this.loadForEdit(actor, id);
const today = tehranDayKey();
const time = row.dueAt
? new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(row.dueAt)
: '23:59';
let dueAt = tehranDateTime(today, time);
if (dueAt.getTime() < Date.now()) dueAt = tehranDateTime(today, '23:59');
return this.update(actor, id, { dueAt: dueAt.toISOString() });
}
/* ───────── تنظیمات اعلان ───────── */
async getSettings(actor: Actor): Promise<RoutineUserSettingsDto> {
const row = await this.prisma.routineUserSettings.findUnique({
where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } },
});
return {
remindBeforeMinutes: row ? row.remindBeforeMinutes : DEFAULT_REMIND_MINUTES,
endOfDaySummary: row?.endOfDaySummary ?? true,
overdueAlert: row?.overdueAlert ?? true,
summaryHour: row?.summaryHour ?? 16,
};
}
async updateSettings(actor: Actor, dto: RoutineUserSettingsUpdateDto) {
const data = {
...(dto.remindBeforeMinutes !== undefined ? { remindBeforeMinutes: dto.remindBeforeMinutes } : {}),
...(dto.endOfDaySummary !== undefined ? { endOfDaySummary: dto.endOfDaySummary } : {}),
...(dto.overdueAlert !== undefined ? { overdueAlert: dto.overdueAlert } : {}),
...(dto.summaryHour !== undefined ? { summaryHour: dto.summaryHour } : {}),
};
await this.prisma.routineUserSettings.upsert({
where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } },
create: { organizationId: actor.organizationId, userId: actor.userId, ...data },
update: data,
});
return this.getSettings(actor);
}
/* ───────── کمکی ───────── */
private async findTask(organizationId: string, id: string): Promise<RoutineTaskRow> {
const row = await this.prisma.routineTask.findFirst({
where: { id, organizationId },
include: routineTaskInclude,
});
if (!row) throw new NotFoundException('کار پیدا نشد');
return row;
}
private async loadForEdit(actor: Actor, id: string) {
const scope = await this.scope(actor);
const row = await this.findTask(actor.organizationId, id);
if (row.assigneeId !== actor.userId && !canManage(scope, row.assigneeId)) {
throw new ForbiddenException('اجازه تغییر این کار را ندارید');
}
return { scope, row };
}
private async saveWithEvents(
actor: Actor,
row: RoutineTaskRow,
data: Prisma.RoutineTaskUncheckedUpdateInput,
events: Array<{ type: string; note?: string }>,
): Promise<RoutineTaskRow> {
const nextStatus = (data.status as RoutineTaskStatus | undefined) ?? row.status;
try {
const [updated] = await this.prisma.$transaction([
this.prisma.routineTask.update({
where: { id: row.id },
data,
include: routineTaskInclude,
}),
this.prisma.routineTaskEvent.createMany({
data: events.map((e) => ({
organizationId: actor.organizationId,
taskId: row.id,
actorId: actor.userId,
type: e.type,
fromStatus: nextStatus !== row.status ? row.status : null,
toStatus: nextStatus !== row.status ? nextStatus : null,
note: e.note ?? null,
})),
}),
]);
return updated;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new BadRequestException('این روتین برای آن روز کار دیگری دارد');
}
throw err;
}
}
private resolveWhen(dueAtIso?: string | null, scheduledFor?: string | null) {
if (dueAtIso) {
const dueAt = new Date(dueAtIso);
if (Number.isNaN(dueAt.getTime())) throw new BadRequestException('زمان نامعتبر است');
return { dueAt, scheduledFor: tehranDayKey(dueAt) };
}
const day = scheduledFor && isDayKey(scheduledFor) ? scheduledFor : tehranDayKey();
return { dueAt: tehranDateTime(day, '23:59'), scheduledFor: day };
}
private async defaultReminder(organizationId: string, userId: string, dueAt: Date) {
const settings = await this.prisma.routineUserSettings.findUnique({
where: { organizationId_userId: { organizationId, userId } },
select: { remindBeforeMinutes: true },
});
const minutes = settings ? settings.remindBeforeMinutes : DEFAULT_REMIND_MINUTES;
return minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null;
}
private async assertMember(organizationId: string, userId: string) {
const m = await this.prisma.membership.findFirst({
where: { organizationId, userId, isActive: true },
select: { id: true },
});
if (!m) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست');
}
private async assertRefs(
organizationId: string,
dto: {
categoryId?: string | null;
leadId?: string | null;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
invoiceId?: string | null;
activityId?: string | null;
},
) {
const checks: Array<[string | null | undefined, () => Promise<number>, string]> = [
[dto.categoryId, () => this.prisma.routineCategory.count({ where: { id: dto.categoryId!, organizationId } }), 'دسته‌بندی'],
[dto.leadId, () => this.prisma.lead.count({ where: { id: dto.leadId!, organizationId } }), 'لید'],
[dto.personId, () => this.prisma.person.count({ where: { id: dto.personId!, organizationId } }), 'شخص'],
[dto.companyId, () => this.prisma.company.count({ where: { id: dto.companyId!, organizationId } }), 'شرکت'],
[dto.dealId, () => this.prisma.deal.count({ where: { id: dto.dealId!, organizationId } }), 'معامله'],
[dto.invoiceId, () => this.prisma.invoice.count({ where: { id: dto.invoiceId!, organizationId } }), 'فاکتور'],
[dto.activityId, () => this.prisma.activity.count({ where: { id: dto.activityId!, organizationId } }), 'پیگیری'],
];
for (const [value, count, label] of checks) {
if (value && !(await count())) throw new BadRequestException(`${label} انتخاب‌شده معتبر نیست`);
}
}
}
@@ -0,0 +1,143 @@
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 { RoutinesService } from './routines.service';
import { RoutineStatsService } from './routine-stats.service';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
RoutineRangeQueryDto,
SaveRoutineCategoryDto,
SaveRoutineDto,
ToggleRoutineDto,
} from './dto';
@Controller('routines')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('routines')
export class RoutinesController {
constructor(
private readonly routines: RoutinesService,
private readonly stats: RoutineStatsService,
) {}
@Get('context')
context(@CurrentUser() user: Actor) {
return this.routines.context(user);
}
@Get('categories')
categories(@CurrentUser() user: Actor) {
return this.routines.categories(user.organizationId);
}
@Post('categories')
createCategory(@CurrentUser() user: Actor, @Body() dto: SaveRoutineCategoryDto) {
return this.routines.createCategory(user, dto);
}
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: SaveRoutineCategoryDto,
) {
return this.routines.updateCategory(user, id, dto);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.deleteCategory(user, id);
}
/* ───────── آمار و گزارش ───────── */
@Get('stats/me')
async statsMe(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.performance(await this.routines.scope(user), q);
}
@Get('stats/team')
async statsTeam(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.team(await this.routines.scope(user), q);
}
@Get('stats/weekly')
async statsWeekly(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.weekly(await this.routines.scope(user), q);
}
@Get('stats/monthly')
async statsMonthly(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.monthly(await this.routines.scope(user), q);
}
@Get('stats/routines')
async statsRoutines(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.routinesExecution(await this.routines.scope(user), q);
}
@Get('stats/history')
async statsHistory(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.history(await this.routines.scope(user), q);
}
/* ───────── روتین‌ها ───────── */
@Get()
list(@CurrentUser() user: Actor, @Query() q: ListRoutinesQueryDto) {
return this.routines.list(user, q);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: SaveRoutineDto) {
return this.routines.create(user, dto);
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.get(user, id);
}
@Patch(':id')
update(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRoutineDto) {
return this.routines.update(user, id, dto);
}
@Patch(':id/toggle')
toggle(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: ToggleRoutineDto) {
return this.routines.toggle(user, id, dto.isActive);
}
@Patch(':id/assignee')
assignee(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: ChangeRoutineAssigneeDto,
) {
return this.routines.changeAssignee(user, id, dto);
}
@Post(':id/duplicate')
duplicate(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.duplicate(user, id);
}
@Delete(':id')
remove(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.remove(user, id);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { RoutinesController } from './routines.controller';
import { RoutineTasksController } from './routine-tasks.controller';
import { RoutinesService } from './routines.service';
import { RoutineTasksService } from './routine-tasks.service';
import { RoutineStatsService } from './routine-stats.service';
import { RoutineGeneratorService } from './routine-generator.service';
import { RoutineRemindersService } from './routine-reminders.service';
@Module({
imports: [NotificationsModule],
controllers: [RoutinesController, RoutineTasksController],
providers: [
RoutinesService,
RoutineTasksService,
RoutineStatsService,
RoutineGeneratorService,
RoutineRemindersService,
],
exports: [RoutineTasksService],
})
export class RoutinesModule {}
+498
View File
@@ -0,0 +1,498 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
RoutineCategoryDto,
RoutineContextDto,
RoutineDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { elevated } from '../common/record-scope';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
SaveRoutineCategoryDto,
SaveRoutineDto,
} from './dto';
import { RoutineGeneratorService } from './routine-generator.service';
import { routineInclude, toCategoryDto, toRoutineDto } from './routine-mapper';
import {
type RoutineScope,
assertManager,
canManage,
resolveRoutineScope,
} from './routine-scope';
import { addDays, dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates';
const DEFAULT_CATEGORIES: Array<{ name: string; color: string }> = [
{ name: 'فروش', color: '#14b8a6' },
{ name: 'پیگیری', color: '#3b82f6' },
{ name: 'لید', color: '#8b5cf6' },
{ name: 'مشتری', color: '#f59e0b' },
{ name: 'گزارش‌دهی', color: '#f43f5e' },
];
@Injectable()
export class RoutinesService {
constructor(
private readonly prisma: PrismaService,
private readonly generator: RoutineGeneratorService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── دسته‌بندی‌ها ───────── */
async categories(organizationId: string): Promise<RoutineCategoryDto[]> {
let rows = await this.prisma.routineCategory.findMany({
where: { organizationId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
if (!rows.length) {
await this.prisma.routineCategory.createMany({
data: DEFAULT_CATEGORIES.map((c, i) => ({ organizationId, ...c, sortOrder: i })),
skipDuplicates: true,
});
rows = await this.prisma.routineCategory.findMany({
where: { organizationId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
return rows.map(toCategoryDto);
}
async createCategory(actor: Actor, dto: SaveRoutineCategoryDto) {
assertManager(await this.scope(actor));
try {
const row = await this.prisma.routineCategory.create({
data: {
organizationId: actor.organizationId,
name: dto.name.trim(),
color: dto.color || '#14b8a6',
sortOrder: dto.sortOrder ?? 99,
},
});
return toCategoryDto(row);
} catch {
throw new BadRequestException('دسته‌بندی با این نام وجود دارد');
}
}
async updateCategory(actor: Actor, id: string, dto: SaveRoutineCategoryDto) {
assertManager(await this.scope(actor));
await this.findCategory(actor.organizationId, id);
const row = await this.prisma.routineCategory.update({
where: { id },
data: {
name: dto.name.trim(),
...(dto.color ? { color: dto.color } : {}),
...(dto.sortOrder != null ? { sortOrder: dto.sortOrder } : {}),
},
});
return toCategoryDto(row);
}
async deleteCategory(actor: Actor, id: string) {
assertManager(await this.scope(actor));
await this.findCategory(actor.organizationId, id);
await this.prisma.routineCategory.delete({ where: { id } });
return { ok: true };
}
private async findCategory(organizationId: string, id: string) {
const row = await this.prisma.routineCategory.findFirst({ where: { id, organizationId } });
if (!row) throw new NotFoundException('دسته‌بندی پیدا نشد');
return row;
}
/* ───────── زمینه (کاربران، تیم‌ها) ───────── */
async context(actor: Actor): Promise<RoutineContextDto> {
const scope = await this.scope(actor);
const ids = scope.isManager ? scope.manageableUserIds : scope.viewableUserIds;
const [members, teams, categories] = await Promise.all([
this.prisma.membership.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
user: { isActive: true },
...(ids ? { userId: { in: ids } } : {}),
},
select: { user: { select: { id: true, fullName: true } } },
orderBy: { user: { fullName: 'asc' } },
}),
scope.isManager
? this.prisma.team.findMany({
where: {
organizationId: actor.organizationId,
...(elevated(actor.role)
? {}
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
: Promise.resolve([]),
this.categories(actor.organizationId),
]);
return {
isManager: scope.isManager,
users: members.map((m) => m.user),
teams,
categories,
};
}
/* ───────── روتین‌ها ───────── */
async list(actor: Actor, query: ListRoutinesQueryDto): Promise<RoutineDto[]> {
const scope = await this.scope(actor);
const where: Prisma.RoutineWhereInput = {
organizationId: actor.organizationId,
deletedAt: null,
};
if (query.scope === 'all') {
assertManager(scope);
if (scope.manageableUserIds) {
where.OR = [
{ assigneeId: { in: scope.manageableUserIds } },
{ team: { members: { some: { userId: actor.userId, role: 'manager' } } } },
];
}
} else {
where.OR = [
{ assigneeId: actor.userId },
{ assigneeId: null, team: { members: { some: { userId: actor.userId } } } },
];
where.isActive = true;
}
if (query.q?.trim()) {
where.title = { contains: query.q.trim(), mode: 'insensitive' };
}
const rows = await this.prisma.routine.findMany({
where,
include: routineInclude,
orderBy: [{ isActive: 'desc' }, { timeOfDay: 'asc' }, { createdAt: 'desc' }],
});
const execution = await this.executionMap(
rows.map((r) => r.id),
query.scope === 'all' ? undefined : actor.userId,
);
return rows.map((r) => toRoutineDto(r, execution.get(r.id)));
}
async get(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
const row = await this.findRoutine(actor.organizationId, id);
const isOwnRoutine = row.assigneeId === actor.userId;
if (!isOwnRoutine) assertManager(scope);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({
data: {
...data,
organizationId: actor.organizationId,
createdById: actor.userId,
} as Prisma.RoutineUncheckedCreateInput,
include: routineInclude,
});
await this.generator.generateForDay(tehranDayKey(), { routineId: row.id });
return toRoutineDto(row);
}
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
include: routineInclude,
});
await this.syncTodayTasks(row.id);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
include: routineInclude,
});
await this.syncTodayTasks(row.id);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async changeAssignee(
actor: Actor,
id: string,
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
where: { id },
data: target,
include: routineInclude,
});
await this.generator.generateForDay(tehranDayKey(), { routineId: id });
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src);
const {
id: _id,
createdAt: _c,
updatedAt: _u,
category: _cat,
assignee: _a,
team: _t,
deletedAt: _d,
...rest
} = src;
const row = await this.prisma.routine.create({
data: {
...rest,
title: `${src.title} (کپی)`.slice(0, 200),
isActive: false,
createdById: actor.userId,
},
include: routineInclude,
});
return toRoutineDto(row);
}
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
});
await this.cancelTodayPending(id, 'حذف روتین');
return { ok: true };
}
/* ───────── کمکی ───────── */
/** درصد اجرای روتین در ۳۰ روز اخیر (Taskهای در انتظار امروز حساب نمی‌شوند) */
async executionMap(
routineIds: string[],
assigneeId?: string,
): Promise<Map<string, { total: number; done: number }>> {
const map = new Map<string, { total: number; done: number }>();
if (!routineIds.length) return map;
const today = tehranDayKey();
const groups = await this.prisma.routineTask.groupBy({
by: ['routineId', 'status'],
where: {
routineId: { in: routineIds },
scheduledFor: { gte: dayKeyToDate(addDays(today, -29)), lte: dayKeyToDate(today) },
status: { in: ['completed', 'overdue', 'skipped'] },
...(assigneeId ? { assigneeId } : {}),
},
_count: { _all: true },
});
for (const g of groups) {
if (!g.routineId) continue;
const cur = map.get(g.routineId) ?? { total: 0, done: 0 };
cur.total += g._count._all;
if (g.status === 'completed') cur.done += g._count._all;
map.set(g.routineId, cur);
}
return map;
}
private async findRoutine(organizationId: string, id: string) {
const row = await this.prisma.routine.findFirst({
where: { id, organizationId, deletedAt: null },
include: routineInclude,
});
if (!row) throw new NotFoundException('روتین پیدا نشد');
return row;
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.manageableUserIds === null) return;
if (row.assigneeId && canManage(scope, row.assigneeId)) return;
if (row.createdById === scope.userId) return;
throw new ForbiddenException('اجازه مدیریت این روتین را ندارید');
}
private async resolveTarget(
actor: Actor,
scope: RoutineScope,
assigneeId?: string | null,
teamId?: string | null,
): Promise<{ assigneeId: string | null; teamId: string | null }> {
if (assigneeId) {
if (!canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: assigneeId, isActive: true },
select: { id: true },
});
if (!member) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست');
return { assigneeId, teamId: null };
}
if (teamId) {
const team = await this.prisma.team.findFirst({
where: {
id: teamId,
organizationId: actor.organizationId,
...(elevated(actor.role)
? {}
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
},
select: { id: true },
});
if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست');
return { assigneeId: null, teamId };
}
throw new BadRequestException('مسئول یا تیم روتین را انتخاب کنید');
}
private async buildData(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto) {
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
if (dto.categoryId) await this.findCategory(actor.organizationId, dto.categoryId);
if (dto.endDate && dto.endDate < dto.startDate) {
throw new BadRequestException('تاریخ پایان نباید قبل از تاریخ شروع باشد');
}
const weekdays = [...new Set(dto.weekdays ?? [])].sort((a, b) => a - b);
const specificDates = [...new Set(dto.specificDates ?? [])].sort();
if (['weekdays', 'biweekly'].includes(dto.recurrence) && !weekdays.length) {
throw new BadRequestException('حداقل یک روز هفته را انتخاب کنید');
}
if (dto.recurrence === 'every_n_days' && !dto.intervalDays) {
throw new BadRequestException('فاصله تکرار (روز) را وارد کنید');
}
if (dto.recurrence === 'specific_dates' && !specificDates.length) {
throw new BadRequestException('حداقل یک تاریخ انتخاب کنید');
}
return {
title: dto.title.trim(),
description: dto.description?.trim() || null,
...target,
categoryId: dto.categoryId || null,
priority: dto.priority ?? 'medium',
timeOfDay: dto.timeOfDay,
durationMinutes: dto.durationMinutes ?? null,
startDate: dayKeyToDate(dto.startDate),
endDate: dto.endDate ? dayKeyToDate(dto.endDate) : null,
recurrence: dto.recurrence,
weekdays,
intervalDays: dto.recurrence === 'every_n_days' ? dto.intervalDays ?? 1 : null,
monthDay: dto.recurrence === 'monthly' ? dto.monthDay ?? null : null,
specificDates: dto.recurrence === 'specific_dates' ? specificDates.map(dayKeyToDate) : [],
isMandatory: dto.isMandatory ?? true,
allowSkip: dto.allowSkip ?? false,
skipReasonRequired: dto.skipReasonRequired ?? true,
reportRequired: dto.reportRequired ?? false,
allowAfterDeadline: dto.allowAfterDeadline ?? true,
carryOverOverdue: dto.carryOverOverdue ?? false,
entityLink: dto.entityLink ?? 'none',
reminderMinutesBefore: dto.reminderMinutesBefore ?? null,
isActive: dto.isActive ?? true,
};
}
/** همگام کردن Task امروزِ هنوز شروع‌نشده با تغییرات روتین */
private async syncTodayTasks(routineId: string) {
const routine = await this.prisma.routine.findUnique({ where: { id: routineId } });
if (!routine) return;
if (!routine.isActive || routine.deletedAt) {
await this.cancelTodayPending(routineId, 'غیرفعال شدن روتین');
return;
}
const today = tehranDayKey();
const pending = await this.prisma.routineTask.findMany({
where: { routineId, scheduledFor: dayKeyToDate(today), status: 'todo' },
select: { id: true },
});
if (pending.length) {
const dueAt = tehranDateTime(today, routine.timeOfDay);
await this.prisma.routineTask.updateMany({
where: { id: { in: pending.map((p) => p.id) } },
data: {
title: routine.title,
description: routine.description,
categoryId: routine.categoryId,
priority: routine.priority,
isMandatory: routine.isMandatory,
dueAt,
...(routine.reminderMinutesBefore != null
? {
reminderAt: new Date(dueAt.getTime() - routine.reminderMinutesBefore * 60_000),
reminderSentAt: null,
}
: {}),
},
});
}
await this.generator.generateForDay(today, { routineId });
}
private async cancelTodayPending(routineId: string, note: string) {
const rows = await this.prisma.routineTask.findMany({
where: {
routineId,
scheduledFor: dayKeyToDate(tehranDayKey()),
status: { in: ['todo', 'in_progress'] },
},
select: { id: true, organizationId: true, status: true },
});
if (!rows.length) return;
await this.prisma.$transaction([
this.prisma.routineTask.updateMany({
where: { id: { in: rows.map((r) => r.id) } },
data: { status: 'cancelled' },
}),
this.prisma.routineTaskEvent.createMany({
data: rows.map((r) => ({
organizationId: r.organizationId,
taskId: r.id,
type: 'status_changed',
fromStatus: r.status,
toStatus: 'cancelled' as const,
note,
})),
}),
]);
}
}