Files
CRM_Montzar/apps/api/src/notifications/notifications.service.ts
T

284 lines
7.7 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { brand } from '@mento/shared';
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
export type CreateNotificationInput = {
organizationId: string;
userId: string;
type: string;
title: string;
body?: string | null;
link?: string | null;
entityType?: string | null;
entityId?: string | null;
/** If true, skip when an unread notification with same type+entityId exists for user */
dedupe?: boolean;
};
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
) {}
async list(
organizationId: string,
userId: string,
limit = 30,
): Promise<NotificationsListDto> {
await this.syncDueItems(organizationId, userId);
const [items, unreadCount] = await this.prisma.$transaction([
this.prisma.notification.findMany({
where: { organizationId, userId },
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(limit, 1), 100),
}),
this.prisma.notification.count({
where: { organizationId, userId, readAt: null },
}),
]);
return {
items: items.map((row) => this.toDto(row)),
unreadCount,
};
}
async unreadCount(organizationId: string, userId: string): Promise<number> {
await this.syncDueItems(organizationId, userId);
return this.prisma.notification.count({
where: { organizationId, userId, readAt: null },
});
}
async markRead(
organizationId: string,
userId: string,
id: string,
): Promise<NotificationDto | null> {
const row = await this.prisma.notification.findFirst({
where: { id, organizationId, userId },
});
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(updated);
}
async markAllRead(organizationId: string, userId: string): Promise<number> {
const result = await this.prisma.notification.updateMany({
where: { organizationId, userId, readAt: null },
data: { readAt: new Date() },
});
return result.count;
}
async create(input: CreateNotificationInput): Promise<boolean> {
if (input.dedupe && input.entityId) {
const existing = await this.prisma.notification.findFirst({
where: {
userId: input.userId,
type: input.type,
entityId: input.entityId,
},
select: { id: true },
});
if (existing) return false;
}
await this.prisma.notification.create({
data: {
organizationId: input.organizationId,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body ?? null,
link: input.link ?? null,
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
},
});
return true;
}
/** Create overdue/reminder notifications for the current user (idempotent). */
async syncDueItems(organizationId: string, userId: string): Promise<void> {
await this.collapseDuplicates(userId);
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const overdue = await this.prisma.activity.findMany({
where: {
organizationId,
assigneeId: userId,
status: 'open',
dueAt: { lt: startOfToday },
},
select: { id: true, title: true },
take: 20,
});
for (const activity of overdue) {
await this.create({
organizationId,
userId,
type: 'activity_overdue',
title: 'فعالیت عقب‌افتاده',
body: activity.title,
link: `/activities/${activity.id}`,
entityType: 'activity',
entityId: activity.id,
dedupe: true,
});
}
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,
authorId: userId,
reminderAt: { lte: now },
},
select: { id: true, title: true, body: true },
take: 20,
});
for (const note of dueNotes) {
await this.create({
organizationId,
userId,
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,
) {
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 = `${brand.reminderPrefixFa} ${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({
where: { userId, entityId: { not: null } },
orderBy: { createdAt: 'desc' },
select: { id: true, type: true, entityId: true },
});
const seen = new Set<string>();
const toDelete: string[] = [];
for (const row of rows) {
const key = `${row.type}:${row.entityId}`;
if (seen.has(key)) toDelete.push(row.id);
else seen.add(key);
}
if (toDelete.length) {
await this.prisma.notification.deleteMany({
where: { id: { in: toDelete } },
});
}
}
private toDto(row: {
id: string;
type: string;
title: string;
body: string | null;
link: string | null;
entityType: string | null;
entityId: string | null;
readAt: Date | null;
createdAt: Date;
}): NotificationDto {
return {
id: row.id,
type: row.type,
title: row.title,
body: row.body,
link: row.link,
entityType: row.entityType,
entityId: row.entityId,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}