Redesign notes list and editor to match Keep-style mockups.

Add checklist, activity link, location, analyze flag, and audio attachments with a real API-backed masonry UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 19:13:01 +03:30
co-authored by Cursor
parent 32af147237
commit 4e3dbb4e00
7 changed files with 2049 additions and 838 deletions
@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "checklist" JSONB;
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "activityId" TEXT;
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "locationText" TEXT;
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "analyzeEnabled" BOOLEAN NOT NULL DEFAULT true;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Note_activityId_idx" ON "Note"("activityId");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Note_activityId_fkey'
) THEN
ALTER TABLE "Note"
ADD CONSTRAINT "Note_activityId_fkey"
FOREIGN KEY ("activityId") REFERENCES "Activity"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
+8
View File
@@ -383,6 +383,7 @@ model Activity {
assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
createdById String?
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
notes Note[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -405,12 +406,18 @@ model Note {
color String? @default("default")
tags String[] @default([])
reminderAt DateTime?
/// [{ id: string, text: string, done: boolean }]
checklist Json?
locationText String?
analyzeEnabled Boolean @default(true)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
activityId String?
activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull)
authorId String?
author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull)
attachments NoteAttachment[]
@@ -424,6 +431,7 @@ model Note {
@@index([personId])
@@index([companyId])
@@index([dealId])
@@index([activityId])
@@index([authorId])
}
+67
View File
@@ -12,6 +12,7 @@ import {
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
const NOTE_TYPES = [
@@ -21,8 +22,28 @@ const NOTE_TYPES = [
'important',
'warning',
'next_action',
'follow_up',
'idea',
'suggestion',
'personal',
] as const;
const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const;
export class NoteChecklistItemDto {
@IsString()
@MinLength(1)
@MaxLength(64)
id!: string;
@IsString()
@MaxLength(500)
text!: string;
@IsBoolean()
done!: boolean;
}
export class ListNotesQueryDto {
@IsOptional()
@IsString()
@@ -53,6 +74,10 @@ export class ListNotesQueryDto {
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@IsIn(NOTE_SORTS)
sort?: (typeof NOTE_SORTS)[number];
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -101,6 +126,22 @@ export class CreateNoteDto {
@IsDateString()
reminderAt?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NoteChecklistItemDto)
checklist?: NoteChecklistItemDto[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(500)
locationText?: string | null;
@IsOptional()
@IsBoolean()
analyzeEnabled?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -115,6 +156,11 @@ export class CreateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
activityId?: string | null;
}
export class UpdateNoteDto {
@@ -153,6 +199,22 @@ export class UpdateNoteDto {
@IsDateString()
reminderAt?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NoteChecklistItemDto)
checklist?: NoteChecklistItemDto[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(500)
locationText?: string | null;
@IsOptional()
@IsBoolean()
analyzeEnabled?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -167,6 +229,11 @@ export class UpdateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
activityId?: string | null;
}
class NoteAiNextActionDtoClass {
+1
View File
@@ -43,6 +43,7 @@ export class NotesController {
dealId: query.dealId,
tag: query.tag,
noteType: query.noteType,
sort: query.sort,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
+75 -2
View File
@@ -6,6 +6,7 @@ import {
import type {
CreateNoteRequest,
NoteAttachmentDto,
NoteChecklistItemDto,
NoteDto,
PaginatedResponse,
UpdateNoteRequest,
@@ -32,11 +33,15 @@ type NoteRow = {
color: string | null;
tags: string[];
reminderAt: Date | null;
checklist: Prisma.JsonValue | null;
locationText: string | null;
analyzeEnabled: boolean;
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;
@@ -51,6 +56,7 @@ 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: {
@@ -81,6 +87,12 @@ const ALLOWED_MIME = new Set([
'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()
@@ -101,6 +113,7 @@ export class NotesService {
dealId?: string;
tag?: string;
noteType?: string;
sort?: 'updatedAt' | 'createdAt' | 'reminderAt';
page?: number;
limit?: number;
},
@@ -131,11 +144,17 @@ export class NotesService {
: 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: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
orderBy,
skip: (page - 1) * limit,
take: limit,
}),
@@ -174,6 +193,8 @@ export class NotesService {
actor ?? { userId, role: 'owner' },
dto,
);
const analyzeEnabled = dto.analyzeEnabled !== false;
const checklist = parseChecklist(dto.checklist);
const row = await this.prisma.note.create({
data: {
organizationId,
@@ -185,9 +206,15 @@ export class NotesService {
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,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
activityId: emptyToNull(dto.activityId ?? null),
},
include,
});
@@ -213,9 +240,24 @@ export class NotesService {
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 ('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 row = await this.prisma.note.update({
where: { id },
@@ -308,7 +350,7 @@ export class NotesService {
const mimeType = (file.mimetype || '').toLowerCase();
if (!ALLOWED_MIME.has(mimeType)) {
throw new BadRequestException(
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن یا ZIP بفرستید.',
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن، ZIP یا صوت بفرستید.',
);
}
@@ -404,11 +446,13 @@ export class NotesService {
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) {
@@ -432,6 +476,13 @@ export class NotesService {
});
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 {
@@ -444,6 +495,9 @@ export class NotesService {
color: row.color,
tags: row.tags,
reminderAt: row.reminderAt?.toISOString() ?? null,
checklist: parseChecklist(row.checklist),
locationText: row.locationText ?? null,
analyzeEnabled: row.analyzeEnabled ?? true,
person: row.person
? {
id: row.person.id,
@@ -452,6 +506,7 @@ export class NotesService {
: null,
company: row.company,
deal: row.deal,
activity: row.activity,
author: row.author,
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
createdAt: row.createdAt.toISOString(),
@@ -500,3 +555,21 @@ function sanitizeFileName(raw: string): string {
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);
}
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -548,7 +548,11 @@ export type NoteType =
| "meeting"
| "important"
| "warning"
| "next_action";
| "next_action"
| "follow_up"
| "idea"
| "suggestion"
| "personal";
export interface NoteAttachmentDto {
id: string;
@@ -558,6 +562,12 @@ export interface NoteAttachmentDto {
createdAt: string;
}
export interface NoteChecklistItemDto {
id: string;
text: string;
done: boolean;
}
export interface NoteDto {
id: string;
title: string | null;
@@ -567,9 +577,13 @@ export interface NoteDto {
color: string | null;
tags: string[];
reminderAt: string | null;
checklist: NoteChecklistItemDto[];
locationText: string | null;
analyzeEnabled: boolean;
person: PersonSummaryDto | null;
company: CompanySummaryDto | null;
deal: DealSummaryDto | null;
activity: { id: string; title: string } | null;
author: UserSummaryDto | null;
attachments: NoteAttachmentDto[];
createdAt: string;
@@ -584,9 +598,13 @@ export interface CreateNoteRequest {
color?: string;
tags?: string[];
reminderAt?: string | null;
checklist?: NoteChecklistItemDto[];
locationText?: string | null;
analyzeEnabled?: boolean;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
activityId?: string | null;
}
export interface UpdateNoteRequest {
@@ -597,9 +615,13 @@ export interface UpdateNoteRequest {
color?: string | null;
tags?: string[];
reminderAt?: string | null;
checklist?: NoteChecklistItemDto[];
locationText?: string | null;
analyzeEnabled?: boolean;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
activityId?: string | null;
}
export type NoteAiSuggestionStatus =