diff --git a/apps/api/prisma/migrations/20261006130000_activity_reminder_sent_at/migration.sql b/apps/api/prisma/migrations/20261006130000_activity_reminder_sent_at/migration.sql new file mode 100644 index 0000000..f90196d --- /dev/null +++ b/apps/api/prisma/migrations/20261006130000_activity_reminder_sent_at/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c508eba..b92c027 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 778a465..3bd3cd6 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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 diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index b921d59..ba50a6c 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -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 { + 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 { + 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(); + 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 { + 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'}`); diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index 19fb16c..f1d3a8c 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -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(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 }); > حداقل یکی از روش‌های یادآوری را انتخاب کنید.

+

+ شمارهٔ موبایل برای {{ smsAssigneesWithoutMobile.join('، ') }} ثبت نشده؛ پیامک برایشان ارسال نمی‌شود. +

+

+ این یادآوری در {{ reminderSentLabel }} ارسال شد. برای یادآوری دوباره، زمان جدیدی انتخاب کنید. +

@@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 16c510b..dfdc832 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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;