Files
CRM_Montzar/apps/api/src/notes/notes.service.ts
T

706 lines
21 KiB
TypeScript
Raw Normal View History

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateNoteRequest,
NoteAttachmentDto,
NoteChecklistItemDto,
NoteDto,
NoteRevisionDto,
NoteVisibility,
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 { buildSearchWhere, ci } from '../common/search';
import { LocalFileStorage } from '../storage/local-file.storage';
import { GamificationService } from '../gamification/gamification.service';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import {
type Actor,
noteAccessWhere,
ownerWhere,
dealAccessWhere,
} from '../common/record-scope';
type NoteActor = Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
>;
type NoteRow = {
id: string;
title: string | null;
body: string;
noteType: string;
visibility: string;
pinned: boolean;
color: string | null;
tags: string[];
reminderAt: Date | null;
checklist: Prisma.JsonValue | null;
locationText: string | null;
analyzeEnabled: boolean;
customFields: Prisma.JsonValue;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
activity: { id: string; title: string } | null;
author: { id: string; fullName: string } | null;
attachments: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
_count?: { revisions: number };
};
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
activity: { 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 },
},
_count: { select: { revisions: true } },
} 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',
'audio/webm',
'audio/wav',
'audio/mpeg',
'audio/mp4',
'audio/ogg',
'audio/x-m4a',
]);
@Injectable()
export class NotesService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly game: GamificationService,
) {}
async list(
organizationId: string,
actor: NoteActor,
opts: {
q?: string;
pinned?: boolean;
personId?: string;
companyId?: string;
dealId?: string;
tag?: string;
noteType?: string;
visibility?: string;
sort?: 'updatedAt' | 'createdAt' | 'reminderAt';
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<NoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const access = noteAccessWhere(actor);
const search = buildSearchWhere<Prisma.NoteWhereInput>(opts.q, (t) => [
{ title: ci(t) },
{ body: ci(t) },
{ tags: { has: t } },
]);
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.visibility === 'personal' || opts.visibility === 'team'
? { visibility: opts.visibility }
: {}),
...(opts.tag?.trim() ? { tags: { has: opts.tag.trim() } } : {}),
...(search
? { AND: [access, search] }
: access),
};
const sortField = opts.sort ?? 'updatedAt';
const orderBy: Prisma.NoteOrderByWithRelationInput[] = [
{ pinned: 'desc' },
{ [sortField]: 'desc' },
];
const [rows, total] = await this.prisma.$transaction([
this.prisma.note.findMany({
where,
include,
orderBy,
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 analyzeEnabled = dto.analyzeEnabled !== false;
const checklist = parseChecklist(dto.checklist);
const row = await this.prisma.note.create({
data: {
organizationId,
authorId: userId,
title: emptyToNull(dto.title),
body: dto.body.trim(),
noteType: dto.noteType?.trim() || 'general',
visibility: normalizeVisibility(dto.visibility),
pinned: dto.pinned ?? false,
color: emptyToNull(dto.color) ?? 'default',
tags: normalizeTags(dto.tags),
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
checklist: checklist.length
? (checklist as unknown as Prisma.InputJsonValue)
: Prisma.JsonNull,
locationText: emptyToNull(dto.locationText ?? null),
analyzeEnabled,
customFields: toJsonCustomFields(dto.customFields),
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
activityId: emptyToNull(dto.activityId ?? null),
},
include,
});
this.game.emit({
organizationId,
userId,
event: 'NOTE_CREATED',
entityType: 'note',
entityId: row.id,
entityTitle: row.title ?? row.body.slice(0, 60),
facts: { hasDescription: row.body.trim().length >= 20 },
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: NoteActor,
dto: UpdateNoteRequest,
): Promise<NoteDto> {
const existing = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
});
if (!existing) throw new NotFoundException('یادداشت پیدا نشد');
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.visibility !== undefined) {
data.visibility = normalizeVisibility(dto.visibility);
}
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 (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;
}
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
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);
if ('activityId' in dto) {
data.activityId = emptyToNull(dto.activityId ?? null);
}
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,
},
});
}
const row = await this.prisma.note.update({
where: { id },
data,
include,
});
return this.toDto(row);
}
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),
});
}
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;
activityId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const activityId = emptyToNull(dto.activityId ?? 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, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
if (activityId) {
const activity = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: { id: true },
});
if (!activity) throw new BadRequestException('فعالیت انتخاب‌شده معتبر نیست');
}
}
private toDto(row: NoteRow): NoteDto {
return {
id: row.id,
title: row.title,
body: row.body,
noteType: row.noteType,
visibility: normalizeVisibility(row.visibility),
pinned: row.pinned,
color: row.color,
tags: row.tags,
reminderAt: row.reminderAt?.toISOString() ?? null,
checklist: parseChecklist(row.checklist),
locationText: row.locationText ?? null,
analyzeEnabled: row.analyzeEnabled ?? true,
customFields: asCustomFields(row.customFields),
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
activity: row.activity,
author: row.author,
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
revisionCount: row._count?.revisions ?? 0,
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 normalizeVisibility(value?: string | null): NoteVisibility {
return value === 'personal' ? 'personal' : 'team';
}
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);
}
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);
}