Add Smart Notes AI analysis and Mento Memory with user approval.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 14:29:34 +03:30
co-authored by Cursor
parent 4d9f3c04cd
commit d152d1c19f
20 changed files with 1577 additions and 9 deletions
@@ -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;
+52
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AiService } from './ai.service';
@Global()
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
+154
View File
@@ -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<string>('AI_ENABLED') === 'true' &&
Boolean(this.config.get<string>('AI_API_KEY')?.trim())
);
}
async extractFromNote(input: {
body: string;
title?: string | null;
noteType?: string;
personName?: string | null;
}): Promise<NoteAiExtract> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
}
const model = this.config.get<string>('AI_MODEL')?.trim() || 'gpt-4o-mini';
const baseUrl =
this.config.get<string>('AI_BASE_URL')?.trim() ||
'https://api.openai.com/v1';
const apiKey = this.config.get<string>('AI_API_KEY')!.trim();
const timeoutMs = Number(this.config.get<string>('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<NoteAiExtract>);
} 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>): 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,
};
}
}
+4
View File
@@ -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 {}
+45
View File
@@ -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;
}
+57
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+167
View File
@@ -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<PaginatedResponse<MemoryItemDto>> {
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<MemoryItemDto> {
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<MemoryItemDto> {
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<void> {
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<MemoryItemDto> {
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(),
};
}
}
+51
View File
@@ -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[];
}
+249
View File
@@ -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(),
};
}
}
+30 -2
View File
@@ -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', {
+4 -1
View 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 {}
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { MemoryItemDto, PaginatedResponse } from '@mento/shared';
const props = defineProps<{
personId: string;
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const items = ref<MemoryItemDto[]>([]);
const draft = ref('');
const editingId = ref<string | null>(null);
const editContent = ref('');
const sourceLabel: Record<string, string> = {
note: 'از یادداشت',
manual: 'دستی',
};
async function load() {
if (!props.personId) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
params: { personId: props.personId, limit: 100 },
});
items.value = data.items;
} catch {
error.value = 'بارگذاری حافظه مشتری ناموفق بود';
} finally {
loading.value = false;
}
}
async function addItem() {
if (!draft.value.trim()) return;
saving.value = true;
error.value = '';
try {
await api.post('/memory', {
personId: props.personId,
content: draft.value.trim(),
});
draft.value = '';
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
function startEdit(item: MemoryItemDto) {
editingId.value = item.id;
editContent.value = item.content;
}
function cancelEdit() {
editingId.value = null;
editContent.value = '';
}
async function saveEdit(id: string) {
if (!editContent.value.trim()) return;
saving.value = true;
error.value = '';
try {
await api.patch(`/memory/${id}`, { content: editContent.value.trim() });
cancelEdit();
await load();
} catch {
error.value = 'ویرایش ناموفق بود';
} finally {
saving.value = false;
}
}
async function removeItem(id: string) {
if (!confirm('این مورد از حافظه حذف شود؟')) return;
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
}
}
function formatWhen(value: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
}
const hasItems = computed(() => items.value.length > 0);
watch(() => props.personId, load, { immediate: true });
onMounted(load);
</script>
<template>
<section class="memory-panel">
<header>
<div>
<p class="eyebrow">Mento Memory</p>
<h2>حافظه مشتری</h2>
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.</p>
</div>
</header>
<form class="add-form" @submit.prevent="addItem">
<textarea
v-model="draft"
rows="2"
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبح‌ها"
/>
<button class="btn btn-ghost" type="submit" :disabled="saving || !draft.trim()">
افزودن
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
<ul v-else class="items">
<li v-for="item in items" :key="item.id">
<template v-if="editingId === item.id">
<textarea v-model="editContent" rows="2" />
<div class="row-actions">
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)">
ذخیره
</button>
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button>
</div>
</template>
<template v-else>
<p class="content">{{ item.content }}</p>
<p class="meta">
<span>{{ sourceLabel[item.sourceType] ?? item.sourceType }}</span>
<span v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</span>
<span> · {{ formatWhen(item.updatedAt) }}</span>
</p>
<div class="row-actions">
<button type="button" @click="startEdit(item)">ویرایش</button>
<button type="button" class="danger" @click="removeItem(item.id)">حذف</button>
</div>
</template>
</li>
</ul>
</section>
</template>
<style scoped>
.memory-panel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
h2 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.lead {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.add-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.6rem;
align-items: start;
}
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
width: 100%;
resize: vertical;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.items li {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.85rem;
background: #fff;
}
.content {
margin: 0;
white-space: pre-wrap;
}
.meta {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.row-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.row-actions button {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.row-actions button.danger {
color: #b42318;
}
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
</style>
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { NoteAiPayloadDto, NoteAiSuggestionDto } from '@mento/shared';
const props = defineProps<{
noteId: string;
hasPerson: boolean;
}>();
const emit = defineEmits<{
applied: [];
dismissed: [];
}>();
const loading = ref(false);
const applying = ref(false);
const error = ref('');
const suggestion = ref<NoteAiSuggestionDto | null>(null);
const applyNextAction = ref(true);
const selectedMemory = reactive<Record<number, boolean>>({});
const nextTitle = ref('');
const nextDueAt = ref('');
const memoryEdits = reactive<Record<number, string>>({});
const payload = computed(() => suggestion.value?.payload ?? null);
const isPending = computed(() => suggestion.value?.status === 'pending');
const memoryItems = computed(() => payload.value?.memoryItems ?? []);
watch(
() => props.noteId,
() => {
void load();
},
{ immediate: true },
);
watch(payload, (value) => {
if (!value) return;
applyNextAction.value = Boolean(value.nextAction);
nextTitle.value = value.nextAction?.title ?? '';
nextDueAt.value = value.nextAction?.dueAt
? value.nextAction.dueAt.slice(0, 16)
: '';
value.memoryItems.forEach((item, index) => {
selectedMemory[index] = true;
memoryEdits[index] = item.content;
});
});
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<NoteAiSuggestionDto | null>(
`/notes/${props.noteId}/ai-suggestion`,
);
suggestion.value = data;
} catch {
suggestion.value = null;
} finally {
loading.value = false;
}
}
async function analyze() {
loading.value = true;
error.value = '';
try {
const { data } = await api.post<NoteAiSuggestionDto>(
`/notes/${props.noteId}/analyze`,
);
suggestion.value = data;
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تحلیل ناموفق بود');
} else {
error.value = 'تحلیل ناموفق بود';
}
await load();
} finally {
loading.value = false;
}
}
async function apply() {
if (!payload.value || !isPending.value) return;
applying.value = true;
error.value = '';
try {
const memoryIndices = memoryItems.value
.map((_, index) => index)
.filter((index) => selectedMemory[index]);
await api.post(`/notes/${props.noteId}/ai-suggestion/apply`, {
applyNextAction: applyNextAction.value && Boolean(nextTitle.value.trim()),
memoryIndices: props.hasPerson ? memoryIndices : [],
nextAction: applyNextAction.value
? {
title: nextTitle.value.trim(),
description: payload.value.nextAction?.description,
dueAt: nextDueAt.value
? new Date(nextDueAt.value).toISOString()
: null,
}
: undefined,
memoryItems: memoryIndices.map((index) => ({
index,
content: memoryEdits[index]?.trim() || memoryItems.value[index]?.content,
})),
});
await load();
emit('applied');
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'اعمال پیشنهاد ناموفق بود');
} else {
error.value = 'اعمال پیشنهاد ناموفق بود';
}
} finally {
applying.value = false;
}
}
async function dismiss() {
applying.value = true;
error.value = '';
try {
await api.post(`/notes/${props.noteId}/ai-suggestion/dismiss`);
await load();
emit('dismissed');
} catch {
error.value = 'رد پیشنهاد ناموفق بود';
} finally {
applying.value = false;
}
}
function formatPayloadSummary(value: NoteAiPayloadDto | null) {
if (!value?.summary) return '';
return value.summary;
}
</script>
<template>
<section class="ai-panel">
<header>
<strong>پیشنهاد Mento AI</strong>
<button
v-if="!isPending"
type="button"
class="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? '…' : 'تحلیل مجدد' }}
</button>
<button
v-else-if="!suggestion"
type="button"
class="link"
:disabled="loading"
@click="analyze"
>
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</button>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
<p v-else-if="suggestion?.status === 'failed'" class="error">
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
<button type="button" class="link" @click="analyze">تلاش دوباره</button>
</p>
<p v-else-if="!suggestion" class="muted">
<button type="button" class="link" :disabled="loading" @click="analyze">
تحلیل این یادداشت با AI
</button>
</p>
<template v-else-if="isPending && payload">
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
<div v-if="payload.nextAction" class="block">
<label class="check">
<input v-model="applyNextAction" type="checkbox" />
کار بعدی
</label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<input v-model="nextDueAt" type="datetime-local" />
</div>
<div v-if="memoryItems.length" class="block">
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p>
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
<label
v-for="(_, index) in memoryItems"
:key="index"
class="memory-row"
>
<input
v-model="selectedMemory[index]"
type="checkbox"
:disabled="!hasPerson"
/>
<textarea v-model="memoryEdits[index]" rows="2" />
</label>
</div>
<div class="actions">
<button type="button" class="btn" :disabled="applying" @click="apply">
{{ applying ? '…' : 'اعمال انتخاب‌شده' }}
</button>
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
رد کردن
</button>
</div>
</template>
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
</section>
</template>
<style scoped>
.ai-panel {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: 12px;
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.link {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.block {
display: grid;
gap: 0.45rem;
}
.block-title {
margin: 0;
font-weight: 600;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
.memory-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem;
align-items: start;
}
input[type='text'],
input[type='datetime-local'],
textarea {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.summary {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.muted {
margin: 0;
color: var(--muted);
}
.ok {
color: #067647;
}
.warn {
margin: 0;
color: #b54708;
font-size: 0.88rem;
}
.error {
margin: 0;
color: #b42318;
}
</style>
+18 -1
View File
@@ -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);
<div>
<p class="eyebrow">Smart Notes</p>
<h1>یادداشت‌ها</h1>
<p class="lead">مثل Keep بنویسید؛ به شخص یا معامله وصل کنید. AI و حافظه مشتری در گام‌های بعد می‌آید.</p>
<p class="lead">مثل Keep بنویسید؛ AI کار بعدی و حافظه مشتری را پیشنهاد می‌دهد — فقط با تأیید شما اعمال می‌شود.</p>
</div>
<form class="search" @submit.prevent="load">
<input v-model="q" type="search" placeholder="جستجو در متن و برچسب…" />
@@ -408,6 +417,10 @@ onMounted(load);
<span v-if="note.deal"> · {{ note.deal.title }}</span>
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
</p>
<NoteAiSuggestionPanel
:note-id="note.id"
:has-person="Boolean(note.person?.id)"
/>
<footer>
<button type="button" @click="startEdit(note)">ویرایش</button>
<label class="attach-label">
@@ -474,6 +487,10 @@ onMounted(load);
<span v-if="note.deal"> · {{ note.deal.title }}</span>
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
</p>
<NoteAiSuggestionPanel
:note-id="note.id"
:has-person="Boolean(note.person?.id)"
/>
<footer>
<button type="button" @click="startEdit(note)">ویرایش</button>
<label class="attach-label">
+3
View File
@@ -11,6 +11,7 @@ import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
@@ -337,6 +338,8 @@ onMounted(load);
<CustomFieldsSection entity-type="person" v-model="customFields" />
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline
v-if="!isNew && id"
entity-type="person"
+8
View File
@@ -25,3 +25,11 @@ SMS_BASE_URL=http://smspanel.Trez.ir
SMS_SEND_PATH=/SendMessageWithCode.ashx
SMS_OTP_TEMPLATE=کد ورود پرتال منتو: {code}
# SMS_TIMEOUT_MS=20000
# AI — GapGPT (OpenAI-compatible)
AI_ENABLED=true
AI_PROVIDER=openai
AI_BASE_URL=https://api.gapgpt.app/v1
AI_API_KEY=change-me
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_MS=45000
+5 -5
View File
@@ -75,10 +75,10 @@
| [x] | تایم‌لاین یکپارچه (پایه) | روی پرونده‌ها |
| [x] | انواع Note | معمولی، نتیجه تماس، جلسه، نکته مهم، هشدار، کار بعدی |
| [x] | پیوست فایل روی Note | ذخیره محلی (`uploads/`)؛ آماده ارتقا به MinIO/S3 |
| [ ] | AI: استخراج Next Action | با تأیید کاربر |
| [ ] | پیشنهاد اقدام هوشمند | یادآور/فعالیت با تأیید |
| [ ] | Mento Memory با منبع | خلاصه پایدار مشتری |
| [ ] | ویرایش/حذف Memory Item | |
| [x] | AI: استخراج Next Action | با تأیید کاربر |
| [x] | پیشنهاد اقدام هوشمند | یادآور/فعالیت با تأیید |
| [x] | Mento Memory با منبع | خلاصه پایدار مشتری |
| [x] | ویرایش/حذف Memory Item | |
| [ ] | Voice-to-Note / خلاصه تماس | فاز بعدتر |
| [ ] | Ask Mento | پرسش زبان طبیعی |
| [ ] | Note شخصی در برابر تیمی | |
@@ -140,7 +140,7 @@
1. API عمومی و وب‌هوک
2. پنل پیامکی / درگاه پرداخت
3. Smart Notes AI / Memory وقتی کلید مدل مشخص شد
3. Voice-to-Note / Ask Mento (فاز بعدتر Smart Notes)
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
---
+63
View File
@@ -469,6 +469,69 @@ export interface UpdateNoteRequest {
dealId?: string | null;
}
export type NoteAiSuggestionStatus =
| 'pending'
| 'applied'
| 'dismissed'
| 'failed';
export type MemorySourceType = 'note' | 'manual';
export interface NoteAiNextActionDto {
title: string;
description?: string;
dueAt?: string | null;
}
export interface NoteAiMemoryExtractDto {
content: string;
category?: string;
}
export interface NoteAiPayloadDto {
nextAction: NoteAiNextActionDto | null;
memoryItems: NoteAiMemoryExtractDto[];
summary?: string;
}
export interface NoteAiSuggestionDto {
id: string;
noteId: string;
status: NoteAiSuggestionStatus;
payload: NoteAiPayloadDto | null;
errorMessage: string | null;
analyzedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ApplyNoteAiSuggestionRequest {
applyNextAction?: boolean;
memoryIndices?: number[];
nextAction?: NoteAiNextActionDto;
memoryItems?: Array<{ index: number; content: string }>;
}
export interface MemoryItemDto {
id: string;
personId: string;
content: string;
sourceType: MemorySourceType;
sourceId: string | null;
confirmedBy: UserSummaryDto | null;
createdAt: string;
updatedAt: string;
}
export interface CreateMemoryItemRequest {
personId: string;
content: string;
}
export interface UpdateMemoryItemRequest {
content: string;
}
export interface ImportContactsResult {
created: number;
skipped: number;