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 }}

+
+

+ بررسی مسیرها + +

+
    +
  • + {{ 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;