Add Ask Mento, customer mood, and Pipecat speech-to-text.
Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PersonMood" AS ENUM ('newcomer', 'happy', 'unhappy', 'special');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "mood" "PersonMood" NOT NULL DEFAULT 'newcomer';
|
||||
@@ -41,6 +41,13 @@ enum ActivityPriority {
|
||||
very_high
|
||||
}
|
||||
|
||||
enum PersonMood {
|
||||
newcomer
|
||||
happy
|
||||
unhappy
|
||||
special
|
||||
}
|
||||
|
||||
enum CustomFieldEntity {
|
||||
person
|
||||
company
|
||||
@@ -234,6 +241,7 @@ model Person {
|
||||
mobile String?
|
||||
position String?
|
||||
notes String?
|
||||
mood PersonMood @default(newcomer)
|
||||
customFields Json @default("{}")
|
||||
loyaltyPoints Int @default(0)
|
||||
loyaltyTierId String?
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
AskMentoResponse,
|
||||
AskMentoSourceDto,
|
||||
PersonAiSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
noteAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
type CatalogItem = AskMentoSourceDto;
|
||||
|
||||
function clip(value: string | null | undefined, max = 280) {
|
||||
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiAskService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ai: AiService,
|
||||
) {}
|
||||
|
||||
async ask(
|
||||
actor: Actor,
|
||||
question: string,
|
||||
personId?: string,
|
||||
): Promise<AskMentoResponse> {
|
||||
const { context, catalog } = await this.buildContext(actor, personId);
|
||||
const raw = await this.ai.completeJson<{
|
||||
answer?: string;
|
||||
sources?: Array<{ type?: string; id?: string; label?: string }>;
|
||||
}>(
|
||||
`You are Mento, a Persian CRM sales assistant.
|
||||
Answer ONLY from the CRM context. If the context is insufficient, say so in Persian.
|
||||
Do not invent customers, amounts, dates, or promises.
|
||||
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
|
||||
Use at most 4 sources, and only ids that appear in the context.
|
||||
Write the answer in Persian, concise, with dates when present.`,
|
||||
`Question:\n${question.trim()}\n\nCRM context:\n${context}`,
|
||||
);
|
||||
|
||||
const allowed = new Map(catalog.map((item) => [item.id, item]));
|
||||
const sources = (Array.isArray(raw.sources) ? raw.sources : [])
|
||||
.map((item) => allowed.get(String(item?.id ?? '')))
|
||||
.filter((item): item is CatalogItem => Boolean(item))
|
||||
.slice(0, 4);
|
||||
|
||||
const answer = String(raw.answer ?? '').trim().slice(0, 2000);
|
||||
return {
|
||||
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
async summarizePerson(actor: Actor, personId: string): Promise<PersonAiSummaryDto> {
|
||||
const { context } = await this.buildContext(actor, personId);
|
||||
const raw = await this.ai.completeJson<{
|
||||
text?: string;
|
||||
cta?: string;
|
||||
ctaKind?: string;
|
||||
}>(
|
||||
`You write a short live briefing for a CRM customer card in Persian.
|
||||
Return JSON: { "text": string, "cta": string, "ctaKind": "call"|"activity" }
|
||||
Rules:
|
||||
- 2 to 4 short sentences from the context only.
|
||||
- Mention durable memory, last contact, open deals, or next follow-up when present.
|
||||
- cta is one concrete next step, starting with «پیشنهاد منتو:».
|
||||
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
|
||||
- Do not invent facts.`,
|
||||
`Customer briefing context:\n${context}`,
|
||||
);
|
||||
|
||||
const text = String(raw.text ?? '').trim().slice(0, 700);
|
||||
const cta = String(raw.cta ?? '').trim().slice(0, 120);
|
||||
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
|
||||
return {
|
||||
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
|
||||
cta: cta || 'پیشنهاد منتو: امروز با مشتری تماس بگیر',
|
||||
ctaKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildContext(actor: Actor, personId?: string) {
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
id: personId,
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
include: { company: { select: { name: true } } },
|
||||
});
|
||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||
}
|
||||
|
||||
const [notes, activities, deals, memories] = await Promise.all([
|
||||
this.prisma.note.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...noteAccessWhere(actor),
|
||||
...(personId ? { personId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
body: true,
|
||||
createdAt: true,
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: personId ? 15 : 8,
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...assigneeWhere(actor),
|
||||
...(personId ? { personId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
title: true,
|
||||
description: true,
|
||||
dueAt: true,
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: personId ? 15 : 10,
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(personId ? { personId } : { status: 'open' }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
status: true,
|
||||
isStale: true,
|
||||
isRotten: true,
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
personId
|
||||
? this.prisma.memoryItem.findMany({
|
||||
where: { organizationId: actor.organizationId, personId },
|
||||
select: { id: true, content: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const catalog: CatalogItem[] = [];
|
||||
const lines: string[] = [
|
||||
`Today: ${new Date().toISOString()}`,
|
||||
personId ? `Focus personId: ${personId}` : 'Scope: current user records',
|
||||
];
|
||||
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId },
|
||||
include: { company: { select: { name: true } } },
|
||||
});
|
||||
if (person) {
|
||||
catalog.push({
|
||||
type: 'person',
|
||||
id: person.id,
|
||||
label: `${person.firstName} ${person.lastName}`.trim(),
|
||||
});
|
||||
lines.push(
|
||||
`Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of memories) {
|
||||
catalog.push({ type: 'memory', id: item.id, label: clip(item.content, 60) });
|
||||
lines.push(`Memory ${item.id}: ${clip(item.content, 220)}`);
|
||||
}
|
||||
for (const note of notes) {
|
||||
const who = note.person
|
||||
? `${note.person.firstName} ${note.person.lastName}`
|
||||
: '';
|
||||
catalog.push({
|
||||
type: 'note',
|
||||
id: note.id,
|
||||
label: clip(note.title || note.body, 60),
|
||||
});
|
||||
lines.push(
|
||||
`Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
|
||||
);
|
||||
}
|
||||
for (const activity of activities) {
|
||||
const who = activity.person
|
||||
? `${activity.person.firstName} ${activity.person.lastName}`
|
||||
: '';
|
||||
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
|
||||
lines.push(
|
||||
`Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
|
||||
);
|
||||
}
|
||||
for (const deal of deals) {
|
||||
const who = deal.person
|
||||
? `${deal.person.firstName} ${deal.person.lastName}`
|
||||
: '';
|
||||
catalog.push({ type: 'deal', id: deal.id, label: deal.title });
|
||||
const flags = [
|
||||
deal.isStale ? 'stale' : null,
|
||||
deal.isRotten ? 'rotten' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
lines.push(
|
||||
`Deal ${deal.id} ${deal.title} status=${deal.status} stage=${deal.stage.name} amount=${deal.amount != null ? String(deal.amount) : '—'} ${deal.currency}${flags ? ` flags=${flags}` : ''}${who ? ` person=${who}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { context: lines.join('\n').slice(0, 12000), catalog };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class AiSttService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return (
|
||||
this.config.get<string>('STT_ENABLED') === 'true' &&
|
||||
Boolean(this.sttUrl())
|
||||
);
|
||||
}
|
||||
|
||||
async transcribe(file: Express.Multer.File | undefined) {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست');
|
||||
}
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل صوتی ارسال نشده است');
|
||||
}
|
||||
if (file.size > MAX_AUDIO_BYTES) {
|
||||
throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است');
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'audio',
|
||||
new Blob([new Uint8Array(file.buffer)], {
|
||||
type: file.mimetype || 'audio/wav',
|
||||
}),
|
||||
file.originalname || 'audio.wav',
|
||||
);
|
||||
|
||||
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 60000);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
|
||||
if (token) headers['X-STT-Token'] = token;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${this.sttUrl()}/transcribe`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'سرویس تبدیل صوت در دسترس نیست. Pipecat STT را اجرا کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
text?: string;
|
||||
detail?: string | Array<{ msg?: string }>;
|
||||
};
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = Array.isArray(payload.detail)
|
||||
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
|
||||
: payload.detail;
|
||||
throw new ServiceUnavailableException(
|
||||
detail || 'تبدیل صوت به متن ناموفق بود',
|
||||
);
|
||||
}
|
||||
|
||||
return { text: (payload.text ?? '').trim() };
|
||||
}
|
||||
|
||||
private sttUrl(): string {
|
||||
return (this.config.get<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
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 type { Actor } from '../common/record-scope';
|
||||
import { AiAskService } from './ai-ask.service';
|
||||
import { AskMentoDto } from './dto';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiSttService } from './ai-stt.service';
|
||||
|
||||
@Controller('ai')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiController {
|
||||
constructor(
|
||||
private readonly ai: AiService,
|
||||
private readonly ask: AiAskService,
|
||||
private readonly stt: AiSttService,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
status() {
|
||||
return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() };
|
||||
}
|
||||
|
||||
@Post('transcribe')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('audio', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
transcribe(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.stt.transcribe(file);
|
||||
}
|
||||
|
||||
@Post('ask')
|
||||
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
|
||||
return this.ask.ask(user, dto.question, dto.personId);
|
||||
}
|
||||
|
||||
@Get('people/:personId/summary')
|
||||
personSummary(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('personId') personId: string,
|
||||
) {
|
||||
return this.ask.summarizePerson(user, personId);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AiAskService } from './ai-ask.service';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiSttService } from './ai-stt.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AiAskService, AiSttService],
|
||||
exports: [AiService, AiSttService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -32,17 +32,6 @@ export class AiService {
|
||||
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:
|
||||
{
|
||||
@@ -65,6 +54,25 @@ Rules:
|
||||
`Note:\n${input.body.trim()}`,
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = await this.completeJson<Partial<NoteAiExtract>>(
|
||||
system,
|
||||
userParts.join('\n'),
|
||||
);
|
||||
return this.normalizeExtract(raw);
|
||||
}
|
||||
|
||||
async completeJson<T>(system: string, user: string): Promise<T> {
|
||||
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 controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@@ -81,7 +89,7 @@ Rules:
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: userParts.join('\n') },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
@@ -102,15 +110,18 @@ Rules:
|
||||
throw new BadRequestException('پاسخ مدل خالی بود');
|
||||
}
|
||||
|
||||
return this.normalizeExtract(JSON.parse(raw) as Partial<NoteAiExtract>);
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException || err instanceof ServiceUnavailableException) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof SyntaxError) {
|
||||
throw new BadRequestException('پاسخ مدل قابل خواندن نبود');
|
||||
}
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new ServiceUnavailableException('زمان پاسخ مدل هوش مصنوعی تمام شد');
|
||||
}
|
||||
throw new ServiceUnavailableException('تحلیل یادداشت با خطا مواجه شد');
|
||||
throw new ServiceUnavailableException('ارتباط با مدل هوش مصنوعی ناموفق بود');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class AskMentoDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
question!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { ImportContactsResult } from '@mento/shared';
|
||||
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
@@ -21,6 +22,13 @@ function cellStr(value: ExcelJS.CellValue | undefined): string {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function parseMood(raw?: string): PersonMood | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
if (PERSON_MOODS.includes(value as PersonMood)) return value as PersonMood;
|
||||
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
||||
}
|
||||
|
||||
function normalizeHeader(raw: string) {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
@@ -44,6 +52,10 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
شرکت: 'companyName',
|
||||
notes: 'notes',
|
||||
یادداشت: 'notes',
|
||||
mood: 'mood',
|
||||
وضعیت: 'mood',
|
||||
وضعیتمشتری: 'mood',
|
||||
'وضعیت مشتری': 'mood',
|
||||
};
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
@@ -86,6 +98,7 @@ export class ContactsExcelService {
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'سمت', key: 'position', width: 18 },
|
||||
{ header: 'شرکت', key: 'companyName', width: 24 },
|
||||
{ header: 'وضعیت مشتری', key: 'mood', width: 18 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
];
|
||||
for (const p of items) {
|
||||
@@ -97,6 +110,7 @@ export class ContactsExcelService {
|
||||
email: p.email ?? '',
|
||||
position: p.position ?? '',
|
||||
companyName: p.company?.name ?? '',
|
||||
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
notes: p.notes ?? '',
|
||||
});
|
||||
}
|
||||
@@ -174,6 +188,7 @@ export class ContactsExcelService {
|
||||
mobile: row.mobile || undefined,
|
||||
position: row.position || undefined,
|
||||
notes: row.notes || undefined,
|
||||
mood: parseMood(row.mood),
|
||||
companyId,
|
||||
});
|
||||
result.created += 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { PERSON_MOODS } from '@mento/shared';
|
||||
|
||||
export class ListQueryDto {
|
||||
@IsOptional()
|
||||
@@ -29,6 +31,14 @@ export class ListQueryDto {
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['name', 'newest'])
|
||||
sort?: 'name' | 'newest';
|
||||
}
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@@ -160,6 +170,10 @@ export class CreatePersonDto {
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -213,6 +227,10 @@ export class UpdatePersonDto {
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -37,6 +37,7 @@ export class PeopleController {
|
||||
query.q,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
{ mood: query.mood, sort: query.sort },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CreatePersonRequest,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
PersonMood,
|
||||
UpdatePersonRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -41,6 +42,7 @@ type PersonRow = {
|
||||
mobile: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
mood: PersonMood;
|
||||
customFields: unknown;
|
||||
loyaltyPoints: number;
|
||||
createdAt: Date;
|
||||
@@ -67,10 +69,12 @@ export class PeopleService {
|
||||
q?: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
opts?: { mood?: PersonMood; sort?: 'name' | 'newest' },
|
||||
): Promise<PaginatedResponse<PersonDto>> {
|
||||
const where: Prisma.PersonWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
@@ -89,7 +93,10 @@ export class PeopleService {
|
||||
this.prisma.person.findMany({
|
||||
where,
|
||||
include: personInclude,
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
orderBy:
|
||||
opts?.sort === 'newest'
|
||||
? [{ createdAt: 'desc' }]
|
||||
: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
@@ -128,6 +135,7 @@ export class PeopleService {
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
notes: emptyToNull(dto.notes),
|
||||
mood: dto.mood ?? 'newcomer',
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
@@ -156,6 +164,7 @@ export class PeopleService {
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if (dto.mood !== undefined) data.mood = dto.mood;
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
@@ -238,6 +247,7 @@ export class PeopleService {
|
||||
mobile: row.mobile,
|
||||
position: row.position,
|
||||
notes: row.notes,
|
||||
mood: row.mood,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
loyaltyPoints: row.loyaltyPoints ?? 0,
|
||||
loyaltyTier: row.loyaltyTier,
|
||||
|
||||
Reference in New Issue
Block a user