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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-06 19:54:38 +03:30
co-authored by Cursor
parent 3de2d0fa67
commit b3784818fc
58 changed files with 13018 additions and 18 deletions
+311
View File
@@ -0,0 +1,311 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
const RECURRENCES = [
'daily',
'weekdays',
'weekly',
'biweekly',
'monthly',
'every_n_days',
'specific_dates',
] as const;
const SOURCES = ['routine', 'manual', 'follow_up', 'lead', 'customer', 'deal'] as const;
const STATUSES = ['todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled'] as const;
const ENTITY_LINKS = ['none', 'optional', 'lead', 'customer'] as const;
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
const nullable = () => ValidateIf((_, v) => v !== '' && v != null);
export class SaveRoutineCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
}
export class SaveRoutineDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional()
@nullable()
@IsString()
assigneeId?: string | null;
@IsOptional()
@nullable()
@IsString()
teamId?: string | null;
@IsOptional()
@nullable()
@IsString()
categoryId?: string | null;
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@Matches(TIME)
timeOfDay!: string;
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(24 * 60)
durationMinutes?: number | null;
@Matches(DAY_KEY)
startDate!: string;
@IsOptional()
@nullable()
@Matches(DAY_KEY)
endDate?: string | null;
@IsIn(RECURRENCES)
recurrence!: (typeof RECURRENCES)[number];
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
weekdays?: number[];
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
intervalDays?: number | null;
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(31)
monthDay?: number | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(366)
@Matches(DAY_KEY, { each: true })
specificDates?: string[];
@IsOptional() @IsBoolean() isMandatory?: boolean;
@IsOptional() @IsBoolean() allowSkip?: boolean;
@IsOptional() @IsBoolean() skipReasonRequired?: boolean;
@IsOptional() @IsBoolean() reportRequired?: boolean;
@IsOptional() @IsBoolean() allowAfterDeadline?: boolean;
@IsOptional() @IsBoolean() carryOverOverdue?: boolean;
@IsOptional()
@IsIn(ENTITY_LINKS)
entityLink?: (typeof ENTITY_LINKS)[number];
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(24 * 60)
reminderMinutesBefore?: number | null;
@IsOptional() @IsBoolean() isActive?: boolean;
}
export class ToggleRoutineDto {
@IsBoolean()
isActive!: boolean;
}
export class ChangeRoutineAssigneeDto {
@IsOptional()
@nullable()
@IsString()
assigneeId?: string | null;
@IsOptional()
@nullable()
@IsString()
teamId?: string | null;
}
export class CreateRoutineTaskDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional()
@nullable()
@IsDateString()
dueAt?: string | null;
@IsOptional()
@nullable()
@Matches(DAY_KEY)
scheduledFor?: string | null;
@IsOptional()
@IsIn(PRIORITIES)
priority?: (typeof PRIORITIES)[number];
@IsOptional() @nullable() @IsString() categoryId?: string | null;
@IsOptional() @nullable() @IsString() assigneeId?: string | null;
@IsOptional() @IsBoolean() isMandatory?: boolean;
@IsOptional()
@IsIn(SOURCES)
source?: (typeof SOURCES)[number];
@IsOptional() @nullable() @IsString() leadId?: string | null;
@IsOptional() @nullable() @IsString() personId?: string | null;
@IsOptional() @nullable() @IsString() companyId?: string | null;
@IsOptional() @nullable() @IsString() dealId?: string | null;
@IsOptional() @nullable() @IsString() invoiceId?: string | null;
@IsOptional() @nullable() @IsString() activityId?: string | null;
}
export class UpdateRoutineTaskDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsOptional() @nullable() @IsDateString() dueAt?: string | null;
@IsOptional() @nullable() @Matches(DAY_KEY) scheduledFor?: string | null;
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
@IsOptional() @nullable() @IsString() categoryId?: string | null;
@IsOptional() @nullable() @IsString() assigneeId?: string | null;
@IsOptional() @IsBoolean() isMandatory?: boolean;
@IsOptional() @nullable() @IsString() leadId?: string | null;
@IsOptional() @nullable() @IsString() personId?: string | null;
@IsOptional() @nullable() @IsString() companyId?: string | null;
@IsOptional() @nullable() @IsString() dealId?: string | null;
@IsOptional() @nullable() @IsString() invoiceId?: string | null;
}
export class CompleteRoutineTaskDto {
@IsOptional()
@nullable()
@IsString()
@MaxLength(2000)
note?: string | null;
}
export class SkipRoutineTaskDto {
@IsOptional()
@nullable()
@IsString()
@MaxLength(1000)
reason?: string | null;
}
export class RescheduleRoutineTaskDto {
@IsDateString()
dueAt!: string;
}
export class RoutineTaskQueryDto {
@IsOptional() @IsString() userId?: string;
@IsOptional() @Matches(DAY_KEY) date?: string;
}
export class RoutineRangeQueryDto {
@IsOptional() @Matches(DAY_KEY) from?: string;
@IsOptional() @Matches(DAY_KEY) to?: string;
@IsOptional() @IsString() userId?: string;
@IsOptional() @IsString() teamId?: string;
@IsOptional() @IsString() categoryId?: string;
@IsOptional() @IsIn(SOURCES) source?: (typeof SOURCES)[number];
@IsOptional() @IsIn(STATUSES) status?: (typeof STATUSES)[number];
@IsOptional() @IsIn(['me', 'team']) view?: 'me' | 'team';
}
export class RoutineEntityQueryDto {
@IsOptional() @IsString() leadId?: string;
@IsOptional() @IsString() personId?: string;
@IsOptional() @IsString() companyId?: string;
@IsOptional() @IsString() dealId?: string;
}
export class ListRoutinesQueryDto {
@IsOptional() @IsIn(['mine', 'all']) scope?: 'mine' | 'all';
@IsOptional() @IsString() q?: string;
}
export class RoutineUserSettingsUpdateDto {
@IsOptional()
@nullable()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(24 * 60)
remindBeforeMinutes?: number | null;
@IsOptional() @IsBoolean() endOfDaySummary?: boolean;
@IsOptional() @IsBoolean() overdueAlert?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
summaryHour?: number;
}
+101
View File
@@ -0,0 +1,101 @@
import type { RoutineRecurrence } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import {
addDays,
dayDiff,
jalaliDayOfMonth,
persianWeekday,
toFaDigits,
weekStartKey,
} from './routine-dates';
export type RecurrenceRule = {
recurrence: RoutineRecurrence;
startDate: string;
endDate: string | null;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: string[];
};
function weeklyDays(rule: RecurrenceRule): number[] {
return rule.weekdays.length ? rule.weekdays : [persianWeekday(rule.startDate)];
}
export function occursOn(rule: RecurrenceRule, day: string): boolean {
if (day < rule.startDate) return false;
if (rule.endDate && day > rule.endDate) return false;
switch (rule.recurrence) {
case 'daily':
return true;
case 'weekdays':
case 'weekly':
return weeklyDays(rule).includes(persianWeekday(day));
case 'biweekly': {
if (!weeklyDays(rule).includes(persianWeekday(day))) return false;
const weeks = Math.floor(
dayDiff(weekStartKey(rule.startDate), weekStartKey(day)) / 7,
);
return weeks % 2 === 0;
}
case 'monthly': {
const target = rule.monthDay ?? jalaliDayOfMonth(rule.startDate);
const current = jalaliDayOfMonth(day);
if (current === target) return true;
// ماه‌هایی که روز هدف را ندارند (مثلاً ۳۱ در مهر): آخرین روز ماه
const isLastDay = jalaliDayOfMonth(addDays(day, 1)) === 1;
return isLastDay && target > current;
}
case 'every_n_days': {
const n = Math.max(1, rule.intervalDays ?? 1);
return dayDiff(rule.startDate, day) % n === 0;
}
case 'specific_dates':
return rule.specificDates.includes(day);
default:
return false;
}
}
function joinFa(items: string[]): string {
if (items.length <= 1) return items.join('');
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
}
function describeWeekdays(days: number[]): string {
const sorted = [...new Set(days)].sort((a, b) => a - b);
if (sorted.length === 7) return 'هر روز';
const contiguous =
sorted.length >= 3 &&
sorted.every((d, i) => i === 0 || d === sorted[i - 1]! + 1);
if (contiguous) {
return `${ROUTINE_WEEKDAY_LABELS[sorted[0]!]} تا ${ROUTINE_WEEKDAY_LABELS[sorted[sorted.length - 1]!]}`;
}
return `هر ${joinFa(sorted.map((d) => ROUTINE_WEEKDAY_LABELS[d]!))}`;
}
export function describeRecurrence(rule: RecurrenceRule): string {
switch (rule.recurrence) {
case 'daily':
return 'هر روز';
case 'weekdays':
case 'weekly':
return describeWeekdays(weeklyDays(rule));
case 'biweekly':
return `هر دو هفته، ${joinFa(weeklyDays(rule).map((d) => ROUTINE_WEEKDAY_LABELS[d]!))}`;
case 'monthly': {
const day = rule.monthDay ?? jalaliDayOfMonth(rule.startDate);
return day === 1 ? 'اول هر ماه' : `روز ${toFaDigits(day)} هر ماه`;
}
case 'every_n_days': {
const n = Math.max(1, rule.intervalDays ?? 1);
return n === 1 ? 'هر روز' : `هر ${toFaDigits(n)} روز یک‌بار`;
}
case 'specific_dates':
return `${toFaDigits(rule.specificDates.length)} تاریخ مشخص`;
default:
return '';
}
}
+100
View File
@@ -0,0 +1,100 @@
/** کلید روز (YYYY-MM-DD) و زمان‌ها همگی به وقت تهران (بدون ساعت تابستانی، +03:30). */
export const TEHRAN_OFFSET = '+03:30';
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const HOUR_FMT = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
hourCycle: 'h23',
});
const JALALI_DAY_FMT = new Intl.DateTimeFormat('en-US-u-ca-persian', {
timeZone: 'UTC',
day: 'numeric',
});
const DAY_KEY_RE = /^\d{4}-\d{2}-\d{2}$/;
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
export function isDayKey(value: unknown): value is string {
return typeof value === 'string' && DAY_KEY_RE.test(value);
}
export function isTimeOfDay(value: unknown): value is string {
return typeof value === 'string' && TIME_RE.test(value);
}
export function tehranDayKey(d: Date = new Date()): string {
return DAY_FMT.format(d);
}
export function tehranHour(d: Date = new Date()): number {
return Number(HOUR_FMT.format(d)) % 24;
}
/** مقدار مناسب برای ستون‌های @db.Date */
export function dayKeyToDate(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
}
/** خواندن ستون‌های @db.Date */
export function dateToDayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
export function addDays(key: string, n: number): string {
const d = dayKeyToDate(key);
d.setUTCDate(d.getUTCDate() + n);
return dateToDayKey(d);
}
/** b - a بر حسب روز */
export function dayDiff(a: string, b: string): number {
return Math.round(
(dayKeyToDate(b).getTime() - dayKeyToDate(a).getTime()) / 86_400_000,
);
}
/** ۰ = شنبه … ۶ = جمعه */
export function persianWeekday(key: string): number {
return (dayKeyToDate(key).getUTCDay() + 1) % 7;
}
/** شنبهٔ همان هفته */
export function weekStartKey(key: string): string {
return addDays(key, -persianWeekday(key));
}
export function jalaliDayOfMonth(key: string): number {
return Number(JALALI_DAY_FMT.format(dayKeyToDate(key)));
}
export function tehranDateTime(key: string, hhmm: string): Date {
return new Date(`${key}T${hhmm}:00${TEHRAN_OFFSET}`);
}
export function tehranDayStart(key: string): Date {
return tehranDateTime(key, '00:00');
}
export function eachDay(from: string, to: string): string[] {
const out: string[] = [];
let cur = from;
let guard = 0;
while (cur <= to && guard < 800) {
out.push(cur);
cur = addDays(cur, 1);
guard += 1;
}
return out;
}
export function toFaDigits(value: number | string): string {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
}
@@ -0,0 +1,167 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { occursOn } from './recurrence';
import { routineRule } from './routine-mapper';
import { dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates';
export const DEFAULT_REMIND_MINUTES = 30;
@Injectable()
export class RoutineGeneratorService implements OnApplicationBootstrap {
private readonly logger = new Logger(RoutineGeneratorService.name);
private overdueRunning = false;
constructor(private readonly prisma: PrismaService) {}
onApplicationBootstrap() {
// جبران cron از دست رفته (ری‌استارت سرور حوالی نیمه‌شب)
setTimeout(() => {
void this.generateForDay(tehranDayKey()).catch((err) =>
this.logger.warn(`bootstrap generate failed: ${err instanceof Error ? err.message : err}`),
);
void this.markOverdue();
}, 8000);
}
@Cron('5 0 * * *', { timeZone: 'Asia/Tehran' })
async generateToday(): Promise<void> {
try {
const count = await this.generateForDay(tehranDayKey());
if (count) this.logger.log(`generated ${count} routine tasks`);
} catch (err) {
this.logger.warn(`daily generate failed: ${err instanceof Error ? err.message : err}`);
}
}
/** ساخت Taskهای یک روز برای روتین‌های فعال (idempotent). */
async generateForDay(
day: string,
filter: { organizationId?: string; routineId?: string } = {},
): Promise<number> {
const date = dayKeyToDate(day);
const routines = await this.prisma.routine.findMany({
where: {
isActive: true,
deletedAt: null,
startDate: { lte: date },
OR: [{ endDate: null }, { endDate: { gte: date } }],
...(filter.organizationId ? { organizationId: filter.organizationId } : {}),
...(filter.routineId ? { id: filter.routineId } : {}),
},
include: { team: { select: { members: { select: { userId: true } } } } },
});
if (!routines.length) return 0;
const candidates = new Set<string>();
const planned: Array<{ routine: (typeof routines)[number]; userIds: string[] }> = [];
for (const routine of routines) {
if (!occursOn(routineRule(routine), day)) continue;
const userIds = routine.assigneeId
? [routine.assigneeId]
: [...new Set(routine.team?.members.map((m) => m.userId) ?? [])];
if (!userIds.length) continue;
userIds.forEach((id) => candidates.add(`${routine.organizationId}:${id}`));
planned.push({ routine, userIds });
}
if (!planned.length) return 0;
const memberships = await this.prisma.membership.findMany({
where: {
isActive: true,
userId: { in: [...candidates].map((c) => c.split(':')[1]!) },
organizationId: { in: [...new Set(planned.map((p) => p.routine.organizationId))] },
user: { isActive: true },
},
select: { userId: true, organizationId: true },
});
const active = new Set(memberships.map((m) => `${m.organizationId}:${m.userId}`));
const offsets = await this.reminderOffsets(memberships);
const rows: Prisma.RoutineTaskCreateManyInput[] = [];
for (const { routine, userIds } of planned) {
const dueAt = tehranDateTime(day, routine.timeOfDay);
for (const assigneeId of userIds) {
const key = `${routine.organizationId}:${assigneeId}`;
if (!active.has(key)) continue;
const minutes = routine.reminderMinutesBefore ?? offsets.get(key) ?? null;
const reminderAt = minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null;
rows.push({
organizationId: routine.organizationId,
routineId: routine.id,
assigneeId,
title: routine.title,
description: routine.description,
categoryId: routine.categoryId,
priority: routine.priority,
scheduledFor: date,
dueAt,
reminderAt,
source: 'routine',
isMandatory: routine.isMandatory,
createdById: routine.createdById,
});
}
}
if (!rows.length) return 0;
const result = await this.prisma.routineTask.createMany({ data: rows, skipDuplicates: true });
return result.count;
}
/** دقیقه‌های یادآور پیش‌فرض هر کاربر (تنظیمات شخصی؛ بدون تنظیم = ۳۰) */
async reminderOffsets(
pairs: Array<{ organizationId: string; userId: string }>,
): Promise<Map<string, number | null>> {
const map = new Map<string, number | null>();
for (const p of pairs) map.set(`${p.organizationId}:${p.userId}`, DEFAULT_REMIND_MINUTES);
if (!pairs.length) return map;
const settings = await this.prisma.routineUserSettings.findMany({
where: { userId: { in: [...new Set(pairs.map((p) => p.userId))] } },
select: { organizationId: true, userId: true, remindBeforeMinutes: true },
});
for (const s of settings) {
const key = `${s.organizationId}:${s.userId}`;
if (map.has(key)) map.set(key, s.remindBeforeMinutes);
}
return map;
}
@Cron('*/5 * * * *')
async markOverdue(): Promise<void> {
if (this.overdueRunning) return;
this.overdueRunning = true;
try {
const now = new Date();
for (let i = 0; i < 20; i += 1) {
const due = await this.prisma.routineTask.findMany({
where: { status: { in: ['todo', 'in_progress'] }, dueAt: { lt: now } },
select: { id: true, organizationId: true, status: true },
take: 500,
});
if (!due.length) break;
await this.prisma.$transaction([
this.prisma.routineTask.updateMany({
where: { id: { in: due.map((t) => t.id) }, status: { in: ['todo', 'in_progress'] } },
data: { status: 'overdue' },
}),
this.prisma.routineTaskEvent.createMany({
data: due.map((t) => ({
organizationId: t.organizationId,
taskId: t.id,
type: 'status_changed',
fromStatus: t.status,
toStatus: 'overdue' as const,
note: 'مهلت انجام گذشت',
})),
}),
]);
if (due.length < 500) break;
}
} catch (err) {
this.logger.warn(`overdue run failed: ${err instanceof Error ? err.message : err}`);
} finally {
this.overdueRunning = false;
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import type {
RoutineCategoryDto,
RoutineDto,
RoutineEntityLink,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskEventDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { describeRecurrence } from './recurrence';
import { dateToDayKey } from './routine-dates';
const userSelect = { select: { id: true, fullName: true } } as const;
export const routineTaskInclude = {
category: true,
assignee: userSelect,
createdBy: userSelect,
routine: {
select: {
id: true,
allowSkip: true,
skipReasonRequired: true,
reportRequired: true,
allowAfterDeadline: true,
entityLink: true,
},
},
lead: { select: { id: true, title: true, contactName: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
invoice: { select: { id: true, number: true, title: true } },
activity: { select: { id: true, title: true } },
} satisfies Prisma.RoutineTaskInclude;
export type RoutineTaskRow = Prisma.RoutineTaskGetPayload<{
include: typeof routineTaskInclude;
}>;
export const routineInclude = {
category: true,
assignee: userSelect,
team: { select: { id: true, name: true } },
} satisfies Prisma.RoutineInclude;
export type RoutineRow = Prisma.RoutineGetPayload<{ include: typeof routineInclude }>;
export function toCategoryDto(row: {
id: string;
name: string;
color: string;
sortOrder: number;
}): RoutineCategoryDto {
return { id: row.id, name: row.name, color: row.color, sortOrder: row.sortOrder };
}
function normalizeEntityLink(value: string | null | undefined): RoutineEntityLink {
return value === 'optional' || value === 'lead' || value === 'customer' ? value : 'none';
}
export function toTaskDto(row: RoutineTaskRow): RoutineTaskDto {
const entities: RoutineEntityRefDto[] = [];
if (row.lead) {
entities.push({ type: 'lead', id: row.lead.id, label: row.lead.title || row.lead.contactName || 'لید' });
}
if (row.person) {
entities.push({
type: 'person',
id: row.person.id,
label: `${row.person.firstName} ${row.person.lastName}`.trim(),
});
}
if (row.company) entities.push({ type: 'company', id: row.company.id, label: row.company.name });
if (row.deal) entities.push({ type: 'deal', id: row.deal.id, label: row.deal.title });
if (row.invoice) {
entities.push({
type: 'invoice',
id: row.invoice.id,
label: row.invoice.title || `فاکتور ${row.invoice.number}`,
});
}
if (row.activity) entities.push({ type: 'activity', id: row.activity.id, label: row.activity.title });
const r = row.routine;
return {
id: row.id,
routineId: row.routineId,
title: row.title,
description: row.description,
status: row.status,
priority: row.priority,
category: row.category ? toCategoryDto(row.category) : null,
scheduledFor: dateToDayKey(row.scheduledFor),
dueAt: row.dueAt?.toISOString() ?? null,
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
resultNote: row.resultNote,
skipReason: row.skipReason,
source: row.source,
isMandatory: row.isMandatory,
rules: {
allowSkip: r ? r.allowSkip : true,
skipReasonRequired: r ? r.skipReasonRequired : false,
reportRequired: r ? r.reportRequired : false,
allowAfterDeadline: r ? r.allowAfterDeadline : true,
entityLink: normalizeEntityLink(r?.entityLink ?? 'optional'),
},
assignee: row.assignee,
createdBy: row.createdBy,
entities,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function toEventDto(row: {
id: string;
type: string;
fromStatus: RoutineTaskDto['status'] | null;
toStatus: RoutineTaskDto['status'] | null;
note: string | null;
createdAt: Date;
actor: { id: string; fullName: string } | null;
}): RoutineTaskEventDto {
return {
id: row.id,
type: row.type as RoutineTaskEventDto['type'],
fromStatus: row.fromStatus,
toStatus: row.toStatus,
note: row.note,
actor: row.actor,
createdAt: row.createdAt.toISOString(),
};
}
export function routineRule(row: {
recurrence: RoutineDto['recurrence'];
startDate: Date;
endDate: Date | null;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: Date[];
}) {
return {
recurrence: row.recurrence,
startDate: dateToDayKey(row.startDate),
endDate: row.endDate ? dateToDayKey(row.endDate) : null,
weekdays: row.weekdays,
intervalDays: row.intervalDays,
monthDay: row.monthDay,
specificDates: row.specificDates.map(dateToDayKey),
};
}
export function toRoutineDto(
row: RoutineRow,
execution: { total: number; done: number } = { total: 0, done: 0 },
): RoutineDto {
const rule = routineRule(row);
return {
id: row.id,
title: row.title,
description: row.description,
assignee: row.assignee,
team: row.team,
category: row.category ? toCategoryDto(row.category) : null,
priority: row.priority,
timeOfDay: row.timeOfDay,
durationMinutes: row.durationMinutes,
startDate: rule.startDate,
endDate: rule.endDate,
recurrence: row.recurrence,
weekdays: row.weekdays,
intervalDays: row.intervalDays,
monthDay: row.monthDay,
specificDates: rule.specificDates,
isMandatory: row.isMandatory,
allowSkip: row.allowSkip,
skipReasonRequired: row.skipReasonRequired,
reportRequired: row.reportRequired,
allowAfterDeadline: row.allowAfterDeadline,
carryOverOverdue: row.carryOverOverdue,
entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
executionDone: execution.done,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { dayKeyToDate, tehranDayKey, tehranHour, toFaDigits } from './routine-dates';
const TIME_FMT = new Intl.DateTimeFormat('fa-IR', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
@Injectable()
export class RoutineRemindersService {
private readonly logger = new Logger(RoutineRemindersService.name);
private running = false;
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
@Cron('* * * * *')
async run(): Promise<void> {
if (this.running) return;
this.running = true;
try {
await this.sendDueReminders();
await this.sendOverdueAlerts();
await this.sendDaySummaries();
} catch (err) {
this.logger.warn(`routine reminders failed: ${err instanceof Error ? err.message : err}`);
} finally {
this.running = false;
}
}
/** یادآور قبل از مهلت («یک Task شما ۳۰ دقیقه دیگر Deadline دارد») */
private async sendDueReminders() {
const now = new Date();
const due = await this.prisma.routineTask.findMany({
where: {
status: { in: ['todo', 'in_progress'] },
reminderAt: { lte: now },
reminderSentAt: null,
},
select: { id: true, organizationId: true, assigneeId: true, title: true, dueAt: true, reminderAt: true },
take: 300,
});
for (const task of due) {
const claimed = await this.prisma.routineTask.updateMany({
where: { id: task.id, reminderSentAt: null },
data: { reminderSentAt: now },
});
if (!claimed.count) continue;
// یادآورهای خیلی قدیمی (قطعی سرور) ارسال نمی‌شوند
if (task.reminderAt && now.getTime() - task.reminderAt.getTime() > 2 * 60 * 60_000) continue;
const minutes = task.dueAt ? Math.max(0, Math.round((task.dueAt.getTime() - now.getTime()) / 60_000)) : null;
await this.notifications.create({
organizationId: task.organizationId,
userId: task.assigneeId,
type: 'routine_task_reminder',
title:
minutes != null && minutes > 0
? `یک کار شما ${toFaDigits(minutes)} دقیقه دیگر مهلت دارد`
: 'یادآوری کار روتین',
body: task.dueAt ? `${task.title} — ساعت ${TIME_FMT.format(task.dueAt)}` : task.title,
link: `/routines?task=${task.id}`,
entityType: 'routine_task',
entityId: task.id,
});
}
}
/** هشدار Task اجباری که مهلتش گذشته («… هنوز انجام نشده») */
private async sendOverdueAlerts() {
const rows = await this.prisma.routineTask.findMany({
where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null },
select: { id: true, organizationId: true, assigneeId: true, title: true },
take: 300,
});
if (!rows.length) return;
const settings = await this.prisma.routineUserSettings.findMany({
where: { userId: { in: [...new Set(rows.map((r) => r.assigneeId))] }, overdueAlert: false },
select: { organizationId: true, userId: true },
});
const muted = new Set(settings.map((s) => `${s.organizationId}:${s.userId}`));
const now = new Date();
for (const task of rows) {
const claimed = await this.prisma.routineTask.updateMany({
where: { id: task.id, overdueNotifiedAt: null },
data: { overdueNotifiedAt: now },
});
if (!claimed.count || muted.has(`${task.organizationId}:${task.assigneeId}`)) continue;
await this.notifications.create({
organizationId: task.organizationId,
userId: task.assigneeId,
type: 'routine_task_overdue',
title: `کار «${task.title}» هنوز انجام نشده`,
body: 'مهلت این کار اجباری گذشته است.',
link: `/routines/overdue?task=${task.id}`,
entityType: 'routine_task',
entityId: task.id,
});
}
}
/** خلاصه پایان روز («۳ کار تا پایان امروز باقی مانده است») */
private async sendDaySummaries() {
const hour = tehranHour();
const today = tehranDayKey();
const todayDate = dayKeyToDate(today);
const groups = await this.prisma.routineTask.groupBy({
by: ['organizationId', 'assigneeId'],
where: { scheduledFor: todayDate, status: { in: ['todo', 'in_progress'] } },
_count: { _all: true },
});
if (!groups.length) return;
const settings = await this.prisma.routineUserSettings.findMany({
where: { userId: { in: [...new Set(groups.map((g) => g.assigneeId))] } },
});
const byKey = new Map(settings.map((s) => [`${s.organizationId}:${s.userId}`, s]));
for (const g of groups) {
const key = `${g.organizationId}:${g.assigneeId}`;
const s = byKey.get(key);
if (s && !s.endOfDaySummary) continue;
if (hour < (s?.summaryHour ?? 16)) continue;
if (s?.lastSummaryOn && s.lastSummaryOn.getTime() >= todayDate.getTime()) continue;
await this.prisma.routineUserSettings.upsert({
where: { organizationId_userId: { organizationId: g.organizationId, userId: g.assigneeId } },
create: { organizationId: g.organizationId, userId: g.assigneeId, lastSummaryOn: todayDate },
update: { lastSummaryOn: todayDate },
});
await this.notifications.create({
organizationId: g.organizationId,
userId: g.assigneeId,
type: 'routine_day_summary',
title: `${toFaDigits(g._count._all)} کار تا پایان امروز باقی مانده است`,
body: 'برای تکمیل روز، کارهای باقی‌مانده را انجام دهید.',
link: '/routines',
entityType: 'routine_day',
entityId: `${g.assigneeId}:${today}`,
});
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ForbiddenException } from '@nestjs/common';
import { type Actor, elevated, scopedOwnerIds } from '../common/record-scope';
import type { PrismaService } from '../prisma/prisma.service';
export type RoutineScope = {
userId: string;
organizationId: string;
/** مالک/مدیر سازمان یا مدیر حداقل یک تیم */
isManager: boolean;
/** کاربرانی که می‌تواند برایشان روتین/Task تعریف کند؛ null = همه */
manageableUserIds: string[] | null;
/** کاربرانی که Taskهایشان را می‌بیند؛ null = همه */
viewableUserIds: string[] | null;
};
export async function resolveRoutineScope(
prisma: PrismaService,
actor: Actor,
): Promise<RoutineScope> {
const base = {
userId: actor.userId,
organizationId: actor.organizationId,
};
if (elevated(actor.role)) {
return { ...base, isManager: true, manageableUserIds: null, viewableUserIds: null };
}
const managed = await prisma.teamMember.findMany({
where: { userId: actor.userId, role: 'manager', team: { organizationId: actor.organizationId } },
select: { teamId: true },
});
let manageable: string[] = [actor.userId];
if (managed.length) {
const members = await prisma.teamMember.findMany({
where: { teamId: { in: managed.map((m) => m.teamId) } },
select: { userId: true },
});
manageable = [...new Set([actor.userId, ...members.map((m) => m.userId)])];
}
const visible = scopedOwnerIds(actor, 'routines');
const viewable = visible === null ? null : [...new Set([...visible, ...manageable])];
return {
...base,
isManager: managed.length > 0,
manageableUserIds: manageable,
viewableUserIds: viewable,
};
}
export function canView(scope: RoutineScope, userId: string): boolean {
return scope.viewableUserIds === null || scope.viewableUserIds.includes(userId);
}
export function canManage(scope: RoutineScope, userId: string): boolean {
return scope.manageableUserIds === null || scope.manageableUserIds.includes(userId);
}
export function assertManager(scope: RoutineScope): void {
if (!scope.isManager) {
throw new ForbiddenException('فقط مدیران به این بخش دسترسی دارند');
}
}
/** فیلتر Prisma روی assigneeId بر اساس دید کاربر و فیلتر کاربر درخواستی */
export function assigneeFilter(
scope: RoutineScope,
requestedUserId?: string | null,
): { assigneeId?: string | { in: string[] } } {
if (requestedUserId) {
if (!canView(scope, requestedUserId)) {
throw new ForbiddenException('دسترسی به کارهای این کاربر ندارید');
}
return { assigneeId: requestedUserId };
}
if (scope.viewableUserIds === null) return {};
return { assigneeId: { in: scope.viewableUserIds } };
}
@@ -0,0 +1,565 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type {
RoutineCalendarDayDto,
RoutineCategoryStatDto,
RoutineDayState,
RoutineExecutionRowDto,
RoutineHistoryDayDto,
RoutineMemberStatDto,
RoutineMonthlyReportDto,
RoutinePerformanceDto,
RoutinePeriodTotalsDto,
RoutineScoreDto,
RoutineStatusBreakdownDto,
RoutineTaskStatus,
RoutineTeamPerformanceDto,
RoutineTrendPointDto,
RoutineWeeklyReportDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { RoutineRangeQueryDto } from './dto';
import { describeRecurrence } from './recurrence';
import { routineRule } from './routine-mapper';
import { type RoutineScope, assertManager, canView } from './routine-scope';
import {
addDays,
dateToDayKey,
dayDiff,
dayKeyToDate,
eachDay,
jalaliDayOfMonth,
tehranDayKey,
weekStartKey,
} from './routine-dates';
type StatTask = {
assigneeId: string;
scheduledFor: Date;
status: RoutineTaskStatus;
isMandatory: boolean;
categoryId: string | null;
routineId: string | null;
source: string;
dueAt: Date | null;
completedAt: Date | null;
createdAt: Date;
};
type Counts = {
total: number;
completed: number;
overdue: number;
skipped: number;
cancelled: number;
pending: number;
};
const statSelect = {
assigneeId: true,
scheduledFor: true,
status: true,
isMandatory: true,
categoryId: true,
routineId: true,
source: true,
dueAt: true,
completedAt: true,
createdAt: true,
} satisfies Prisma.RoutineTaskSelect;
function emptyCounts(): Counts {
return { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 };
}
function addTo(c: Counts, status: RoutineTaskStatus, n = 1) {
c.total += n;
if (status === 'completed') c.completed += n;
else if (status === 'overdue') c.overdue += n;
else if (status === 'skipped') c.skipped += n;
else if (status === 'cancelled') c.cancelled += n;
else c.pending += n;
}
/** Taskهای لغو و رد شده در درصد حساب نمی‌شوند */
export function effectiveTotal(c: Counts): number {
return c.total - c.cancelled - c.skipped;
}
export function percentOf(c: Counts): number | null {
const eff = effectiveTotal(c);
return eff > 0 ? Math.round((c.completed / eff) * 100) : null;
}
export function dayState(c: Counts, day: string, today: string): RoutineDayState {
const eff = effectiveTotal(c);
if (eff <= 0 || day > today) return 'empty';
if (c.completed >= eff) return 'complete';
if (c.completed === 0 && day < today) return 'missed';
return 'partial';
}
export function jalaliMonthStart(day: string): string {
return addDays(day, -(jalaliDayOfMonth(day) - 1));
}
@Injectable()
export class RoutineStatsService {
constructor(private readonly prisma: PrismaService) {}
/* ───────── امتیاز روزانه و Streak ───────── */
async score(organizationId: string, userId: string, day = tehranDayKey()): Promise<RoutineScoreDto> {
const monthStart = jalaliMonthStart(day);
const weekStart = weekStartKey(day);
const from = [monthStart, weekStart, addDays(day, -1)].sort()[0]!;
const tasks = await this.prisma.routineTask.findMany({
where: {
organizationId,
assigneeId: userId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) },
},
select: { scheduledFor: true, status: true },
});
const range = (a: string, b: string) => {
const c = emptyCounts();
for (const t of tasks) {
const k = dateToDayKey(t.scheduledFor);
if (k >= a && k <= b) addTo(c, t.status);
}
return percentOf(c);
};
const yesterday = addDays(day, -1);
return {
today: range(day, day),
yesterday: range(yesterday, yesterday),
week: range(weekStart, day),
month: range(monthStart, day),
streak: (await this.streaks(organizationId, [userId], day)).get(userId) ?? 0,
};
}
/** روزهای متوالی که همه Taskهای اجباری انجام (یا مجاز رد) شده‌اند؛ روز بدون Task اجباری خنثی است */
async streaks(
organizationId: string,
userIds: string[],
today = tehranDayKey(),
): Promise<Map<string, number>> {
const out = new Map<string, number>();
if (!userIds.length) return out;
const groups = await this.prisma.routineTask.groupBy({
by: ['assigneeId', 'scheduledFor', 'status'],
where: {
organizationId,
assigneeId: { in: userIds },
isMandatory: true,
status: { not: 'cancelled' },
scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) },
},
_count: { _all: true },
});
const byUser = new Map<string, Map<string, { total: number; ok: number }>>();
for (const g of groups) {
const days = byUser.get(g.assigneeId) ?? new Map();
const key = dateToDayKey(g.scheduledFor);
const cur = days.get(key) ?? { total: 0, ok: 0 };
cur.total += g._count._all;
if (g.status === 'completed' || g.status === 'skipped') cur.ok += g._count._all;
days.set(key, cur);
byUser.set(g.assigneeId, days);
}
for (const userId of userIds) {
const days = byUser.get(userId);
let streak = 0;
if (days) {
const keys = [...days.keys()].sort().reverse();
for (const key of keys) {
const d = days.get(key)!;
const complete = d.ok >= d.total;
if (key === today) {
if (complete) streak += 1;
continue;
}
if (!complete) break;
streak += 1;
}
}
out.set(userId, streak);
}
return out;
}
/* ───────── خلاصه روزها (تقویم/تاریخچه) ───────── */
summarizeDays(tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, from: string, to: string) {
const today = tehranDayKey();
const map = new Map<string, Counts>();
for (const t of tasks) {
const k = dateToDayKey(t.scheduledFor);
const c = map.get(k) ?? emptyCounts();
addTo(c, t.status);
map.set(k, c);
}
return eachDay(from, to).map((date) => {
const c = map.get(date) ?? emptyCounts();
return { date, counts: c, state: dayState(c, date, today), percent: percentOf(c) };
});
}
toCalendarDays(
tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>,
from: string,
to: string,
): RoutineCalendarDayDto[] {
return this.summarizeDays(tasks, from, to).map(({ date, counts, state, percent }) => ({
date,
total: effectiveTotal(counts),
completed: counts.completed,
overdue: counts.overdue,
percent,
state,
}));
}
/* ───────── گزارش‌ها ───────── */
async performance(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutinePerformanceDto> {
const userId = q.userId || scope.userId;
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
const { from, to } = this.range(q, 29);
const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]);
const agg = await this.aggregate(scope.organizationId, tasks, from, to);
const streak = (await this.streaks(scope.organizationId, [userId])).get(userId) ?? 0;
return {
from,
to,
total: agg.effTotal,
completed: agg.counts.completed,
notDone: agg.status.notDone,
overdue: agg.counts.overdue,
percent: percentOf(agg.counts),
avgCompletionMinutes: this.avgCompletion(tasks),
completeDays: agg.days.complete,
partialDays: agg.days.partial,
missedDays: agg.days.missed,
streak,
status: agg.status,
trend: agg.trend,
byCategory: agg.byCategory,
};
}
async team(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineTeamPerformanceDto> {
assertManager(scope);
const { from, to } = this.range(q, 29);
const users = await this.teamUsers(scope, q);
const tasks = await this.fetch(scope.organizationId, from, to, q, users.map((u) => u.id));
const agg = await this.aggregate(scope.organizationId, tasks, from, to);
return {
from,
to,
total: agg.effTotal,
completed: agg.counts.completed,
overdue: agg.counts.overdue,
percent: percentOf(agg.counts),
status: agg.status,
trend: agg.trend,
byCategory: agg.byCategory,
members: await this.memberStats(scope.organizationId, users, tasks),
};
}
async weekly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineWeeklyReportDto> {
const from = weekStartKey(q.from || tehranDayKey());
const to = addDays(from, 6);
const prevFrom = addDays(from, -7);
const userIds = await this.viewUsers(scope, q);
const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds);
const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from);
const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from);
return {
from,
to,
days: this.toCalendarDays(current, from, to),
totals: this.periodTotals(current, from, to),
previous: this.periodTotals(previous, prevFrom, addDays(from, -1)),
};
}
async monthly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineMonthlyReportDto> {
const from = q.from || jalaliMonthStart(tehranDayKey());
const to = q.to || addDays(jalaliMonthStart(addDays(from, 32)), -1);
const length = dayDiff(from, to) + 1;
const prevTo = addDays(from, -1);
const prevFrom = addDays(from, -length);
const userIds = await this.viewUsers(scope, q);
const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds);
const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from);
const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from);
const status = this.statusBreakdown(current);
let byUser: RoutineMemberStatDto[] = [];
if (q.view === 'team') {
const users = await this.teamUsers(scope, q);
byUser = await this.memberStats(scope.organizationId, users, current);
}
return {
from,
to,
totals: this.periodTotals(current, from, to),
previous: this.periodTotals(previous, prevFrom, prevTo),
status,
days: this.toCalendarDays(current, from, to),
byUser,
};
}
async routinesExecution(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineExecutionRowDto[]> {
assertManager(scope);
const { from, to } = this.range(q, 29);
const users = await this.teamUsers(scope, q);
const where: Prisma.RoutineTaskWhereInput = {
organizationId: scope.organizationId,
routineId: { not: null },
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
assigneeId: { in: users.map((u) => u.id) },
status: { notIn: ['cancelled'] },
...(q.categoryId ? { categoryId: q.categoryId } : {}),
};
const groups = await this.prisma.routineTask.groupBy({
by: ['routineId', 'status'],
where,
_count: { _all: true },
});
const map = new Map<string, Counts>();
for (const g of groups) {
if (!g.routineId) continue;
const c = map.get(g.routineId) ?? emptyCounts();
addTo(c, g.status, g._count._all);
map.set(g.routineId, c);
}
if (!map.size) return [];
const routines = await this.prisma.routine.findMany({
where: { id: { in: [...map.keys()] } },
include: { assignee: { select: { fullName: true } }, team: { select: { name: true } } },
});
return routines
.map((r) => {
const c = map.get(r.id)!;
const resolved = c.completed + c.overdue + c.skipped;
return {
routineId: r.id,
title: r.deletedAt ? `${r.title} (حذف‌شده)` : r.title,
assigneeLabel: r.assignee?.fullName ?? r.team?.name ?? '—',
recurrenceLabel: describeRecurrence(routineRule(r)),
occurrences: c.total,
completed: c.completed,
notDone: c.overdue + c.pending,
skipped: c.skipped,
percent: resolved ? Math.round((c.completed / resolved) * 100) : null,
};
})
.sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101));
}
async history(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineHistoryDayDto[]> {
const userId = q.userId || scope.userId;
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
const { from, to } = this.range(q, 59);
const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]);
return this.summarizeDays(tasks, from, to)
.filter((d) => d.counts.total > 0)
.reverse()
.map(({ date, counts, state, percent }) => ({
date,
total: counts.total,
completed: counts.completed,
overdue: counts.overdue,
skipped: counts.skipped,
cancelled: counts.cancelled,
percent,
state,
}));
}
/* ───────── کمکی ───────── */
private range(q: { from?: string; to?: string }, defaultDays: number) {
const to = q.to || tehranDayKey();
let from = q.from || addDays(to, -defaultDays);
if (from > to) from = to;
if (dayDiff(from, to) > 400) from = addDays(to, -400);
return { from, to };
}
private async viewUsers(scope: RoutineScope, q: RoutineRangeQueryDto) {
if (q.view === 'team') return (await this.teamUsers(scope, q)).map((u) => u.id);
const userId = q.userId || scope.userId;
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
return [userId];
}
async teamUsers(scope: RoutineScope, q: { userId?: string; teamId?: string }) {
assertManager(scope);
let ids = scope.manageableUserIds;
if (q.teamId) {
const members = await this.prisma.teamMember.findMany({
where: { teamId: q.teamId, team: { organizationId: scope.organizationId } },
select: { userId: true },
});
const teamIds = members.map((m) => m.userId);
ids = ids ? ids.filter((id) => teamIds.includes(id)) : teamIds;
}
if (q.userId) {
ids = ids ? ids.filter((id) => id === q.userId) : [q.userId];
}
const rows = await this.prisma.membership.findMany({
where: {
organizationId: scope.organizationId,
isActive: true,
user: { isActive: true },
...(ids ? { userId: { in: ids } } : {}),
},
select: { user: { select: { id: true, fullName: true } } },
orderBy: { user: { fullName: 'asc' } },
});
return rows.map((r) => r.user);
}
private fetch(
organizationId: string,
from: string,
to: string,
q: RoutineRangeQueryDto,
userIds: string[],
): Promise<StatTask[]> {
return this.prisma.routineTask.findMany({
where: {
organizationId,
assigneeId: { in: userIds },
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.source ? { source: q.source } : {}),
...(q.status ? { status: q.status } : {}),
},
select: statSelect,
});
}
private statusBreakdown(tasks: StatTask[]): RoutineStatusBreakdownDto & { notDone: number } {
const c = emptyCounts();
for (const t of tasks) addTo(c, t.status);
return {
completed: c.completed,
notDone: c.pending + c.overdue,
overdue: c.overdue,
skipped: c.skipped,
cancelled: c.cancelled,
};
}
private periodTotals(tasks: StatTask[], from: string, to: string): RoutinePeriodTotalsDto {
const c = emptyCounts();
for (const t of tasks) addTo(c, t.status);
const days = this.summarizeDays(tasks, from, to);
const percents = days.map((d) => d.percent).filter((p): p is number => p != null);
return {
total: effectiveTotal(c),
completed: c.completed,
notDone: c.pending + c.overdue,
overdue: c.overdue,
percent: percentOf(c),
completeDays: days.filter((d) => d.state === 'complete').length,
partialDays: days.filter((d) => d.state === 'partial').length,
missedDays: days.filter((d) => d.state === 'missed').length,
avgPercent: percents.length
? Math.round(percents.reduce((s, p) => s + p, 0) / percents.length)
: null,
};
}
private async aggregate(organizationId: string, tasks: StatTask[], from: string, to: string) {
const counts = emptyCounts();
for (const t of tasks) addTo(counts, t.status);
const days = this.summarizeDays(tasks, from, to);
const trend: RoutineTrendPointDto[] = days.map((d) => ({
date: d.date,
total: effectiveTotal(d.counts),
completed: d.counts.completed,
percent: d.percent,
}));
const catCounts = new Map<string | null, Counts>();
for (const t of tasks) {
const c = catCounts.get(t.categoryId) ?? emptyCounts();
addTo(c, t.status);
catCounts.set(t.categoryId, c);
}
const catIds = [...catCounts.keys()].filter((id): id is string => !!id);
const cats = catIds.length
? await this.prisma.routineCategory.findMany({
where: { organizationId, id: { in: catIds } },
})
: [];
const byCategory: RoutineCategoryStatDto[] = [...catCounts.entries()]
.map(([id, c]) => {
const cat = cats.find((x) => x.id === id);
return {
categoryId: id,
name: cat?.name ?? 'بدون دسته',
color: cat?.color ?? '#94a3b8',
total: effectiveTotal(c),
completed: c.completed,
percent: percentOf(c),
};
})
.sort((a, b) => b.total - a.total);
return {
counts,
effTotal: effectiveTotal(counts),
status: this.statusBreakdown(tasks),
trend,
byCategory,
days: {
complete: days.filter((d) => d.state === 'complete').length,
partial: days.filter((d) => d.state === 'partial').length,
missed: days.filter((d) => d.state === 'missed').length,
},
};
}
private async memberStats(
organizationId: string,
users: Array<{ id: string; fullName: string }>,
tasks: StatTask[],
): Promise<RoutineMemberStatDto[]> {
const streaks = await this.streaks(organizationId, users.map((u) => u.id));
return users.map((user) => {
const own = tasks.filter((t) => t.assigneeId === user.id);
const c = emptyCounts();
for (const t of own) addTo(c, t.status);
return {
user,
total: effectiveTotal(c),
completed: c.completed,
overdue: c.overdue,
skipped: c.skipped,
percent: percentOf(c),
streak: streaks.get(user.id) ?? 0,
avgCompletionMinutes: this.avgCompletion(own),
};
});
}
/** میانگین فاصله انجام از زمان برنامه (روتین: ساعت برنامه، بقیه: زمان ثبت) */
private avgCompletion(tasks: StatTask[]): number | null {
const values: number[] = [];
for (const t of tasks) {
if (t.status !== 'completed' || !t.completedAt) continue;
const ref = t.source === 'routine' && t.dueAt ? t.dueAt : t.createdAt;
values.push(Math.max(0, (t.completedAt.getTime() - ref.getTime()) / 60_000));
}
if (!values.length) return null;
return Math.round(values.reduce((s, v) => s + v, 0) / values.length);
}
}
@@ -0,0 +1,129 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { RoutineTasksService } from './routine-tasks.service';
import {
CompleteRoutineTaskDto,
CreateRoutineTaskDto,
RescheduleRoutineTaskDto,
RoutineEntityQueryDto,
RoutineRangeQueryDto,
RoutineTaskQueryDto,
RoutineUserSettingsUpdateDto,
SkipRoutineTaskDto,
UpdateRoutineTaskDto,
} from './dto';
@Controller('routine-tasks')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('routines')
export class RoutineTasksController {
constructor(private readonly tasks: RoutineTasksService) {}
@Get('today')
today(@CurrentUser() user: Actor, @Query() q: RoutineTaskQueryDto) {
return this.tasks.today(user, q.userId, q.date);
}
@Get('badge')
badge(@CurrentUser() user: Actor) {
return this.tasks.badge(user);
}
@Get('overdue')
overdue(@CurrentUser() user: Actor, @Query() q: RoutineTaskQueryDto) {
return this.tasks.overdue(user, q.userId);
}
@Get('calendar')
calendar(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.tasks.calendar(user, q);
}
@Get('by-entity')
byEntity(@CurrentUser() user: Actor, @Query() q: RoutineEntityQueryDto) {
return this.tasks.byEntity(user, q);
}
@Get('settings')
settings(@CurrentUser() user: Actor) {
return this.tasks.getSettings(user);
}
@Put('settings')
updateSettings(@CurrentUser() user: Actor, @Body() dto: RoutineUserSettingsUpdateDto) {
return this.tasks.updateSettings(user, dto);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateRoutineTaskDto) {
return this.tasks.create(user, dto);
}
@Post('from-activity/:activityId')
fromActivity(@CurrentUser() user: Actor, @Param('activityId') activityId: string) {
return this.tasks.createFromActivity(user, activityId);
}
@Get(':id')
detail(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.detail(user, id);
}
@Patch(':id')
update(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: UpdateRoutineTaskDto) {
return this.tasks.update(user, id, dto);
}
@Post(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: CompleteRoutineTaskDto) {
return this.tasks.complete(user, id, dto.note);
}
@Post(':id/uncomplete')
uncomplete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.uncomplete(user, id);
}
@Post(':id/start')
start(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.start(user, id);
}
@Post(':id/skip')
skip(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SkipRoutineTaskDto) {
return this.tasks.skip(user, id, dto.reason);
}
@Post(':id/reschedule')
reschedule(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: RescheduleRoutineTaskDto,
) {
return this.tasks.reschedule(user, id, dto.dueAt);
}
@Post(':id/move-today')
moveToday(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.tasks.moveToToday(user, id);
}
@Post(':id/cancel')
cancel(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SkipRoutineTaskDto) {
return this.tasks.cancel(user, id, dto.reason);
}
}
@@ -0,0 +1,609 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
RoutineCalendarDto,
RoutineTaskDetailDto,
RoutineTaskDto,
RoutineTaskSource,
RoutineTaskStatus,
RoutineTodayDto,
RoutineUserSettingsDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import type { Actor } from '../common/record-scope';
import type {
CreateRoutineTaskDto,
RoutineEntityQueryDto,
RoutineRangeQueryDto,
RoutineUserSettingsUpdateDto,
UpdateRoutineTaskDto,
} from './dto';
import {
type RoutineScope,
assigneeFilter,
canManage,
canView,
resolveRoutineScope,
} from './routine-scope';
import {
type RoutineTaskRow,
routineTaskInclude,
toEventDto,
toTaskDto,
} from './routine-mapper';
import { DEFAULT_REMIND_MINUTES } from './routine-generator.service';
import { RoutineStatsService, effectiveTotal } from './routine-stats.service';
import {
addDays,
dayKeyToDate,
isDayKey,
tehranDateTime,
tehranDayKey,
} from './routine-dates';
const OPEN_STATUSES: RoutineTaskStatus[] = ['todo', 'in_progress', 'overdue'];
@Injectable()
export class RoutineTasksService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly stats: RoutineStatsService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── خواندن ───────── */
async today(actor: Actor, userId?: string, date?: string): Promise<RoutineTodayDto> {
const scope = await this.scope(actor);
const target = userId || actor.userId;
if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید');
const day = date && isDayKey(date) ? date : tehranDayKey();
const dayDate = dayKeyToDate(day);
const [todayRows, overdueRows, score] = await Promise.all([
this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
assigneeId: target,
scheduledFor: dayDate,
status: { not: 'cancelled' },
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }],
}),
this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
assigneeId: target,
status: 'overdue',
scheduledFor: { lt: dayDate },
},
include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } },
orderBy: [{ dueAt: 'asc' }],
take: 100,
}),
this.stats.score(actor.organizationId, target, day),
]);
const carried = overdueRows.filter((r) => r.routine?.carryOverOverdue);
const overdue = overdueRows.filter((r) => !r.routine?.carryOverOverdue);
const counts = { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 };
for (const r of todayRows) {
counts.total += 1;
if (r.status === 'completed') counts.completed += 1;
else if (r.status === 'skipped') counts.skipped += 1;
else if (r.status === 'overdue') counts.overdue += 1;
else counts.pending += 1;
}
const eff = effectiveTotal(counts);
return {
date: day,
tasks: [...todayRows, ...carried].map((r) => toTaskDto(r as RoutineTaskRow)),
overdue: overdue.map((r) => toTaskDto(r as RoutineTaskRow)),
stats: {
total: eff,
completed: counts.completed,
remaining: counts.pending,
overdue: counts.overdue + overdueRows.length,
percent: eff > 0 ? Math.round((counts.completed / eff) * 100) : null,
},
score,
};
}
/** شمارش کارهای باقی‌مانده امروز برای Badge منو */
async badge(actor: Actor): Promise<{ remaining: number; overdue: number }> {
const day = dayKeyToDate(tehranDayKey());
const [remaining, overdue] = await Promise.all([
this.prisma.routineTask.count({
where: {
organizationId: actor.organizationId,
assigneeId: actor.userId,
scheduledFor: day,
status: { in: ['todo', 'in_progress'] },
},
}),
this.prisma.routineTask.count({
where: { organizationId: actor.organizationId, assigneeId: actor.userId, status: 'overdue' },
}),
]);
return { remaining, overdue };
}
async overdue(actor: Actor, userId?: string): Promise<RoutineTaskDto[]> {
const scope = await this.scope(actor);
const filter =
userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId);
const rows = await this.prisma.routineTask.findMany({
where: { organizationId: actor.organizationId, status: 'overdue', ...filter },
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }],
take: 300,
});
return rows.map(toTaskDto);
}
async calendar(actor: Actor, q: RoutineRangeQueryDto): Promise<RoutineCalendarDto> {
const scope = await this.scope(actor);
const to = q.to || tehranDayKey();
let from = q.from || addDays(to, -30);
if (from > to) from = to;
const filter = assigneeFilter(scope, q.userId || actor.userId);
const where: Prisma.RoutineTaskWhereInput = {
organizationId: actor.organizationId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...filter,
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.status ? { status: q.status } : {}),
...(q.source ? { source: q.source } : {}),
};
const rows = await this.prisma.routineTask.findMany({
where,
include: routineTaskInclude,
orderBy: [{ scheduledFor: 'asc' }, { dueAt: 'asc' }],
take: 3000,
});
return {
days: this.stats.toCalendarDays(rows, from, to),
tasks: rows.map(toTaskDto),
};
}
async byEntity(actor: Actor, q: RoutineEntityQueryDto): Promise<RoutineTaskDto[]> {
const or: Prisma.RoutineTaskWhereInput[] = [];
if (q.leadId) or.push({ leadId: q.leadId });
if (q.personId) or.push({ personId: q.personId });
if (q.companyId) or.push({ companyId: q.companyId });
if (q.dealId) or.push({ dealId: q.dealId });
if (!or.length) return [];
const scope = await this.scope(actor);
const rows = await this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
OR: or,
status: { not: 'cancelled' },
...assigneeFilter(scope),
},
include: routineTaskInclude,
orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }],
take: 100,
});
return rows.map(toTaskDto);
}
async detail(actor: Actor, id: string): Promise<RoutineTaskDetailDto> {
const scope = await this.scope(actor);
const row = await this.findTask(actor.organizationId, id);
if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید');
const events = await this.prisma.routineTaskEvent.findMany({
where: { taskId: id },
include: { actor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'asc' },
});
return { ...toTaskDto(row), events: events.map(toEventDto) };
}
/* ───────── ایجاد / ویرایش ───────── */
async create(actor: Actor, dto: CreateRoutineTaskDto): Promise<RoutineTaskDto> {
const scope = await this.scope(actor);
const assigneeId = dto.assigneeId || actor.userId;
if (assigneeId !== actor.userId && !canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه ثبت کار برای این کاربر را ندارید');
}
await this.assertMember(actor.organizationId, assigneeId);
await this.assertRefs(actor.organizationId, dto);
const { scheduledFor, dueAt } = this.resolveWhen(dto.dueAt, dto.scheduledFor);
const source: RoutineTaskSource =
dto.source ??
(dto.activityId
? 'follow_up'
: dto.leadId
? 'lead'
: dto.personId || dto.companyId
? 'customer'
: dto.dealId
? 'deal'
: 'manual');
const reminderAt = dto.dueAt ? await this.defaultReminder(actor.organizationId, assigneeId, dueAt) : null;
const row = await this.prisma.routineTask.create({
data: {
organizationId: actor.organizationId,
assigneeId,
title: dto.title.trim(),
description: dto.description?.trim() || null,
priority: dto.priority ?? 'medium',
categoryId: dto.categoryId || null,
isMandatory: dto.isMandatory ?? false,
source,
scheduledFor: dayKeyToDate(scheduledFor),
dueAt,
reminderAt,
status: dueAt.getTime() < Date.now() ? 'overdue' : 'todo',
leadId: dto.leadId || null,
personId: dto.personId || null,
companyId: dto.companyId || null,
dealId: dto.dealId || null,
invoiceId: dto.invoiceId || null,
activityId: dto.activityId || null,
createdById: actor.userId,
events: {
create: {
organizationId: actor.organizationId,
actorId: actor.userId,
type: 'created',
toStatus: 'todo',
},
},
},
include: routineTaskInclude,
});
if (assigneeId !== actor.userId) {
await this.notifications.create({
organizationId: actor.organizationId,
userId: assigneeId,
type: 'routine_task_assigned',
title: 'کار جدید برای شما ثبت شد',
body: row.title,
link: `/routines?task=${row.id}`,
entityType: 'routine_task',
entityId: row.id,
});
}
return toTaskDto(row);
}
async createFromActivity(actor: Actor, activityId: string): Promise<RoutineTaskDto> {
const activity = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
description: true,
dueAt: true,
priority: true,
personId: true,
companyId: true,
dealId: true,
assigneeId: true,
},
});
if (!activity) throw new NotFoundException('پیگیری پیدا نشد');
const existing = await this.prisma.routineTask.findFirst({
where: { organizationId: actor.organizationId, activityId, status: { not: 'cancelled' } },
include: routineTaskInclude,
});
if (existing) return toTaskDto(existing);
const scope = await this.scope(actor);
const assigneeId =
activity.assigneeId && canManage(scope, activity.assigneeId)
? activity.assigneeId
: actor.userId;
return this.create(actor, {
title: activity.title,
description: activity.description,
dueAt: activity.dueAt?.toISOString() ?? null,
priority: activity.priority,
assigneeId,
personId: activity.personId,
companyId: activity.companyId,
dealId: activity.dealId,
activityId: activity.id,
source: 'follow_up',
});
}
async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise<RoutineTaskDto> {
const { scope, row } = await this.loadForEdit(actor, id);
await this.assertRefs(actor.organizationId, dto);
const data: Prisma.RoutineTaskUncheckedUpdateInput = {};
const events: Array<{ type: string; note?: string }> = [];
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
if (dto.priority) data.priority = dto.priority;
if (dto.categoryId !== undefined) data.categoryId = dto.categoryId || null;
if (dto.isMandatory !== undefined) data.isMandatory = dto.isMandatory;
for (const key of ['leadId', 'personId', 'companyId', 'dealId', 'invoiceId'] as const) {
if (dto[key] !== undefined) data[key] = dto[key] || null;
}
if (dto.assigneeId && dto.assigneeId !== row.assigneeId) {
if (!canManage(scope, dto.assigneeId)) {
throw new ForbiddenException('اجازه ارجاع به این کاربر را ندارید');
}
await this.assertMember(actor.organizationId, dto.assigneeId);
data.assigneeId = dto.assigneeId;
events.push({ type: 'reassigned' });
}
if (dto.dueAt !== undefined || dto.scheduledFor !== undefined) {
const when = this.resolveWhen(dto.dueAt, dto.scheduledFor);
data.scheduledFor = dayKeyToDate(when.scheduledFor);
data.dueAt = when.dueAt;
data.reminderAt = await this.defaultReminder(
actor.organizationId,
(data.assigneeId as string) ?? row.assigneeId,
when.dueAt,
);
data.reminderSentAt = null;
if (row.status === 'overdue' && when.dueAt.getTime() > Date.now()) data.status = 'todo';
events.push({ type: 'rescheduled' });
}
if (!events.length) events.push({ type: 'updated' });
const updated = await this.saveWithEvents(actor, row, data, events);
return toTaskDto(updated);
}
/* ───────── عملیات وضعیت ───────── */
async complete(actor: Actor, id: string, note?: string | null) {
const { row } = await this.loadForEdit(actor, id);
if (row.status === 'completed') {
if (!note?.trim()) return toTaskDto(row);
const updated = await this.saveWithEvents(actor, row, { resultNote: note.trim() }, [
{ type: 'note', note: note.trim() },
]);
return toTaskDto(updated);
}
const rules = toTaskDto(row).rules;
if (row.status === 'cancelled') throw new BadRequestException('کار لغو شده قابل انجام نیست');
if (row.status === 'overdue' && !rules.allowAfterDeadline) {
throw new BadRequestException('انجام این کار بعد از مهلت مجاز نیست');
}
if (rules.reportRequired && !note?.trim()) {
throw new BadRequestException('ثبت گزارش/نتیجه برای این کار الزامی است');
}
const updated = await this.saveWithEvents(
actor,
row,
{ status: 'completed', completedAt: new Date(), resultNote: note?.trim() || row.resultNote },
[{ type: 'status_changed', note: note?.trim() || undefined }],
);
return toTaskDto(updated);
}
async uncomplete(actor: Actor, id: string) {
const { row } = await this.loadForEdit(actor, id);
if (!['completed', 'skipped', 'cancelled'].includes(row.status)) return toTaskDto(row);
const status: RoutineTaskStatus =
row.dueAt && row.dueAt.getTime() < Date.now() ? 'overdue' : 'todo';
const updated = await this.saveWithEvents(
actor,
row,
{ status, completedAt: null, skipReason: null },
[{ type: 'status_changed' }],
);
return toTaskDto(updated);
}
async start(actor: Actor, id: string) {
const { row } = await this.loadForEdit(actor, id);
if (row.status !== 'todo') return toTaskDto(row);
const updated = await this.saveWithEvents(
actor,
row,
{ status: 'in_progress', startedAt: new Date() },
[{ type: 'status_changed' }],
);
return toTaskDto(updated);
}
async skip(actor: Actor, id: string, reason?: string | null) {
const { row } = await this.loadForEdit(actor, id);
const rules = toTaskDto(row).rules;
if (!OPEN_STATUSES.includes(row.status)) throw new BadRequestException('این کار باز نیست');
if (!rules.allowSkip) throw new BadRequestException('رد کردن این کار مجاز نیست');
if (rules.skipReasonRequired && !reason?.trim()) {
throw new BadRequestException('ثبت دلیل رد کردن الزامی است');
}
const updated = await this.saveWithEvents(
actor,
row,
{ status: 'skipped', skipReason: reason?.trim() || null },
[{ type: 'status_changed', note: reason?.trim() || undefined }],
);
return toTaskDto(updated);
}
async cancel(actor: Actor, id: string, reason?: string | null) {
const { row } = await this.loadForEdit(actor, id);
if (row.status === 'cancelled') return toTaskDto(row);
const updated = await this.saveWithEvents(actor, row, { status: 'cancelled' }, [
{ type: 'status_changed', note: reason?.trim() || undefined },
]);
return toTaskDto(updated);
}
async reschedule(actor: Actor, id: string, dueAtIso: string) {
return this.update(actor, id, { dueAt: dueAtIso });
}
async moveToToday(actor: Actor, id: string) {
const { row } = await this.loadForEdit(actor, id);
const today = tehranDayKey();
const time = row.dueAt
? new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(row.dueAt)
: '23:59';
let dueAt = tehranDateTime(today, time);
if (dueAt.getTime() < Date.now()) dueAt = tehranDateTime(today, '23:59');
return this.update(actor, id, { dueAt: dueAt.toISOString() });
}
/* ───────── تنظیمات اعلان ───────── */
async getSettings(actor: Actor): Promise<RoutineUserSettingsDto> {
const row = await this.prisma.routineUserSettings.findUnique({
where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } },
});
return {
remindBeforeMinutes: row ? row.remindBeforeMinutes : DEFAULT_REMIND_MINUTES,
endOfDaySummary: row?.endOfDaySummary ?? true,
overdueAlert: row?.overdueAlert ?? true,
summaryHour: row?.summaryHour ?? 16,
};
}
async updateSettings(actor: Actor, dto: RoutineUserSettingsUpdateDto) {
const data = {
...(dto.remindBeforeMinutes !== undefined ? { remindBeforeMinutes: dto.remindBeforeMinutes } : {}),
...(dto.endOfDaySummary !== undefined ? { endOfDaySummary: dto.endOfDaySummary } : {}),
...(dto.overdueAlert !== undefined ? { overdueAlert: dto.overdueAlert } : {}),
...(dto.summaryHour !== undefined ? { summaryHour: dto.summaryHour } : {}),
};
await this.prisma.routineUserSettings.upsert({
where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } },
create: { organizationId: actor.organizationId, userId: actor.userId, ...data },
update: data,
});
return this.getSettings(actor);
}
/* ───────── کمکی ───────── */
private async findTask(organizationId: string, id: string): Promise<RoutineTaskRow> {
const row = await this.prisma.routineTask.findFirst({
where: { id, organizationId },
include: routineTaskInclude,
});
if (!row) throw new NotFoundException('کار پیدا نشد');
return row;
}
private async loadForEdit(actor: Actor, id: string) {
const scope = await this.scope(actor);
const row = await this.findTask(actor.organizationId, id);
if (row.assigneeId !== actor.userId && !canManage(scope, row.assigneeId)) {
throw new ForbiddenException('اجازه تغییر این کار را ندارید');
}
return { scope, row };
}
private async saveWithEvents(
actor: Actor,
row: RoutineTaskRow,
data: Prisma.RoutineTaskUncheckedUpdateInput,
events: Array<{ type: string; note?: string }>,
): Promise<RoutineTaskRow> {
const nextStatus = (data.status as RoutineTaskStatus | undefined) ?? row.status;
try {
const [updated] = await this.prisma.$transaction([
this.prisma.routineTask.update({
where: { id: row.id },
data,
include: routineTaskInclude,
}),
this.prisma.routineTaskEvent.createMany({
data: events.map((e) => ({
organizationId: actor.organizationId,
taskId: row.id,
actorId: actor.userId,
type: e.type,
fromStatus: nextStatus !== row.status ? row.status : null,
toStatus: nextStatus !== row.status ? nextStatus : null,
note: e.note ?? null,
})),
}),
]);
return updated;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new BadRequestException('این روتین برای آن روز کار دیگری دارد');
}
throw err;
}
}
private resolveWhen(dueAtIso?: string | null, scheduledFor?: string | null) {
if (dueAtIso) {
const dueAt = new Date(dueAtIso);
if (Number.isNaN(dueAt.getTime())) throw new BadRequestException('زمان نامعتبر است');
return { dueAt, scheduledFor: tehranDayKey(dueAt) };
}
const day = scheduledFor && isDayKey(scheduledFor) ? scheduledFor : tehranDayKey();
return { dueAt: tehranDateTime(day, '23:59'), scheduledFor: day };
}
private async defaultReminder(organizationId: string, userId: string, dueAt: Date) {
const settings = await this.prisma.routineUserSettings.findUnique({
where: { organizationId_userId: { organizationId, userId } },
select: { remindBeforeMinutes: true },
});
const minutes = settings ? settings.remindBeforeMinutes : DEFAULT_REMIND_MINUTES;
return minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null;
}
private async assertMember(organizationId: string, userId: string) {
const m = await this.prisma.membership.findFirst({
where: { organizationId, userId, isActive: true },
select: { id: true },
});
if (!m) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست');
}
private async assertRefs(
organizationId: string,
dto: {
categoryId?: string | null;
leadId?: string | null;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
invoiceId?: string | null;
activityId?: string | null;
},
) {
const checks: Array<[string | null | undefined, () => Promise<number>, string]> = [
[dto.categoryId, () => this.prisma.routineCategory.count({ where: { id: dto.categoryId!, organizationId } }), 'دسته‌بندی'],
[dto.leadId, () => this.prisma.lead.count({ where: { id: dto.leadId!, organizationId } }), 'لید'],
[dto.personId, () => this.prisma.person.count({ where: { id: dto.personId!, organizationId } }), 'شخص'],
[dto.companyId, () => this.prisma.company.count({ where: { id: dto.companyId!, organizationId } }), 'شرکت'],
[dto.dealId, () => this.prisma.deal.count({ where: { id: dto.dealId!, organizationId } }), 'معامله'],
[dto.invoiceId, () => this.prisma.invoice.count({ where: { id: dto.invoiceId!, organizationId } }), 'فاکتور'],
[dto.activityId, () => this.prisma.activity.count({ where: { id: dto.activityId!, organizationId } }), 'پیگیری'],
];
for (const [value, count, label] of checks) {
if (value && !(await count())) throw new BadRequestException(`${label} انتخاب‌شده معتبر نیست`);
}
}
}
@@ -0,0 +1,143 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { RoutinesService } from './routines.service';
import { RoutineStatsService } from './routine-stats.service';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
RoutineRangeQueryDto,
SaveRoutineCategoryDto,
SaveRoutineDto,
ToggleRoutineDto,
} from './dto';
@Controller('routines')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('routines')
export class RoutinesController {
constructor(
private readonly routines: RoutinesService,
private readonly stats: RoutineStatsService,
) {}
@Get('context')
context(@CurrentUser() user: Actor) {
return this.routines.context(user);
}
@Get('categories')
categories(@CurrentUser() user: Actor) {
return this.routines.categories(user.organizationId);
}
@Post('categories')
createCategory(@CurrentUser() user: Actor, @Body() dto: SaveRoutineCategoryDto) {
return this.routines.createCategory(user, dto);
}
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: SaveRoutineCategoryDto,
) {
return this.routines.updateCategory(user, id, dto);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.deleteCategory(user, id);
}
/* ───────── آمار و گزارش ───────── */
@Get('stats/me')
async statsMe(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.performance(await this.routines.scope(user), q);
}
@Get('stats/team')
async statsTeam(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.team(await this.routines.scope(user), q);
}
@Get('stats/weekly')
async statsWeekly(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.weekly(await this.routines.scope(user), q);
}
@Get('stats/monthly')
async statsMonthly(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.monthly(await this.routines.scope(user), q);
}
@Get('stats/routines')
async statsRoutines(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.routinesExecution(await this.routines.scope(user), q);
}
@Get('stats/history')
async statsHistory(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) {
return this.stats.history(await this.routines.scope(user), q);
}
/* ───────── روتین‌ها ───────── */
@Get()
list(@CurrentUser() user: Actor, @Query() q: ListRoutinesQueryDto) {
return this.routines.list(user, q);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: SaveRoutineDto) {
return this.routines.create(user, dto);
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.get(user, id);
}
@Patch(':id')
update(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRoutineDto) {
return this.routines.update(user, id, dto);
}
@Patch(':id/toggle')
toggle(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: ToggleRoutineDto) {
return this.routines.toggle(user, id, dto.isActive);
}
@Patch(':id/assignee')
assignee(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: ChangeRoutineAssigneeDto,
) {
return this.routines.changeAssignee(user, id, dto);
}
@Post(':id/duplicate')
duplicate(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.duplicate(user, id);
}
@Delete(':id')
remove(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.routines.remove(user, id);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { RoutinesController } from './routines.controller';
import { RoutineTasksController } from './routine-tasks.controller';
import { RoutinesService } from './routines.service';
import { RoutineTasksService } from './routine-tasks.service';
import { RoutineStatsService } from './routine-stats.service';
import { RoutineGeneratorService } from './routine-generator.service';
import { RoutineRemindersService } from './routine-reminders.service';
@Module({
imports: [NotificationsModule],
controllers: [RoutinesController, RoutineTasksController],
providers: [
RoutinesService,
RoutineTasksService,
RoutineStatsService,
RoutineGeneratorService,
RoutineRemindersService,
],
exports: [RoutineTasksService],
})
export class RoutinesModule {}
+498
View File
@@ -0,0 +1,498 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
RoutineCategoryDto,
RoutineContextDto,
RoutineDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { elevated } from '../common/record-scope';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
SaveRoutineCategoryDto,
SaveRoutineDto,
} from './dto';
import { RoutineGeneratorService } from './routine-generator.service';
import { routineInclude, toCategoryDto, toRoutineDto } from './routine-mapper';
import {
type RoutineScope,
assertManager,
canManage,
resolveRoutineScope,
} from './routine-scope';
import { addDays, dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates';
const DEFAULT_CATEGORIES: Array<{ name: string; color: string }> = [
{ name: 'فروش', color: '#14b8a6' },
{ name: 'پیگیری', color: '#3b82f6' },
{ name: 'لید', color: '#8b5cf6' },
{ name: 'مشتری', color: '#f59e0b' },
{ name: 'گزارش‌دهی', color: '#f43f5e' },
];
@Injectable()
export class RoutinesService {
constructor(
private readonly prisma: PrismaService,
private readonly generator: RoutineGeneratorService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── دسته‌بندی‌ها ───────── */
async categories(organizationId: string): Promise<RoutineCategoryDto[]> {
let rows = await this.prisma.routineCategory.findMany({
where: { organizationId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
if (!rows.length) {
await this.prisma.routineCategory.createMany({
data: DEFAULT_CATEGORIES.map((c, i) => ({ organizationId, ...c, sortOrder: i })),
skipDuplicates: true,
});
rows = await this.prisma.routineCategory.findMany({
where: { organizationId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
return rows.map(toCategoryDto);
}
async createCategory(actor: Actor, dto: SaveRoutineCategoryDto) {
assertManager(await this.scope(actor));
try {
const row = await this.prisma.routineCategory.create({
data: {
organizationId: actor.organizationId,
name: dto.name.trim(),
color: dto.color || '#14b8a6',
sortOrder: dto.sortOrder ?? 99,
},
});
return toCategoryDto(row);
} catch {
throw new BadRequestException('دسته‌بندی با این نام وجود دارد');
}
}
async updateCategory(actor: Actor, id: string, dto: SaveRoutineCategoryDto) {
assertManager(await this.scope(actor));
await this.findCategory(actor.organizationId, id);
const row = await this.prisma.routineCategory.update({
where: { id },
data: {
name: dto.name.trim(),
...(dto.color ? { color: dto.color } : {}),
...(dto.sortOrder != null ? { sortOrder: dto.sortOrder } : {}),
},
});
return toCategoryDto(row);
}
async deleteCategory(actor: Actor, id: string) {
assertManager(await this.scope(actor));
await this.findCategory(actor.organizationId, id);
await this.prisma.routineCategory.delete({ where: { id } });
return { ok: true };
}
private async findCategory(organizationId: string, id: string) {
const row = await this.prisma.routineCategory.findFirst({ where: { id, organizationId } });
if (!row) throw new NotFoundException('دسته‌بندی پیدا نشد');
return row;
}
/* ───────── زمینه (کاربران، تیم‌ها) ───────── */
async context(actor: Actor): Promise<RoutineContextDto> {
const scope = await this.scope(actor);
const ids = scope.isManager ? scope.manageableUserIds : scope.viewableUserIds;
const [members, teams, categories] = await Promise.all([
this.prisma.membership.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
user: { isActive: true },
...(ids ? { userId: { in: ids } } : {}),
},
select: { user: { select: { id: true, fullName: true } } },
orderBy: { user: { fullName: 'asc' } },
}),
scope.isManager
? this.prisma.team.findMany({
where: {
organizationId: actor.organizationId,
...(elevated(actor.role)
? {}
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
: Promise.resolve([]),
this.categories(actor.organizationId),
]);
return {
isManager: scope.isManager,
users: members.map((m) => m.user),
teams,
categories,
};
}
/* ───────── روتین‌ها ───────── */
async list(actor: Actor, query: ListRoutinesQueryDto): Promise<RoutineDto[]> {
const scope = await this.scope(actor);
const where: Prisma.RoutineWhereInput = {
organizationId: actor.organizationId,
deletedAt: null,
};
if (query.scope === 'all') {
assertManager(scope);
if (scope.manageableUserIds) {
where.OR = [
{ assigneeId: { in: scope.manageableUserIds } },
{ team: { members: { some: { userId: actor.userId, role: 'manager' } } } },
];
}
} else {
where.OR = [
{ assigneeId: actor.userId },
{ assigneeId: null, team: { members: { some: { userId: actor.userId } } } },
];
where.isActive = true;
}
if (query.q?.trim()) {
where.title = { contains: query.q.trim(), mode: 'insensitive' };
}
const rows = await this.prisma.routine.findMany({
where,
include: routineInclude,
orderBy: [{ isActive: 'desc' }, { timeOfDay: 'asc' }, { createdAt: 'desc' }],
});
const execution = await this.executionMap(
rows.map((r) => r.id),
query.scope === 'all' ? undefined : actor.userId,
);
return rows.map((r) => toRoutineDto(r, execution.get(r.id)));
}
async get(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
const row = await this.findRoutine(actor.organizationId, id);
const isOwnRoutine = row.assigneeId === actor.userId;
if (!isOwnRoutine) assertManager(scope);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({
data: {
...data,
organizationId: actor.organizationId,
createdById: actor.userId,
} as Prisma.RoutineUncheckedCreateInput,
include: routineInclude,
});
await this.generator.generateForDay(tehranDayKey(), { routineId: row.id });
return toRoutineDto(row);
}
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
include: routineInclude,
});
await this.syncTodayTasks(row.id);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
include: routineInclude,
});
await this.syncTodayTasks(row.id);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async changeAssignee(
actor: Actor,
id: string,
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
where: { id },
data: target,
include: routineInclude,
});
await this.generator.generateForDay(tehranDayKey(), { routineId: id });
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src);
const {
id: _id,
createdAt: _c,
updatedAt: _u,
category: _cat,
assignee: _a,
team: _t,
deletedAt: _d,
...rest
} = src;
const row = await this.prisma.routine.create({
data: {
...rest,
title: `${src.title} (کپی)`.slice(0, 200),
isActive: false,
createdById: actor.userId,
},
include: routineInclude,
});
return toRoutineDto(row);
}
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
});
await this.cancelTodayPending(id, 'حذف روتین');
return { ok: true };
}
/* ───────── کمکی ───────── */
/** درصد اجرای روتین در ۳۰ روز اخیر (Taskهای در انتظار امروز حساب نمی‌شوند) */
async executionMap(
routineIds: string[],
assigneeId?: string,
): Promise<Map<string, { total: number; done: number }>> {
const map = new Map<string, { total: number; done: number }>();
if (!routineIds.length) return map;
const today = tehranDayKey();
const groups = await this.prisma.routineTask.groupBy({
by: ['routineId', 'status'],
where: {
routineId: { in: routineIds },
scheduledFor: { gte: dayKeyToDate(addDays(today, -29)), lte: dayKeyToDate(today) },
status: { in: ['completed', 'overdue', 'skipped'] },
...(assigneeId ? { assigneeId } : {}),
},
_count: { _all: true },
});
for (const g of groups) {
if (!g.routineId) continue;
const cur = map.get(g.routineId) ?? { total: 0, done: 0 };
cur.total += g._count._all;
if (g.status === 'completed') cur.done += g._count._all;
map.set(g.routineId, cur);
}
return map;
}
private async findRoutine(organizationId: string, id: string) {
const row = await this.prisma.routine.findFirst({
where: { id, organizationId, deletedAt: null },
include: routineInclude,
});
if (!row) throw new NotFoundException('روتین پیدا نشد');
return row;
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.manageableUserIds === null) return;
if (row.assigneeId && canManage(scope, row.assigneeId)) return;
if (row.createdById === scope.userId) return;
throw new ForbiddenException('اجازه مدیریت این روتین را ندارید');
}
private async resolveTarget(
actor: Actor,
scope: RoutineScope,
assigneeId?: string | null,
teamId?: string | null,
): Promise<{ assigneeId: string | null; teamId: string | null }> {
if (assigneeId) {
if (!canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: assigneeId, isActive: true },
select: { id: true },
});
if (!member) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست');
return { assigneeId, teamId: null };
}
if (teamId) {
const team = await this.prisma.team.findFirst({
where: {
id: teamId,
organizationId: actor.organizationId,
...(elevated(actor.role)
? {}
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
},
select: { id: true },
});
if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست');
return { assigneeId: null, teamId };
}
throw new BadRequestException('مسئول یا تیم روتین را انتخاب کنید');
}
private async buildData(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto) {
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
if (dto.categoryId) await this.findCategory(actor.organizationId, dto.categoryId);
if (dto.endDate && dto.endDate < dto.startDate) {
throw new BadRequestException('تاریخ پایان نباید قبل از تاریخ شروع باشد');
}
const weekdays = [...new Set(dto.weekdays ?? [])].sort((a, b) => a - b);
const specificDates = [...new Set(dto.specificDates ?? [])].sort();
if (['weekdays', 'biweekly'].includes(dto.recurrence) && !weekdays.length) {
throw new BadRequestException('حداقل یک روز هفته را انتخاب کنید');
}
if (dto.recurrence === 'every_n_days' && !dto.intervalDays) {
throw new BadRequestException('فاصله تکرار (روز) را وارد کنید');
}
if (dto.recurrence === 'specific_dates' && !specificDates.length) {
throw new BadRequestException('حداقل یک تاریخ انتخاب کنید');
}
return {
title: dto.title.trim(),
description: dto.description?.trim() || null,
...target,
categoryId: dto.categoryId || null,
priority: dto.priority ?? 'medium',
timeOfDay: dto.timeOfDay,
durationMinutes: dto.durationMinutes ?? null,
startDate: dayKeyToDate(dto.startDate),
endDate: dto.endDate ? dayKeyToDate(dto.endDate) : null,
recurrence: dto.recurrence,
weekdays,
intervalDays: dto.recurrence === 'every_n_days' ? dto.intervalDays ?? 1 : null,
monthDay: dto.recurrence === 'monthly' ? dto.monthDay ?? null : null,
specificDates: dto.recurrence === 'specific_dates' ? specificDates.map(dayKeyToDate) : [],
isMandatory: dto.isMandatory ?? true,
allowSkip: dto.allowSkip ?? false,
skipReasonRequired: dto.skipReasonRequired ?? true,
reportRequired: dto.reportRequired ?? false,
allowAfterDeadline: dto.allowAfterDeadline ?? true,
carryOverOverdue: dto.carryOverOverdue ?? false,
entityLink: dto.entityLink ?? 'none',
reminderMinutesBefore: dto.reminderMinutesBefore ?? null,
isActive: dto.isActive ?? true,
};
}
/** همگام کردن Task امروزِ هنوز شروع‌نشده با تغییرات روتین */
private async syncTodayTasks(routineId: string) {
const routine = await this.prisma.routine.findUnique({ where: { id: routineId } });
if (!routine) return;
if (!routine.isActive || routine.deletedAt) {
await this.cancelTodayPending(routineId, 'غیرفعال شدن روتین');
return;
}
const today = tehranDayKey();
const pending = await this.prisma.routineTask.findMany({
where: { routineId, scheduledFor: dayKeyToDate(today), status: 'todo' },
select: { id: true },
});
if (pending.length) {
const dueAt = tehranDateTime(today, routine.timeOfDay);
await this.prisma.routineTask.updateMany({
where: { id: { in: pending.map((p) => p.id) } },
data: {
title: routine.title,
description: routine.description,
categoryId: routine.categoryId,
priority: routine.priority,
isMandatory: routine.isMandatory,
dueAt,
...(routine.reminderMinutesBefore != null
? {
reminderAt: new Date(dueAt.getTime() - routine.reminderMinutesBefore * 60_000),
reminderSentAt: null,
}
: {}),
},
});
}
await this.generator.generateForDay(today, { routineId });
}
private async cancelTodayPending(routineId: string, note: string) {
const rows = await this.prisma.routineTask.findMany({
where: {
routineId,
scheduledFor: dayKeyToDate(tehranDayKey()),
status: { in: ['todo', 'in_progress'] },
},
select: { id: true, organizationId: true, status: true },
});
if (!rows.length) return;
await this.prisma.$transaction([
this.prisma.routineTask.updateMany({
where: { id: { in: rows.map((r) => r.id) } },
data: { status: 'cancelled' },
}),
this.prisma.routineTaskEvent.createMany({
data: rows.map((r) => ({
organizationId: r.organizationId,
taskId: r.id,
type: 'status_changed',
fromStatus: r.status,
toStatus: 'cancelled' as const,
note,
})),
}),
]);
}
}