Add Smart Notes AI analysis and Mento Memory with user approval.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user