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 { actorCanAssignToOthers, assignOthersDeniedMessage } from '../common/assign-access'; 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 { return resolveRoutineScope(this.prisma, actor); } /* ───────── دسته‌بندی‌ها ───────── */ async categories(organizationId: string): Promise { 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 { 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 { 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 { 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 { const scope = await this.scope(actor); const data = await this.buildData(actor, scope, this.ownRoutineInput(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 { const scope = await this.scope(actor); const existing = await this.findRoutine(actor.organizationId, id); this.assertCanWriteRoutine(scope, existing); const data = await this.buildData(actor, scope, this.ownRoutineInput(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 { const scope = await this.scope(actor); const existing = await this.findRoutine(actor.organizationId, id); this.assertCanWriteRoutine(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 { const scope = await this.scope(actor); const existing = await this.findRoutine(actor.organizationId, id); this.assertCanWriteRoutine(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 { const scope = await this.scope(actor); const src = await this.findRoutine(actor.organizationId, id); this.assertCanWriteRoutine(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); const existing = await this.findRoutine(actor.organizationId, id); this.assertCanWriteRoutine(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> { const map = new Map(); 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 ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto { if (scope.isManager) return dto; if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) { throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید'); } return { ...dto, assigneeId: actor.userId, teamId: null }; } private assertCanWriteRoutine( scope: RoutineScope, row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, ) { if (scope.isManager) { this.assertCanManageRoutine(scope, row); return; } if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return; throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید'); } 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 && assigneeId !== actor.userId) || teamId) { if (!(await actorCanAssignToOthers(this.prisma, actor, 'routines'))) { throw new ForbiddenException(assignOthersDeniedMessage('routines')); } } 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, })), }), ]); } }