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
@@ -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({