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:
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user