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 { 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 { 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(); 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> { const map = new Map(); 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 { 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; } } }