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
@@ -0,0 +1,162 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"storeId" TEXT NOT NULL DEFAULT '',
"tokenCiphertext" TEXT NOT NULL,
"tokenIv" TEXT NOT NULL,
"tokenTag" TEXT NOT NULL,
"tokenLast4" TEXT NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"webhookRemoteId" TEXT,
"webhookSecretCiphertext" TEXT,
"webhookSecretIv" TEXT,
"webhookSecretTag" TEXT,
"customersCursor" TIMESTAMP(3),
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
"syncStartedAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"lastSyncResult" JSONB,
"lastHealthAt" TIMESTAMP(3),
"lastHealthOk" BOOLEAN,
"lastError" TEXT,
"lastWebhookAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyLink" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"externalUpdatedAt" TIMESTAMP(3),
"ordersSyncedAt" TIMESTAMP(3),
"data" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyOrder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"customerExternalId" TEXT,
"personId" TEXT,
"orderReference" TEXT,
"orderStatus" TEXT,
"paymentStatus" TEXT,
"paymentType" TEXT,
"totalPrice" DECIMAL(20,2),
"discountTotalPrice" DECIMAL(20,2),
"totalDiscounts" DECIMAL(20,2),
"totalTax" DECIMAL(20,2),
"totalQty" DECIMAL(14,3),
"profit" DECIMAL(20,2),
"percentProfit" DECIMAL(10,3),
"sumGold" DECIMAL(14,3),
"sumSilver" DECIMAL(14,3),
"address" JSONB,
"note" TEXT,
"cancelReason" TEXT,
"cancelledAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"codeDiscount" TEXT,
"items" JSONB NOT NULL DEFAULT '[]',
"externalCreatedAt" TIMESTAMP(3),
"externalUpdatedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"raw" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyTransaction" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"customerExternalId" TEXT,
"personId" TEXT,
"type" TEXT,
"amount" DECIMAL(24,4),
"currency" TEXT,
"status" TEXT,
"referenceCode" TEXT,
"externalCreatedAt" TIMESTAMP(3),
"raw" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyWebhookDelivery" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"deliveryId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MontifyWebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MontifyConnection_organizationId_enabled_idx" ON "MontifyConnection"("organizationId", "enabled");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyLink_connectionId_entityType_externalId_key" ON "MontifyLink"("connectionId", "entityType", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyLink_organizationId_entityType_entityId_idx" ON "MontifyLink"("organizationId", "entityType", "entityId");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyOrder_connectionId_externalId_key" ON "MontifyOrder"("connectionId", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_personId_idx" ON "MontifyOrder"("organizationId", "personId");
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_externalCreatedAt_idx" ON "MontifyOrder"("organizationId", "externalCreatedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyTransaction_connectionId_externalId_key" ON "MontifyTransaction"("connectionId", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyTransaction_organizationId_personId_idx" ON "MontifyTransaction"("organizationId", "personId");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_deliveryId_key" ON "MontifyWebhookDelivery"("connectionId", "deliveryId");
CREATE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_createdAt_idx" ON "MontifyWebhookDelivery"("connectionId", "createdAt");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MontifyConnection" ADD CONSTRAINT "MontifyConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyLink" ADD CONSTRAINT "MontifyLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyOrder" ADD CONSTRAINT "MontifyOrder_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyTransaction" ADD CONSTRAINT "MontifyTransaction_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyWebhookDelivery" ADD CONSTRAINT "MontifyWebhookDelivery_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ماژول جدید برای محیط‌هایی که فهرست ماژول ذخیره‌شده دارند و ووکامرس فعال است
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'montify')
WHERE cardinality("enabledModules") > 0
AND 'woocommerce' = ANY("enabledModules")
AND NOT ('montify' = ANY("enabledModules"));
+142
View File
@@ -201,6 +201,7 @@ model Organization {
webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[]
wooCommerceConnections WooCommerceConnection[]
montifyConnections MontifyConnection[]
voipConnections VoipConnection[]
reservationServices ReservationService[]
reservationAppointments ReservationAppointment[]
@@ -2080,6 +2081,147 @@ model WooCommerceOrderLink {
@@index([invoiceId])
}
/// اتصال فروشگاه Montify (هر فروشگاه: Base URL + توکن اختصاصی)
model MontifyConnection {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String
baseUrl String
storeId String @default("")
tokenCiphertext String
tokenIv String
tokenTag String
tokenLast4 String @default("")
enabled Boolean @default(true)
webhookRemoteId String?
webhookSecretCiphertext String?
webhookSecretIv String?
webhookSecretTag String?
/// بیشترین updated_at مشتریان دریافت‌شده (برای updated_after)
customersCursor DateTime?
/// idle | running | ok | error
syncStatus String @default("idle")
syncStartedAt DateTime?
lastSyncAt DateTime?
lastSyncResult Json?
lastHealthAt DateTime?
lastHealthOk Boolean?
lastError String?
lastWebhookAt DateTime?
links MontifyLink[]
orders MontifyOrder[]
transactions MontifyTransaction[]
deliveries MontifyWebhookDelivery[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, enabled])
}
/// پیوند مشتری فروشگاه با شخص CRM (entityType = customer، entityId = Person.id)
model MontifyLink {
id String @id @default(cuid())
organizationId String
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
entityType String
externalId String
entityId String
externalUpdatedAt DateTime?
/// آخرین زمان دریافت سفارش‌های این مشتری (updated_after سفارش‌ها)
ordersSyncedAt DateTime?
/// پروفایل فروشگاهی مشتری: wallet_balance، gold_wallet_balance، summary، engagement، …
data Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, entityType, externalId])
@@index([organizationId, entityType, entityId])
}
/// سفارش فروشگاه (فقط‌خواندنی، نگاشت مستند: code → order_reference، sts → order_status)
model MontifyOrder {
id String @id @default(cuid())
organizationId String
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
externalId String
customerExternalId String?
personId String?
orderReference String?
orderStatus String?
paymentStatus String?
paymentType String?
totalPrice Decimal? @db.Decimal(20, 2)
discountTotalPrice Decimal? @db.Decimal(20, 2)
totalDiscounts Decimal? @db.Decimal(20, 2)
totalTax Decimal? @db.Decimal(20, 2)
totalQty Decimal? @db.Decimal(14, 3)
profit Decimal? @db.Decimal(20, 2)
percentProfit Decimal? @db.Decimal(10, 3)
sumGold Decimal? @db.Decimal(14, 3)
sumSilver Decimal? @db.Decimal(14, 3)
address Json?
note String?
cancelReason String?
cancelledAt DateTime?
closedAt DateTime?
codeDiscount String?
items Json @default("[]")
externalCreatedAt DateTime?
externalUpdatedAt DateTime?
deletedAt DateTime?
raw Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, externalId])
@@index([organizationId, personId])
@@index([organizationId, externalCreatedAt])
}
/// تراکنش مالی مشتری در فروشگاه (واریز/برداشت ریالی یا گرم طلا)
model MontifyTransaction {
id String @id @default(cuid())
organizationId String
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
externalId String
customerExternalId String?
personId String?
type String?
amount Decimal? @db.Decimal(24, 4)
currency String?
status String?
referenceCode String?
externalCreatedAt DateTime?
raw Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, externalId])
@@index([organizationId, personId])
}
model MontifyWebhookDelivery {
id String @id @default(cuid())
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
deliveryId String
event String
/// received | processed | failed
status String @default("received")
error String?
createdAt DateTime @default(now())
@@unique([connectionId, deliveryId])
@@index([connectionId, createdAt])
}
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی)
model VoipConnection {
id String @id @default(cuid())
+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,
};
}
}