168 lines
6.4 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|