From b87ebb22e0fbcc32fe7b34a210c52c898be10a6f Mon Sep 17 00:00:00 2001
From: Mahdi
Date: Sat, 10 Oct 2026 18:01:55 +0330
Subject: [PATCH] Montify: robust pagination (beyond 50), endpoint diagnose
button
---
.../montify/connections.service.ts | 46 ++++-
.../integrations/montify/montify-client.ts | 110 +++++++++--
.../montify/montify.controller.ts | 5 +
.../src/integrations/montify/sync.service.ts | 171 +++++++++++-------
apps/web/src/views/MontifySettingsView.vue | 63 +++++++
packages/shared/src/index.ts | 13 ++
6 files changed, 332 insertions(+), 76 deletions(-)
diff --git a/apps/api/src/integrations/montify/connections.service.ts b/apps/api/src/integrations/montify/connections.service.ts
index cf5f52f..927b4e2 100644
--- a/apps/api/src/integrations/montify/connections.service.ts
+++ b/apps/api/src/integrations/montify/connections.service.ts
@@ -7,6 +7,8 @@ import { ConfigService } from '@nestjs/config';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
+ MontifyDiagnoseCheckDto,
+ MontifyDiagnoseDto,
MontifySyncResultDto,
MontifySyncStatus,
MontifyTestResultDto,
@@ -171,7 +173,7 @@ export class MontifyConnectionsService {
where: { id },
data: { lastHealthAt: new Date(), lastHealthOk: true, lastError: null },
});
- return { ok: true, totalCustomers: Number.isFinite(page.meta.total) ? page.meta.total : null };
+ return { ok: true, totalCustomers: page.meta.total || null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطای ناشناخته';
await this.prisma.montifyConnection.update({
@@ -182,6 +184,48 @@ export class MontifyConnectionsService {
}
}
+ /** همهٔ مسیرهای مستند را با توکن همین فروشگاه صدا میزند و وضعیت هر کدام را برمیگرداند */
+ async diagnose(organizationId: string, id: string): Promise {
+ const row = await this.getOrThrow(organizationId, id);
+ const client = this.clientFor(row);
+ const checks: MontifyDiagnoseCheckDto[] = [];
+ const describe = (status: number, body: unknown, text: string) => {
+ if (status >= 200 && status < 300) return MontifyClient.describePage(body);
+ return text.replace(/\s+/g, ' ').slice(0, 200) || 'بدون پاسخ';
+ };
+
+ const run = async (name: string, path: string) => {
+ const r = await client.probe(path);
+ checks.push({
+ name,
+ path,
+ status: r.status,
+ ok: r.status >= 200 && r.status < 300,
+ info: describe(r.status, r.body, r.text),
+ });
+ return r;
+ };
+
+ const p1 = await run('لیست مشتریان (صفحه ۱)', '/montact/customers?page=1&limit=50');
+ await run('لیست مشتریان (صفحه ۲)', '/montact/customers?page=2&limit=50');
+
+ const first = (() => {
+ const b = p1.body as { data?: Array<{ id?: unknown }> } | null;
+ const list = Array.isArray(b) ? b : b?.data;
+ return Array.isArray(list) && list[0]?.id !== undefined ? String(list[0].id) : null;
+ })();
+
+ if (first) {
+ const c = encodeURIComponent(first);
+ await run('جزئیات مشتری', `/montact/customers/${c}`);
+ await run('سفارشهای مشتری', `/montact/customers/${c}/orders?page=1&limit=50`);
+ await run('آدرسهای مشتری', `/montact/customers/${c}/addresses?page=1&limit=50`);
+ await run('تراکنشهای مشتری', `/montact/customers/${c}/transactions?page=1&limit=50`);
+ await run('تعاملات مشتری', `/montact/customers/${c}/engagement`);
+ }
+ return { customerId: first, checks };
+ }
+
webhookUrl(id: string): string {
return `${this.publicApiBaseUrl()}/public/integrations/montify/${id}/webhook`;
}
diff --git a/apps/api/src/integrations/montify/montify-client.ts b/apps/api/src/integrations/montify/montify-client.ts
index 4b69199..af05b07 100644
--- a/apps/api/src/integrations/montify/montify-client.ts
+++ b/apps/api/src/integrations/montify/montify-client.ts
@@ -136,26 +136,73 @@ function unwrap(body: unknown): T {
return body as T;
}
+function firstNumber(...values: unknown[]): number | null {
+ for (const v of values) {
+ if (v === null || v === undefined || v === '') continue;
+ const n = Number(v);
+ if (Number.isFinite(n)) return n;
+ }
+ return null;
+}
+
+/** meta ناشناخته = 0؛ در آن صفحهها تا رسیدن به صفحهٔ خالی خوانده میشوند */
function toPage(body: unknown): MontifyPage {
if (Array.isArray(body)) {
return {
data: body as T[],
- meta: { current_page: 1, per_page: body.length, total: body.length, last_page: 1 },
+ meta: { current_page: 0, per_page: 0, total: 0, last_page: 0 },
};
}
- const b = (body ?? {}) as Partial>;
- const data = Array.isArray(b.data) ? b.data : [];
+ const b = (body ?? {}) as Record;
+ const rawData = b.data ?? b.items ?? b.results;
+ const data = (Array.isArray(rawData) ? rawData : []) as T[];
+ const m = ((b.meta ?? b.pagination ?? b) || {}) as Record;
+ const perPage = firstNumber(m.per_page, m.perPage, m.limit) ?? 0;
+ const total = firstNumber(m.total, m.totalItems, m.total_count, m.count) ?? 0;
+ const lastPage =
+ firstNumber(m.last_page, m.lastPage, m.total_pages, m.totalPages, m.pages) ??
+ (total && perPage ? Math.ceil(total / perPage) : 0);
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),
+ current_page: firstNumber(m.current_page, m.currentPage, m.page) ?? 0,
+ per_page: perPage,
+ total,
+ last_page: lastPage,
},
};
}
+/**
+ * همهٔ صفحهها را میخواند. به meta اعتماد کامل نمیکند: تا وقتی total بیشتر از خواندهشدههاست
+ * یا meta نامعلوم است ادامه میدهد؛ صفحهٔ خالی یا تکراری (نادیده گرفتن page) پایان است.
+ */
+export async function forEachPage(
+ fetchPage: (page: number) => Promise>,
+ onPage: (res: MontifyPage, page: number) => Promise,
+ maxPages = 2000,
+): Promise {
+ let fetched = 0;
+ let prevSignature: string | null = null;
+ for (let page = 1; page <= maxPages; page += 1) {
+ const res = await fetchPage(page);
+ if (!res.data.length) break;
+ const signature = JSON.stringify(
+ res.data.map((d) => (d && typeof d === 'object' && 'id' in d ? (d as { id: unknown }).id : d)),
+ );
+ if (signature === prevSignature) break;
+ prevSignature = signature;
+ fetched += res.data.length;
+ await onPage(res, page);
+ const { total, last_page: lastPage } = res.meta;
+ if (total > 0) {
+ if (fetched >= total) break;
+ } else if (lastPage > 0 && page >= lastPage) {
+ break;
+ }
+ }
+}
+
export class MontifyClient {
private readonly base: string;
private readonly throttleKey: string;
@@ -219,14 +266,53 @@ export class MontifyClient {
/** این مسیرها صفحهبندی دارند ولی updated_after ندارند */
private async allPages(path: string, maxPages = 100): Promise {
const out: T[] = [];
- for (let page = 1; page <= maxPages; page += 1) {
- const res = toPage(await this.request('GET', `${path}?page=${page}&limit=50`));
- out.push(...res.data);
- if (!res.data.length || page >= res.meta.last_page) break;
- }
+ await forEachPage(
+ async (page) => toPage(await this.request('GET', `${path}?page=${page}&limit=50`)),
+ async (res) => {
+ out.push(...res.data);
+ },
+ maxPages,
+ );
return out;
}
+ /** برای عیبیابی: درخواست خام بدون پرتاب خطا */
+ async probe(path: string): Promise<{ status: number; body: unknown; text: string }> {
+ await this.throttle();
+ try {
+ const res = await fetch(`${this.base}${path}`, {
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ Accept: 'application/json',
+ 'User-Agent': 'MontactCRM/1.0',
+ },
+ signal: AbortSignal.timeout(30000),
+ });
+ const text = await res.text().catch(() => '');
+ let body: unknown = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ body = null;
+ }
+ return { status: res.status, body, text };
+ } catch (e) {
+ return { status: 0, body: null, text: e instanceof Error ? e.message : String(e) };
+ }
+ }
+
+ static describePage(body: unknown): string {
+ const p = toPage(body);
+ const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body) : [];
+ return (
+ `${p.data.length} رکورد` +
+ (p.meta.total ? ` · total=${p.meta.total}` : '') +
+ (p.meta.per_page ? ` · per_page=${p.meta.per_page}` : '') +
+ (p.meta.last_page ? ` · last_page=${p.meta.last_page}` : ' · meta نامعلوم') +
+ (keys.length ? ` · کلیدها: ${keys.join(', ')}` : '')
+ );
+ }
+
async getCustomerEngagement(customerId: string | number): Promise {
return (
unwrap(
diff --git a/apps/api/src/integrations/montify/montify.controller.ts b/apps/api/src/integrations/montify/montify.controller.ts
index 9c10d98..239d6c4 100644
--- a/apps/api/src/integrations/montify/montify.controller.ts
+++ b/apps/api/src/integrations/montify/montify.controller.ts
@@ -71,6 +71,11 @@ export class MontifyController {
return this.connections.testConnection(user.organizationId, id);
}
+ @Post('connections/:id/diagnose')
+ diagnose(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
+ return this.connections.diagnose(user.organizationId, id);
+ }
+
@Post('connections/:id/sync')
syncNow(
@CurrentUser() user: { organizationId: string },
diff --git a/apps/api/src/integrations/montify/sync.service.ts b/apps/api/src/integrations/montify/sync.service.ts
index d286e4b..35aacc0 100644
--- a/apps/api/src/integrations/montify/sync.service.ts
+++ b/apps/api/src/integrations/montify/sync.service.ts
@@ -1,9 +1,4 @@
-import {
- BadRequestException,
- Injectable,
- Logger,
- NotFoundException,
-} from '@nestjs/common';
+import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import {
Prisma,
@@ -26,6 +21,7 @@ import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service';
import {
+ forEachPage,
MontifyClient,
MontifyHttpError,
type MontifyAddress,
@@ -56,7 +52,11 @@ type CustomerProfile = {
goldWalletBalance: number | null;
summary: { totalOrders: number | null; totalSpent: number | null };
addresses: MontifyAddress[];
- engagement: { saved: unknown[]; likes: unknown[]; comments: unknown[] } | null;
+ engagement: {
+ saved: unknown[];
+ likes: unknown[];
+ comments: unknown[];
+ } | null;
};
function num(v: unknown): number | null {
@@ -119,7 +119,10 @@ function formatAddress(a: MontifyAddress): string | null {
return line;
}
-function splitName(c: MontifyCustomer): { firstName: string; lastName: string } {
+function splitName(c: MontifyCustomer): {
+ firstName: string;
+ lastName: string;
+} {
let firstName = str(c.first_name) ?? '';
let lastName = str(c.last_name) ?? '';
if (!firstName && !lastName) {
@@ -137,7 +140,11 @@ function splitName(c: MontifyCustomer): { firstName: string; lastName: string }
function customerLinkKey(connectionId: string, externalId: string) {
return {
- connectionId_entityType_externalId: { connectionId, entityType: 'customer', externalId },
+ connectionId_entityType_externalId: {
+ connectionId,
+ entityType: 'customer',
+ externalId,
+ },
};
}
@@ -234,35 +241,46 @@ export class MontifySyncService {
mode === 'incremental' && row.customersCursor
? row.customersCursor.toISOString()
: undefined;
- let page = 1;
- let lastPage = 1;
- this.logger.log(`Montify sync ${row.id} (${row.label}) start ${mode}${updatedAfter ? ` after ${updatedAfter}` : ''}`);
- do {
- const res = await client.listCustomers({ page, limit: CUSTOMER_PAGE_SIZE, updatedAfter });
- lastPage = res.meta.last_page;
- this.logger.log(
- `Montify sync ${row.id} page ${page}/${lastPage}: ${res.data.length} customers (total ${res.meta.total})`,
- );
- for (const customer of res.data) {
- result.customersFetched += 1;
- try {
- const outcome = await this.syncCustomerDeep(row, client, customer, mode, result, endpoints);
- 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}`);
+ this.logger.log(
+ `Montify sync ${row.id} (${row.label}) start ${mode}${updatedAfter ? ` after ${updatedAfter}` : ''}`,
+ );
+ await forEachPage(
+ (page) =>
+ client.listCustomers({
+ page,
+ limit: CUSTOMER_PAGE_SIZE,
+ updatedAfter,
+ }),
+ async (res, page) => {
+ this.logger.log(
+ `Montify sync ${row.id} page ${page}/${res.meta.last_page || '?'}: ${res.data.length} customers (total ${res.meta.total || '?'})`,
+ );
+ for (const customer of res.data) {
+ result.customersFetched += 1;
+ try {
+ const outcome = await this.syncCustomerDeep(
+ row,
+ client,
+ customer,
+ mode,
+ result,
+ endpoints,
+ );
+ 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}`);
+ }
+ if (result.customersFetched % 5 === 0) await saveProgress();
}
- if (result.customersFetched % 5 === 0) await saveProgress();
- }
- await saveProgress();
- if (!res.data.length) break;
- page += 1;
- } while (page <= lastPage && page <= 2000);
+ await saveProgress();
+ },
+ );
} catch (e) {
fatal = e instanceof Error ? e.message : String(e);
result.errors.push(fatal);
@@ -354,19 +372,18 @@ export class MontifySyncService {
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);
+ await forEachPage(
+ (page) =>
+ client.listCustomerOrders(externalId, {
+ page,
+ updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined,
+ }),
+ async (res) => {
+ if (result) result.ordersFetched += res.data.length;
+ for (const order of res.data) await this.upsertOrder(row, order, personId, externalId);
+ },
+ 200,
+ );
return true;
});
if (ordersOk) {
@@ -392,7 +409,11 @@ export class MontifySyncService {
c: MontifyCustomer,
addresses: MontifyAddress[],
engagement: MontifyEngagement | null,
- ): Promise<{ personId: string; outcome: 'created' | 'updated'; ordersSyncedAt: Date | 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);
@@ -429,7 +450,10 @@ export class MontifySyncService {
let person = existingLink
? await this.prisma.person.findFirst({
- where: { id: existingLink.entityId, organizationId: row.organizationId },
+ where: {
+ id: existingLink.entityId,
+ organizationId: row.organizationId,
+ },
})
: null;
@@ -438,9 +462,7 @@ export class MontifySyncService {
where: {
organizationId: row.organizationId,
OR: [
- ...(mobile
- ? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }])
- : []),
+ ...(mobile ? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }]) : []),
...(email ? [{ email }] : []),
],
},
@@ -550,8 +572,7 @@ export class MontifySyncService {
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 personId = personIdHint ?? (await this.personForCustomer(row.id, customerExternalId));
const items = (o.order_items ?? []).map((i) => ({
id: str(i.id),
productId: str(i.product_id),
@@ -592,7 +613,12 @@ export class MontifySyncService {
};
await this.prisma.montifyOrder.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
- create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
+ create: {
+ organizationId: row.organizationId,
+ connectionId: row.id,
+ externalId,
+ ...data,
+ },
update: data,
});
}
@@ -619,7 +645,12 @@ export class MontifySyncService {
};
await this.prisma.montifyTransaction.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
- create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
+ create: {
+ organizationId: row.organizationId,
+ connectionId: row.id,
+ externalId,
+ ...data,
+ },
update: data,
});
}
@@ -691,12 +722,20 @@ export class MontifySyncService {
async personStores(actor: Actor, personId: string): Promise {
const person = await this.prisma.person.findFirst({
- where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
+ 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 },
+ where: {
+ organizationId: actor.organizationId,
+ entityType: 'customer',
+ entityId: personId,
+ },
include: { connection: { select: { label: true } } },
orderBy: { syncedAt: 'desc' },
});
@@ -705,12 +744,18 @@ export class MontifySyncService {
for (const l of links) {
const [orders, transactions] = await Promise.all([
this.prisma.montifyOrder.findMany({
- where: { connectionId: l.connectionId, customerExternalId: l.externalId },
+ 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 },
+ where: {
+ connectionId: l.connectionId,
+ customerExternalId: l.externalId,
+ },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
diff --git a/apps/web/src/views/MontifySettingsView.vue b/apps/web/src/views/MontifySettingsView.vue
index 55965ff..b74b254 100644
--- a/apps/web/src/views/MontifySettingsView.vue
+++ b/apps/web/src/views/MontifySettingsView.vue
@@ -5,6 +5,7 @@ import { api } from '@/lib/api';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
+ MontifyDiagnoseDto,
MontifyOrderDto,
MontifyTestResultDto,
UpdateMontifyConnectionRequest,
@@ -158,6 +159,17 @@ function test(c: MontifyConnectionDto) {
});
}
+const diagnoses = ref>({});
+
+function diagnose(c: MontifyConnectionDto) {
+ return run(c.id, async () => {
+ const { data } = await api.post(
+ `/integrations/montify/connections/${c.id}/diagnose`,
+ );
+ diagnoses.value = { ...diagnoses.value, [c.id]: data };
+ });
+}
+
function sync(c: MontifyConnectionDto, mode: 'incremental' | 'full') {
if (
mode === 'full' &&
@@ -314,11 +326,30 @@ onBeforeUnmount(() => {
{{ c.lastError }}
+
+
+ بررسی مسیرها
+
+ (با مشتری {{ diagnoses[c.id]!.customerId }})
+
+
+
+ -
+ {{ ch.ok ? '✓' : '✗' }} {{ ch.name }}
+ {{ ch.status || 'خطای شبکه' }}
+ {{ ch.path }}
+ {{ ch.info }}
+
+
+
تست اتصال
+
+ بررسی مسیرها
+
{
justify-content: end;
max-width: 340px;
}
+.diag {
+ margin-top: 0.4rem;
+ padding: 0.6rem;
+ background: #f8fafc;
+ border-radius: var(--radius);
+}
+.diag ul {
+ list-style: none;
+ margin: 0.3rem 0 0;
+ padding: 0;
+ display: grid;
+ gap: 0.35rem;
+ font-size: 0.82rem;
+}
+.diag li {
+ display: grid;
+ gap: 0.1rem;
+}
+.diag .code {
+ font-weight: 700;
+}
+.diag .path,
+.diag .info {
+ color: #64748b;
+ word-break: break-all;
+}
+.ok-line strong {
+ color: #166534;
+}
+.bad-line strong {
+ color: #991b1b;
+}
.warn {
color: #b45309;
font-size: 0.85rem;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 483a4fb..56760cf 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -4173,6 +4173,19 @@ export interface MontifyTestResultDto {
totalCustomers: number | null;
}
+export interface MontifyDiagnoseCheckDto {
+ name: string;
+ path: string;
+ status: number;
+ ok: boolean;
+ info: string;
+}
+
+export interface MontifyDiagnoseDto {
+ customerId: string | null;
+ checks: MontifyDiagnoseCheckDto[];
+}
+
export interface MontifyOrderItemDto {
productId: string | null;
productName: string | null;