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) assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
createdById String? createdById String?
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
notes Note[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -405,12 +406,18 @@ model Note {
color String? @default("default") color String? @default("default")
tags String[] @default([]) tags String[] @default([])
reminderAt DateTime? reminderAt DateTime?
/// [{ id: string, text: string, done: boolean }]
checklist Json?
locationText String?
analyzeEnabled Boolean @default(true)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String? companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
dealId String? dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
activityId String?
activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull)
authorId String? authorId String?
author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull) author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull)
attachments NoteAttachment[] attachments NoteAttachment[]
@@ -424,6 +431,7 @@ model Note {
@@index([personId]) @@index([personId])
@@index([companyId]) @@index([companyId])
@@index([dealId]) @@index([dealId])
@@index([activityId])
@@index([authorId]) @@index([authorId])
} }
+67
View File
@@ -12,6 +12,7 @@ import {
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
const NOTE_TYPES = [ const NOTE_TYPES = [
@@ -21,8 +22,28 @@ const NOTE_TYPES = [
'important', 'important',
'warning', 'warning',
'next_action', 'next_action',
'follow_up',
'idea',
'suggestion',
'personal',
] as const; ] 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 { export class ListNotesQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -53,6 +74,10 @@ export class ListNotesQueryDto {
@IsIn(NOTE_TYPES) @IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number]; noteType?: (typeof NOTE_TYPES)[number];
@IsOptional()
@IsIn(NOTE_SORTS)
sort?: (typeof NOTE_SORTS)[number];
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@@ -101,6 +126,22 @@ export class CreateNoteDto {
@IsDateString() @IsDateString()
reminderAt?: string | null; 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() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -115,6 +156,11 @@ export class CreateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
dealId?: string | null; dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
activityId?: string | null;
} }
export class UpdateNoteDto { export class UpdateNoteDto {
@@ -153,6 +199,22 @@ export class UpdateNoteDto {
@IsDateString() @IsDateString()
reminderAt?: string | null; 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() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -167,6 +229,11 @@ export class UpdateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
dealId?: string | null; dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
activityId?: string | null;
} }
class NoteAiNextActionDtoClass { class NoteAiNextActionDtoClass {
+1
View File
@@ -43,6 +43,7 @@ export class NotesController {
dealId: query.dealId, dealId: query.dealId,
tag: query.tag, tag: query.tag,
noteType: query.noteType, noteType: query.noteType,
sort: query.sort,
page: query.page ?? 1, page: query.page ?? 1,
limit: query.limit ?? 50, limit: query.limit ?? 50,
}); });
+75 -2
View File
@@ -6,6 +6,7 @@ import {
import type { import type {
CreateNoteRequest, CreateNoteRequest,
NoteAttachmentDto, NoteAttachmentDto,
NoteChecklistItemDto,
NoteDto, NoteDto,
PaginatedResponse, PaginatedResponse,
UpdateNoteRequest, UpdateNoteRequest,
@@ -32,11 +33,15 @@ type NoteRow = {
color: string | null; color: string | null;
tags: string[]; tags: string[];
reminderAt: Date | null; reminderAt: Date | null;
checklist: Prisma.JsonValue | null;
locationText: string | null;
analyzeEnabled: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null; person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null; company: { id: string; name: string } | null;
deal: { id: string; title: string } | null; deal: { id: string; title: string } | null;
activity: { id: string; title: string } | null;
author: { id: string; fullName: string } | null; author: { id: string; fullName: string } | null;
attachments: Array<{ attachments: Array<{
id: string; id: string;
@@ -51,6 +56,7 @@ const include = {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } }, company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } }, deal: { select: { id: true, title: true } },
activity: { select: { id: true, title: true } },
author: { select: { id: true, fullName: true } }, author: { select: { id: true, fullName: true } },
attachments: { attachments: {
select: { select: {
@@ -81,6 +87,12 @@ const ALLOWED_MIME = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/zip',
'application/x-zip-compressed', 'application/x-zip-compressed',
'audio/webm',
'audio/wav',
'audio/mpeg',
'audio/mp4',
'audio/ogg',
'audio/x-m4a',
]); ]);
@Injectable() @Injectable()
@@ -101,6 +113,7 @@ export class NotesService {
dealId?: string; dealId?: string;
tag?: string; tag?: string;
noteType?: string; noteType?: string;
sort?: 'updatedAt' | 'createdAt' | 'reminderAt';
page?: number; page?: number;
limit?: number; limit?: number;
}, },
@@ -131,11 +144,17 @@ export class NotesService {
: access), : access),
}; };
const sortField = opts.sort ?? 'updatedAt';
const orderBy: Prisma.NoteOrderByWithRelationInput[] = [
{ pinned: 'desc' },
{ [sortField]: 'desc' },
];
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
this.prisma.note.findMany({ this.prisma.note.findMany({
where, where,
include, include,
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }], orderBy,
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
}), }),
@@ -174,6 +193,8 @@ export class NotesService {
actor ?? { userId, role: 'owner' }, actor ?? { userId, role: 'owner' },
dto, dto,
); );
const analyzeEnabled = dto.analyzeEnabled !== false;
const checklist = parseChecklist(dto.checklist);
const row = await this.prisma.note.create({ const row = await this.prisma.note.create({
data: { data: {
organizationId, organizationId,
@@ -185,9 +206,15 @@ export class NotesService {
color: emptyToNull(dto.color) ?? 'default', color: emptyToNull(dto.color) ?? 'default',
tags: normalizeTags(dto.tags), tags: normalizeTags(dto.tags),
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null, 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), personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null), companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null), dealId: emptyToNull(dto.dealId ?? null),
activityId: emptyToNull(dto.activityId ?? null),
}, },
include, include,
}); });
@@ -213,9 +240,24 @@ export class NotesService {
if ('reminderAt' in dto) { if ('reminderAt' in dto) {
data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null; 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 ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? 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({ const row = await this.prisma.note.update({
where: { id }, where: { id },
@@ -308,7 +350,7 @@ export class NotesService {
const mimeType = (file.mimetype || '').toLowerCase(); const mimeType = (file.mimetype || '').toLowerCase();
if (!ALLOWED_MIME.has(mimeType)) { if (!ALLOWED_MIME.has(mimeType)) {
throw new BadRequestException( throw new BadRequestException(
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن یا ZIP بفرستید.', 'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن، ZIP یا صوت بفرستید.',
); );
} }
@@ -404,11 +446,13 @@ export class NotesService {
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
activityId?: string | null;
}, },
) { ) {
const personId = emptyToNull(dto.personId ?? null); const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null); const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null); const dealId = emptyToNull(dto.dealId ?? null);
const activityId = emptyToNull(dto.activityId ?? null);
const scope = ownerWhere(actor); const scope = ownerWhere(actor);
if (personId) { if (personId) {
@@ -432,6 +476,13 @@ export class NotesService {
}); });
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست'); 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 { private toDto(row: NoteRow): NoteDto {
@@ -444,6 +495,9 @@ export class NotesService {
color: row.color, color: row.color,
tags: row.tags, tags: row.tags,
reminderAt: row.reminderAt?.toISOString() ?? null, reminderAt: row.reminderAt?.toISOString() ?? null,
checklist: parseChecklist(row.checklist),
locationText: row.locationText ?? null,
analyzeEnabled: row.analyzeEnabled ?? true,
person: row.person person: row.person
? { ? {
id: row.person.id, id: row.person.id,
@@ -452,6 +506,7 @@ export class NotesService {
: null, : null,
company: row.company, company: row.company,
deal: row.deal, deal: row.deal,
activity: row.activity,
author: row.author, author: row.author,
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)), attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
@@ -500,3 +555,21 @@ function sanitizeFileName(raw: string): string {
const trimmed = base.trim() || 'file'; const trimmed = base.trim() || 'file';
return trimmed.slice(0, 180); 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" | "meeting"
| "important" | "important"
| "warning" | "warning"
| "next_action"; | "next_action"
| "follow_up"
| "idea"
| "suggestion"
| "personal";
export interface NoteAttachmentDto { export interface NoteAttachmentDto {
id: string; id: string;
@@ -558,6 +562,12 @@ export interface NoteAttachmentDto {
createdAt: string; createdAt: string;
} }
export interface NoteChecklistItemDto {
id: string;
text: string;
done: boolean;
}
export interface NoteDto { export interface NoteDto {
id: string; id: string;
title: string | null; title: string | null;
@@ -567,9 +577,13 @@ export interface NoteDto {
color: string | null; color: string | null;
tags: string[]; tags: string[];
reminderAt: string | null; reminderAt: string | null;
checklist: NoteChecklistItemDto[];
locationText: string | null;
analyzeEnabled: boolean;
person: PersonSummaryDto | null; person: PersonSummaryDto | null;
company: CompanySummaryDto | null; company: CompanySummaryDto | null;
deal: DealSummaryDto | null; deal: DealSummaryDto | null;
activity: { id: string; title: string } | null;
author: UserSummaryDto | null; author: UserSummaryDto | null;
attachments: NoteAttachmentDto[]; attachments: NoteAttachmentDto[];
createdAt: string; createdAt: string;
@@ -584,9 +598,13 @@ export interface CreateNoteRequest {
color?: string; color?: string;
tags?: string[]; tags?: string[];
reminderAt?: string | null; reminderAt?: string | null;
checklist?: NoteChecklistItemDto[];
locationText?: string | null;
analyzeEnabled?: boolean;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
activityId?: string | null;
} }
export interface UpdateNoteRequest { export interface UpdateNoteRequest {
@@ -597,9 +615,13 @@ export interface UpdateNoteRequest {
color?: string | null; color?: string | null;
tags?: string[]; tags?: string[];
reminderAt?: string | null; reminderAt?: string | null;
checklist?: NoteChecklistItemDto[];
locationText?: string | null;
analyzeEnabled?: boolean;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
activityId?: string | null;
} }
export type NoteAiSuggestionStatus = export type NoteAiSuggestionStatus =