diff --git a/apps/api/prisma/migrations/20260901140000_smart_notes_ai_memory/migration.sql b/apps/api/prisma/migrations/20260901140000_smart_notes_ai_memory/migration.sql new file mode 100644 index 0000000..476bc3f --- /dev/null +++ b/apps/api/prisma/migrations/20260901140000_smart_notes_ai_memory/migration.sql @@ -0,0 +1,65 @@ +-- CreateEnum +CREATE TYPE "NoteAiSuggestionStatus" AS ENUM ('pending', 'applied', 'dismissed', 'failed'); + +-- CreateEnum +CREATE TYPE "MemorySourceType" AS ENUM ('note', 'manual'); + +-- CreateTable +CREATE TABLE "NoteAiSuggestion" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "noteId" TEXT NOT NULL, + "status" "NoteAiSuggestionStatus" NOT NULL DEFAULT 'pending', + "payload" JSONB, + "errorMessage" TEXT, + "analyzedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NoteAiSuggestion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemoryItem" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "personId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "sourceType" "MemorySourceType" NOT NULL DEFAULT 'manual', + "sourceId" TEXT, + "confirmedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MemoryItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "NoteAiSuggestion_noteId_key" ON "NoteAiSuggestion"("noteId"); + +-- CreateIndex +CREATE INDEX "NoteAiSuggestion_organizationId_status_idx" ON "NoteAiSuggestion"("organizationId", "status"); + +-- CreateIndex +CREATE INDEX "MemoryItem_organizationId_personId_updatedAt_idx" ON "MemoryItem"("organizationId", "personId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "MemoryItem_personId_idx" ON "MemoryItem"("personId"); + +-- CreateIndex +CREATE INDEX "MemoryItem_confirmedById_idx" ON "MemoryItem"("confirmedById"); + +-- AddForeignKey +ALTER TABLE "NoteAiSuggestion" ADD CONSTRAINT "NoteAiSuggestion_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NoteAiSuggestion" ADD CONSTRAINT "NoteAiSuggestion_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemoryItem" ADD CONSTRAINT "MemoryItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemoryItem" ADD CONSTRAINT "MemoryItem_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemoryItem" ADD CONSTRAINT "MemoryItem_confirmedById_fkey" FOREIGN KEY ("confirmedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ee137b2..a509fe5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -103,6 +103,18 @@ enum AutomationAction { add_loyalty_points } +enum NoteAiSuggestionStatus { + pending + applied + dismissed + failed +} + +enum MemorySourceType { + note + manual +} + model Organization { id String @id @default(cuid()) name String @@ -134,6 +146,8 @@ model Organization { projectCards ProjectCard[] loyaltyTiers LoyaltyTier[] automationRules AutomationRule[] + memoryItems MemoryItem[] + noteAiSuggestions NoteAiSuggestion[] } model User { @@ -158,6 +172,7 @@ model User { assignedTickets Ticket[] @relation("TicketAssignee") reportedTickets Ticket[] @relation("TicketReporter") ticketMessages TicketMessage[] @relation("TicketMessageAuthor") + confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -223,6 +238,7 @@ model Person { owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull) tickets Ticket[] projectCards ProjectCard[] + memoryItems MemoryItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -359,6 +375,7 @@ model Note { authorId String? author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull) attachments NoteAttachment[] + aiSuggestion NoteAiSuggestion? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -390,6 +407,41 @@ model NoteAttachment { @@index([uploadedById]) } +model NoteAiSuggestion { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + noteId String @unique + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + status NoteAiSuggestionStatus @default(pending) + payload Json? + errorMessage String? + analyzedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, status]) +} + +model MemoryItem { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + personId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + content String + sourceType MemorySourceType @default(manual) + sourceId String? + confirmedById String? + confirmedBy User? @relation("MemoryConfirmedBy", fields: [confirmedById], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, personId, updatedAt]) + @@index([personId]) + @@index([confirmedById]) +} + model CustomFieldDefinition { id String @id @default(cuid()) organizationId String diff --git a/apps/api/src/ai/ai.module.ts b/apps/api/src/ai/ai.module.ts new file mode 100644 index 0000000..3c2adc3 --- /dev/null +++ b/apps/api/src/ai/ai.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { AiService } from './ai.service'; + +@Global() +@Module({ + providers: [AiService], + exports: [AiService], +}) +export class AiModule {} diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts new file mode 100644 index 0000000..632a5ae --- /dev/null +++ b/apps/api/src/ai/ai.service.ts @@ -0,0 +1,154 @@ +import { + BadRequestException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type NoteAiExtract = { + nextAction: { + title: string; + description?: string; + dueAt?: string | null; + } | null; + memoryItems: Array<{ content: string; category?: string }>; + summary?: string; +}; + +@Injectable() +export class AiService { + constructor(private readonly config: ConfigService) {} + + isEnabled(): boolean { + return ( + this.config.get('AI_ENABLED') === 'true' && + Boolean(this.config.get('AI_API_KEY')?.trim()) + ); + } + + async extractFromNote(input: { + body: string; + title?: string | null; + noteType?: string; + personName?: string | null; + }): Promise { + if (!this.isEnabled()) { + throw new ServiceUnavailableException('هوش مصنوعی فعال نیست'); + } + + const model = this.config.get('AI_MODEL')?.trim() || 'gpt-4o-mini'; + const baseUrl = + this.config.get('AI_BASE_URL')?.trim() || + 'https://api.openai.com/v1'; + const apiKey = this.config.get('AI_API_KEY')!.trim(); + const timeoutMs = Number(this.config.get('AI_TIMEOUT_MS') || 45000); + + const system = `You analyze CRM notes in Persian/Farsi and extract structured data. +Return ONLY valid JSON with this exact shape: +{ + "nextAction": { "title": string, "description"?: string, "dueAt"?: string|null } | null, + "memoryItems": [{ "content": string, "category"?: string }], + "summary"?: string +} +Rules: +- nextAction: one concrete follow-up task if the note implies action; otherwise null. +- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null. +- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks. +- Keep text in Persian when the note is Persian. +- Do not invent facts not supported by the note. +- Maximum 5 memory items.`; + + const userParts = [ + input.title ? `Title: ${input.title}` : null, + input.noteType ? `Type: ${input.noteType}` : null, + input.personName ? `Customer: ${input.personName}` : null, + `Note:\n${input.body.trim()}`, + ].filter(Boolean); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + temperature: 0.2, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: userParts.join('\n') }, + ], + }), + signal: controller.signal, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new ServiceUnavailableException( + `خطا در ارتباط با مدل هوش مصنوعی (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`, + ); + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const raw = data.choices?.[0]?.message?.content?.trim(); + if (!raw) { + throw new BadRequestException('پاسخ مدل خالی بود'); + } + + return this.normalizeExtract(JSON.parse(raw) as Partial); + } catch (err) { + if (err instanceof BadRequestException || err instanceof ServiceUnavailableException) { + throw err; + } + if (err instanceof Error && err.name === 'AbortError') { + throw new ServiceUnavailableException('زمان پاسخ مدل هوش مصنوعی تمام شد'); + } + throw new ServiceUnavailableException('تحلیل یادداشت با خطا مواجه شد'); + } finally { + clearTimeout(timer); + } + } + + private normalizeExtract(raw: Partial): NoteAiExtract { + const nextAction = + raw.nextAction && typeof raw.nextAction === 'object' + ? { + title: String(raw.nextAction.title ?? '').trim().slice(0, 200), + description: raw.nextAction.description + ? String(raw.nextAction.description).trim().slice(0, 2000) + : undefined, + dueAt: raw.nextAction.dueAt + ? String(raw.nextAction.dueAt).trim() + : null, + } + : null; + + const memoryItems = Array.isArray(raw.memoryItems) + ? raw.memoryItems + .map((item) => ({ + content: String(item?.content ?? '').trim().slice(0, 500), + category: item?.category + ? String(item.category).trim().slice(0, 40) + : undefined, + })) + .filter((item) => item.content.length > 0) + .slice(0, 5) + : []; + + const cleanedNext = + nextAction && nextAction.title.length > 0 ? nextAction : null; + + return { + nextAction: cleanedNext, + memoryItems, + summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined, + }; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ae24869..484d61d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -24,6 +24,8 @@ import { ReportsModule } from './reports/reports.module'; import { ProjectBoardsModule } from './project-boards/project-boards.module'; import { LoyaltyModule } from './loyalty/loyalty.module'; import { AutomationsModule } from './automations/automations.module'; +import { AiModule } from './ai/ai.module'; +import { MemoryModule } from './memory/memory.module'; @Module({ imports: [ @@ -31,6 +33,7 @@ import { AutomationsModule } from './automations/automations.module'; isGlobal: true, envFilePath: ['.env', '../../.env'], }), + AiModule, PrismaModule, StorageModule, HealthModule, @@ -55,6 +58,7 @@ import { AutomationsModule } from './automations/automations.module'; ProjectBoardsModule, LoyaltyModule, AutomationsModule, + MemoryModule, ], }) export class AppModule {} diff --git a/apps/api/src/memory/dto.ts b/apps/api/src/memory/dto.ts new file mode 100644 index 0000000..880ca95 --- /dev/null +++ b/apps/api/src/memory/dto.ts @@ -0,0 +1,45 @@ +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class ListMemoryQueryDto { + @IsString() + @MinLength(1) + personId!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number; +} + +export class CreateMemoryItemDto { + @IsString() + @MinLength(1) + personId!: string; + + @IsString() + @MinLength(2) + @MaxLength(500) + content!: string; +} + +export class UpdateMemoryItemDto { + @IsString() + @MinLength(2) + @MaxLength(500) + content!: string; +} diff --git a/apps/api/src/memory/memory.controller.ts b/apps/api/src/memory/memory.controller.ts new file mode 100644 index 0000000..6f74451 --- /dev/null +++ b/apps/api/src/memory/memory.controller.ts @@ -0,0 +1,57 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { + CreateMemoryItemDto, + ListMemoryQueryDto, + UpdateMemoryItemDto, +} from './dto'; +import { MemoryService } from './memory.service'; + +@Controller('memory') +@UseGuards(JwtAuthGuard) +export class MemoryController { + constructor(private readonly memory: MemoryService) {} + + @Get() + list(@CurrentUser() user: Actor, @Query() query: ListMemoryQueryDto) { + return this.memory.list( + user.organizationId, + user, + query.personId, + query.page ?? 1, + query.limit ?? 50, + ); + } + + @Post() + create(@CurrentUser() user: Actor, @Body() dto: CreateMemoryItemDto) { + return this.memory.create(user.organizationId, user.userId, user, dto); + } + + @Patch(':id') + update( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateMemoryItemDto, + ) { + return this.memory.update(user.organizationId, id, user, dto); + } + + @Delete(':id') + async remove(@CurrentUser() user: Actor, @Param('id') id: string) { + await this.memory.remove(user.organizationId, id, user); + return { ok: true }; + } +} diff --git a/apps/api/src/memory/memory.module.ts b/apps/api/src/memory/memory.module.ts new file mode 100644 index 0000000..1a2a11c --- /dev/null +++ b/apps/api/src/memory/memory.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MemoryController } from './memory.controller'; +import { MemoryService } from './memory.service'; + +@Module({ + controllers: [MemoryController], + providers: [MemoryService], + exports: [MemoryService], +}) +export class MemoryModule {} diff --git a/apps/api/src/memory/memory.service.ts b/apps/api/src/memory/memory.service.ts new file mode 100644 index 0000000..1b69892 --- /dev/null +++ b/apps/api/src/memory/memory.service.ts @@ -0,0 +1,167 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + CreateMemoryItemRequest, + MemoryItemDto, + PaginatedResponse, + UpdateMemoryItemRequest, +} from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { type Actor, ownerWhere } from '../common/record-scope'; + +@Injectable() +export class MemoryService { + constructor(private readonly prisma: PrismaService) {} + + async list( + organizationId: string, + actor: Actor, + personId: string, + page = 1, + limit = 50, + ): Promise> { + await this.assertPersonAccess(organizationId, actor, personId); + + const where = { organizationId, personId }; + const [rows, total] = await this.prisma.$transaction([ + this.prisma.memoryItem.findMany({ + where, + include: { + confirmedBy: { select: { id: true, fullName: true } }, + }, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.memoryItem.count({ where }), + ]); + + return { + items: rows.map((row) => this.toDto(row)), + total, + page, + limit, + }; + } + + async create( + organizationId: string, + userId: string, + actor: Actor, + dto: CreateMemoryItemRequest, + ): Promise { + await this.assertPersonAccess(organizationId, actor, dto.personId); + + const row = await this.prisma.memoryItem.create({ + data: { + organizationId, + personId: dto.personId, + content: dto.content.trim(), + sourceType: 'manual', + confirmedById: userId, + }, + include: { + confirmedBy: { select: { id: true, fullName: true } }, + }, + }); + return this.toDto(row); + } + + async update( + organizationId: string, + id: string, + actor: Actor, + dto: UpdateMemoryItemRequest, + ): Promise { + const existing = await this.findAccessible(organizationId, id, actor); + const row = await this.prisma.memoryItem.update({ + where: { id: existing.id }, + data: { content: dto.content.trim() }, + include: { + confirmedBy: { select: { id: true, fullName: true } }, + }, + }); + return this.toDto(row); + } + + async remove(organizationId: string, id: string, actor: Actor): Promise { + const existing = await this.findAccessible(organizationId, id, actor); + await this.prisma.memoryItem.delete({ where: { id: existing.id } }); + } + + async createFromNote( + organizationId: string, + userId: string, + personId: string, + noteId: string, + content: string, + ): Promise { + const row = await this.prisma.memoryItem.create({ + data: { + organizationId, + personId, + content: content.trim(), + sourceType: 'note', + sourceId: noteId, + confirmedById: userId, + }, + include: { + confirmedBy: { select: { id: true, fullName: true } }, + }, + }); + return this.toDto(row); + } + + private async findAccessible( + organizationId: string, + id: string, + actor: Actor, + ) { + const row = await this.prisma.memoryItem.findFirst({ + where: { id, organizationId }, + include: { person: { select: { id: true, ownerId: true } } }, + }); + if (!row) throw new NotFoundException('مورد حافظه پیدا نشد'); + await this.assertPersonAccess(organizationId, actor, row.personId); + return row; + } + + private async assertPersonAccess( + organizationId: string, + actor: Actor, + personId: string, + ) { + const person = await this.prisma.person.findFirst({ + where: { id: personId, organizationId, ...ownerWhere(actor) }, + select: { id: true }, + }); + if (!person) { + throw new BadRequestException('شخص انتخاب‌شده معتبر نیست'); + } + } + + private toDto(row: { + id: string; + personId: string; + content: string; + sourceType: string; + sourceId: string | null; + confirmedBy: { id: string; fullName: string } | null; + createdAt: Date; + updatedAt: Date; + }): MemoryItemDto { + return { + id: row.id, + personId: row.personId, + content: row.content, + sourceType: row.sourceType as MemoryItemDto['sourceType'], + sourceId: row.sourceId, + confirmedBy: row.confirmedBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/api/src/notes/dto.ts b/apps/api/src/notes/dto.ts index 65691d6..6a5228d 100644 --- a/apps/api/src/notes/dto.ts +++ b/apps/api/src/notes/dto.ts @@ -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[]; +} diff --git a/apps/api/src/notes/note-ai.service.ts b/apps/api/src/notes/note-ai.service.ts new file mode 100644 index 0000000..0b62ca7 --- /dev/null +++ b/apps/api/src/notes/note-ai.service.ts @@ -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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts index 1312438..25a0cd0 100644 --- a/apps/api/src/notes/notes.controller.ts +++ b/apps/api/src/notes/notes.controller.ts @@ -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', { diff --git a/apps/api/src/notes/notes.module.ts b/apps/api/src/notes/notes.module.ts index 72195ee..861ce98 100644 --- a/apps/api/src/notes/notes.module.ts +++ b/apps/api/src/notes/notes.module.ts @@ -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 {} diff --git a/apps/web/src/components/MentoMemoryPanel.vue b/apps/web/src/components/MentoMemoryPanel.vue new file mode 100644 index 0000000..057a07e --- /dev/null +++ b/apps/web/src/components/MentoMemoryPanel.vue @@ -0,0 +1,265 @@ + + +