Add Smart Notes AI analysis and Mento Memory with user approval.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 14:29:34 +03:30
co-authored by Cursor
parent 4d9f3c04cd
commit d152d1c19f
20 changed files with 1577 additions and 9 deletions
+51
View File
@@ -168,3 +168,54 @@ export class UpdateNoteDto {
@IsString()
dealId?: string | null;
}
class NoteAiNextActionDtoClass {
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsDateString()
dueAt?: string | null;
}
class ApplyMemoryEditDto {
@Type(() => Number)
@IsInt()
@Min(0)
index!: number;
@IsString()
@MinLength(2)
@MaxLength(500)
content!: string;
}
export class ApplyNoteAiSuggestionDto {
@IsOptional()
@IsBoolean()
applyNextAction?: boolean;
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
memoryIndices?: number[];
@IsOptional()
@Type(() => NoteAiNextActionDtoClass)
nextAction?: NoteAiNextActionDtoClass;
@IsOptional()
@IsArray()
@Type(() => ApplyMemoryEditDto)
memoryItems?: ApplyMemoryEditDto[];
}
+249
View File
@@ -0,0 +1,249 @@
import {
BadRequestException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import type {
ApplyNoteAiSuggestionRequest,
NoteAiPayloadDto,
NoteAiSuggestionDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AiService } from '../ai/ai.service';
import { MemoryService } from '../memory/memory.service';
import { type Actor, noteAccessWhere } from '../common/record-scope';
@Injectable()
export class NoteAiService {
constructor(
private readonly prisma: PrismaService,
private readonly ai: AiService,
private readonly memory: MemoryService,
) {}
async analyze(
organizationId: string,
noteId: string,
actor: Actor,
): Promise<NoteAiSuggestionDto> {
const note = await this.loadNote(organizationId, noteId, actor);
if (!this.ai.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
}
try {
const extract = await this.ai.extractFromNote({
body: note.body,
title: note.title,
noteType: note.noteType,
personName: note.person
? `${note.person.firstName} ${note.person.lastName}`.trim()
: null,
});
const row = await this.prisma.noteAiSuggestion.upsert({
where: { noteId },
create: {
organizationId,
noteId,
status: 'pending',
payload: extract,
analyzedAt: new Date(),
},
update: {
status: 'pending',
payload: extract,
errorMessage: null,
analyzedAt: new Date(),
},
});
return this.toDto(row);
} catch (err) {
const message =
err instanceof Error ? err.message : 'تحلیل یادداشت ناموفق بود';
await this.prisma.noteAiSuggestion.upsert({
where: { noteId },
create: {
organizationId,
noteId,
status: 'failed',
errorMessage: message,
analyzedAt: new Date(),
},
update: {
status: 'failed',
errorMessage: message,
analyzedAt: new Date(),
},
});
throw err instanceof ServiceUnavailableException ||
err instanceof BadRequestException
? err
: new ServiceUnavailableException(message);
}
}
async getSuggestion(
organizationId: string,
noteId: string,
actor: Actor,
): Promise<NoteAiSuggestionDto | null> {
await this.ensureNoteAccess(organizationId, noteId, actor);
const row = await this.prisma.noteAiSuggestion.findUnique({
where: { noteId },
});
return row ? this.toDto(row) : null;
}
async apply(
organizationId: string,
userId: string,
noteId: string,
actor: Actor,
dto: ApplyNoteAiSuggestionRequest,
): Promise<{
suggestion: NoteAiSuggestionDto;
activityId?: string;
memoryItemIds: string[];
}> {
const note = await this.loadNote(organizationId, noteId, actor);
const suggestion = await this.prisma.noteAiSuggestion.findUnique({
where: { noteId },
});
if (!suggestion || suggestion.status !== 'pending') {
throw new BadRequestException('پیشنهاد فعالی برای این یادداشت وجود ندارد');
}
const payload = suggestion.payload as NoteAiPayloadDto | null;
if (!payload) {
throw new BadRequestException('داده پیشنهاد نامعتبر است');
}
let activityId: string | undefined;
const memoryItemIds: string[] = [];
if (dto.applyNextAction) {
const next = dto.nextAction ?? payload.nextAction;
if (!next?.title?.trim()) {
throw new BadRequestException('عنوان کار بعدی الزامی است');
}
const activity = await this.prisma.activity.create({
data: {
organizationId,
type: 'task',
title: next.title.trim().slice(0, 200),
description: next.description?.trim() || note.body,
dueAt: next.dueAt ? new Date(next.dueAt) : note.reminderAt,
personId: note.personId,
companyId: note.companyId,
dealId: note.dealId,
assigneeId: userId,
},
});
activityId = activity.id;
}
if (dto.memoryIndices?.length) {
if (!note.personId) {
throw new BadRequestException(
'برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد',
);
}
const indices = [...new Set(dto.memoryIndices)].filter(
(i) => i >= 0 && i < payload.memoryItems.length,
);
for (const index of indices) {
const edited = dto.memoryItems?.find((m) => m.index === index);
const content =
edited?.content?.trim() ||
payload.memoryItems[index]?.content?.trim();
if (!content) continue;
const item = await this.memory.createFromNote(
organizationId,
userId,
note.personId,
noteId,
content,
);
memoryItemIds.push(item.id);
}
}
const updated = await this.prisma.noteAiSuggestion.update({
where: { noteId },
data: { status: 'applied' },
});
return {
suggestion: this.toDto(updated),
activityId,
memoryItemIds,
};
}
async dismiss(
organizationId: string,
noteId: string,
actor: Actor,
): Promise<NoteAiSuggestionDto> {
await this.ensureNoteAccess(organizationId, noteId, actor);
const existing = await this.prisma.noteAiSuggestion.findUnique({
where: { noteId },
});
if (!existing) {
throw new NotFoundException('پیشنهادی برای این یادداشت وجود ندارد');
}
const row = await this.prisma.noteAiSuggestion.update({
where: { noteId },
data: { status: 'dismissed' },
});
return this.toDto(row);
}
private async loadNote(
organizationId: string,
noteId: string,
actor: Actor,
) {
const note = await this.prisma.note.findFirst({
where: { id: noteId, organizationId, ...noteAccessWhere(actor) },
include: {
person: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
return note;
}
private async ensureNoteAccess(
organizationId: string,
noteId: string,
actor: Actor,
) {
await this.loadNote(organizationId, noteId, actor);
}
private toDto(row: {
id: string;
noteId: string;
status: string;
payload: unknown;
errorMessage: string | null;
analyzedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): NoteAiSuggestionDto {
return {
id: row.id,
noteId: row.noteId,
status: row.status as NoteAiSuggestionDto['status'],
payload: (row.payload as NoteAiPayloadDto | null) ?? null,
errorMessage: row.errorMessage,
analyzedAt: row.analyzedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+30 -2
View File
@@ -16,7 +16,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto } from './dto';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
import { NoteAiService } from './note-ai.service';
import { NotesService } from './notes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@@ -24,7 +25,10 @@ type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('notes')
@UseGuards(JwtAuthGuard)
export class NotesController {
constructor(private readonly notesService: NotesService) {}
constructor(
private readonly notesService: NotesService,
private readonly noteAi: NoteAiService,
) {}
@Get()
list(
@@ -83,6 +87,30 @@ export class NotesController {
);
}
@Post(':id/analyze')
analyze(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.noteAi.analyze(user.organizationId, id, user);
}
@Get(':id/ai-suggestion')
getAiSuggestion(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.noteAi.getSuggestion(user.organizationId, id, user);
}
@Post(':id/ai-suggestion/apply')
applyAiSuggestion(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: ApplyNoteAiSuggestionDto,
) {
return this.noteAi.apply(user.organizationId, user.userId, id, user, dto);
}
@Post(':id/ai-suggestion/dismiss')
dismissAiSuggestion(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.noteAi.dismiss(user.organizationId, id, user);
}
@Post(':id/attachments')
@UseInterceptors(
FileInterceptor('file', {
+4 -1
View File
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { MemoryModule } from '../memory/memory.module';
import { NotesController } from './notes.controller';
import { NoteAiService } from './note-ai.service';
import { NotesService } from './notes.service';
@Module({
imports: [MemoryModule],
controllers: [NotesController],
providers: [NotesService],
providers: [NotesService, NoteAiService],
exports: [NotesService],
})
export class NotesModule {}