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
+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 {}