Montify stores integration: multi-store connections, customer/order/transaction sync, webhooks

This commit is contained in:
2026-10-10 15:59:12 +03:30
parent fa3fe0d364
commit e82bd2b2df
18 changed files with 3712 additions and 0 deletions
+2
View File
@@ -42,6 +42,7 @@ import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { MontifyModule } from './integrations/montify/montify.module';
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
@@ -97,6 +98,7 @@ import { RoutinesModule } from './routines/routines.module';
TrackingModule,
TeamsModule,
WooCommerceModule,
MontifyModule,
VoipModule,
ReservationsModule,
ConversationsModule,
@@ -0,0 +1,289 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
MontifySyncResultDto,
MontifySyncStatus,
MontifyTestResultDto,
UpdateMontifyConnectionRequest,
} from '@mento/shared';
import { Prisma, type MontifyConnection } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { MontifyClient, normalizeMontifyBaseUrl } from './montify-client';
export const MONTIFY_WEBHOOK_EVENTS = [
'customer.created',
'customer.updated',
'order.created',
'order.updated',
'order.paid',
'order.cancelled',
];
@Injectable()
export class MontifyConnectionsService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: TahesabCryptoService,
private readonly config: ConfigService,
) {}
async list(organizationId: string): Promise<MontifyConnectionDto[]> {
const rows = await this.prisma.montifyConnection.findMany({
where: { organizationId },
orderBy: { createdAt: 'asc' },
});
const [customerCounts, orderCounts] = await Promise.all([
this.prisma.montifyLink.groupBy({
by: ['connectionId'],
where: { organizationId, entityType: 'customer' },
_count: { _all: true },
}),
this.prisma.montifyOrder.groupBy({
by: ['connectionId'],
where: { organizationId },
_count: { _all: true },
}),
]);
const countIn = (list: { connectionId: string; _count: { _all: number } }[], id: string) =>
list.find((c) => c.connectionId === id)?._count._all ?? 0;
return rows.map((r) =>
this.toDto(r, countIn(customerCounts, r.id), countIn(orderCounts, r.id)),
);
}
async getDto(organizationId: string, id: string): Promise<MontifyConnectionDto> {
const row = await this.getOrThrow(organizationId, id);
const [customers, orders] = await Promise.all([
this.prisma.montifyLink.count({ where: { connectionId: id, entityType: 'customer' } }),
this.prisma.montifyOrder.count({ where: { connectionId: id } }),
]);
return this.toDto(row, customers, orders);
}
async getOrThrow(organizationId: string, id: string) {
const row = await this.prisma.montifyConnection.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('اتصال فروشگاه پیدا نشد');
return row;
}
async create(
organizationId: string,
dto: CreateMontifyConnectionRequest,
): Promise<MontifyConnectionDto> {
const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام فروشگاه الزامی است');
const baseUrl = this.parseBaseUrl(dto.baseUrl);
const token = dto.token?.trim();
if (!token) throw new BadRequestException('توکن الزامی است');
const enc = this.crypto.encrypt(token);
const row = await this.prisma.montifyConnection.create({
data: {
organizationId,
label,
baseUrl,
storeId: dto.storeId?.trim() ?? '',
tokenCiphertext: enc.ciphertext,
tokenIv: enc.iv,
tokenTag: enc.tag,
tokenLast4: this.crypto.last4(token),
enabled: dto.enabled ?? true,
},
});
return this.toDto(row, 0, 0);
}
async update(
organizationId: string,
id: string,
dto: UpdateMontifyConnectionRequest,
): Promise<MontifyConnectionDto> {
await this.getOrThrow(organizationId, id);
const data: Prisma.MontifyConnectionUpdateInput = {};
if (dto.label !== undefined) {
const label = dto.label.trim();
if (!label) throw new BadRequestException('نام فروشگاه الزامی است');
data.label = label;
}
if (dto.baseUrl !== undefined) data.baseUrl = this.parseBaseUrl(dto.baseUrl);
if (dto.token?.trim()) {
const token = dto.token.trim();
const enc = this.crypto.encrypt(token);
data.tokenCiphertext = enc.ciphertext;
data.tokenIv = enc.iv;
data.tokenTag = enc.tag;
data.tokenLast4 = this.crypto.last4(token);
data.lastError = null;
}
if (dto.storeId !== undefined) data.storeId = dto.storeId.trim();
if (dto.enabled !== undefined) data.enabled = dto.enabled;
await this.prisma.montifyConnection.update({ where: { id }, data });
return this.getDto(organizationId, id);
}
async remove(organizationId: string, id: string): Promise<void> {
const row = await this.getOrThrow(organizationId, id);
if (row.webhookRemoteId) {
try {
await this.clientFor(row).deleteWebhook(row.webhookRemoteId);
} catch {
// حذف اتصال نباید به در دسترس بودن فروشگاه وابسته باشد
}
}
await this.prisma.montifyConnection.delete({ where: { id } });
}
clientFor(row: Pick<MontifyConnection, 'baseUrl' | 'tokenCiphertext' | 'tokenIv' | 'tokenTag'>) {
const token = this.crypto.decrypt({
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
tag: row.tokenTag,
});
return new MontifyClient(row.baseUrl, token);
}
decryptWebhookSecret(row: MontifyConnection): string | null {
if (!row.webhookSecretCiphertext || !row.webhookSecretIv || !row.webhookSecretTag) {
return null;
}
return this.crypto.decrypt({
ciphertext: row.webhookSecretCiphertext,
iv: row.webhookSecretIv,
tag: row.webhookSecretTag,
});
}
async testConnection(organizationId: string, id: string): Promise<MontifyTestResultDto> {
const row = await this.getOrThrow(organizationId, id);
try {
const page = await this.clientFor(row).listCustomers({ page: 1, limit: 1 });
await this.prisma.montifyConnection.update({
where: { id },
data: { lastHealthAt: new Date(), lastHealthOk: true, lastError: null },
});
return { ok: true, totalCustomers: Number.isFinite(page.meta.total) ? page.meta.total : null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطای ناشناخته';
await this.prisma.montifyConnection.update({
where: { id },
data: { lastHealthAt: new Date(), lastHealthOk: false, lastError: msg.slice(0, 500) },
});
throw new BadRequestException(`اتصال ناموفق: ${msg}`);
}
}
webhookUrl(id: string): string {
return `${this.publicApiBaseUrl()}/public/integrations/montify/${id}/webhook`;
}
async registerWebhook(organizationId: string, id: string): Promise<MontifyConnectionDto> {
const row = await this.getOrThrow(organizationId, id);
const url = this.webhookUrl(id);
if (!/^https:\/\//i.test(url)) {
throw new BadRequestException(
`آدرس وب‌هوک باید HTTPS باشد (${url}). PUBLIC_API_BASE_URL را در تنظیمات سرور تعیین کنید.`,
);
}
const client = this.clientFor(row);
if (row.webhookRemoteId) {
try {
await client.deleteWebhook(row.webhookRemoteId);
} catch {
// وب‌هوک قبلی ممکن است در فروشگاه حذف شده باشد
}
}
let registered: { id: string; secret: string };
try {
registered = await client.registerWebhook(url, MONTIFY_WEBHOOK_EVENTS);
} catch (e) {
throw new BadRequestException(
`ثبت وب‌هوک ناموفق: ${e instanceof Error ? e.message : e}`,
);
}
const enc = this.crypto.encrypt(registered.secret);
await this.prisma.montifyConnection.update({
where: { id },
data: {
webhookRemoteId: registered.id,
webhookSecretCiphertext: enc.ciphertext,
webhookSecretIv: enc.iv,
webhookSecretTag: enc.tag,
},
});
return this.getDto(organizationId, id);
}
async unregisterWebhook(organizationId: string, id: string): Promise<MontifyConnectionDto> {
const row = await this.getOrThrow(organizationId, id);
if (row.webhookRemoteId) {
try {
await this.clientFor(row).deleteWebhook(row.webhookRemoteId);
} catch (e) {
throw new BadRequestException(
`حذف وب‌هوک در فروشگاه ناموفق: ${e instanceof Error ? e.message : e}`,
);
}
}
await this.prisma.montifyConnection.update({
where: { id },
data: {
webhookRemoteId: null,
webhookSecretCiphertext: null,
webhookSecretIv: null,
webhookSecretTag: null,
},
});
return this.getDto(organizationId, id);
}
private publicApiBaseUrl(): string {
const explicit = this.config.get<string>('PUBLIC_API_BASE_URL')?.trim();
if (explicit) return explicit.replace(/\/$/, '');
const cors = this.config.get<string>('CORS_ORIGIN')?.split(',')[0]?.trim();
if (cors) return `${cors.replace(/\/$/, '')}/api`;
return 'http://localhost:3000/api';
}
private parseBaseUrl(raw: string): string {
try {
return normalizeMontifyBaseUrl(raw ?? '');
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : 'Base URL نامعتبر است');
}
}
private toDto(row: MontifyConnection, customers: number, orders: number): MontifyConnectionDto {
return {
id: row.id,
label: row.label,
baseUrl: row.baseUrl,
storeId: row.storeId,
tokenMasked: row.tokenLast4 ? `••••••${row.tokenLast4}` : '••••',
enabled: row.enabled,
webhookRegistered: Boolean(row.webhookRemoteId && row.webhookSecretCiphertext),
webhookUrl: this.webhookUrl(row.id),
syncStatus: row.syncStatus as MontifySyncStatus,
syncStartedAt: row.syncStartedAt?.toISOString() ?? null,
lastSyncAt: row.lastSyncAt?.toISOString() ?? null,
lastSyncResult: (row.lastSyncResult as unknown as MontifySyncResultDto | null) ?? null,
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk,
lastError: row.lastError,
lastWebhookAt: row.lastWebhookAt?.toISOString() ?? null,
linkedCustomers: customers,
orders,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+61
View File
@@ -0,0 +1,61 @@
import {
IsBoolean,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateMontifyConnectionDto {
@IsString()
@MinLength(2)
@MaxLength(80)
label!: string;
@IsString()
@MinLength(8)
@MaxLength(300)
baseUrl!: string;
@IsString()
@MinLength(8)
@MaxLength(500)
token!: string;
@IsOptional()
@IsString()
@MaxLength(100)
storeId?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateMontifyConnectionDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
label?: string;
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(300)
baseUrl?: string;
@IsOptional()
@IsString()
@MaxLength(500)
token?: string;
@IsOptional()
@IsString()
@MaxLength(100)
storeId?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
@@ -0,0 +1,333 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type MontifyAddress = {
id?: number | string;
province?: string | null;
city?: string | null;
address?: string | null;
postal_code?: string | null;
receiver_name?: string | null;
receiver_phone?: string | null;
is_default?: boolean | null;
};
export type MontifyTransaction = {
id: number | string;
user_id?: number | string | null;
type?: string | null;
amount?: number | string | null;
currency?: string | null;
status?: string | null;
reference_code?: string | null;
created_at?: string | null;
};
export type MontifyEngagement = {
saved?: unknown[] | null;
likes?: unknown[] | null;
comments?: unknown[] | null;
};
export type MontifyCustomer = {
id: number | string;
name?: string | null;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
email?: string | null;
username?: string | null;
birthday?: string | null;
gender?: string | null;
type?: string | null;
store_id?: number | string | null;
created_at?: string | null;
updated_at?: string | null;
description?: string | null;
bio?: string | null;
job?: string | null;
cash_balance?: number | string | null;
gold_balance?: number | string | null;
avatar?: string | null;
summary?: { total_orders?: number | null; total_spent?: number | null } | null;
addresses?: MontifyAddress[] | null;
orders?: MontifyOrder[] | null;
transactions?: MontifyTransaction[] | null;
engagement?: MontifyEngagement | null;
};
export type MontifyOrderItem = {
id?: number | string;
product_id?: number | string | null;
product_name?: string | null;
qty?: number | string | null;
price?: number | string | null;
weight?: number | string | null;
total_price?: number | string | null;
};
export type MontifyOrder = {
id: number | string;
user_id?: number | string | null;
store_id?: number | string | null;
code?: string | null;
sts?: string | null;
payment_sts?: string | null;
payment_type?: string | null;
total_price?: number | string | null;
discount_total_price?: number | string | null;
total_discounts?: number | string | null;
total_tax?: number | string | null;
total_qty?: number | string | null;
profit?: number | string | null;
percent_profit?: number | string | null;
sum_gold?: number | string | null;
sum_silver?: number | string | null;
address?: unknown;
note?: string | null;
cancel_reason?: string | null;
cancelled_at?: string | null;
closed_at?: string | null;
code_discount?: string | null;
created_at?: string | null;
updated_at?: string | null;
deleted_at?: string | null;
order_items?: MontifyOrderItem[] | null;
};
export type MontifyPage<T> = {
data: T[];
meta: { current_page: number; per_page: number; total: number; last_page: number };
};
export class MontifyHttpError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
const MIN_INTERVAL_MS = 650;
const lastRequestAt = new Map<string, number>();
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export function normalizeMontifyBaseUrl(url: string): string {
const u = url.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(u)) {
throw new Error('Base URL باید با https شروع شود');
}
return u;
}
function unwrap<T>(body: unknown): T {
if (
body &&
typeof body === 'object' &&
'data' in body &&
!Array.isArray(body) &&
!('id' in body)
) {
return (body as { data: T }).data;
}
return body as T;
}
function toPage<T>(body: unknown): MontifyPage<T> {
if (Array.isArray(body)) {
return {
data: body as T[],
meta: { current_page: 1, per_page: body.length, total: body.length, last_page: 1 },
};
}
const b = (body ?? {}) as Partial<MontifyPage<T>>;
const data = Array.isArray(b.data) ? b.data : [];
return {
data,
meta: {
current_page: Number(b.meta?.current_page ?? 1),
per_page: Number(b.meta?.per_page ?? data.length),
total: Number(b.meta?.total ?? data.length),
last_page: Number(b.meta?.last_page ?? 1),
},
};
}
export class MontifyClient {
private readonly base: string;
private readonly throttleKey: string;
constructor(
baseUrl: string,
private readonly token: string,
) {
this.base = normalizeMontifyBaseUrl(baseUrl);
this.throttleKey = `${this.base}|${token.slice(-8)}`;
}
async listCustomers(opts: {
page?: number;
limit?: number;
updatedAfter?: string;
}): Promise<MontifyPage<MontifyCustomer>> {
const params = new URLSearchParams();
params.set('page', String(opts.page ?? 1));
params.set('limit', String(opts.limit ?? 50));
if (opts.updatedAfter) params.set('updated_after', opts.updatedAfter);
return toPage<MontifyCustomer>(
await this.request('GET', `/montact/customers?${params.toString()}`),
);
}
async getCustomer(id: string | number): Promise<MontifyCustomer> {
return unwrap<MontifyCustomer>(
await this.request('GET', `/montact/customers/${encodeURIComponent(String(id))}`),
);
}
async listCustomerOrders(
customerId: string | number,
opts: { page?: number; updatedAfter?: string } = {},
): Promise<MontifyPage<MontifyOrder>> {
const params = new URLSearchParams();
params.set('page', String(opts.page ?? 1));
params.set('limit', '50');
if (opts.updatedAfter) params.set('updated_after', opts.updatedAfter);
return toPage<MontifyOrder>(
await this.request(
'GET',
`/montact/customers/${encodeURIComponent(String(customerId))}/orders?${params.toString()}`,
),
);
}
async listCustomerAddresses(customerId: string | number): Promise<MontifyAddress[]> {
return this.allPages<MontifyAddress>(
`/montact/customers/${encodeURIComponent(String(customerId))}/addresses`,
);
}
async listCustomerTransactions(customerId: string | number): Promise<MontifyTransaction[]> {
return this.allPages<MontifyTransaction>(
`/montact/customers/${encodeURIComponent(String(customerId))}/transactions`,
);
}
/** این مسیرها صفحه‌بندی دارند ولی updated_after ندارند */
private async allPages<T>(path: string, maxPages = 100): Promise<T[]> {
const out: T[] = [];
for (let page = 1; page <= maxPages; page += 1) {
const res = toPage<T>(await this.request('GET', `${path}?page=${page}&limit=50`));
out.push(...res.data);
if (!res.data.length || page >= res.meta.last_page) break;
}
return out;
}
async getCustomerEngagement(customerId: string | number): Promise<MontifyEngagement> {
return (
unwrap<MontifyEngagement>(
await this.request(
'GET',
`/montact/customers/${encodeURIComponent(String(customerId))}/engagement`,
),
) ?? {}
);
}
async getOrder(id: string | number): Promise<MontifyOrder> {
return unwrap<MontifyOrder>(
await this.request('GET', `/montact/orders/${encodeURIComponent(String(id))}`),
);
}
async registerWebhook(
url: string,
events: string[],
): Promise<{ id: string; secret: string }> {
const res = unwrap<{ id?: string | number; secret?: string }>(
await this.request('POST', '/montact/webhooks/register', { url, events }),
);
if (!res?.id || !res.secret) {
throw new Error('پاسخ ثبت وب‌هوک شناسه یا secret ندارد');
}
return { id: String(res.id), secret: res.secret };
}
async deleteWebhook(id: string): Promise<void> {
await this.request('DELETE', `/montact/webhooks/${encodeURIComponent(id)}`);
}
static verifySignature(
rawBody: Buffer,
header: string | undefined,
secret: string,
): boolean {
const received = (header ?? '').trim().replace(/^sha256=/i, '');
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
}
private async throttle() {
const last = lastRequestAt.get(this.throttleKey) ?? 0;
const wait = last + MIN_INTERVAL_MS - Date.now();
if (wait > 0) await sleep(wait);
lastRequestAt.set(this.throttleKey, Date.now());
}
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
for (let attempt = 0; ; attempt += 1) {
await this.throttle();
let res: Response;
try {
res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'MontactCRM/1.0',
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
} catch (e) {
if (attempt < 2) {
await sleep(2000 * (attempt + 1));
continue;
}
throw new MontifyHttpError(0, `خطای شبکه: ${e instanceof Error ? e.message : e}`);
}
if (res.status === 429 && attempt < 3) {
const retryAfter = Number(res.headers.get('retry-after'));
await sleep((Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 30) * 1000);
continue;
}
if (res.status >= 500 && attempt < 2) {
await sleep(3000 * (attempt + 1));
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
if (res.status === 401) {
throw new MontifyHttpError(401, 'توکن نامعتبر یا منقضی است؛ توکن جدید از فروشگاه بگیرید');
}
if (res.status === 403) {
throw new MontifyHttpError(403, 'توکن به این بخش دسترسی ندارد (scope)');
}
throw new MontifyHttpError(
res.status,
`Montify HTTP ${res.status}: ${text.slice(0, 200) || res.statusText}`,
);
}
if (res.status === 204) return undefined;
const text = await res.text();
return text ? JSON.parse(text) : undefined;
}
}
}
@@ -0,0 +1,169 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { Prisma } from '@prisma/client';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { CurrentUser } from '../../auth/current-user.decorator';
import { Roles } from '../../auth/roles.decorator';
import { RolesGuard } from '../../auth/roles.guard';
import { OrgModuleGuard } from '../../billing/org-module.guard';
import { RequireOrgModule } from '../../billing/org-module.decorator';
import type { Actor } from '../../common/record-scope';
import { PrismaService } from '../../prisma/prisma.service';
import { MontifyConnectionsService } from './connections.service';
import { CreateMontifyConnectionDto, UpdateMontifyConnectionDto } from './dto';
import { MontifyClient } from './montify-client';
import { MontifySyncService } from './sync.service';
@Controller('integrations/montify')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('montify')
@Roles('owner', 'admin')
export class MontifyController {
constructor(
private readonly connections: MontifyConnectionsService,
private readonly sync: MontifySyncService,
) {}
@Get('connections')
list(@CurrentUser() user: { organizationId: string }) {
return this.connections.list(user.organizationId);
}
@Post('connections')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateMontifyConnectionDto,
) {
return this.connections.create(user.organizationId, dto);
}
@Patch('connections/:id')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateMontifyConnectionDto,
) {
return this.connections.update(user.organizationId, id, dto);
}
@Delete('connections/:id')
async remove(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
await this.connections.remove(user.organizationId, id);
return { ok: true };
}
@Post('connections/:id/test')
test(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
return this.connections.testConnection(user.organizationId, id);
}
@Post('connections/:id/sync')
syncNow(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Query('mode') mode?: string,
) {
return this.sync.startSync(user.organizationId, id, mode === 'full' ? 'full' : 'incremental');
}
@Post('connections/:id/webhook')
registerWebhook(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
return this.connections.registerWebhook(user.organizationId, id);
}
@Delete('connections/:id/webhook')
unregisterWebhook(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
return this.connections.unregisterWebhook(user.organizationId, id);
}
@Get('orders')
recentOrders(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
) {
return this.sync.listOrders(user.organizationId, connectionId);
}
}
@Controller('integrations/montify/people')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('montify')
export class MontifyPeopleController {
constructor(private readonly sync: MontifySyncService) {}
@Get(':personId')
personStores(@CurrentUser() user: Actor, @Param('personId') personId: string) {
return this.sync.personStores(user, personId);
}
}
@Controller('public/integrations/montify')
export class MontifyWebhookController {
constructor(
private readonly prisma: PrismaService,
private readonly connections: MontifyConnectionsService,
private readonly sync: MontifySyncService,
) {}
@Post(':connectionId/webhook')
@HttpCode(200)
async webhook(
@Param('connectionId') connectionId: string,
@Headers('x-montact-signature') signature: string | undefined,
@Headers('x-montact-event') eventHeader: string | undefined,
@Headers('x-montact-delivery-id') deliveryHeader: string | undefined,
@Req() req: Request & { rawBody?: Buffer },
@Body() body: { event?: string; delivery_id?: string; data?: Record<string, unknown> },
) {
const row = await this.prisma.montifyConnection.findFirst({
where: { id: connectionId, enabled: true },
});
if (!row) throw new UnauthorizedException('اتصال نامعتبر است');
const secret = this.connections.decryptWebhookSecret(row);
if (!secret) throw new UnauthorizedException('وب‌هوک برای این اتصال ثبت نشده است');
const raw = req.rawBody ?? Buffer.from(JSON.stringify(body ?? {}), 'utf8');
if (!MontifyClient.verifySignature(raw, signature, secret)) {
throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
}
const event = (eventHeader ?? body?.event ?? '').trim();
const deliveryId = (deliveryHeader ?? body?.delivery_id ?? '').trim();
if (!event || !deliveryId) return { ok: true, skipped: true };
let deliveryRowId: string;
try {
const created = await this.prisma.montifyWebhookDelivery.create({
data: { connectionId, deliveryId, event },
});
deliveryRowId = created.id;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return { ok: true, duplicate: true };
}
throw e;
}
await this.prisma.montifyConnection.update({
where: { id: connectionId },
data: { lastWebhookAt: new Date() },
});
setImmediate(() => {
void this.sync.processWebhook(row, deliveryRowId, event, body);
});
return { ok: true };
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { BillingModule } from '../../billing/billing.module';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { MontifyConnectionsService } from './connections.service';
import {
MontifyController,
MontifyPeopleController,
MontifyWebhookController,
} from './montify.controller';
import { MontifySyncService } from './sync.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [MontifyController, MontifyPeopleController, MontifyWebhookController],
providers: [TahesabCryptoService, MontifyConnectionsService, MontifySyncService],
exports: [MontifyConnectionsService, MontifySyncService],
})
export class MontifyModule {}
@@ -0,0 +1,753 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import {
Prisma,
type MontifyConnection,
type MontifyOrder as MontifyOrderRow,
type MontifyTransaction as MontifyTransactionRow,
} from '@prisma/client';
import type {
MontifyConnectionDto,
MontifyEngagementDto,
MontifyOrderDto,
MontifyOrderItemDto,
MontifyPersonStoreDto,
MontifySyncResultDto,
MontifyTransactionDto,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service';
import {
MontifyClient,
MontifyHttpError,
type MontifyAddress,
type MontifyCustomer,
type MontifyEngagement,
type MontifyOrder,
type MontifyTransaction,
} from './montify-client';
const CUSTOMER_PAGE_SIZE = 50;
const ENGAGEMENT_KEEP = 50;
/** پروفایل فروشگاهی مشتری (MontifyLink.data) — نام‌ها طبق جدول نگاشت مستند */
type CustomerProfile = {
fullName: string | null;
username: string | null;
avatar: string | null;
birthday: string | null;
gender: string | null;
type: string | null;
job: string | null;
storeId: string | null;
walletBalance: number | null;
goldWalletBalance: number | null;
summary: { totalOrders: number | null; totalSpent: number | null };
addresses: MontifyAddress[];
engagement: { saved: unknown[]; likes: unknown[]; comments: unknown[] } | null;
};
function num(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function dec(v: unknown): Prisma.Decimal | null {
const n = num(v);
return n === null ? null : new Prisma.Decimal(n);
}
function decNum(v: Prisma.Decimal | null): number | null {
return v === null ? null : Number(v);
}
function str(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s ? s : null;
}
/** متن نمایشی فروشگاه (مثل sts_txt) اگر باشد، وگرنه مقدار خام */
function label(obj: unknown, key: string): string | null {
if (!obj || typeof obj !== 'object') return null;
const o = obj as Record<string, unknown>;
return str(o[`${key}_txt`]) ?? str(o[key]);
}
function parseDate(v: unknown): Date | null {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, '');
let core = digits;
if (core.startsWith('0098')) core = core.slice(4);
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
else if (core.startsWith('0')) core = core.slice(1);
if (!core) return [mobile];
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
}
function formatAddress(a: MontifyAddress): string | null {
const parts = [str(a.province), str(a.city), str(a.address)].filter(Boolean);
if (!parts.length) return null;
let line = parts.join('، ');
const postal = str(a.postal_code);
if (postal) line += ` — کدپستی ${postal}`;
const receiver = [str(a.receiver_name), str(a.receiver_phone)].filter(Boolean).join(' ');
if (receiver) line += ` — گیرنده: ${receiver}`;
return line;
}
function splitName(c: MontifyCustomer): { firstName: string; lastName: string } {
let firstName = str(c.first_name) ?? '';
let lastName = str(c.last_name) ?? '';
if (!firstName && !lastName) {
const parts = (str(c.name) ?? '').split(/\s+/).filter(Boolean);
if (parts.length > 1) {
firstName = parts[0]!;
lastName = parts.slice(1).join(' ');
} else {
lastName = parts[0] ?? '';
}
}
if (!firstName && !lastName) lastName = str(c.username) ?? `مشتری ${c.id}`;
return { firstName, lastName };
}
function customerLinkKey(connectionId: string, externalId: string) {
return {
connectionId_entityType_externalId: { connectionId, entityType: 'customer', externalId },
};
}
@Injectable()
export class MontifySyncService {
private readonly logger = new Logger(MontifySyncService.name);
private readonly running = new Set<string>();
constructor(
private readonly prisma: PrismaService,
private readonly connections: MontifyConnectionsService,
) {}
/* ——— فاز ۱ و ۲: Polling و Incremental Sync ——— */
async startSync(
organizationId: string,
connectionId: string,
mode: 'incremental' | 'full',
): Promise<MontifyConnectionDto> {
const row = await this.connections.getOrThrow(organizationId, connectionId);
if (!row.enabled) throw new BadRequestException('این اتصال غیرفعال است');
if (this.running.has(connectionId)) {
throw new BadRequestException('همگام‌سازی این فروشگاه در حال اجراست');
}
void this.runSync(row, mode);
return this.connections.getDto(organizationId, connectionId);
}
@Cron('*/10 * * * *')
async cronSyncAll(): Promise<void> {
const rows = await this.prisma.montifyConnection.findMany({
where: { enabled: true },
take: 50,
});
for (const row of rows) {
if (this.running.has(row.id)) continue;
await this.runSync(row, 'incremental');
}
}
private async runSync(row: MontifyConnection, mode: 'incremental' | 'full'): Promise<void> {
if (this.running.has(row.id)) return;
this.running.add(row.id);
const startedAt = new Date();
const result: MontifySyncResultDto = {
mode,
customersFetched: 0,
customersCreated: 0,
customersUpdated: 0,
ordersFetched: 0,
transactionsFetched: 0,
errors: [],
startedAt: startedAt.toISOString(),
finishedAt: null,
};
const saveProgress = () =>
this.prisma.montifyConnection.update({
where: { id: row.id },
data: { lastSyncResult: result as unknown as Prisma.InputJsonValue },
});
await this.prisma.montifyConnection.update({
where: { id: row.id },
data: {
syncStatus: 'running',
syncStartedAt: startedAt,
lastSyncResult: result as unknown as Prisma.InputJsonValue,
},
});
let cursor = row.customersCursor;
let fatal: string | null = null;
try {
const client = this.connections.clientFor(row);
const updatedAfter =
mode === 'incremental' && row.customersCursor
? row.customersCursor.toISOString()
: undefined;
let page = 1;
let lastPage = 1;
do {
const res = await client.listCustomers({ page, limit: CUSTOMER_PAGE_SIZE, updatedAfter });
lastPage = res.meta.last_page;
result.customersFetched += res.data.length;
for (const customer of res.data) {
try {
const outcome = await this.syncCustomerDeep(row, client, customer, mode, result);
if (outcome === 'created') result.customersCreated += 1;
else result.customersUpdated += 1;
const updatedAt = parseDate(customer.updated_at);
if (updatedAt && (!cursor || updatedAt > cursor)) cursor = updatedAt;
} catch (e) {
if (e instanceof MontifyHttpError && e.status === 401) throw e;
const msg = e instanceof Error ? e.message : String(e);
result.errors.push(`مشتری ${customer.id}: ${msg}`);
this.logger.warn(`Montify ${row.id} customer ${customer.id}: ${msg}`);
}
}
await saveProgress();
if (!res.data.length) break;
page += 1;
} while (page <= lastPage && page <= 2000);
} catch (e) {
fatal = e instanceof Error ? e.message : String(e);
result.errors.push(fatal);
this.logger.warn(`Montify sync ${row.id}: ${fatal}`);
} finally {
this.running.delete(row.id);
}
result.finishedAt = new Date().toISOString();
result.errors = result.errors.slice(0, 50);
await this.prisma.montifyConnection.update({
where: { id: row.id },
data: {
syncStatus: fatal ? 'error' : 'ok',
lastSyncAt: fatal ? undefined : new Date(),
customersCursor: cursor,
lastSyncResult: result as unknown as Prisma.InputJsonValue,
lastHealthAt: new Date(),
lastHealthOk: !fatal,
lastError: fatal
? fatal.slice(0, 500)
: result.errors.length
? result.errors.slice(0, 3).join(' | ').slice(0, 500)
: null,
},
});
}
/**
* گام ۲ استراتژی مستند: برای هر مشتری، آدرس‌ها، سفارش‌ها، تراکنش‌ها و داده‌های تعاملی
* جداگانه دریافت و Upsert می‌شوند.
*/
private async syncCustomerDeep(
row: MontifyConnection,
client: MontifyClient,
customer: MontifyCustomer,
mode: 'incremental' | 'full',
result: MontifySyncResultDto | null,
): Promise<'created' | 'updated'> {
const externalId = String(customer.id);
const step = async <T>(label: string, fn: () => Promise<T>): Promise<T | null> => {
try {
return await fn();
} catch (e) {
if (e instanceof MontifyHttpError && e.status === 401) throw e;
const msg = e instanceof Error ? e.message : String(e);
result?.errors.push(`مشتری ${externalId} (${label}): ${msg}`);
return null;
}
};
const addresses =
(await step('آدرس‌ها', () => client.listCustomerAddresses(externalId))) ??
customer.addresses ??
[];
const engagement =
(await step('تعاملات', () => client.getCustomerEngagement(externalId))) ??
customer.engagement ??
null;
const { personId, outcome, ordersSyncedAt } = await this.upsertCustomer(
row,
customer,
addresses,
engagement,
);
const ordersStartedAt = new Date();
const ordersOk = await step('سفارش‌ها', async () => {
const since = mode === 'full' ? null : ordersSyncedAt;
let page = 1;
let lastPage = 1;
do {
const res = await client.listCustomerOrders(externalId, {
page,
updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined,
});
lastPage = res.meta.last_page;
if (result) result.ordersFetched += res.data.length;
for (const order of res.data) await this.upsertOrder(row, order, personId, externalId);
if (!res.data.length) break;
page += 1;
} while (page <= lastPage && page <= 200);
return true;
});
if (ordersOk) {
await this.prisma.montifyLink.update({
where: customerLinkKey(row.id, externalId),
data: { ordersSyncedAt: ordersStartedAt },
});
}
await step('تراکنش‌ها', async () => {
const list = await client.listCustomerTransactions(externalId);
if (result) result.transactionsFetched += list.length;
for (const t of list) await this.upsertTransaction(row, t, personId, externalId);
});
return outcome;
}
/* ——— مشتری → شخص ——— */
private async upsertCustomer(
row: MontifyConnection,
c: MontifyCustomer,
addresses: MontifyAddress[],
engagement: MontifyEngagement | null,
): Promise<{ personId: string; outcome: 'created' | 'updated'; ordersSyncedAt: Date | null }> {
const externalId = String(c.id);
const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null);
const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = {
fullName: str(c.name) ?? (`${firstName} ${lastName}`.trim() || null),
username: str(c.username),
avatar: str(c.avatar),
birthday: str(c.birthday),
gender: label(c, 'gender'),
type: label(c, 'type'),
job: str(c.job),
storeId: str(c.store_id),
walletBalance: num(c.cash_balance),
goldWalletBalance: num(c.gold_balance),
summary: {
totalOrders: num(c.summary?.total_orders),
totalSpent: num(c.summary?.total_spent),
},
addresses,
engagement: engagement
? {
saved: arr(engagement.saved).slice(0, ENGAGEMENT_KEEP),
likes: arr(engagement.likes).slice(0, ENGAGEMENT_KEEP),
comments: arr(engagement.comments).slice(0, ENGAGEMENT_KEEP),
}
: null,
};
const existingLink = await this.prisma.montifyLink.findUnique({
where: customerLinkKey(row.id, externalId),
});
let person = existingLink
? await this.prisma.person.findFirst({
where: { id: existingLink.entityId, organizationId: row.organizationId },
})
: null;
if (!person && (mobile || email)) {
person = await this.prisma.person.findFirst({
where: {
organizationId: row.organizationId,
OR: [
...(mobile
? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }])
: []),
...(email ? [{ email }] : []),
],
},
orderBy: { createdAt: 'asc' },
});
}
let personId: string;
let outcome: 'created' | 'updated';
if (person) {
const ownedByStore = person.importSource === 'montify';
const current = Array.isArray(person.addresses)
? (person.addresses as unknown[]).map((a) => String(a))
: [];
const merged = [...current];
for (const a of addressLines) if (!merged.includes(a)) merged.push(a);
const tags = person.tags.includes(row.label) ? person.tags : [...person.tags, row.label];
await this.prisma.person.update({
where: { id: person.id },
data: {
...(ownedByStore ? { firstName, lastName } : {}),
...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}),
...(email && (ownedByStore || !person.email) ? { email } : {}),
...(str(c.job) && !person.position ? { position: str(c.job) } : {}),
addresses: merged as Prisma.InputJsonValue,
tags,
...(!existingLink && !ownedByStore && !person.previousImportSource
? carryPreviousSource(person, 'montify')
: {}),
},
});
personId = person.id;
outcome = 'updated';
} else {
const notes = [str(c.bio), str(c.description)].filter(Boolean).join('\n') || null;
const created = await this.prisma.person.create({
data: {
organizationId: row.organizationId,
firstName,
lastName,
mobile,
email,
position: str(c.job),
notes,
addresses: addressLines as Prisma.InputJsonValue,
tags: [row.label],
importSource: 'montify',
sourceRef: externalId,
},
});
personId = created.id;
outcome = 'created';
}
const link = await this.prisma.montifyLink.upsert({
where: customerLinkKey(row.id, externalId),
create: {
organizationId: row.organizationId,
connectionId: row.id,
entityType: 'customer',
externalId,
entityId: personId,
externalUpdatedAt: parseDate(c.updated_at),
data: profile as unknown as Prisma.InputJsonValue,
},
update: {
entityId: personId,
externalUpdatedAt: parseDate(c.updated_at),
data: profile as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
},
});
if (!existingLink || existingLink.entityId !== personId) {
await Promise.all([
this.prisma.montifyOrder.updateMany({
where: { connectionId: row.id, customerExternalId: externalId },
data: { personId },
}),
this.prisma.montifyTransaction.updateMany({
where: { connectionId: row.id, customerExternalId: externalId },
data: { personId },
}),
]);
}
return { personId, outcome, ordersSyncedAt: link.ordersSyncedAt };
}
/* ——— سفارش و تراکنش ——— */
private async personForCustomer(connectionId: string, customerExternalId: string | null) {
if (!customerExternalId) return null;
const link = await this.prisma.montifyLink.findUnique({
where: customerLinkKey(connectionId, customerExternalId),
select: { entityId: true },
});
return link?.entityId ?? null;
}
private async upsertOrder(
row: MontifyConnection,
o: MontifyOrder,
personIdHint: string | null,
customerHint: string | null,
): Promise<void> {
if (!o?.id) return;
const externalId = String(o.id);
const customerExternalId = str(o.user_id) ?? customerHint;
const personId =
personIdHint ?? (await this.personForCustomer(row.id, customerExternalId));
const items = (o.order_items ?? []).map((i) => ({
id: str(i.id),
productId: str(i.product_id),
productName: str(i.product_name),
qty: num(i.qty),
price: num(i.price),
weight: num(i.weight),
totalPrice: num(i.total_price),
}));
const data = {
customerExternalId,
personId,
orderReference: str(o.code),
orderStatus: str(o.sts),
paymentStatus: str(o.payment_sts),
paymentType: str(o.payment_type),
totalPrice: dec(o.total_price),
discountTotalPrice: dec(o.discount_total_price),
totalDiscounts: dec(o.total_discounts),
totalTax: dec(o.total_tax),
totalQty: dec(o.total_qty),
profit: dec(o.profit),
percentProfit: dec(o.percent_profit),
sumGold: dec(o.sum_gold),
sumSilver: dec(o.sum_silver),
address: (o.address ?? Prisma.JsonNull) as Prisma.InputJsonValue,
note: str(o.note),
cancelReason: str(o.cancel_reason),
cancelledAt: parseDate(o.cancelled_at),
closedAt: parseDate(o.closed_at),
codeDiscount: str(o.code_discount),
items: items as unknown as Prisma.InputJsonValue,
externalCreatedAt: parseDate(o.created_at),
externalUpdatedAt: parseDate(o.updated_at),
deletedAt: parseDate(o.deleted_at),
raw: o as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
};
await this.prisma.montifyOrder.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
update: data,
});
}
private async upsertTransaction(
row: MontifyConnection,
t: MontifyTransaction,
personId: string,
customerExternalId: string,
): Promise<void> {
if (!t?.id) return;
const externalId = String(t.id);
const data = {
customerExternalId: str(t.user_id) ?? customerExternalId,
personId,
type: str(t.type),
amount: dec(t.amount),
currency: str(t.currency),
status: str(t.status),
referenceCode: str(t.reference_code),
externalCreatedAt: parseDate(t.created_at),
raw: t as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
};
await this.prisma.montifyTransaction.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
update: data,
});
}
/* ——— فاز ۳: وب‌هوک ——— */
async processWebhook(
row: MontifyConnection,
deliveryRowId: string,
event: string,
payload: { data?: Record<string, unknown> } | null,
): Promise<void> {
try {
const data = payload?.data ?? {};
const id = str(data.id);
if (!id) throw new Error('شناسه رکورد در payload نیست');
const client = this.connections.clientFor(row);
if (event.startsWith('customer.')) {
const customer = await client.getCustomer(id);
await this.syncCustomerDeep(row, client, customer, 'incremental', null);
} else if (event.startsWith('order.')) {
let order: MontifyOrder;
try {
order = await client.getOrder(id);
} catch {
order = data as unknown as MontifyOrder;
}
const customerId = str(order.user_id);
const personId = await this.personForCustomer(row.id, customerId);
if (!personId && customerId) {
const customer = await client.getCustomer(customerId);
await this.syncCustomerDeep(row, client, customer, 'incremental', null);
}
await this.upsertOrder(row, order, null, customerId);
}
await this.prisma.montifyWebhookDelivery.update({
where: { id: deliveryRowId },
data: { status: 'processed' },
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(`Montify webhook ${row.id} ${event}: ${msg}`);
await this.prisma.montifyWebhookDelivery.update({
where: { id: deliveryRowId },
data: { status: 'failed', error: msg.slice(0, 500) },
});
}
}
/* ——— نمایش ——— */
async listOrders(organizationId: string, connectionId?: string): Promise<MontifyOrderDto[]> {
const rows = await this.prisma.montifyOrder.findMany({
where: { organizationId, ...(connectionId ? { connectionId } : {}) },
include: { connection: { select: { label: true } } },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 50,
});
const personIds = [...new Set(rows.map((r) => r.personId).filter((x): x is string => !!x))];
const people = personIds.length
? await this.prisma.person.findMany({
where: { id: { in: personIds } },
select: { id: true, firstName: true, lastName: true },
})
: [];
const nameOf = new Map(people.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]));
return rows.map((r) => this.orderDto(r, r.connection.label, nameOf));
}
async personStores(actor: Actor, personId: string): Promise<MontifyPersonStoreDto[]> {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const links = await this.prisma.montifyLink.findMany({
where: { organizationId: actor.organizationId, entityType: 'customer', entityId: personId },
include: { connection: { select: { label: true } } },
orderBy: { syncedAt: 'desc' },
});
const nameOf = new Map([[person.id, `${person.firstName} ${person.lastName}`.trim()]]);
const out: MontifyPersonStoreDto[] = [];
for (const l of links) {
const [orders, transactions] = await Promise.all([
this.prisma.montifyOrder.findMany({
where: { connectionId: l.connectionId, customerExternalId: l.externalId },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
this.prisma.montifyTransaction.findMany({
where: { connectionId: l.connectionId, customerExternalId: l.externalId },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
]);
const p = (l.data ?? {}) as Partial<CustomerProfile>;
const engagement: MontifyEngagementDto | null = p.engagement
? {
saved: arr(p.engagement.saved).length,
likes: arr(p.engagement.likes).length,
comments: arr(p.engagement.comments).length,
}
: null;
out.push({
connectionId: l.connectionId,
connectionLabel: l.connection.label,
externalCustomerId: l.externalId,
fullName: p.fullName ?? null,
username: p.username ?? null,
customerType: p.type ?? null,
walletBalance: p.walletBalance ?? null,
goldWalletBalance: p.goldWalletBalance ?? null,
totalOrders: p.summary?.totalOrders ?? null,
totalSpent: p.summary?.totalSpent ?? null,
engagement,
orders: orders.map((o) => this.orderDto(o, l.connection.label, nameOf)),
transactions: transactions.map((t) => this.transactionDto(t)),
syncedAt: l.syncedAt.toISOString(),
});
}
return out;
}
private orderDto(
r: MontifyOrderRow,
connectionLabel: string,
nameOf: Map<string, string>,
): MontifyOrderDto {
const items = (Array.isArray(r.items) ? r.items : []) as unknown as MontifyOrderItemDto[];
const raw = r.raw;
return {
id: r.id,
connectionId: r.connectionId,
connectionLabel,
externalId: r.externalId,
customerExternalId: r.customerExternalId,
personId: r.personId,
personName: r.personId ? (nameOf.get(r.personId) ?? null) : null,
orderReference: r.orderReference,
orderStatus: label(raw, 'sts') ?? r.orderStatus,
paymentStatus: label(raw, 'payment_sts') ?? r.paymentStatus,
paymentType: label(raw, 'payment_type') ?? r.paymentType,
totalPrice: decNum(r.totalPrice),
discountTotalPrice: decNum(r.discountTotalPrice),
totalDiscounts: decNum(r.totalDiscounts),
totalQty: decNum(r.totalQty),
sumGold: decNum(r.sumGold),
sumSilver: decNum(r.sumSilver),
note: r.note,
cancelReason: r.cancelReason,
cancelledAt: r.cancelledAt?.toISOString() ?? null,
closedAt: r.closedAt?.toISOString() ?? null,
items: items.map((i) => ({
productId: i.productId ?? null,
productName: i.productName ?? null,
qty: i.qty ?? null,
price: i.price ?? null,
weight: i.weight ?? null,
totalPrice: i.totalPrice ?? null,
})),
createdAt: r.externalCreatedAt?.toISOString() ?? null,
updatedAt: r.externalUpdatedAt?.toISOString() ?? null,
};
}
private transactionDto(t: MontifyTransactionRow): MontifyTransactionDto {
return {
id: t.id,
externalId: t.externalId,
type: label(t.raw, 'type') ?? t.type,
amount: decNum(t.amount),
currency: label(t.raw, 'currency') ?? t.currency,
status: label(t.raw, 'status') ?? t.status,
referenceCode: t.referenceCode,
createdAt: t.externalCreatedAt?.toISOString() ?? null,
};
}
}