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> { const page = opts.page ?? 1; const limit = opts.limit ?? 50; const access = noteAccessWhere(actor); const search = buildSearchWhere(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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; 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(); 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; 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); }