diff --git a/apps/api/prisma/migrations/20260905020000_activity_reminder_channels/migration.sql b/apps/api/prisma/migrations/20260905020000_activity_reminder_channels/migration.sql new file mode 100644 index 0000000..d151e7f --- /dev/null +++ b/apps/api/prisma/migrations/20260905020000_activity_reminder_channels/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "reminderNotifyApp" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "reminderNotifySms" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d5cf610..5b98711 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -368,6 +368,10 @@ model Activity { dueAt DateTime? durationMinutes Int? reminderAt DateTime? + /// اعلان داخل پنل هنگام رسیدن یادآور + reminderNotifyApp Boolean @default(true) + /// پیامک به موبایل مسئول هنگام رسیدن یادآور + reminderNotifySms Boolean @default(false) completedAt DateTime? personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 9fe7481..d21aa5d 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -28,6 +28,8 @@ type ActivityRow = { dueAt: Date | null; durationMinutes: number | null; reminderAt: Date | null; + reminderNotifyApp: boolean; + reminderNotifySms: boolean; completedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -183,6 +185,8 @@ export class ActivitiesService { dueAt: dto.dueAt ? new Date(dto.dueAt) : null, durationMinutes: dto.durationMinutes ?? null, reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null, + reminderNotifyApp: dto.reminderNotifyApp ?? true, + reminderNotifySms: dto.reminderNotifySms ?? false, personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), dealId: emptyToNull(dto.dealId ?? null), @@ -225,6 +229,8 @@ export class ActivitiesService { 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 (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp; + if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms; if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); @@ -412,6 +418,8 @@ export class ActivitiesService { dueAt: row.dueAt?.toISOString() ?? null, durationMinutes: row.durationMinutes, reminderAt: row.reminderAt?.toISOString() ?? null, + reminderNotifyApp: row.reminderNotifyApp, + reminderNotifySms: row.reminderNotifySms, completedAt: row.completedAt?.toISOString() ?? null, person: row.person ? { diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index a6edd42..d62839f 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { + IsBoolean, IsDateString, IsIn, IsInt, @@ -98,6 +99,14 @@ export class CreateActivityDto { @IsDateString() reminderAt?: string | null; + @IsOptional() + @IsBoolean() + reminderNotifyApp?: boolean; + + @IsOptional() + @IsBoolean() + reminderNotifySms?: boolean; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() @@ -160,6 +169,14 @@ export class UpdateActivityDto { @IsDateString() reminderAt?: string | null; + @IsOptional() + @IsBoolean() + reminderNotifyApp?: boolean; + + @IsOptional() + @IsBoolean() + reminderNotifySms?: boolean; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index e311df4..bf6d7fb 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; +import { SmsService } from '../common/sms.service'; @Module({ controllers: [NotificationsController], - providers: [NotificationsService], + providers: [NotificationsService, SmsService], exports: [NotificationsService], }) export class NotificationsModule {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index a496cf7..0b4d2a4 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import type { NotificationDto, NotificationsListDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { SmsService } from '../common/sms.service'; export type CreateNotificationInput = { organizationId: string; @@ -17,7 +18,12 @@ export type CreateNotificationInput = { @Injectable() export class NotificationsService { - constructor(private readonly prisma: PrismaService) {} + private readonly logger = new Logger(NotificationsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly sms: SmsService, + ) {} async list( organizationId: string, @@ -55,16 +61,16 @@ export class NotificationsService { userId: string, id: string, ): Promise { - const existing = await this.prisma.notification.findFirst({ + const row = await this.prisma.notification.findFirst({ where: { id, organizationId, userId }, }); - if (!existing) return null; - if (existing.readAt) return this.toDto(existing); - const row = await this.prisma.notification.update({ - where: { id }, + if (!row) return null; + if (row.readAt) return this.toDto(row); + const updated = await this.prisma.notification.update({ + where: { id: row.id }, data: { readAt: new Date() }, }); - return this.toDto(row); + return this.toDto(updated); } async markAllRead(organizationId: string, userId: string): Promise { @@ -136,6 +142,51 @@ export class NotificationsService { }); } + const dueActivities = await this.prisma.activity.findMany({ + where: { + organizationId, + assigneeId: userId, + status: 'open', + reminderAt: { lte: now }, + }, + select: { + id: true, + title: true, + reminderNotifyApp: true, + reminderNotifySms: true, + assignee: { select: { mobile: true, fullName: true } }, + }, + take: 20, + }); + + 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 }, + }); + } + const dueNotes = await this.prisma.note.findMany({ where: { organizationId, @@ -158,7 +209,6 @@ export class NotificationsService { entityId: note.id, dedupe: true, }); - // یادآور فقط یک‌بار شلیک می‌شود؛ تکرار بعد از خواندن نباید رخ دهد await this.prisma.note.update({ where: { id: note.id }, data: { reminderAt: null }, @@ -166,6 +216,25 @@ export class NotificationsService { } } + private async sendActivityReminderSms( + mobile: string | null | undefined, + title: string, + ) { + if (!mobile) { + this.logger.warn('Activity reminder SMS skipped: assignee has no mobile'); + return; + } + if (!this.sms.isConfigured()) { + this.logger.warn('Activity reminder SMS skipped: SMS not configured'); + return; + } + const text = `یادآوری منتو: ${title}`.slice(0, 160); + const result = await this.sms.sendSmsDetailed(mobile, text); + if (!result.ok) { + this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`); + } + } + /** Keep newest notification per type+entity; drop repeated sync spam. */ private async collapseDuplicates(userId: string): Promise { const rows = await this.prisma.notification.findMany({ diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index ffa1e5d..f8b12c7 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -55,6 +55,8 @@ const assigneeId = ref(''); const isDone = ref(false); const needsNote = ref(false); const reminderAt = ref(''); +const reminderNotifyApp = ref(true); +const reminderNotifySms = ref(false); const typeOpen = ref(false); const assigneeOpen = ref(false); @@ -136,6 +138,8 @@ function resetForm() { isDone.value = false; needsNote.value = false; reminderAt.value = ''; + reminderNotifyApp.value = true; + reminderNotifySms.value = false; tab.value = 'main'; error.value = ''; } @@ -177,6 +181,8 @@ async function load() { reminderAt.value = data.reminderAt ? formatLocalDateTimeInput(new Date(data.reminderAt)) : ''; + reminderNotifyApp.value = data.reminderNotifyApp ?? true; + reminderNotifySms.value = data.reminderNotifySms ?? false; personId.value = data.person?.id ?? ''; dealId.value = data.deal?.id ?? ''; assigneeId.value = data.assignee?.id ?? auth.user?.id ?? ''; @@ -218,6 +224,8 @@ function payload(): CreateActivityRequest { dueAt: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null, durationMinutes: durationMinutes.value || null, reminderAt: reminder && !Number.isNaN(reminder.getTime()) ? reminder.toISOString() : null, + reminderNotifyApp: reminder ? reminderNotifyApp.value : false, + reminderNotifySms: reminder ? reminderNotifySms.value : false, personId: personId.value || null, companyId: selectedPerson.value?.company?.id ?? null, dealId: dealId.value || null, @@ -232,6 +240,9 @@ function validate() { if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.'; if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.'; if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.'; + if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) { + return 'برای یادآوری، اعلان یا پیامک را انتخاب کنید.'; + } return ''; } @@ -481,6 +492,29 @@ watch([id, isNew], () => void load(), { immediate: true }); icon="bell" placeholder="یادآوری تنظیم نشده است" /> +
+

چگونه یادآوری شود؟

+ + +

+ حداقل یکی از روش‌های یادآوری را انتخاب کنید. +

+
@@ -914,6 +948,65 @@ watch([id, isNew], () => void load(), { immediate: true }); padding-inline: 0.15rem; } +.reminder-channels { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px dashed #e2e8f0; +} + +.channel-title { + margin: 0; + font-size: 0.88rem; + font-weight: 800; + color: #0f172a; +} + +.channel { + display: flex; + align-items: flex-start; + gap: 0.55rem; + cursor: pointer; + padding: 0.55rem 0.65rem; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} + +.channel:has(input:checked) { + border-color: #99f6e4; + background: #f0fdfa; +} + +.channel input { + margin-top: 0.2rem; + accent-color: #0d9488; +} + +.channel span { + display: grid; + gap: 0.15rem; + min-width: 0; +} + +.channel strong { + font-size: 0.9rem; + color: #0f172a; +} + +.channel small { + color: #64748b; + font-size: 0.78rem; + line-height: 1.45; +} + +.channel-warn { + margin: 0; + color: #b45309; + 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 97e1fe3..4e913e0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -489,6 +489,8 @@ export interface ActivityDto { dueAt: string | null; durationMinutes: number | null; reminderAt: string | null; + reminderNotifyApp: boolean; + reminderNotifySms: boolean; completedAt: string | null; person: PersonSummaryDto | null; company: CompanySummaryDto | null; @@ -507,6 +509,8 @@ export interface CreateActivityRequest { dueAt?: string | null; durationMinutes?: number | null; reminderAt?: string | null; + reminderNotifyApp?: boolean; + reminderNotifySms?: boolean; personId?: string | null; companyId?: string | null; dealId?: string | null; @@ -522,6 +526,8 @@ export interface UpdateActivityRequest { dueAt?: string | null; durationMinutes?: number | null; reminderAt?: string | null; + reminderNotifyApp?: boolean; + reminderNotifySms?: boolean; personId?: string | null; companyId?: string | null; dealId?: string | null; @@ -741,6 +747,7 @@ export interface DealLineItemInput { export type NotificationType = | "activity_assigned" | "activity_overdue" + | "activity_reminder" | "deal_moved" | "note_reminder" | "ticket_assigned"