Add personal/team note visibility and edit history.

Notes can be marked personal or team, with revision snapshots and restore from the notes UI.
This commit is contained in:
2026-09-27 13:19:08 +03:30
parent 55a287563d
commit 00f8b91d78
12 changed files with 655 additions and 22 deletions
+116 -2
View File
@@ -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<Actor, 'userId' | 'role'>;
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<NoteDto> {
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<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,
@@ -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<string>();