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
+170
View File
@@ -0,0 +1,170 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const NOTE_TYPES = [
'general',
'call_result',
'meeting',
'important',
'warning',
'next_action',
] as const;
export class ListNotesQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
pinned?: boolean;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
dealId?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 50;
}
export class CreateNoteDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsString()
@MinLength(1)
@MaxLength(8000)
body!: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@IsBoolean()
pinned?: boolean;
@IsOptional()
@IsString()
@MaxLength(32)
color?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
reminderAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
}
export class UpdateNoteDto {
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(200)
title?: string | null;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(8000)
body?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@IsBoolean()
pinned?: boolean;
@IsOptional()
@IsString()
@MaxLength(32)
color?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
reminderAt?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
personId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
}
+147
View File
@@ -0,0 +1,147 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto } from './dto';
import { NotesService } from './notes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('notes')
@UseGuards(JwtAuthGuard)
export class NotesController {
constructor(private readonly notesService: NotesService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() query: ListNotesQueryDto,
) {
return this.notesService.list(user.organizationId, user, {
q: query.q,
pinned: query.pinned,
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
tag: query.tag,
noteType: query.noteType,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.notesService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateNoteDto) {
return this.notesService.create(
user.organizationId,
user.userId,
dto,
user,
);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateNoteDto,
) {
return this.notesService.update(user.organizationId, id, user, dto);
}
@Patch(':id/pin')
togglePin(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.notesService.togglePin(user.organizationId, id, user);
}
@Post(':id/convert-to-task')
convertToTask(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.notesService.convertToTask(
user.organizationId,
user.userId,
id,
user,
);
}
@Post(':id/attachments')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
uploadAttachment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.notesService.addAttachment(
user.organizationId,
user.userId,
id,
file,
user,
);
}
@Get(':id/attachments/:attachmentId/download')
async downloadAttachment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
): Promise<StreamableFile> {
const { stream, fileName, mimeType } =
await this.notesService.openAttachmentDownload(
user.organizationId,
id,
attachmentId,
user,
);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: mimeType,
disposition: `attachment; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
});
}
@Delete(':id/attachments/:attachmentId')
async removeAttachment(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
) {
await this.notesService.removeAttachment(
user.organizationId,
id,
attachmentId,
user,
);
return { ok: true };
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.notesService.remove(user.organizationId, id, user);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotesController } from './notes.controller';
import { NotesService } from './notes.service';
@Module({
controllers: [NotesController],
providers: [NotesService],
exports: [NotesService],
})
export class NotesModule {}
+502
View File
@@ -0,0 +1,502 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateNoteRequest,
NoteAttachmentDto,
NoteDto,
PaginatedResponse,
UpdateNoteRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import {
type Actor,
noteAccessWhere,
ownerWhere,
} from '../common/record-scope';
type NoteActor = Pick<Actor, 'userId' | 'role'>;
type NoteRow = {
id: string;
title: string | null;
body: string;
noteType: string;
pinned: boolean;
color: string | null;
tags: string[];
reminderAt: Date | null;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
author: { id: string; fullName: string } | null;
attachments: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
};
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
author: { select: { id: true, fullName: true } },
attachments: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' as const },
},
} satisfies Prisma.NoteInclude;
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const MAX_ATTACHMENTS_PER_NOTE = 8;
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/x-zip-compressed',
]);
@Injectable()
export class NotesService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
) {}
async list(
organizationId: string,
actor: NoteActor,
opts: {
q?: string;
pinned?: boolean;
personId?: string;
companyId?: string;
dealId?: string;
tag?: string;
noteType?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<NoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const access = noteAccessWhere(actor);
const search = opts.q?.trim()
? {
OR: [
{ title: { contains: opts.q.trim(), mode: 'insensitive' as const } },
{ body: { contains: opts.q.trim(), mode: 'insensitive' as const } },
{ tags: { has: opts.q.trim() } },
],
}
: null;
const where: Prisma.NoteWhereInput = {
organizationId,
...(opts.pinned != null ? { pinned: opts.pinned } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.companyId ? { companyId: opts.companyId } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.noteType ? { noteType: opts.noteType } : {}),
...(opts.tag?.trim() ? { tags: { has: opts.tag.trim() } } : {}),
...(search
? { AND: [access, search] }
: access),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.note.findMany({
where,
include,
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.note.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(
organizationId: string,
id: string,
actor: NoteActor,
): Promise<NoteDto> {
const row = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include,
});
if (!row) throw new NotFoundException('یادداشت پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateNoteRequest,
actor?: NoteActor,
): Promise<NoteDto> {
await this.assertLinks(
organizationId,
actor ?? { userId, role: 'owner' },
dto,
);
const row = await this.prisma.note.create({
data: {
organizationId,
authorId: userId,
title: emptyToNull(dto.title),
body: dto.body.trim(),
noteType: dto.noteType?.trim() || 'general',
pinned: dto.pinned ?? false,
color: emptyToNull(dto.color) ?? 'default',
tags: normalizeTags(dto.tags),
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
},
include,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: NoteActor,
dto: UpdateNoteRequest,
): Promise<NoteDto> {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto);
const data: Prisma.NoteUncheckedUpdateInput = {};
if ('title' in dto) data.title = emptyToNull(dto.title);
if (dto.body !== undefined) data.body = dto.body.trim();
if (dto.noteType !== undefined) data.noteType = dto.noteType.trim() || 'general';
if (dto.pinned !== undefined) data.pinned = dto.pinned;
if ('color' in dto) data.color = emptyToNull(dto.color) ?? 'default';
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
if ('reminderAt' in dto) {
data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
const row = await this.prisma.note.update({
where: { id },
data,
include,
});
return this.toDto(row);
}
async togglePin(
organizationId: string,
id: string,
actor: NoteActor,
): Promise<NoteDto> {
const existing = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
select: { pinned: true },
});
if (!existing) throw new NotFoundException('یادداشت پیدا نشد');
const row = await this.prisma.note.update({
where: { id },
data: { pinned: !existing.pinned },
include,
});
return this.toDto(row);
}
async convertToTask(
organizationId: string,
userId: string,
id: string,
actor: NoteActor,
): Promise<{ note: NoteDto; activityId: string }> {
const note = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include,
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
const activity = await this.prisma.activity.create({
data: {
organizationId,
type: 'task',
title: (note.title?.trim() || note.body.trim()).slice(0, 200),
description: note.body,
dueAt: note.reminderAt,
personId: note.personId,
companyId: note.companyId,
dealId: note.dealId,
assigneeId: userId,
},
});
return { note: this.toDto(note), activityId: activity.id };
}
async remove(
organizationId: string,
id: string,
actor: NoteActor,
): Promise<void> {
const note = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include: { attachments: { select: { storageKey: true } } },
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
for (const att of note.attachments) {
await this.files.remove(att.storageKey);
}
await this.prisma.note.delete({ where: { id } });
}
async addAttachment(
organizationId: string,
userId: string,
noteId: string,
file: Express.Multer.File,
actor: NoteActor,
): Promise<NoteAttachmentDto> {
await this.ensureExists(organizationId, noteId, actor);
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new BadRequestException('حداکثر حجم فایل ۱۰ مگابایت است');
}
const mimeType = (file.mimetype || '').toLowerCase();
if (!ALLOWED_MIME.has(mimeType)) {
throw new BadRequestException(
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن یا ZIP بفرستید.',
);
}
const count = await this.prisma.noteAttachment.count({ where: { noteId } });
if (count >= MAX_ATTACHMENTS_PER_NOTE) {
throw new BadRequestException(
`حداکثر ${MAX_ATTACHMENTS_PER_NOTE} پیوست برای هر یادداشت مجاز است`,
);
}
const fileName = sanitizeFileName(file.originalname);
const ext = path.extname(fileName).slice(0, 12);
const attachmentId = randomUUID().replace(/-/g, '').slice(0, 24);
const storageKey = path
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
.replace(/\\/g, '/');
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.noteAttachment.create({
data: {
id: attachmentId,
organizationId,
noteId,
fileName,
mimeType,
sizeBytes: file.size,
storageKey,
uploadedById: userId,
},
});
return this.toAttachmentDto(row);
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async removeAttachment(
organizationId: string,
noteId: string,
attachmentId: string,
actor: NoteActor,
): Promise<void> {
await this.ensureExists(organizationId, noteId, actor);
const row = await this.prisma.noteAttachment.findFirst({
where: { id: attachmentId, noteId, organizationId },
});
if (!row) throw new NotFoundException('پیوست پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
}
async openAttachmentDownload(
organizationId: string,
noteId: string,
attachmentId: string,
actor: NoteActor,
): Promise<{
stream: ReturnType<LocalFileStorage['openReadStream']>;
fileName: string;
mimeType: string;
}> {
await this.ensureExists(organizationId, noteId, actor);
const row = await this.prisma.noteAttachment.findFirst({
where: { id: attachmentId, noteId, organizationId },
});
if (!row) throw new NotFoundException('پیوست پیدا نشد');
return {
stream: this.files.openReadStream(row.storageKey),
fileName: row.fileName,
mimeType: row.mimeType,
};
}
private async ensureExists(
organizationId: string,
id: string,
actor: NoteActor,
) {
const found = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('یادداشت پیدا نشد');
}
private async assertLinks(
organizationId: string,
actor: NoteActor,
dto: {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const scope = ownerWhere(actor);
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...scope },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...scope },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
}
private toDto(row: NoteRow): NoteDto {
return {
id: row.id,
title: row.title,
body: row.body,
noteType: row.noteType,
pinned: row.pinned,
color: row.color,
tags: row.tags,
reminderAt: row.reminderAt?.toISOString() ?? null,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
author: row.author,
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toAttachmentDto(row: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}): NoteAttachmentDto {
return {
id: row.id,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeTags(tags?: string[]): string[] {
if (!tags?.length) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const cleaned = tag.trim().replace(/^#/, '');
if (!cleaned || seen.has(cleaned)) continue;
seen.add(cleaned);
result.push(cleaned.slice(0, 40));
}
return result.slice(0, 12);
}
function sanitizeFileName(raw: string): string {
const base = path.basename(raw || 'file').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const trimmed = base.trim() || 'file';
return trimmed.slice(0, 180);
}