feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user