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'; import { internalRoutineTaskWhere } from './internal-routine'; 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 { 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, ...internalRoutineTaskWhere, }, 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, ...internalRoutineTaskWhere }, 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'] }, ...internalRoutineTaskWhere, }, _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}`, }); } } }