250 lines
7.0 KiB
TypeScript
250 lines
7.0 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|