Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
class ListNotificationsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 30;
|
||||
}
|
||||
|
||||
@Controller('notifications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Query() query: ListNotificationsQueryDto,
|
||||
) {
|
||||
return this.notificationsService.list(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
query.limit ?? 30,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
async unreadCount(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
) {
|
||||
const count = await this.notificationsService.unreadCount(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Patch('read-all')
|
||||
async markAllRead(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
) {
|
||||
const count = await this.notificationsService.markAllRead(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
);
|
||||
return { ok: true, count };
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
async markRead(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const item = await this.notificationsService.markRead(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
);
|
||||
return item ?? { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.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 {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
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 existing = 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 },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
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 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user