Ask activity reminder channel: in-app notification and/or SMS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<NotificationDto | null> {
|
||||
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<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({
|
||||
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<void> {
|
||||
const rows = await this.prisma.notification.findMany({
|
||||
|
||||
Reference in New Issue
Block a user