Persist manual, campaign, and automation SMS; expose history APIs for people and leads. Co-authored-by: Cursor <cursoragent@cursor.com>
746 lines
24 KiB
TypeScript
746 lines
24 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
ServiceUnavailableException,
|
|
type OnModuleInit,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
CreatePersonRequest,
|
|
FilteredIdsResponse,
|
|
PaginatedResponse,
|
|
PersonDto,
|
|
PersonImportSourceStatDto,
|
|
PersonMood,
|
|
UpdatePersonRequest,
|
|
} from '@mento/shared';
|
|
import { FILTERED_IDS_MAX } from '@mento/shared';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
|
import { CompaniesService } from './companies.service';
|
|
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
|
import {
|
|
asAddresses,
|
|
asCustomFields,
|
|
toJsonAddresses,
|
|
toJsonCustomFields,
|
|
} from '../common/custom-fields';
|
|
import {
|
|
type Actor,
|
|
canReassignOwner,
|
|
ownerWhere,
|
|
resolveOwnerId,
|
|
} from '../common/record-scope';
|
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
|
import { AutomationsService } from '../automations/automations.service';
|
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
|
import { WebhooksService } from '../public-api/webhooks.service';
|
|
import { GamificationService } from '../gamification/gamification.service';
|
|
import { parseBirthDate } from '../common/excel';
|
|
import { SmsService } from '../common/sms.service';
|
|
import { selectMonthBirthdays } from './birthday-month';
|
|
import { renderSmsText } from '../common/sms-text';
|
|
import { normalizeTags } from '../common/tags';
|
|
import { buildSearchWhere, ci } from '../common/search';
|
|
import {
|
|
normalizePersonImportSource,
|
|
personImportSourceLabel,
|
|
} from './person-source';
|
|
|
|
const personInclude = {
|
|
company: { select: { id: true, name: true, province: true, city: true } },
|
|
owner: { select: { id: true, fullName: true } },
|
|
loyaltyTier: {
|
|
select: { id: true, name: true, minPoints: true, color: true },
|
|
},
|
|
} satisfies Prisma.PersonInclude;
|
|
|
|
type PersonRow = {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
previousFirstName: string | null;
|
|
previousLastName: string | null;
|
|
importSource: string;
|
|
sourceRef: string | null;
|
|
previousImportSource: string | null;
|
|
previousSourceRef: string | null;
|
|
email: string | null;
|
|
phone: string | null;
|
|
mobile: string | null;
|
|
position: string | null;
|
|
website: string | null;
|
|
instagram: string | null;
|
|
birthDate: Date | null;
|
|
referrer: string | null;
|
|
addresses: unknown;
|
|
notes: string | null;
|
|
mood: PersonMood;
|
|
tags: string[];
|
|
customFields: unknown;
|
|
loyaltyPoints: number;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
company: {
|
|
id: string;
|
|
name: string;
|
|
province: string | null;
|
|
city: string | null;
|
|
} | null;
|
|
owner: { id: string; fullName: string } | null;
|
|
loyaltyTier: {
|
|
id: string;
|
|
name: string;
|
|
minPoints: number;
|
|
color: string | null;
|
|
} | null;
|
|
};
|
|
|
|
@Injectable()
|
|
export class PeopleService implements OnModuleInit {
|
|
private readonly logger = new Logger(PeopleService.name);
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly companies: CompaniesService,
|
|
private readonly loyalty: LoyaltyService,
|
|
private readonly automations: AutomationsService,
|
|
private readonly messageAutomations: MessageAutomationsService,
|
|
private readonly webhooks: WebhooksService,
|
|
private readonly game: GamificationService,
|
|
private readonly sms: SmsService,
|
|
private readonly outboundSms: OutboundSmsLogService,
|
|
) {}
|
|
|
|
private personListWhere(
|
|
actor: Actor,
|
|
q?: string,
|
|
opts?: {
|
|
mood?: PersonMood;
|
|
importSource?: string;
|
|
province?: string;
|
|
city?: string;
|
|
hasMobile?: boolean;
|
|
},
|
|
): Prisma.PersonWhereInput {
|
|
const sourceFilter = opts?.importSource?.trim()
|
|
? normalizePersonImportSource(opts.importSource)
|
|
: undefined;
|
|
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
|
{ firstName: ci(t) },
|
|
{ lastName: ci(t) },
|
|
{ previousFirstName: ci(t) },
|
|
{ previousLastName: ci(t) },
|
|
{ email: ci(t) },
|
|
{ mobile: { contains: t } },
|
|
{ phone: { contains: t } },
|
|
{ sourceRef: ci(t) },
|
|
{ previousSourceRef: ci(t) },
|
|
{ company: { name: ci(t) } },
|
|
{ company: { province: ci(t) } },
|
|
{ company: { city: ci(t) } },
|
|
]);
|
|
const province = opts?.province?.trim();
|
|
const city = opts?.city?.trim();
|
|
const and: Prisma.PersonWhereInput[] = [];
|
|
if (search) and.push(search);
|
|
if (province) {
|
|
and.push({
|
|
OR: [
|
|
{ company: { province: { contains: province, mode: 'insensitive' } } },
|
|
{ company: { city: { contains: province, mode: 'insensitive' } } },
|
|
],
|
|
});
|
|
}
|
|
if (city) {
|
|
and.push({
|
|
OR: [
|
|
{ company: { city: { contains: city, mode: 'insensitive' } } },
|
|
{ company: { province: { contains: city, mode: 'insensitive' } } },
|
|
],
|
|
});
|
|
}
|
|
if (opts?.hasMobile) {
|
|
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
|
|
}
|
|
return {
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
...(opts?.mood ? { mood: opts.mood } : {}),
|
|
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
|
...(and.length ? { AND: and } : {}),
|
|
};
|
|
}
|
|
|
|
async list(
|
|
actor: Actor,
|
|
q?: string,
|
|
page = 1,
|
|
limit = 20,
|
|
opts?: {
|
|
mood?: PersonMood;
|
|
sort?: 'name' | 'newest';
|
|
importSource?: string;
|
|
province?: string;
|
|
city?: string;
|
|
hasMobile?: boolean;
|
|
},
|
|
): Promise<PaginatedResponse<PersonDto>> {
|
|
const where = this.personListWhere(actor, q, opts);
|
|
|
|
const [rows, total] = await this.prisma.$transaction([
|
|
this.prisma.person.findMany({
|
|
where,
|
|
include: personInclude,
|
|
orderBy:
|
|
opts?.sort === 'newest'
|
|
? [{ createdAt: 'desc' }]
|
|
: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
}),
|
|
this.prisma.person.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
items: rows.map((row) => this.toDto(row)),
|
|
total,
|
|
page,
|
|
limit,
|
|
};
|
|
}
|
|
|
|
async listIds(
|
|
actor: Actor,
|
|
q?: string,
|
|
opts?: {
|
|
mood?: PersonMood;
|
|
importSource?: string;
|
|
province?: string;
|
|
city?: string;
|
|
hasMobile?: boolean;
|
|
},
|
|
max = FILTERED_IDS_MAX,
|
|
): Promise<FilteredIdsResponse> {
|
|
const where = this.personListWhere(actor, q, opts);
|
|
const total = await this.prisma.person.count({ where });
|
|
if (total > max) {
|
|
throw new BadRequestException(
|
|
`تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`,
|
|
);
|
|
}
|
|
const rows = await this.prisma.person.findMany({
|
|
where,
|
|
select: { id: true },
|
|
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
|
take: max,
|
|
});
|
|
return { ids: rows.map((r) => r.id), total };
|
|
}
|
|
|
|
async sourceStats(actor: Actor): Promise<PersonImportSourceStatDto[]> {
|
|
const rows = await this.prisma.person.groupBy({
|
|
by: ['importSource'],
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
_count: { _all: true },
|
|
});
|
|
return rows
|
|
.map((row) => ({
|
|
source: row.importSource || 'manual',
|
|
label: personImportSourceLabel(row.importSource || 'manual'),
|
|
count: row._count._all,
|
|
}))
|
|
.sort((a, b) => b.count - a.count);
|
|
}
|
|
|
|
onModuleInit() {
|
|
void this.backfillStoredBirthDates();
|
|
}
|
|
|
|
/** تاریخ تولدی که قبلاً در فیلد اضافی دیدار/اکسل یا پروفایل مونتیفای مانده، روی شخص مینشیند. */
|
|
async backfillStoredBirthDates() {
|
|
try {
|
|
const fromFields = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
|
|
SELECT id,
|
|
COALESCE(
|
|
NULLIF("customFields"->>'birthDate', ''),
|
|
NULLIF("customFields"->>'birthDate2', '')
|
|
) AS birth
|
|
FROM "Person"
|
|
WHERE "birthDate" IS NULL
|
|
AND (
|
|
COALESCE("customFields"->>'birthDate', '') <> ''
|
|
OR COALESCE("customFields"->>'birthDate2', '') <> ''
|
|
)
|
|
LIMIT 20000
|
|
`;
|
|
const fromMontify = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
|
|
SELECT p.id, m.data->>'birthday' AS birth
|
|
FROM "MontifyLink" m
|
|
JOIN "Person" p ON p.id = m."entityId"
|
|
WHERE m."entityType" = 'customer'
|
|
AND p."birthDate" IS NULL
|
|
AND COALESCE(m.data->>'birthday', '') <> ''
|
|
LIMIT 20000
|
|
`;
|
|
const seen = new Set<string>();
|
|
let filled = 0;
|
|
for (const row of [...fromFields, ...fromMontify]) {
|
|
if (!row.birth || seen.has(row.id)) continue;
|
|
seen.add(row.id);
|
|
const birthDate = parseBirthDate(row.birth);
|
|
if (!birthDate) continue;
|
|
const updated = await this.prisma.person.updateMany({
|
|
where: { id: row.id, birthDate: null },
|
|
data: { birthDate },
|
|
});
|
|
filled += updated.count;
|
|
}
|
|
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از دادههای ذخیرهشده پر شد`);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`پر کردن تاریخ تولد ذخیرهشده ناموفق بود: ${err instanceof Error ? err.message : err}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async fillBirthDateIfEmpty(
|
|
organizationId: string,
|
|
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
|
) {
|
|
const or: Prisma.PersonWhereInput[] = [];
|
|
if (input.mobile) or.push({ mobile: input.mobile });
|
|
if (input.email) or.push({ email: input.email });
|
|
if (!or.length) return;
|
|
await this.prisma.person.updateMany({
|
|
where: { organizationId, birthDate: null, OR: or },
|
|
data: { birthDate: input.birthDate },
|
|
});
|
|
}
|
|
|
|
async get(actor: Actor, id: string): Promise<PersonDto> {
|
|
const row = await this.prisma.person.findFirst({
|
|
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
|
include: personInclude,
|
|
});
|
|
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
|
|
const companyId = emptyToNull(dto.companyId ?? null);
|
|
if (companyId) {
|
|
await this.companies.ensureInOrg(actor.organizationId, companyId, actor);
|
|
}
|
|
|
|
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
|
const data: Prisma.PersonUncheckedCreateInput = {
|
|
organizationId: actor.organizationId,
|
|
firstName: (dto.firstName ?? '').trim(),
|
|
lastName: (dto.lastName ?? '').trim(),
|
|
previousFirstName: emptyToNull(dto.previousFirstName),
|
|
previousLastName: emptyToNull(dto.previousLastName),
|
|
importSource: normalizePersonImportSource(dto.importSource),
|
|
sourceRef: emptyToNull(dto.sourceRef),
|
|
email: normalizeEmail(dto.email),
|
|
phone: normalizePhone(dto.phone),
|
|
mobile: normalizePhone(dto.mobile),
|
|
position: emptyToNull(dto.position),
|
|
website: emptyToNull(dto.website),
|
|
instagram: emptyToNull(dto.instagram),
|
|
birthDate: parseBirthDate(dto.birthDate),
|
|
referrer: emptyToNull(dto.referrer),
|
|
addresses: toJsonAddresses(dto.addresses),
|
|
notes: emptyToNull(dto.notes),
|
|
mood: dto.mood ?? 'newcomer',
|
|
tags: normalizeTags(dto.tags),
|
|
companyId,
|
|
ownerId: resolveOwnerId(actor, dto.ownerId),
|
|
customFields: toJsonCustomFields(dto.customFields),
|
|
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
|
? { createdAt: registeredAt }
|
|
: {}),
|
|
};
|
|
await this.assertNoDuplicate(actor.organizationId, data);
|
|
|
|
const row = await this.prisma.person.create({
|
|
data,
|
|
include: personInclude,
|
|
});
|
|
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
|
const createdCtx = {
|
|
organizationId: actor.organizationId,
|
|
entityType: 'person' as const,
|
|
entityId: row.id,
|
|
title: `${row.firstName} ${row.lastName}`.trim(),
|
|
personId: row.id,
|
|
companyId: row.companyId,
|
|
ownerId: row.ownerId,
|
|
};
|
|
await this.automations.runTrigger('person_created', createdCtx);
|
|
await this.messageAutomations.onEvent('person_created', createdCtx);
|
|
if (row.importSource === 'manual') {
|
|
this.game.emit({
|
|
organizationId: actor.organizationId,
|
|
userId: actor.userId,
|
|
event: 'CUSTOMER_CREATED',
|
|
entityType: 'person',
|
|
entityId: row.id,
|
|
entityTitle: createdCtx.title,
|
|
facts: { source: row.importSource },
|
|
});
|
|
}
|
|
this.webhooks.emit(actor.organizationId, 'person.created', {
|
|
id: row.id,
|
|
fullName: createdCtx.title,
|
|
mobile: row.mobile,
|
|
email: row.email,
|
|
companyId: row.companyId,
|
|
});
|
|
const fresh = await this.prisma.person.findFirst({
|
|
where: { id: row.id },
|
|
include: personInclude,
|
|
});
|
|
return this.toDto(fresh ?? row);
|
|
}
|
|
|
|
async update(
|
|
actor: Actor,
|
|
id: string,
|
|
dto: UpdatePersonRequest,
|
|
): Promise<PersonDto> {
|
|
await this.ensureExists(actor, id);
|
|
|
|
const data: Prisma.PersonUncheckedUpdateInput = {};
|
|
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
|
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
|
if ('previousFirstName' in dto) {
|
|
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
|
}
|
|
if ('previousLastName' in dto) {
|
|
data.previousLastName = emptyToNull(dto.previousLastName);
|
|
}
|
|
if (dto.importSource !== undefined) {
|
|
data.importSource = normalizePersonImportSource(dto.importSource);
|
|
}
|
|
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
|
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
|
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
|
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
|
if ('position' in dto) data.position = emptyToNull(dto.position);
|
|
if ('website' in dto) data.website = emptyToNull(dto.website);
|
|
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
|
if ('birthDate' in dto) data.birthDate = parseBirthDate(dto.birthDate);
|
|
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
|
|
if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses);
|
|
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
|
if (dto.mood !== undefined) data.mood = dto.mood;
|
|
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
|
if ('customFields' in dto) {
|
|
data.customFields = toJsonCustomFields(dto.customFields);
|
|
}
|
|
if ('companyId' in dto) {
|
|
const nextCompanyId = emptyToNull(dto.companyId ?? null);
|
|
if (nextCompanyId) {
|
|
await this.companies.ensureInOrg(
|
|
actor.organizationId,
|
|
nextCompanyId,
|
|
actor,
|
|
);
|
|
}
|
|
data.companyId = nextCompanyId;
|
|
}
|
|
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
|
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
|
}
|
|
|
|
await this.assertNoDuplicate(actor.organizationId, data, id);
|
|
const row = await this.prisma.person.update({
|
|
where: { id },
|
|
data,
|
|
include: personInclude,
|
|
});
|
|
this.webhooks.emit(actor.organizationId, 'person.updated', {
|
|
id: row.id,
|
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
|
mobile: row.mobile,
|
|
email: row.email,
|
|
companyId: row.companyId,
|
|
});
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async remove(actor: Actor, id: string): Promise<void> {
|
|
await this.ensureExists(actor, id);
|
|
await this.prisma.person.delete({ where: { id } });
|
|
}
|
|
|
|
async removeMany(actor: Actor, ids: string[]): Promise<{ deleted: number }> {
|
|
const unique = [...new Set(ids.filter(Boolean))];
|
|
if (!unique.length) return { deleted: 0 };
|
|
const res = await this.prisma.person.deleteMany({
|
|
where: {
|
|
id: { in: unique },
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
});
|
|
return { deleted: res.count };
|
|
}
|
|
|
|
private async ensureExists(actor: Actor, id: string) {
|
|
const found = await this.prisma.person.findFirst({
|
|
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
|
select: { id: true },
|
|
});
|
|
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
|
}
|
|
|
|
private async assertNoDuplicate(
|
|
organizationId: string,
|
|
data: { email?: unknown; mobile?: unknown },
|
|
excludeId?: string,
|
|
) {
|
|
const email = typeof data.email === 'string' ? data.email : null;
|
|
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
|
|
|
const or: Prisma.PersonWhereInput[] = [];
|
|
if (email) or.push({ email });
|
|
if (mobile) or.push({ mobile });
|
|
if (!or.length) return;
|
|
|
|
const clash = await this.prisma.person.findFirst({
|
|
where: {
|
|
organizationId,
|
|
OR: or,
|
|
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
|
},
|
|
});
|
|
if (!clash) return;
|
|
|
|
if (email && clash.email === email) {
|
|
throw new ConflictException('شخصی با این ایمیل از قبل وجود دارد');
|
|
}
|
|
if (mobile && clash.mobile === mobile) {
|
|
throw new ConflictException('شخصی با این موبایل از قبل وجود دارد');
|
|
}
|
|
throw new ConflictException('شخص تکراری است');
|
|
}
|
|
|
|
async birthdaysThisMonth(actor: Actor) {
|
|
const rows = await this.prisma.person.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
ownerId: actor.userId,
|
|
birthDate: { not: null },
|
|
},
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
mobile: true,
|
|
phone: true,
|
|
birthDate: true,
|
|
company: { select: { name: true } },
|
|
},
|
|
});
|
|
const selected = selectMonthBirthdays(
|
|
rows.flatMap((row) =>
|
|
row.birthDate
|
|
? [
|
|
{
|
|
id: row.id,
|
|
firstName: row.firstName,
|
|
lastName: row.lastName,
|
|
mobile: row.mobile || row.phone,
|
|
birthDate: row.birthDate,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
);
|
|
const companyById = new Map(rows.map((row) => [row.id, row.company?.name ?? '']));
|
|
const toItem = (row: (typeof selected.items)[number]) => ({
|
|
id: row.id,
|
|
firstName: row.firstName,
|
|
lastName: row.lastName,
|
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
|
mobile: row.mobile,
|
|
company: companyById.get(row.id) ?? '',
|
|
day: row.day,
|
|
});
|
|
return {
|
|
month: selected.month,
|
|
monthName: selected.monthName,
|
|
today: selected.today,
|
|
total: selected.total,
|
|
preview: selected.preview.map(toItem),
|
|
items: selected.items.map(toItem),
|
|
};
|
|
}
|
|
|
|
async sendBirthdaySms(actor: Actor, personId: string, text: string) {
|
|
const body = text.trim();
|
|
if (!body || body.length > 500) {
|
|
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
|
}
|
|
const person = await this.prisma.person.findFirst({
|
|
where: { id: personId, organizationId: actor.organizationId },
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
mobile: true,
|
|
phone: true,
|
|
ownerId: true,
|
|
birthDate: true,
|
|
company: { select: { name: true } },
|
|
},
|
|
});
|
|
if (!person) throw new NotFoundException('مشتری پیدا نشد');
|
|
if (person.ownerId !== actor.userId) {
|
|
throw new ForbiddenException('فقط مسئول این مشتری میتواند پیامک تبریک بفرستد.');
|
|
}
|
|
if (!person.birthDate) {
|
|
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
|
|
}
|
|
const inMonth = selectMonthBirthdays(
|
|
[
|
|
{
|
|
id: person.id,
|
|
firstName: person.firstName,
|
|
lastName: person.lastName,
|
|
mobile: person.mobile || person.phone,
|
|
birthDate: person.birthDate,
|
|
},
|
|
],
|
|
);
|
|
if (!inMonth.total) {
|
|
throw new BadRequestException('تولد این مشتری در ماه جاری نیست.');
|
|
}
|
|
const mobile = person.mobile || person.phone;
|
|
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
|
const fullName = `${person.firstName} ${person.lastName}`.trim();
|
|
const message = renderSmsText(body, {
|
|
name: fullName,
|
|
phone: mobile,
|
|
title: fullName,
|
|
company: person.company?.name ?? '',
|
|
});
|
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
|
const result = await this.sms.sendSmsDetailed(mobile, message);
|
|
await this.outboundSms.record({
|
|
organizationId: actor.organizationId,
|
|
personId: person.id,
|
|
mobile,
|
|
body: message,
|
|
source: 'manual',
|
|
status: result.ok ? 'sent' : 'failed',
|
|
error: result.ok ? null : result.reason,
|
|
actorUserId: actor.userId,
|
|
});
|
|
if (!result.ok) {
|
|
throw new ServiceUnavailableException(
|
|
result.reason === 'SMS not configured'
|
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
|
);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
async sendSms(actor: Actor, personId: string, text: string) {
|
|
const body = text.trim();
|
|
if (!body || body.length > 500) {
|
|
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
|
}
|
|
const person = await this.get(actor, personId);
|
|
const mobile = person.mobile || person.phone;
|
|
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
|
const message = renderSmsText(body, {
|
|
name: person.fullName,
|
|
phone: mobile,
|
|
title: person.fullName,
|
|
company: person.company?.name ?? '',
|
|
});
|
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
|
const result = await this.sms.sendSmsDetailed(mobile, message);
|
|
await this.outboundSms.record({
|
|
organizationId: actor.organizationId,
|
|
personId: person.id,
|
|
mobile,
|
|
body: message,
|
|
source: 'manual',
|
|
status: result.ok ? 'sent' : 'failed',
|
|
error: result.ok ? null : result.reason,
|
|
actorUserId: actor.userId,
|
|
});
|
|
if (!result.ok) {
|
|
throw new ServiceUnavailableException(
|
|
result.reason === 'SMS not configured'
|
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
|
);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
listSmsHistory(actor: Actor, personId: string, limit?: number) {
|
|
return this.get(actor, personId).then(() =>
|
|
this.outboundSms.historyForPerson(actor.organizationId, personId, limit),
|
|
);
|
|
}
|
|
|
|
private toDto(row: PersonRow): PersonDto {
|
|
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
|
|
const importSource = row.importSource || 'manual';
|
|
return {
|
|
id: row.id,
|
|
firstName: row.firstName,
|
|
lastName: row.lastName,
|
|
previousFirstName: row.previousFirstName,
|
|
previousLastName: row.previousLastName,
|
|
previousFullName: previousFullName || null,
|
|
importSource,
|
|
sourceRef: row.sourceRef,
|
|
previousImportSource: row.previousImportSource,
|
|
previousSourceRef: row.previousSourceRef,
|
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
|
email: row.email,
|
|
phone: row.phone,
|
|
mobile: row.mobile,
|
|
position: row.position,
|
|
website: row.website,
|
|
instagram: row.instagram,
|
|
birthDate: row.birthDate?.toISOString() ?? null,
|
|
referrer: row.referrer,
|
|
addresses: (() => {
|
|
const fromCol = asAddresses(row.addresses);
|
|
if (fromCol.length) return fromCol;
|
|
const extra = asCustomFields(row.customFields);
|
|
return asAddresses(
|
|
[extra.address, extra.homeAddress, extra.address2]
|
|
.map((v) => (typeof v === 'string' ? v : ''))
|
|
.filter(Boolean),
|
|
);
|
|
})(),
|
|
notes: row.notes,
|
|
mood: row.mood,
|
|
tags: row.tags ?? [],
|
|
customFields: asCustomFields(row.customFields),
|
|
loyaltyPoints: row.loyaltyPoints ?? 0,
|
|
loyaltyTier: row.loyaltyTier,
|
|
company: row.company,
|
|
owner: row.owner,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|