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:
2026-09-03 14:20:55 +03:30
co-authored by Cursor
parent 0dd2966f3c
commit 7757c0837d
43 changed files with 5156 additions and 1741 deletions
@@ -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;
+18
View File
@@ -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 },
);
}
+11 -1
View File
@@ -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,