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 @@
+
+
+
+
+
+
+
+
+ {{ error }}
+ در حال بارگذاری…
+ هنوز موردی در حافظه ثبت نشده.
+
+
+ -
+
+
+
+
+
+
+
+
+ {{ item.content }}
+
+ {{ sourceLabel[item.sourceType] ?? item.sourceType }}
+ · {{ item.confirmedBy.fullName }}
+ · {{ formatWhen(item.updatedAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/NoteAiSuggestionPanel.vue b/apps/web/src/components/NoteAiSuggestionPanel.vue
new file mode 100644
index 0000000..6b315ae
--- /dev/null
+++ b/apps/web/src/components/NoteAiSuggestionPanel.vue
@@ -0,0 +1,318 @@
+
+
+
+
+
+ پیشنهاد Mento AI
+
+
+
+
+ {{ error }}
+ در حال تحلیل یادداشت…
+
+ {{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
+
+
+
+
+
+
+
+ {{ formatPayloadSummary(payload) }}
+
+
+
+
+
+
+
+
+
حافظه مشتری (با تأیید شما ذخیره میشود)
+
برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.
+
+
+
+
+
+
+
+
+
+ پیشنهاد اعمال شد.
+ پیشنهاد رد شد.
+
+
+
+
diff --git a/apps/web/src/views/NotesView.vue b/apps/web/src/views/NotesView.vue
index 692a3c7..24319f8 100644
--- a/apps/web/src/views/NotesView.vue
+++ b/apps/web/src/views/NotesView.vue
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
+import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import type {
DealDto,
NoteAttachmentDto,
@@ -198,6 +199,7 @@ async function saveNote() {
: null,
};
const pending = [...draftFiles.value];
+ const wasNew = !editingId.value;
let noteId = editingId.value;
if (editingId.value) {
await api.patch(`/notes/${editingId.value}`, payload);
@@ -208,6 +210,13 @@ async function saveNote() {
if (noteId && pending.length) {
await uploadFiles(noteId, pending);
}
+ if (noteId && wasNew && draftBody.value.trim().length >= 8) {
+ try {
+ await api.post(`/notes/${noteId}/analyze`);
+ } catch {
+ /* panel shows retry */
+ }
+ }
resetDraft();
await load();
} catch (e) {
@@ -264,7 +273,7 @@ onMounted(load);
Smart Notes
یادداشتها
-
مثل Keep بنویسید؛ به شخص یا معامله وصل کنید. AI و حافظه مشتری در گامهای بعد میآید.
+
مثل Keep بنویسید؛ AI کار بعدی و حافظه مشتری را پیشنهاد میدهد — فقط با تأیید شما اعمال میشود.
+