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,151 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
},
|
||||
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 },
|
||||
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'] } },
|
||||
_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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user