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