feat(notes): let organizations edit the note category bar

The filter chips were fixed labels. Owners can rename, hide, reorder, and add categories.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 16:34:34 +03:30
co-authored by Cursor
parent 8e1e94a439
commit 81edf61065
10 changed files with 645 additions and 390 deletions
+68 -19
View File
@@ -16,19 +16,6 @@ import {
ValidateNested,
} from 'class-validator';
const NOTE_TYPES = [
'general',
'call_result',
'meeting',
'important',
'warning',
'next_action',
'follow_up',
'idea',
'suggestion',
'personal',
] as const;
const NOTE_VISIBILITIES = ['team', 'personal'] as const;
const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const;
@@ -74,8 +61,10 @@ export class ListNotesQueryDto {
tag?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -111,8 +100,10 @@ export class CreateNoteDto {
body!: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -192,8 +183,10 @@ export class UpdateNoteDto {
body?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -309,3 +302,59 @@ export class ApplyNoteAiSuggestionDto {
@Type(() => ApplyMemoryEditDto)
memoryItems?: ApplyMemoryEditDto[];
}
const NOTE_CATEGORY_TONES = [
'general',
'follow_up',
'idea',
'suggestion',
'meeting',
'personal',
] as const;
export class CreateNoteCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
}
export class UpdateNoteCategoryDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(40)
label?: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
export class ReorderNoteCategoriesDto {
@IsArray()
@IsString({ each: true })
ids!: string[];
}
@@ -0,0 +1,165 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { NoteCategoryDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateNoteCategoryDto,
UpdateNoteCategoryDto,
} from './dto';
const DEFAULTS: Array<{
key: string;
label: string;
tone: string;
showInBar: boolean;
}> = [
{ key: 'general', label: 'معمولی', tone: 'general', showInBar: true },
{ key: 'follow_up', label: 'پیگیری', tone: 'follow_up', showInBar: true },
{ key: 'idea', label: 'ایده', tone: 'idea', showInBar: true },
{ key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', showInBar: true },
{ key: 'meeting', label: 'جلسه', tone: 'meeting', showInBar: true },
{ key: 'personal', label: 'شخصی', tone: 'personal', showInBar: true },
{ key: 'call_result', label: 'نتیجه تماس', tone: 'follow_up', showInBar: false },
{ key: 'important', label: 'نکته مهم', tone: 'suggestion', showInBar: false },
{ key: 'warning', label: 'هشدار', tone: 'follow_up', showInBar: false },
{ key: 'next_action', label: 'کار بعدی', tone: 'follow_up', showInBar: false },
];
@Injectable()
export class NoteCategoriesService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<NoteCategoryDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.noteCategory.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateNoteCategoryDto,
): Promise<NoteCategoryDto> {
await this.ensureDefaults(organizationId);
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان دسته لازم است');
const max = await this.prisma.noteCategory.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.noteCategory.create({
data: {
organizationId,
key: `cat_${Date.now().toString(36)}`,
label,
tone: dto.tone ?? 'general',
showInBar: dto.showInBar ?? true,
position: (max._max.position ?? -1) + 1,
isSystem: false,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateNoteCategoryDto,
): Promise<NoteCategoryDto> {
const existing = await this.prisma.noteCategory.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('دسته پیدا نشد');
const label = dto.label?.trim();
if (dto.label !== undefined && !label) {
throw new BadRequestException('عنوان دسته لازم است');
}
const row = await this.prisma.noteCategory.update({
where: { id },
data: {
...(label ? { label } : {}),
...(dto.tone !== undefined ? { tone: dto.tone } : {}),
...(dto.showInBar !== undefined ? { showInBar: dto.showInBar } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.position !== undefined ? { position: dto.position } : {}),
},
});
return this.toDto(row);
}
async reorder(organizationId: string, ids: string[]): Promise<NoteCategoryDto[]> {
const rows = await this.prisma.noteCategory.findMany({
where: { organizationId },
select: { id: true },
});
const known = new Set(rows.map((row) => row.id));
const ordered = ids.filter((id) => known.has(id));
await this.prisma.$transaction(
ordered.map((id, index) =>
this.prisma.noteCategory.update({
where: { id },
data: { position: index },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.noteCategory.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('دسته پیدا نشد');
if (existing.isSystem) {
throw new BadRequestException('دستهٔ پیش‌فرض حذف نمی‌شود؛ می‌توانید از نوار پنهانش کنید');
}
await this.prisma.noteCategory.delete({ where: { id } });
}
private async ensureDefaults(organizationId: string) {
const count = await this.prisma.noteCategory.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.noteCategory.createMany({
data: DEFAULTS.map((item, index) => ({
organizationId,
key: item.key,
label: item.label,
tone: item.tone,
position: index,
showInBar: item.showInBar,
isSystem: true,
isActive: true,
})),
skipDuplicates: true,
});
}
private toDto(row: {
id: string;
key: string;
label: string;
tone: string;
position: number;
isActive: boolean;
showInBar: boolean;
isSystem: boolean;
}): NoteCategoryDto {
return {
id: row.id,
key: row.key,
label: row.label,
tone: row.tone,
position: row.position,
isActive: row.isActive,
showInBar: row.showInBar,
isSystem: row.isSystem,
};
}
}
+57 -1
View File
@@ -15,10 +15,21 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
import {
CreateNoteDto,
ListNotesQueryDto,
UpdateNoteDto,
ApplyNoteAiSuggestionDto,
CreateNoteCategoryDto,
UpdateNoteCategoryDto,
ReorderNoteCategoriesDto,
} from './dto';
import { NoteAiService } from './note-ai.service';
import { NoteCategoriesService } from './note-categories.service';
import { NotesService } from './notes.service';
import type { DataVisibility } from '@mento/shared';
@@ -37,8 +48,53 @@ export class NotesController {
constructor(
private readonly notesService: NotesService,
private readonly noteAi: NoteAiService,
private readonly categories: NoteCategoriesService,
) {}
@Get('categories')
listCategories(@CurrentUser() user: AuthUser) {
return this.categories.list(user.organizationId);
}
@Post('categories')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createCategory(
@CurrentUser() user: AuthUser,
@Body() dto: CreateNoteCategoryDto,
) {
return this.categories.create(user.organizationId, dto);
}
@Patch('categories/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderCategories(
@CurrentUser() user: AuthUser,
@Body() dto: ReorderNoteCategoriesDto,
) {
return this.categories.reorder(user.organizationId, dto.ids);
}
@Patch('categories/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateNoteCategoryDto,
) {
return this.categories.update(user.organizationId, id, dto);
}
@Delete('categories/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeCategory(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.categories.remove(user.organizationId, id);
return { ok: true };
}
@Get()
list(
@CurrentUser() user: AuthUser,
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { MemoryModule } from '../memory/memory.module';
import { NotesController } from './notes.controller';
import { NoteAiService } from './note-ai.service';
import { NoteCategoriesService } from './note-categories.service';
import { NotesService } from './notes.service';
@Module({
imports: [MemoryModule],
controllers: [NotesController],
providers: [NotesService, NoteAiService],
providers: [NotesService, NoteAiService, NoteCategoriesService],
exports: [NotesService],
})
export class NotesModule {}