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 { return resolveRoutineScope(this.prisma, actor); } private deepWhere(actor: Actor): Prisma.RoutineTaskWhereInput { return routineTaskAccessWhere(actor); } /* ───────── خواندن ───────── */ async today(actor: Actor, userId?: string, date?: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { throw new BadRequestException( 'پیگیری مشتری در کارهای روتین ثبت نمی‌شود. کارهای روتین فقط برای کار داخلی تیم است.', ); } async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise { 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 { 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 { 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 { 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, 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} انتخاب‌شده معتبر نیست`); } } }