From 4e3dbb4e00582d739cddba68496adb1f3df5d324 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 19:13:01 +0330 Subject: [PATCH] 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 --- .../migration.sql | 21 + apps/api/prisma/schema.prisma | 8 + apps/api/src/notes/dto.ts | 67 + apps/api/src/notes/notes.controller.ts | 1 + apps/api/src/notes/notes.service.ts | 77 +- apps/web/src/views/NotesView.vue | 2689 ++++++++++++----- packages/shared/src/index.ts | 24 +- 7 files changed, 2049 insertions(+), 838 deletions(-) create mode 100644 apps/api/prisma/migrations/20260905190000_note_checklist_extras/migration.sql diff --git a/apps/api/prisma/migrations/20260905190000_note_checklist_extras/migration.sql b/apps/api/prisma/migrations/20260905190000_note_checklist_extras/migration.sql new file mode 100644 index 0000000..dafa712 --- /dev/null +++ b/apps/api/prisma/migrations/20260905190000_note_checklist_extras/migration.sql @@ -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 $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9912463..96d5aa2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) } diff --git a/apps/api/src/notes/dto.ts b/apps/api/src/notes/dto.ts index 6a5228d..59d62ac 100644 --- a/apps/api/src/notes/dto.ts +++ b/apps/api/src/notes/dto.ts @@ -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 { diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts index 25a0cd0..1353852 100644 --- a/apps/api/src/notes/notes.controller.ts +++ b/apps/api/src/notes/notes.controller.ts @@ -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, }); diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index e387d29..3d07070 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -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; + 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); +} diff --git a/apps/web/src/views/NotesView.vue b/apps/web/src/views/NotesView.vue index ff22c15..e066e4c 100644 --- a/apps/web/src/views/NotesView.vue +++ b/apps/web/src/views/NotesView.vue @@ -1,16 +1,25 @@