Files
CRM_Montzar/apps/api/src/routines/routine-tasks.service.ts
T
MahdiandCursor a17c7dbabc fix(routines): keep the daily desk for internal team work
Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 20:53:26 +03:30

621 lines
23 KiB
TypeScript

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, routineTaskAccessWhere } 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 { GamificationService } from '../gamification/gamification.service';
import {
addDays,
dayKeyToDate,
isDayKey,
tehranDateTime,
tehranDayKey,
} from './routine-dates';
import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine';
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,
private readonly game: GamificationService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
private deepWhere(actor: Actor): Prisma.RoutineTaskWhereInput {
return routineTaskAccessWhere(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 deep = this.deepWhere(actor);
const [todayRows, overdueRows, score] = await Promise.all([
this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
assigneeId: target,
scheduledFor: dayDate,
status: { not: 'cancelled' },
...internalRoutineTaskWhere,
...deep,
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }],
}),
this.prisma.routineTask.findMany({
where: {
organizationId: actor.organizationId,
assigneeId: target,
status: 'overdue',
scheduledFor: { lt: dayDate },
...internalRoutineTaskWhere,
...deep,
},
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'] },
...internalRoutineTaskWhere,
},
}),
this.prisma.routineTask.count({
where: {
organizationId: actor.organizationId,
assigneeId: actor.userId,
status: 'overdue',
...internalRoutineTaskWhere,
},
}),
]);
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',
...internalRoutineTaskWhere,
...filter,
...this.deepWhere(actor),
},
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,
...this.deepWhere(actor),
...deskRoutineTaskWhere(q.source),
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.status ? { status: q.status } : {}),
};
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),
...this.deepWhere(actor),
},
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 deep = this.deepWhere(actor);
if (Object.keys(deep).length) {
const allowed = await this.prisma.routineTask.findFirst({
where: { id, organizationId: actor.organizationId, ...deep },
select: { id: true },
});
if (!allowed) 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> {
throw new BadRequestException(
'پیگیری مشتری در کارهای روتین ثبت نمی‌شود. کارهای روتین فقط برای کار داخلی تیم است.',
);
}
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 }],
);
this.game.emit({
organizationId: actor.organizationId,
userId: row.assigneeId,
event: row.routineId ? 'ROUTINE_COMPLETED' : 'TASK_COMPLETED',
entityType: 'routine_task',
entityId: row.id,
entityTitle: row.title,
facts: {
onTime: row.status !== 'overdue' && (!row.dueAt || Date.now() <= row.dueAt.getTime()),
isMandatory: row.isMandatory,
priority: row.priority,
},
});
return toTaskDto(updated);
}
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} انتخاب‌شده معتبر نیست`);
}
}
}