Fix error sending reminder SMS
This commit is contained in:
@@ -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");
|
||||
@@ -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])
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user