Fix error sending reminder SMS

This commit is contained in:
2026-10-06 13:49:03 +03:30
parent 5074c6d791
commit bc01384551
6 changed files with 191 additions and 48 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "reminderSentAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "Activity_reminderAt_reminderSentAt_idx" ON "Activity"("reminderAt", "reminderSentAt");
+3
View File
@@ -608,6 +608,8 @@ model Activity {
reminderNotifyApp Boolean @default(true)
/// پیامک به موبایل مسئول هنگام رسیدن یادآور
reminderNotifySms Boolean @default(false)
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime?
completedAt DateTime?
tags String[] @default([])
personId String?
@@ -629,6 +631,7 @@ model Activity {
@@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId])
@@index([organizationId, createdById])
@@index([reminderAt, reminderSentAt])
@@index([personId])
@@index([companyId])
@@index([dealId])
+13 -1
View File
@@ -33,6 +33,7 @@ type ActivityRow = {
reminderAt: Date | null;
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
reminderSentAt: Date | null;
completedAt: Date | null;
tags: string[];
createdAt: Date;
@@ -285,7 +286,17 @@ export class ActivitiesService {
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null;
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
if ('reminderAt' in dto) {
const nextReminder = dto.reminderAt ? new Date(dto.reminderAt) : null;
const current = await this.prisma.activity.findUnique({
where: { id },
select: { reminderAt: true },
});
const prevMs = current?.reminderAt?.getTime() ?? null;
const nextMs = nextReminder?.getTime() ?? null;
data.reminderAt = nextReminder;
if (prevMs !== nextMs) data.reminderSentAt = null;
}
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
@@ -572,6 +583,7 @@ export class ActivitiesService {
reminderAt: row.reminderAt?.toISOString() ?? null,
reminderNotifyApp: row.reminderNotifyApp,
reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
tags: row.tags ?? [],
person: row.person
@@ -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'}`);
+40 -1
View File
@@ -5,6 +5,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import {
formatJalaliDayClock,
formatJalaliNumeric,
formatLocalDateInput,
formatLocalDateTimeInput,
@@ -85,6 +86,19 @@ const needsNote = ref(false);
const reminderAt = ref('');
const reminderNotifyApp = ref(true);
const reminderNotifySms = ref(false);
const loadedReminderAt = ref('');
const reminderSentAt = ref<string | null>(null);
const reminderChanged = computed(() => reminderAt.value !== loadedReminderAt.value);
const reminderSentLabel = computed(() =>
reminderSentAt.value && !reminderChanged.value
? formatJalaliDayClock(new Date(reminderSentAt.value))
: '',
);
const smsAssigneesWithoutMobile = computed(() =>
users.value
.filter((u) => assigneeIds.value.includes(u.id) && !u.mobile?.trim())
.map((u) => u.fullName),
);
const typeOpen = ref(false);
const assigneeOpen = ref(false);
@@ -257,6 +271,8 @@ function resetForm() {
isDone.value = false;
needsNote.value = false;
reminderAt.value = '';
loadedReminderAt.value = '';
reminderSentAt.value = null;
reminderNotifyApp.value = true;
reminderNotifySms.value = false;
tab.value = 'main';
@@ -301,6 +317,8 @@ async function load() {
reminderAt.value = data.reminderAt
? formatLocalDateTimeInput(new Date(data.reminderAt))
: '';
loadedReminderAt.value = reminderAt.value;
reminderSentAt.value = data.reminderSentAt ?? null;
reminderNotifyApp.value = data.reminderNotifyApp ?? true;
reminderNotifySms.value = data.reminderNotifySms ?? false;
if (data.person?.id) await ensurePersonLoaded(data.person.id);
@@ -359,7 +377,7 @@ function parseTags(raw: string): string[] {
function payload(): CreateActivityRequest {
const due =
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
const reminder = reminderAt.value ? new Date(reminderAt.value) : null;
const reminder = parseLocalInput(reminderAt.value);
return {
title: title.value,
type: type.value || 'task',
@@ -395,6 +413,12 @@ function validate() {
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
if (reminderAt.value && reminderChanged.value) {
const when = parseLocalInput(reminderAt.value);
if (when && when.getTime() < Date.now() - 60_000) {
return 'زمان یادآوری گذشته است؛ زمانی در آینده انتخاب کنید.';
}
}
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
return 'برای یادآوری، اعلان یا پیامک را انتخاب کنید.';
}
@@ -765,6 +789,15 @@ watch([id, isNew], () => void load(), { immediate: true });
>
حداقل یکی از روش‌های یادآوری را انتخاب کنید.
</p>
<p
v-if="reminderNotifySms && smsAssigneesWithoutMobile.length"
class="channel-warn"
>
شمارهٔ موبایل برای {{ smsAssigneesWithoutMobile.join('، ') }} ثبت نشده؛ پیامک برایشان ارسال نمی‌شود.
</p>
<p v-if="reminderSentLabel" class="channel-sent">
این یادآوری در {{ reminderSentLabel }} ارسال شد. برای یادآوری دوباره، زمان جدیدی انتخاب کنید.
</p>
</div>
</div>
</div>
@@ -1554,6 +1587,12 @@ watch([id, isNew], () => void load(), { immediate: true });
font-size: 0.8rem;
}
.channel-sent {
margin: 0;
color: #0f766e;
font-size: 0.8rem;
}
.note-card {
display: grid;
gap: 0.45rem;
+2
View File
@@ -1030,6 +1030,8 @@ export interface ActivityDto {
reminderAt: string | null;
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
/** زمان ارسال یادآور (اگر ارسال شده باشد) */
reminderSentAt: string | null;
completedAt: string | null;
tags: string[];
person: PersonSummaryDto | null;