284 lines
7.7 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|