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:
+68
-19
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,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 {}
|
||||
|
||||
Reference in New Issue
Block a user