Ask activity reminder channel: in-app notification and/or SMS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 02:04:39 +03:30
co-authored by Cursor
parent 83d25ec1c5
commit 36e47e8af3
8 changed files with 212 additions and 10 deletions
@@ -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;
+4
View File
@@ -368,6 +368,10 @@ model Activity {
dueAt DateTime? dueAt DateTime?
durationMinutes Int? durationMinutes Int?
reminderAt DateTime? reminderAt DateTime?
/// اعلان داخل پنل هنگام رسیدن یادآور
reminderNotifyApp Boolean @default(true)
/// پیامک به موبایل مسئول هنگام رسیدن یادآور
reminderNotifySms Boolean @default(false)
completedAt DateTime? completedAt DateTime?
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -28,6 +28,8 @@ type ActivityRow = {
dueAt: Date | null; dueAt: Date | null;
durationMinutes: number | null; durationMinutes: number | null;
reminderAt: Date | null; reminderAt: Date | null;
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
completedAt: Date | null; completedAt: Date | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -183,6 +185,8 @@ export class ActivitiesService {
dueAt: dto.dueAt ? new Date(dto.dueAt) : null, dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
durationMinutes: dto.durationMinutes ?? null, durationMinutes: dto.durationMinutes ?? null,
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null, reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
reminderNotifyApp: dto.reminderNotifyApp ?? true,
reminderNotifySms: dto.reminderNotifySms ?? false,
personId: emptyToNull(dto.personId ?? null), personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null), companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? 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 ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? 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) 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 ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
@@ -412,6 +418,8 @@ export class ActivitiesService {
dueAt: row.dueAt?.toISOString() ?? null, dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes, durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null, reminderAt: row.reminderAt?.toISOString() ?? null,
reminderNotifyApp: row.reminderNotifyApp,
reminderNotifySms: row.reminderNotifySms,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
person: row.person person: row.person
? { ? {
+17
View File
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsBoolean,
IsDateString, IsDateString,
IsIn, IsIn,
IsInt, IsInt,
@@ -98,6 +99,14 @@ export class CreateActivityDto {
@IsDateString() @IsDateString()
reminderAt?: string | null; reminderAt?: string | null;
@IsOptional()
@IsBoolean()
reminderNotifyApp?: boolean;
@IsOptional()
@IsBoolean()
reminderNotifySms?: boolean;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -160,6 +169,14 @@ export class UpdateActivityDto {
@IsDateString() @IsDateString()
reminderAt?: string | null; reminderAt?: string | null;
@IsOptional()
@IsBoolean()
reminderNotifyApp?: boolean;
@IsOptional()
@IsBoolean()
reminderNotifySms?: boolean;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { SmsService } from '../common/sms.service';
@Module({ @Module({
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService], providers: [NotificationsService, SmsService],
exports: [NotificationsService], exports: [NotificationsService],
}) })
export class NotificationsModule {} export class NotificationsModule {}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import type { NotificationDto, NotificationsListDto } from '@mento/shared'; import type { NotificationDto, NotificationsListDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
export type CreateNotificationInput = { export type CreateNotificationInput = {
organizationId: string; organizationId: string;
@@ -17,7 +18,12 @@ export type CreateNotificationInput = {
@Injectable() @Injectable()
export class NotificationsService { 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( async list(
organizationId: string, organizationId: string,
@@ -55,16 +61,16 @@ export class NotificationsService {
userId: string, userId: string,
id: string, id: string,
): Promise<NotificationDto | null> { ): Promise<NotificationDto | null> {
const existing = await this.prisma.notification.findFirst({ const row = await this.prisma.notification.findFirst({
where: { id, organizationId, userId }, where: { id, organizationId, userId },
}); });
if (!existing) return null; if (!row) return null;
if (existing.readAt) return this.toDto(existing); if (row.readAt) return this.toDto(row);
const row = await this.prisma.notification.update({ const updated = await this.prisma.notification.update({
where: { id }, where: { id: row.id },
data: { readAt: new Date() }, data: { readAt: new Date() },
}); });
return this.toDto(row); return this.toDto(updated);
} }
async markAllRead(organizationId: string, userId: string): Promise<number> { async markAllRead(organizationId: string, userId: string): Promise<number> {
@@ -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({ const dueNotes = await this.prisma.note.findMany({
where: { where: {
organizationId, organizationId,
@@ -158,7 +209,6 @@ export class NotificationsService {
entityId: note.id, entityId: note.id,
dedupe: true, dedupe: true,
}); });
// یادآور فقط یک‌بار شلیک می‌شود؛ تکرار بعد از خواندن نباید رخ دهد
await this.prisma.note.update({ await this.prisma.note.update({
where: { id: note.id }, where: { id: note.id },
data: { reminderAt: null }, 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. */ /** Keep newest notification per type+entity; drop repeated sync spam. */
private async collapseDuplicates(userId: string): Promise<void> { private async collapseDuplicates(userId: string): Promise<void> {
const rows = await this.prisma.notification.findMany({ const rows = await this.prisma.notification.findMany({
+93
View File
@@ -55,6 +55,8 @@ const assigneeId = ref('');
const isDone = ref(false); const isDone = ref(false);
const needsNote = ref(false); const needsNote = ref(false);
const reminderAt = ref(''); const reminderAt = ref('');
const reminderNotifyApp = ref(true);
const reminderNotifySms = ref(false);
const typeOpen = ref(false); const typeOpen = ref(false);
const assigneeOpen = ref(false); const assigneeOpen = ref(false);
@@ -136,6 +138,8 @@ function resetForm() {
isDone.value = false; isDone.value = false;
needsNote.value = false; needsNote.value = false;
reminderAt.value = ''; reminderAt.value = '';
reminderNotifyApp.value = true;
reminderNotifySms.value = false;
tab.value = 'main'; tab.value = 'main';
error.value = ''; error.value = '';
} }
@@ -177,6 +181,8 @@ async function load() {
reminderAt.value = data.reminderAt reminderAt.value = data.reminderAt
? formatLocalDateTimeInput(new Date(data.reminderAt)) ? formatLocalDateTimeInput(new Date(data.reminderAt))
: ''; : '';
reminderNotifyApp.value = data.reminderNotifyApp ?? true;
reminderNotifySms.value = data.reminderNotifySms ?? false;
personId.value = data.person?.id ?? ''; personId.value = data.person?.id ?? '';
dealId.value = data.deal?.id ?? ''; dealId.value = data.deal?.id ?? '';
assigneeId.value = data.assignee?.id ?? auth.user?.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, dueAt: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
durationMinutes: durationMinutes.value || null, durationMinutes: durationMinutes.value || null,
reminderAt: reminder && !Number.isNaN(reminder.getTime()) ? reminder.toISOString() : null, reminderAt: reminder && !Number.isNaN(reminder.getTime()) ? reminder.toISOString() : null,
reminderNotifyApp: reminder ? reminderNotifyApp.value : false,
reminderNotifySms: reminder ? reminderNotifySms.value : false,
personId: personId.value || null, personId: personId.value || null,
companyId: selectedPerson.value?.company?.id ?? null, companyId: selectedPerson.value?.company?.id ?? null,
dealId: dealId.value || null, dealId: dealId.value || null,
@@ -232,6 +240,9 @@ function validate() {
if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.'; if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.';
if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.'; if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.'; if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
return 'برای یادآوری، اعلان یا پیامک را انتخاب کنید.';
}
return ''; return '';
} }
@@ -481,6 +492,29 @@ watch([id, isNew], () => void load(), { immediate: true });
icon="bell" icon="bell"
placeholder="یادآوری تنظیم نشده است" placeholder="یادآوری تنظیم نشده است"
/> />
<div v-if="reminderAt" class="reminder-channels">
<p class="channel-title">چگونه یادآوری شود؟</p>
<label class="channel">
<input v-model="reminderNotifyApp" type="checkbox" />
<span>
<strong>اعلان داخل پنل</strong>
<small>در زنگوله اعلان‌های منتو نشان داده می‌شود</small>
</span>
</label>
<label class="channel">
<input v-model="reminderNotifySms" type="checkbox" />
<span>
<strong>پیامک به مسئول</strong>
<small>به شماره موبایل مسئول فعالیت ارسال می‌شود</small>
</span>
</label>
<p
v-if="reminderAt && !reminderNotifyApp && !reminderNotifySms"
class="channel-warn"
>
حداقل یکی از روش‌های یادآوری را انتخاب کنید.
</p>
</div>
</div> </div>
</div> </div>
@@ -914,6 +948,65 @@ watch([id, isNew], () => void load(), { immediate: true });
padding-inline: 0.15rem; 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 { .note-card {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
+7
View File
@@ -489,6 +489,8 @@ export interface ActivityDto {
dueAt: string | null; dueAt: string | null;
durationMinutes: number | null; durationMinutes: number | null;
reminderAt: string | null; reminderAt: string | null;
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
completedAt: string | null; completedAt: string | null;
person: PersonSummaryDto | null; person: PersonSummaryDto | null;
company: CompanySummaryDto | null; company: CompanySummaryDto | null;
@@ -507,6 +509,8 @@ export interface CreateActivityRequest {
dueAt?: string | null; dueAt?: string | null;
durationMinutes?: number | null; durationMinutes?: number | null;
reminderAt?: string | null; reminderAt?: string | null;
reminderNotifyApp?: boolean;
reminderNotifySms?: boolean;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
@@ -522,6 +526,8 @@ export interface UpdateActivityRequest {
dueAt?: string | null; dueAt?: string | null;
durationMinutes?: number | null; durationMinutes?: number | null;
reminderAt?: string | null; reminderAt?: string | null;
reminderNotifyApp?: boolean;
reminderNotifySms?: boolean;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
@@ -741,6 +747,7 @@ export interface DealLineItemInput {
export type NotificationType = export type NotificationType =
| "activity_assigned" | "activity_assigned"
| "activity_overdue" | "activity_overdue"
| "activity_reminder"
| "deal_moved" | "deal_moved"
| "note_reminder" | "note_reminder"
| "ticket_assigned" | "ticket_assigned"