2026-08-29 21:06:30 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import type {
|
|
|
|
|
CreateNoteRequest,
|
|
|
|
|
NoteAttachmentDto,
|
2026-09-05 19:13:01 +03:30
|
|
|
NoteChecklistItemDto,
|
2026-08-29 21:06:30 +03:30
|
|
|
NoteDto,
|
2026-09-27 13:19:08 +03:30
|
|
|
NoteRevisionDto,
|
|
|
|
|
NoteVisibility,
|
2026-08-29 21:06:30 +03:30
|
|
|
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,
|
2026-09-27 14:25:32 +03:30
|
|
|
dealAccessWhere,
|
2026-08-29 21:06:30 +03:30
|
|
|
} from '../common/record-scope';
|
|
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
type NoteActor = Pick<
|
|
|
|
|
Actor,
|
|
|
|
|
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
|
|
|
|
>;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
type NoteRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
body: string;
|
|
|
|
|
noteType: string;
|
2026-09-27 13:19:08 +03:30
|
|
|
visibility: string;
|
2026-08-29 21:06:30 +03:30
|
|
|
pinned: boolean;
|
|
|
|
|
color: string | null;
|
|
|
|
|
tags: string[];
|
|
|
|
|
reminderAt: Date | null;
|
2026-09-05 19:13:01 +03:30
|
|
|
checklist: Prisma.JsonValue | null;
|
|
|
|
|
locationText: string | null;
|
|
|
|
|
analyzeEnabled: boolean;
|
2026-08-29 21:06:30 +03:30
|
|
|
createdAt: Date;
|
|
|
|
|
updatedAt: Date;
|
|
|
|
|
person: { id: string; firstName: string; lastName: string } | null;
|
|
|
|
|
company: { id: string; name: string } | null;
|
|
|
|
|
deal: { id: string; title: string } | null;
|
2026-09-05 19:13:01 +03:30
|
|
|
activity: { id: string; title: string } | null;
|
2026-08-29 21:06:30 +03:30
|
|
|
author: { id: string; fullName: string } | null;
|
|
|
|
|
attachments: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
fileName: string;
|
|
|
|
|
mimeType: string;
|
|
|
|
|
sizeBytes: number;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}>;
|
2026-09-27 13:19:08 +03:30
|
|
|
_count?: { revisions: number };
|
2026-08-29 21:06:30 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const include = {
|
|
|
|
|
person: { select: { id: true, firstName: true, lastName: true } },
|
|
|
|
|
company: { select: { id: true, name: true } },
|
|
|
|
|
deal: { select: { id: true, title: true } },
|
2026-09-05 19:13:01 +03:30
|
|
|
activity: { select: { id: true, title: true } },
|
2026-08-29 21:06:30 +03:30
|
|
|
author: { select: { id: true, fullName: true } },
|
|
|
|
|
attachments: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
fileName: true,
|
|
|
|
|
mimeType: true,
|
|
|
|
|
sizeBytes: true,
|
|
|
|
|
createdAt: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'asc' as const },
|
|
|
|
|
},
|
2026-09-27 13:19:08 +03:30
|
|
|
_count: { select: { revisions: true } },
|
2026-08-29 21:06:30 +03:30
|
|
|
} 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',
|
2026-09-05 19:13:01 +03:30
|
|
|
'audio/webm',
|
|
|
|
|
'audio/wav',
|
|
|
|
|
'audio/mpeg',
|
|
|
|
|
'audio/mp4',
|
|
|
|
|
'audio/ogg',
|
|
|
|
|
'audio/x-m4a',
|
2026-08-29 21:06:30 +03:30
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
@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;
|
2026-09-27 13:19:08 +03:30
|
|
|
visibility?: string;
|
2026-09-05 19:13:01 +03:30
|
|
|
sort?: 'updatedAt' | 'createdAt' | 'reminderAt';
|
2026-08-29 21:06:30 +03:30
|
|
|
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 } : {}),
|
2026-09-27 13:19:08 +03:30
|
|
|
...(opts.visibility === 'personal' || opts.visibility === 'team'
|
|
|
|
|
? { visibility: opts.visibility }
|
|
|
|
|
: {}),
|
2026-08-29 21:06:30 +03:30
|
|
|
...(opts.tag?.trim() ? { tags: { has: opts.tag.trim() } } : {}),
|
|
|
|
|
...(search
|
|
|
|
|
? { AND: [access, search] }
|
|
|
|
|
: access),
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 19:13:01 +03:30
|
|
|
const sortField = opts.sort ?? 'updatedAt';
|
|
|
|
|
const orderBy: Prisma.NoteOrderByWithRelationInput[] = [
|
|
|
|
|
{ pinned: 'desc' },
|
|
|
|
|
{ [sortField]: 'desc' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
const [rows, total] = await this.prisma.$transaction([
|
|
|
|
|
this.prisma.note.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include,
|
2026-09-05 19:13:01 +03:30
|
|
|
orderBy,
|
2026-08-29 21:06:30 +03:30
|
|
|
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,
|
|
|
|
|
);
|
2026-09-05 19:13:01 +03:30
|
|
|
const analyzeEnabled = dto.analyzeEnabled !== false;
|
|
|
|
|
const checklist = parseChecklist(dto.checklist);
|
2026-08-29 21:06:30 +03:30
|
|
|
const row = await this.prisma.note.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
authorId: userId,
|
|
|
|
|
title: emptyToNull(dto.title),
|
|
|
|
|
body: dto.body.trim(),
|
|
|
|
|
noteType: dto.noteType?.trim() || 'general',
|
2026-09-27 13:19:08 +03:30
|
|
|
visibility: normalizeVisibility(dto.visibility),
|
2026-08-29 21:06:30 +03:30
|
|
|
pinned: dto.pinned ?? false,
|
|
|
|
|
color: emptyToNull(dto.color) ?? 'default',
|
|
|
|
|
tags: normalizeTags(dto.tags),
|
|
|
|
|
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
|
2026-09-05 19:13:01 +03:30
|
|
|
checklist: checklist.length
|
|
|
|
|
? (checklist as unknown as Prisma.InputJsonValue)
|
|
|
|
|
: Prisma.JsonNull,
|
|
|
|
|
locationText: emptyToNull(dto.locationText ?? null),
|
|
|
|
|
analyzeEnabled,
|
2026-08-29 21:06:30 +03:30
|
|
|
personId: emptyToNull(dto.personId ?? null),
|
|
|
|
|
companyId: emptyToNull(dto.companyId ?? null),
|
|
|
|
|
dealId: emptyToNull(dto.dealId ?? null),
|
2026-09-05 19:13:01 +03:30
|
|
|
activityId: emptyToNull(dto.activityId ?? null),
|
2026-08-29 21:06:30 +03:30
|
|
|
},
|
|
|
|
|
include,
|
|
|
|
|
});
|
|
|
|
|
return this.toDto(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
id: string,
|
|
|
|
|
actor: NoteActor,
|
|
|
|
|
dto: UpdateNoteRequest,
|
|
|
|
|
): Promise<NoteDto> {
|
2026-09-27 13:19:08 +03:30
|
|
|
const existing = await this.prisma.note.findFirst({
|
|
|
|
|
where: { id, organizationId, ...noteAccessWhere(actor) },
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundException('یادداشت پیدا نشد');
|
2026-08-29 21:06:30 +03:30
|
|
|
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';
|
2026-09-27 13:19:08 +03:30
|
|
|
if (dto.visibility !== undefined) {
|
|
|
|
|
data.visibility = normalizeVisibility(dto.visibility);
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
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;
|
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
if (dto.checklist !== undefined) {
|
|
|
|
|
const checklist = parseChecklist(dto.checklist);
|
|
|
|
|
data.checklist = checklist.length
|
|
|
|
|
? (checklist as unknown as Prisma.InputJsonValue)
|
|
|
|
|
: Prisma.JsonNull;
|
|
|
|
|
}
|
|
|
|
|
if ('locationText' in dto) {
|
|
|
|
|
data.locationText = emptyToNull(dto.locationText ?? null);
|
|
|
|
|
}
|
|
|
|
|
if (dto.analyzeEnabled !== undefined) {
|
|
|
|
|
data.analyzeEnabled = dto.analyzeEnabled;
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
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);
|
2026-09-05 19:13:01 +03:30
|
|
|
if ('activityId' in dto) {
|
|
|
|
|
data.activityId = emptyToNull(dto.activityId ?? null);
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
const nextTitle =
|
|
|
|
|
'title' in dto ? emptyToNull(dto.title) : existing.title;
|
|
|
|
|
const nextBody = dto.body !== undefined ? dto.body.trim() : existing.body;
|
|
|
|
|
const nextType =
|
|
|
|
|
dto.noteType !== undefined
|
|
|
|
|
? dto.noteType.trim() || 'general'
|
|
|
|
|
: existing.noteType;
|
|
|
|
|
const nextVisibility =
|
|
|
|
|
dto.visibility !== undefined
|
|
|
|
|
? normalizeVisibility(dto.visibility)
|
|
|
|
|
: normalizeVisibility(existing.visibility);
|
|
|
|
|
const nextTags =
|
|
|
|
|
dto.tags !== undefined ? normalizeTags(dto.tags) : existing.tags;
|
|
|
|
|
const nextChecklist =
|
|
|
|
|
dto.checklist !== undefined
|
|
|
|
|
? parseChecklist(dto.checklist)
|
|
|
|
|
: parseChecklist(existing.checklist);
|
|
|
|
|
|
|
|
|
|
const contentChanged =
|
|
|
|
|
(nextTitle ?? '') !== (existing.title ?? '') ||
|
|
|
|
|
nextBody !== existing.body ||
|
|
|
|
|
nextType !== existing.noteType ||
|
|
|
|
|
nextVisibility !== normalizeVisibility(existing.visibility) ||
|
|
|
|
|
JSON.stringify(nextTags) !== JSON.stringify(existing.tags) ||
|
|
|
|
|
JSON.stringify(nextChecklist) !==
|
|
|
|
|
JSON.stringify(parseChecklist(existing.checklist));
|
|
|
|
|
|
|
|
|
|
if (contentChanged) {
|
|
|
|
|
await this.prisma.noteRevision.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
noteId: id,
|
|
|
|
|
editorId: actor.userId,
|
|
|
|
|
title: existing.title,
|
|
|
|
|
body: existing.body,
|
|
|
|
|
noteType: existing.noteType,
|
|
|
|
|
visibility: normalizeVisibility(existing.visibility),
|
|
|
|
|
tags: existing.tags,
|
|
|
|
|
checklist: existing.checklist ?? Prisma.JsonNull,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
const row = await this.prisma.note.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data,
|
|
|
|
|
include,
|
|
|
|
|
});
|
|
|
|
|
return this.toDto(row);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
async listRevisions(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
noteId: string,
|
|
|
|
|
actor: NoteActor,
|
|
|
|
|
): Promise<NoteRevisionDto[]> {
|
|
|
|
|
await this.ensureExists(organizationId, noteId, actor);
|
|
|
|
|
const rows = await this.prisma.noteRevision.findMany({
|
|
|
|
|
where: { noteId, organizationId },
|
|
|
|
|
include: { editor: { select: { id: true, fullName: true } } },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
take: 50,
|
|
|
|
|
});
|
|
|
|
|
return rows.map((row) => ({
|
|
|
|
|
id: row.id,
|
|
|
|
|
title: row.title,
|
|
|
|
|
body: row.body,
|
|
|
|
|
noteType: row.noteType,
|
|
|
|
|
visibility: normalizeVisibility(row.visibility),
|
|
|
|
|
tags: row.tags,
|
|
|
|
|
checklist: parseChecklist(row.checklist),
|
|
|
|
|
editor: row.editor,
|
|
|
|
|
createdAt: row.createdAt.toISOString(),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async restoreRevision(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
noteId: string,
|
|
|
|
|
revisionId: string,
|
|
|
|
|
actor: NoteActor,
|
|
|
|
|
): Promise<NoteDto> {
|
|
|
|
|
await this.ensureExists(organizationId, noteId, actor);
|
|
|
|
|
const revision = await this.prisma.noteRevision.findFirst({
|
|
|
|
|
where: { id: revisionId, noteId, organizationId },
|
|
|
|
|
});
|
|
|
|
|
if (!revision) throw new NotFoundException('نسخه پیدا نشد');
|
|
|
|
|
return this.update(organizationId, noteId, actor, {
|
|
|
|
|
title: revision.title,
|
|
|
|
|
body: revision.body,
|
|
|
|
|
noteType: revision.noteType,
|
|
|
|
|
visibility: normalizeVisibility(revision.visibility),
|
|
|
|
|
tags: revision.tags,
|
|
|
|
|
checklist: parseChecklist(revision.checklist),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
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(
|
2026-09-05 19:13:01 +03:30
|
|
|
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن، ZIP یا صوت بفرستید.',
|
2026-08-29 21:06:30 +03:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-05 19:13:01 +03:30
|
|
|
activityId?: string | null;
|
2026-08-29 21:06:30 +03:30
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
const personId = emptyToNull(dto.personId ?? null);
|
|
|
|
|
const companyId = emptyToNull(dto.companyId ?? null);
|
|
|
|
|
const dealId = emptyToNull(dto.dealId ?? null);
|
2026-09-05 19:13:01 +03:30
|
|
|
const activityId = emptyToNull(dto.activityId ?? null);
|
2026-08-29 21:06:30 +03:30
|
|
|
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({
|
2026-09-27 14:25:32 +03:30
|
|
|
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
|
2026-08-29 21:06:30 +03:30
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
if (activityId) {
|
|
|
|
|
const activity = await this.prisma.activity.findFirst({
|
|
|
|
|
where: { id: activityId, organizationId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (!activity) throw new BadRequestException('فعالیت انتخابشده معتبر نیست');
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toDto(row: NoteRow): NoteDto {
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
title: row.title,
|
|
|
|
|
body: row.body,
|
|
|
|
|
noteType: row.noteType,
|
2026-09-27 13:19:08 +03:30
|
|
|
visibility: normalizeVisibility(row.visibility),
|
2026-08-29 21:06:30 +03:30
|
|
|
pinned: row.pinned,
|
|
|
|
|
color: row.color,
|
|
|
|
|
tags: row.tags,
|
|
|
|
|
reminderAt: row.reminderAt?.toISOString() ?? null,
|
2026-09-05 19:13:01 +03:30
|
|
|
checklist: parseChecklist(row.checklist),
|
|
|
|
|
locationText: row.locationText ?? null,
|
|
|
|
|
analyzeEnabled: row.analyzeEnabled ?? true,
|
2026-08-29 21:06:30 +03:30
|
|
|
person: row.person
|
|
|
|
|
? {
|
|
|
|
|
id: row.person.id,
|
|
|
|
|
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
company: row.company,
|
|
|
|
|
deal: row.deal,
|
2026-09-05 19:13:01 +03:30
|
|
|
activity: row.activity,
|
2026-08-29 21:06:30 +03:30
|
|
|
author: row.author,
|
|
|
|
|
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
|
2026-09-27 13:19:08 +03:30
|
|
|
revisionCount: row._count?.revisions ?? 0,
|
2026-08-29 21:06:30 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 13:19:08 +03:30
|
|
|
function normalizeVisibility(value?: string | null): NoteVisibility {
|
|
|
|
|
return value === 'personal' ? 'personal' : 'team';
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
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);
|
|
|
|
|
}
|
2026-09-05 19:13:01 +03:30
|
|
|
|
|
|
|
|
function parseChecklist(raw: unknown): NoteChecklistItemDto[] {
|
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
|
const items: NoteChecklistItemDto[] = [];
|
|
|
|
|
for (const entry of raw) {
|
|
|
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
|
const rec = entry as Record<string, unknown>;
|
|
|
|
|
const id = typeof rec.id === 'string' ? rec.id.trim() : '';
|
|
|
|
|
const text = typeof rec.text === 'string' ? rec.text.trim() : '';
|
|
|
|
|
if (!id && !text) continue;
|
|
|
|
|
items.push({
|
|
|
|
|
id: id || randomUUID(),
|
|
|
|
|
text: text.slice(0, 500),
|
|
|
|
|
done: Boolean(rec.done),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return items.slice(0, 50);
|
|
|
|
|
}
|