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,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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user