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:
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user