Files
CRM_Montzar/apps/api/src/routines/routine-generator.service.ts
T

168 lines
6.4 KiB
TypeScript

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<void> {
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<number> {
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<string>();
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<Map<string, number | null>> {
const map = new Map<string, number | null>();
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<void> {
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;
}
}
}