Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
6.2 KiB
TypeScript
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}`,
|
|
});
|
|
}
|
|
}
|
|
}
|