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:
@@ -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 $$;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1854
-835
File diff suppressed because it is too large
Load Diff
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user