Fix error sending reminder SMS
This commit is contained in:
@@ -1,9 +1,27 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { brand } from '@mento/shared';
|
||||
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
|
||||
/** یادآورهایی که بیش از این دیرتر پردازش شوند (مثلاً قطعی سرور) فقط اعلان میگیرند، نه پیامک. */
|
||||
const REMINDER_SMS_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
const TEHRAN_DATE_TIME = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
function formatTehranDateTime(d: Date): string {
|
||||
return TEHRAN_DATE_TIME.format(d);
|
||||
}
|
||||
|
||||
export type CreateNotificationInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
@@ -143,83 +161,142 @@ export class NotificationsService {
|
||||
});
|
||||
}
|
||||
|
||||
const dueActivities = await this.prisma.activity.findMany({
|
||||
}
|
||||
|
||||
/** یادآورهای فعالیت و یادداشت را سر وقت (مستقل از آنلاین بودن کاربر) ارسال میکند. */
|
||||
@Cron('* * * * *')
|
||||
async processDueReminders(): Promise<void> {
|
||||
if (this.reminderRunning) return;
|
||||
this.reminderRunning = true;
|
||||
try {
|
||||
await this.processActivityReminders();
|
||||
await this.processNoteReminders();
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`reminder run failed: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
);
|
||||
} finally {
|
||||
this.reminderRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private reminderRunning = false;
|
||||
|
||||
private async processActivityReminders(): Promise<void> {
|
||||
const now = new Date();
|
||||
const due = await this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
assigneeId: userId,
|
||||
status: 'open',
|
||||
reminderAt: { lte: now },
|
||||
reminderSentAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
title: true,
|
||||
dueAt: true,
|
||||
reminderAt: true,
|
||||
reminderNotifyApp: true,
|
||||
reminderNotifySms: true,
|
||||
assignee: { select: { mobile: true, fullName: true } },
|
||||
createdById: true,
|
||||
person: { select: { firstName: true, lastName: true, mobile: true } },
|
||||
company: { select: { name: true } },
|
||||
assignee: { select: { id: true, mobile: true } },
|
||||
assignees: { select: { id: true, mobile: true } },
|
||||
},
|
||||
take: 20,
|
||||
orderBy: { reminderAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
for (const activity of dueActivities) {
|
||||
if (activity.reminderNotifyApp) {
|
||||
await this.create({
|
||||
organizationId,
|
||||
userId,
|
||||
type: 'activity_reminder',
|
||||
title: 'یادآوری فعالیت',
|
||||
body: activity.title,
|
||||
link: `/activities/${activity.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: activity.id,
|
||||
dedupe: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (activity.reminderNotifySms) {
|
||||
await this.sendActivityReminderSms(
|
||||
activity.assignee?.mobile,
|
||||
activity.title,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.activity.update({
|
||||
where: { id: activity.id },
|
||||
data: { reminderAt: null },
|
||||
for (const activity of due) {
|
||||
const claimed = await this.prisma.activity.updateMany({
|
||||
where: { id: activity.id, reminderSentAt: null, reminderAt: activity.reminderAt },
|
||||
data: { reminderSentAt: new Date() },
|
||||
});
|
||||
}
|
||||
if (!claimed.count) continue;
|
||||
|
||||
const recipients = new Map<string, string | null>();
|
||||
if (activity.assignee) recipients.set(activity.assignee.id, activity.assignee.mobile);
|
||||
for (const u of activity.assignees) recipients.set(u.id, u.mobile);
|
||||
if (!recipients.size && activity.createdById) {
|
||||
const creator = await this.prisma.user.findUnique({
|
||||
where: { id: activity.createdById },
|
||||
select: { id: true, mobile: true },
|
||||
});
|
||||
if (creator) recipients.set(creator.id, creator.mobile);
|
||||
}
|
||||
|
||||
const personName = activity.person
|
||||
? `${activity.person.firstName} ${activity.person.lastName}`.trim()
|
||||
: '';
|
||||
const related = personName || activity.company?.name || '';
|
||||
const body = related ? `${activity.title} — ${related}` : activity.title;
|
||||
const stale =
|
||||
!!activity.reminderAt &&
|
||||
now.getTime() - activity.reminderAt.getTime() > REMINDER_SMS_MAX_DELAY_MS;
|
||||
|
||||
for (const [userId, mobile] of recipients) {
|
||||
if (activity.reminderNotifyApp || !activity.reminderNotifySms) {
|
||||
await this.create({
|
||||
organizationId: activity.organizationId,
|
||||
userId,
|
||||
type: 'activity_reminder',
|
||||
title: 'یادآوری فعالیت',
|
||||
body,
|
||||
link: `/activities/${activity.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: activity.id,
|
||||
});
|
||||
}
|
||||
if (activity.reminderNotifySms && !stale) {
|
||||
await this.sendActivityReminderSms(mobile, {
|
||||
title: activity.title,
|
||||
related,
|
||||
relatedMobile: activity.person?.mobile ?? null,
|
||||
dueAt: activity.dueAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processNoteReminders(): Promise<void> {
|
||||
const now = new Date();
|
||||
const dueNotes = await this.prisma.note.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
authorId: userId,
|
||||
reminderAt: { lte: now },
|
||||
where: { reminderAt: { lte: now } },
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
authorId: true,
|
||||
title: true,
|
||||
body: true,
|
||||
reminderAt: true,
|
||||
},
|
||||
select: { id: true, title: true, body: true },
|
||||
take: 20,
|
||||
take: 200,
|
||||
});
|
||||
|
||||
for (const note of dueNotes) {
|
||||
const claimed = await this.prisma.note.updateMany({
|
||||
where: { id: note.id, reminderAt: note.reminderAt },
|
||||
data: { reminderAt: null },
|
||||
});
|
||||
if (!claimed.count || !note.authorId) continue;
|
||||
await this.create({
|
||||
organizationId,
|
||||
userId,
|
||||
organizationId: note.organizationId,
|
||||
userId: note.authorId,
|
||||
type: 'note_reminder',
|
||||
title: 'یادآوری یادداشت',
|
||||
body: note.title || note.body.slice(0, 80),
|
||||
link: '/notes',
|
||||
entityType: 'note',
|
||||
entityId: note.id,
|
||||
dedupe: true,
|
||||
});
|
||||
await this.prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: { reminderAt: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendActivityReminderSms(
|
||||
mobile: string | null | undefined,
|
||||
title: string,
|
||||
info: { title: string; related: string; relatedMobile: string | null; dueAt: Date | null },
|
||||
) {
|
||||
if (!mobile) {
|
||||
this.logger.warn('Activity reminder SMS skipped: assignee has no mobile');
|
||||
@@ -229,7 +306,14 @@ export class NotificationsService {
|
||||
this.logger.warn('Activity reminder SMS skipped: SMS not configured');
|
||||
return;
|
||||
}
|
||||
const text = `${brand.reminderPrefixFa} ${title}`.slice(0, 160);
|
||||
const lines = [`${brand.reminderPrefixFa} ${info.title}`];
|
||||
if (info.related) {
|
||||
lines.push(
|
||||
info.relatedMobile ? `مشتری: ${info.related} (${info.relatedMobile})` : `مشتری: ${info.related}`,
|
||||
);
|
||||
}
|
||||
if (info.dueAt) lines.push(`زمان: ${formatTehranDateTime(info.dueAt)}`);
|
||||
const text = lines.join('\n').slice(0, 300);
|
||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
||||
if (!result.ok) {
|
||||
this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`);
|
||||
|
||||
Reference in New Issue
Block a user