Files
CRM_Montzar/apps/api/src/routines/routine-reminders.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

158 lines
6.2 KiB
TypeScript

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<void> {
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}`,
});
}
}
}