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:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
@@ -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(),
};
}
}