diff --git a/apps/api/prisma/migrations/20260927140000_note_visibility_revisions/migration.sql b/apps/api/prisma/migrations/20260927140000_note_visibility_revisions/migration.sql new file mode 100644 index 0000000..337593e --- /dev/null +++ b/apps/api/prisma/migrations/20260927140000_note_visibility_revisions/migration.sql @@ -0,0 +1,40 @@ +-- AlterTable +ALTER TABLE "Note" ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'team'; + +-- CreateIndex +CREATE INDEX "Note_organizationId_visibility_idx" ON "Note"("organizationId", "visibility"); + +-- CreateTable +CREATE TABLE "NoteRevision" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "noteId" TEXT NOT NULL, + "editorId" TEXT, + "title" TEXT, + "body" TEXT NOT NULL, + "noteType" TEXT NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'team', + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "checklist" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "NoteRevision_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "NoteRevision_noteId_createdAt_idx" ON "NoteRevision"("noteId", "createdAt"); + +-- CreateIndex +CREATE INDEX "NoteRevision_organizationId_idx" ON "NoteRevision"("organizationId"); + +-- CreateIndex +CREATE INDEX "NoteRevision_editorId_idx" ON "NoteRevision"("editorId"); + +-- AddForeignKey +ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_editorId_fkey" FOREIGN KEY ("editorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ee6c3c0..766bad8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -162,6 +162,7 @@ model Organization { deals Deal[] activities Activity[] notes Note[] + noteRevisions NoteRevision[] customFields CustomFieldDefinition[] products Product[] notifications Notification[] @@ -231,6 +232,7 @@ model User { activities Activity[] @relation("ActivityAssignee") createdActivities Activity[] @relation("ActivityCreator") notes Note[] @relation("NoteAuthor") + noteRevisions NoteRevision[] @relation("NoteRevisionEditor") noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") notifications Notification[] salesGoals SalesGoal[] @relation("SalesGoalUser") @@ -591,14 +593,18 @@ model Note { activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull) authorId String? author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull) + /// team = قابل‌مشاهده برای تیم/سازمان در محدوده پرونده | personal = فقط نویسنده + visibility String @default("team") attachments NoteAttachment[] aiSuggestion NoteAiSuggestion? + revisions NoteRevision[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([organizationId, pinned, updatedAt]) @@index([organizationId, noteType, updatedAt]) @@index([organizationId, reminderAt]) + @@index([organizationId, visibility]) @@index([personId]) @@index([companyId]) @@index([dealId]) @@ -606,6 +612,28 @@ model Note { @@index([authorId]) } +/// اسنپ‌شات متن قبل از هر ویرایش معنادار یادداشت +model NoteRevision { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + noteId String + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + editorId String? + editor User? @relation("NoteRevisionEditor", fields: [editorId], references: [id], onDelete: SetNull) + title String? + body String + noteType String + visibility String @default("team") + tags String[] @default([]) + checklist Json? + createdAt DateTime @default(now()) + + @@index([noteId, createdAt]) + @@index([organizationId]) + @@index([editorId]) +} + model NoteAttachment { id String @id @default(cuid()) organizationId String diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 3681312..9af0d11 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -61,18 +61,30 @@ export function assigneeWhere( return { assigneeId: { in: ids } }; } -/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید */ +/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */ export function noteAccessWhere( actor: Pick, ): Prisma.NoteWhereInput { const ids = scopedOwnerIds(actor); - if (!ids) return {}; - return { + const personalGate: Prisma.NoteWhereInput = { OR: [ - { authorId: { in: ids } }, - { deal: { ownerId: { in: ids } } }, - { person: { ownerId: { in: ids } } }, - { company: { ownerId: { in: ids } } }, + { visibility: { not: 'personal' } }, + { visibility: 'personal', authorId: actor.userId }, + // رکوردهای قدیمی بدون فیلد visibility در DB با default team می‌آیند + ], + }; + if (!ids) return personalGate; + return { + AND: [ + personalGate, + { + OR: [ + { authorId: { in: ids } }, + { deal: { ownerId: { in: ids } } }, + { person: { ownerId: { in: ids } } }, + { company: { ownerId: { in: ids } } }, + ], + }, ], }; } diff --git a/apps/api/src/notes/dto.ts b/apps/api/src/notes/dto.ts index 59d62ac..41345d5 100644 --- a/apps/api/src/notes/dto.ts +++ b/apps/api/src/notes/dto.ts @@ -28,6 +28,8 @@ const NOTE_TYPES = [ 'personal', ] as const; +const NOTE_VISIBILITIES = ['team', 'personal'] as const; + const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const; export class NoteChecklistItemDto { @@ -74,6 +76,10 @@ export class ListNotesQueryDto { @IsIn(NOTE_TYPES) noteType?: (typeof NOTE_TYPES)[number]; + @IsOptional() + @IsIn(NOTE_VISIBILITIES) + visibility?: (typeof NOTE_VISIBILITIES)[number]; + @IsOptional() @IsIn(NOTE_SORTS) sort?: (typeof NOTE_SORTS)[number]; @@ -107,6 +113,10 @@ export class CreateNoteDto { @IsIn(NOTE_TYPES) noteType?: (typeof NOTE_TYPES)[number]; + @IsOptional() + @IsIn(NOTE_VISIBILITIES) + visibility?: (typeof NOTE_VISIBILITIES)[number]; + @IsOptional() @IsBoolean() pinned?: boolean; @@ -180,6 +190,10 @@ export class UpdateNoteDto { @IsIn(NOTE_TYPES) noteType?: (typeof NOTE_TYPES)[number]; + @IsOptional() + @IsIn(NOTE_VISIBILITIES) + visibility?: (typeof NOTE_VISIBILITIES)[number]; + @IsOptional() @IsBoolean() pinned?: boolean; diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts index 1353852..942790f 100644 --- a/apps/api/src/notes/notes.controller.ts +++ b/apps/api/src/notes/notes.controller.ts @@ -19,8 +19,15 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto'; import { NoteAiService } from './note-ai.service'; import { NotesService } from './notes.service'; +import type { DataVisibility } from '@mento/shared'; -type AuthUser = { organizationId: string; userId: string; role: string }; +type AuthUser = { + organizationId: string; + userId: string; + role: string; + dataVisibility?: DataVisibility; + visibleUserIds?: string[] | null; +}; @Controller('notes') @UseGuards(JwtAuthGuard) @@ -43,12 +50,32 @@ export class NotesController { dealId: query.dealId, tag: query.tag, noteType: query.noteType, + visibility: query.visibility, sort: query.sort, page: query.page ?? 1, limit: query.limit ?? 50, }); } + @Get(':id/revisions') + listRevisions(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.notesService.listRevisions(user.organizationId, id, user); + } + + @Post(':id/revisions/:revisionId/restore') + restoreRevision( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('revisionId') revisionId: string, + ) { + return this.notesService.restoreRevision( + user.organizationId, + id, + revisionId, + user, + ); + } + @Get(':id') get(@CurrentUser() user: AuthUser, @Param('id') id: string) { return this.notesService.get(user.organizationId, id, user); diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index 3d07070..1d31262 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -8,6 +8,8 @@ import type { NoteAttachmentDto, NoteChecklistItemDto, NoteDto, + NoteRevisionDto, + NoteVisibility, PaginatedResponse, UpdateNoteRequest, } from '@mento/shared'; @@ -22,13 +24,17 @@ import { ownerWhere, } from '../common/record-scope'; -type NoteActor = Pick; +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[]; @@ -50,6 +56,7 @@ type NoteRow = { sizeBytes: number; createdAt: Date; }>; + _count?: { revisions: number }; }; const include = { @@ -68,6 +75,7 @@ const include = { }, orderBy: { createdAt: 'asc' as const }, }, + _count: { select: { revisions: true } }, } satisfies Prisma.NoteInclude; const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; @@ -113,6 +121,7 @@ export class NotesService { dealId?: string; tag?: string; noteType?: string; + visibility?: string; sort?: 'updatedAt' | 'createdAt' | 'reminderAt'; page?: number; limit?: number; @@ -138,6 +147,9 @@ export class NotesService { ...(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] } @@ -202,6 +214,7 @@ export class NotesService { 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), @@ -227,13 +240,19 @@ export class NotesService { actor: NoteActor, dto: UpdateNoteRequest, ): Promise { - await this.ensureExists(organizationId, id, actor); + 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); @@ -259,6 +278,49 @@ export class NotesService { 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, @@ -267,6 +329,52 @@ export class NotesService { 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, @@ -491,6 +599,7 @@ export class NotesService { title: row.title, body: row.body, noteType: row.noteType, + visibility: normalizeVisibility(row.visibility), pinned: row.pinned, color: row.color, tags: row.tags, @@ -509,6 +618,7 @@ export class NotesService { 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(), }; @@ -537,6 +647,10 @@ function emptyToNull(value?: string | null): string | null { 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(); diff --git a/apps/web/src/views/NotesView.vue b/apps/web/src/views/NotesView.vue index d8c5d22..6c8855f 100644 --- a/apps/web/src/views/NotesView.vue +++ b/apps/web/src/views/NotesView.vue @@ -21,11 +21,14 @@ import type { NoteAiSuggestionDto, NoteChecklistItemDto, NoteDto, + NoteRevisionDto, NoteType, + NoteVisibility, PaginatedResponse, PersonDto, PipelineBoardDto, } from '@mento/shared'; +import { NOTE_VISIBILITY_LABELS } from '@mento/shared'; type ViewMode = 'grid' | 'list'; type SortKey = 'updatedAt' | 'createdAt' | 'reminderAt'; @@ -39,6 +42,7 @@ const saving = ref(false); const error = ref(''); const q = ref(''); const typeFilter = ref('all'); +const visibilityFilter = ref('all'); const sortKey = ref('updatedAt'); const viewMode = ref('grid'); const notes = ref([]); @@ -67,10 +71,17 @@ const draftReminder = ref(''); const draftLocation = ref(''); const draftAnalyze = ref(true); const draftPinned = ref(false); +const draftVisibility = ref('team'); const draftChecklist = ref([]); const draftFiles = ref([]); const existingAttachments = ref([]); +const historyOpen = ref(false); +const historyLoading = ref(false); +const historyError = ref(''); +const historyNoteId = ref(null); +const historyItems = ref([]); + const imageInput = ref(null); const fileInput = ref(null); const audioInput = ref(null); @@ -127,6 +138,10 @@ const typeLabelMap: Record = { const editorTitle = computed(() => (editingId.value ? 'ویرایش' : 'یادداشت جدید')); const draftColorCss = computed(() => colorToCss(draftColor.value)); +const historyNoteRevisionCount = computed(() => { + if (!editingId.value) return 0; + return notes.value.find((n) => n.id === editingId.value)?.revisionCount ?? 0; +}); const personName = (id: string) => people.value.find((p) => p.id === id)?.fullName ?? ''; @@ -314,6 +329,10 @@ async function load() { params: { q: q.value || undefined, noteType: typeFilter.value === 'all' ? undefined : typeFilter.value, + visibility: + visibilityFilter.value === 'all' + ? undefined + : visibilityFilter.value, sort: sortKey.value, limit: 100, }, @@ -361,6 +380,7 @@ function resetDraft() { draftLocation.value = ''; draftAnalyze.value = true; draftPinned.value = false; + draftVisibility.value = 'team'; draftChecklist.value = []; draftFiles.value = []; existingAttachments.value = []; @@ -384,6 +404,8 @@ function openEditor(mode: OpenMode = 'default', note?: NoteDto) { draftLocation.value = note.locationText ?? ''; draftAnalyze.value = note.analyzeEnabled !== false; draftPinned.value = note.pinned; + draftVisibility.value = + note.visibility === 'personal' ? 'personal' : 'team'; draftChecklist.value = [...(note.checklist ?? [])]; draftFiles.value = []; existingAttachments.value = [...note.attachments]; @@ -487,6 +509,7 @@ async function saveNote() { title: draftTitle.value.trim() || null, body: body || (checklist.length ? 'چک‌لیست' : ''), noteType: draftType.value, + visibility: draftVisibility.value, color: draftColor.value === 'default' ? 'default' : draftColor.value, tags: parseTags(draftTags.value), personId: draftPersonId.value || null, @@ -532,6 +555,56 @@ async function saveNote() { } } +async function openHistory(note: NoteDto) { + menuOpenId.value = null; + historyNoteId.value = note.id; + historyOpen.value = true; + historyLoading.value = true; + historyError.value = ''; + historyItems.value = []; + try { + const { data } = await api.get( + `/notes/${note.id}/revisions`, + ); + historyItems.value = data; + } catch (e) { + historyError.value = axios.isAxiosError(e) + ? String( + (e.response?.data as { message?: string })?.message ?? + 'بارگذاری تاریخچه ناموفق بود.', + ) + : 'بارگذاری تاریخچه ناموفق بود.'; + } finally { + historyLoading.value = false; + } +} + +function closeHistory() { + historyOpen.value = false; + historyNoteId.value = null; + historyItems.value = []; + historyError.value = ''; +} + +async function restoreRevision(revisionId: string) { + if (!historyNoteId.value) return; + if (!confirm('این نسخه جایگزین متن فعلی شود؟')) return; + try { + await api.post( + `/notes/${historyNoteId.value}/revisions/${revisionId}/restore`, + ); + closeHistory(); + await load(); + } catch (e) { + historyError.value = axios.isAxiosError(e) + ? String( + (e.response?.data as { message?: string })?.message ?? + 'بازگردانی ناموفق بود.', + ) + : 'بازگردانی ناموفق بود.'; + } +} + async function togglePin(id: string) { menuOpenId.value = null; await api.patch(`/notes/${id}/pin`); @@ -587,6 +660,9 @@ function onDocClick(ev: MouseEvent) { watch(typeFilter, () => { void load(); }); +watch(visibilityFilter, () => { + void load(); +}); watch(sortKey, () => { void load(); }); @@ -636,6 +712,31 @@ onBeforeUnmount(() => { {{ chip.label }} +