From e82bd2b2df1c283408dc767ad927c57a7422c72d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 10 Oct 2026 15:59:12 +0330 Subject: [PATCH] Montify stores integration: multi-store connections, customer/order/transaction sync, webhooks --- .../migration.sql | 162 ++++ apps/api/prisma/schema.prisma | 142 ++++ apps/api/src/app.module.ts | 2 + .../montify/connections.service.ts | 289 +++++++ apps/api/src/integrations/montify/dto.ts | 61 ++ .../integrations/montify/montify-client.ts | 333 ++++++++ .../montify/montify.controller.ts | 169 ++++ .../integrations/montify/montify.module.ts | 19 + .../src/integrations/montify/sync.service.ts | 753 ++++++++++++++++++ .../person-assets/MontifyStoresCard.vue | 245 ++++++ apps/web/src/layouts/AppLayout.vue | 1 + apps/web/src/lib/nav-hubs.ts | 9 + apps/web/src/router/index.ts | 6 + apps/web/src/views/MontifySettingsView.vue | 509 ++++++++++++ apps/web/src/views/PersonProfileView.vue | 2 + docs/MONTACT_CRM_IMPLEMENTATION_GUIDE.md | 440 ++++++++++ docs/MONTACT_CRM_INTEGRATION.md | 438 ++++++++++ packages/shared/src/index.ts | 132 +++ 18 files changed, 3712 insertions(+) create mode 100644 apps/api/prisma/migrations/20261006190000_montify_integration/migration.sql create mode 100644 apps/api/src/integrations/montify/connections.service.ts create mode 100644 apps/api/src/integrations/montify/dto.ts create mode 100644 apps/api/src/integrations/montify/montify-client.ts create mode 100644 apps/api/src/integrations/montify/montify.controller.ts create mode 100644 apps/api/src/integrations/montify/montify.module.ts create mode 100644 apps/api/src/integrations/montify/sync.service.ts create mode 100644 apps/web/src/components/person-assets/MontifyStoresCard.vue create mode 100644 apps/web/src/views/MontifySettingsView.vue create mode 100644 docs/MONTACT_CRM_IMPLEMENTATION_GUIDE.md create mode 100644 docs/MONTACT_CRM_INTEGRATION.md diff --git a/apps/api/prisma/migrations/20261006190000_montify_integration/migration.sql b/apps/api/prisma/migrations/20261006190000_montify_integration/migration.sql new file mode 100644 index 0000000..92a9ae4 --- /dev/null +++ b/apps/api/prisma/migrations/20261006190000_montify_integration/migration.sql @@ -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")); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 87a93e6..324af8b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 81e395b..09e31ee 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/integrations/montify/connections.service.ts b/apps/api/src/integrations/montify/connections.service.ts new file mode 100644 index 0000000..cf5f52f --- /dev/null +++ b/apps/api/src/integrations/montify/connections.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) { + 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 { + 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 { + 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 { + 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('PUBLIC_API_BASE_URL')?.trim(); + if (explicit) return explicit.replace(/\/$/, ''); + const cors = this.config.get('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(), + }; + } +} diff --git a/apps/api/src/integrations/montify/dto.ts b/apps/api/src/integrations/montify/dto.ts new file mode 100644 index 0000000..d86280a --- /dev/null +++ b/apps/api/src/integrations/montify/dto.ts @@ -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; +} diff --git a/apps/api/src/integrations/montify/montify-client.ts b/apps/api/src/integrations/montify/montify-client.ts new file mode 100644 index 0000000..4b69199 --- /dev/null +++ b/apps/api/src/integrations/montify/montify-client.ts @@ -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 = { + 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(); + +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(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(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 }, + }; + } + const b = (body ?? {}) as Partial>; + 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> { + 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( + await this.request('GET', `/montact/customers?${params.toString()}`), + ); + } + + async getCustomer(id: string | number): Promise { + return unwrap( + await this.request('GET', `/montact/customers/${encodeURIComponent(String(id))}`), + ); + } + + async listCustomerOrders( + customerId: string | number, + opts: { page?: number; updatedAfter?: string } = {}, + ): Promise> { + 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( + await this.request( + 'GET', + `/montact/customers/${encodeURIComponent(String(customerId))}/orders?${params.toString()}`, + ), + ); + } + + async listCustomerAddresses(customerId: string | number): Promise { + return this.allPages( + `/montact/customers/${encodeURIComponent(String(customerId))}/addresses`, + ); + } + + async listCustomerTransactions(customerId: string | number): Promise { + return this.allPages( + `/montact/customers/${encodeURIComponent(String(customerId))}/transactions`, + ); + } + + /** این مسیرها صفحه‌بندی دارند ولی 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; + } + return out; + } + + async getCustomerEngagement(customerId: string | number): Promise { + return ( + unwrap( + await this.request( + 'GET', + `/montact/customers/${encodeURIComponent(String(customerId))}/engagement`, + ), + ) ?? {} + ); + } + + async getOrder(id: string | number): Promise { + return unwrap( + 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 { + 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 { + 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; + } + } +} diff --git a/apps/api/src/integrations/montify/montify.controller.ts b/apps/api/src/integrations/montify/montify.controller.ts new file mode 100644 index 0000000..9c10d98 --- /dev/null +++ b/apps/api/src/integrations/montify/montify.controller.ts @@ -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 }, + ) { + 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 }; + } +} diff --git a/apps/api/src/integrations/montify/montify.module.ts b/apps/api/src/integrations/montify/montify.module.ts new file mode 100644 index 0000000..b3baa9d --- /dev/null +++ b/apps/api/src/integrations/montify/montify.module.ts @@ -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 {} diff --git a/apps/api/src/integrations/montify/sync.service.ts b/apps/api/src/integrations/montify/sync.service.ts new file mode 100644 index 0000000..1a2f6e6 --- /dev/null +++ b/apps/api/src/integrations/montify/sync.service.ts @@ -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; + 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(); + + constructor( + private readonly prisma: PrismaService, + private readonly connections: MontifyConnectionsService, + ) {} + + /* ——— فاز ۱ و ۲: Polling و Incremental Sync ——— */ + + async startSync( + organizationId: string, + connectionId: string, + mode: 'incremental' | 'full', + ): Promise { + 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 { + 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 { + 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 (label: string, fn: () => Promise): Promise => { + 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 { + 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 { + 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 } | null, + ): Promise { + 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 { + 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 { + 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; + 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, + ): 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, + }; + } +} diff --git a/apps/web/src/components/person-assets/MontifyStoresCard.vue b/apps/web/src/components/person-assets/MontifyStoresCard.vue new file mode 100644 index 0000000..e4d7c25 --- /dev/null +++ b/apps/web/src/components/person-assets/MontifyStoresCard.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 05e8069..cc90b75 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -96,6 +96,7 @@ const settingsActive = computed(() => '/settings/currencies', '/settings/tahesab', '/settings/woocommerce', + '/settings/montify', '/settings/voip', '/settings/faq', ]), diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index 8e9297a..a8162ac 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -461,6 +461,15 @@ export const NAV_HUBS: NavHubDef[] = [ module: 'woocommerce', admin: true, }, + { + to: '/settings/montify', + title: 'فروشگاه‌های مونتیفای', + desc: 'اتصال چند فروشگاه: مشتری، سفارش و محصول', + icon: 'box', + tint: 'violet', + module: 'montify', + admin: true, + }, { to: '/settings/voip', title: 'تلفن سازمانی', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index df57298..c99dd7b 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -405,6 +405,12 @@ export const router = createRouter({ component: () => import('@/views/WooCommerceSettingsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'settings/montify', + name: 'montify-settings', + component: () => import('@/views/MontifySettingsView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'settings/voip', name: 'voip-settings', diff --git a/apps/web/src/views/MontifySettingsView.vue b/apps/web/src/views/MontifySettingsView.vue new file mode 100644 index 0000000..fe11551 --- /dev/null +++ b/apps/web/src/views/MontifySettingsView.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/apps/web/src/views/PersonProfileView.vue b/apps/web/src/views/PersonProfileView.vue index 42b36f0..b403284 100644 --- a/apps/web/src/views/PersonProfileView.vue +++ b/apps/web/src/views/PersonProfileView.vue @@ -23,6 +23,7 @@ import AskMentoPanel from '@/components/AskMentoPanel.vue'; import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue'; import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue'; import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue'; +import MontifyStoresCard from '@/components/person-assets/MontifyStoresCard.vue'; import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue'; import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue'; import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue'; @@ -937,6 +938,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
+
diff --git a/docs/MONTACT_CRM_IMPLEMENTATION_GUIDE.md b/docs/MONTACT_CRM_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..6ee1215 --- /dev/null +++ b/docs/MONTACT_CRM_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,440 @@ +# راهنمای پیاده‌سازی یکپارچه‌سازی MONTACT CRM برای زیرسیستم‌های فروشگاهی (Provider Guide) — نسخه v2 + +## ۱. مقدمه و هدف سند + +این سند برای تیم توسعه زیرسیستم‌های فروشگاهی (مانند Montify) نوشته شده است. هدف، فراهم کردن APIای است که سامانه بیرونی **Montact CRM** بتواند داده‌های مشتری، سفارش و تعاملات را Pull کند (فاز ۱ و ۲) و در فاز ۳ رویدادها را به‌صورت Push (Webhook) دریافت نماید. این سند باید کاملاً هم‌راستا با سند سمت کلاینت (`MONTACT_CRM_INTEGRATION.md`) باشد. + +## ۲. معماری کلی + +``` + [Client: Montact CRM] + │ HTTPS + Bearer Token + ▼ + [Middleware احراز هویت] ── بررسی token_hash، expires_at، revoked_at + │ + ▼ + [Provider: Montify API] ── Controller ها (Customers / Orders / Webhooks) + │ + ▼ + [Database: PostgreSQL] + │ + ▼ + [Webhook Dispatcher] ──► Queue (Redis/BullMQ) ──► POST به CRM +``` + +## ۳. مدل داده و Migration ها + +### جدول `api_keys` + +```sql +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + store_id UUID NOT NULL REFERENCES stores(id), + token_hash VARCHAR(255) NOT NULL, + name VARCHAR(100), + scopes JSONB DEFAULT '[]', + last_used_at TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + revoked_at TIMESTAMP NULL +); + +CREATE INDEX idx_api_keys_store_id ON api_keys(store_id); +``` + +### جدول `webhook_endpoints` + +```sql +CREATE TABLE webhook_endpoints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + store_id UUID NOT NULL REFERENCES stores(id), + url VARCHAR(500) NOT NULL, + secret VARCHAR(255) NOT NULL, + events JSONB NOT NULL DEFAULT '[]', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); +``` + +### جدول `webhook_deliveries` + +```sql +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + webhook_endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id), + event VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|success|failed + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempted_at TIMESTAMP NULL, + response_status INTEGER NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_webhook_deliveries_status ON webhook_deliveries(status); +``` + +## ۴. Middleware احراز هویت (NestJS Guard) + +```typescript +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; +import * as crypto from 'crypto'; + +@Injectable() +export class MontactAuthGuard implements CanActivate { + constructor(private readonly apiKeyRepository: any) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers['authorization']; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('Token missing'); + } + + const token = authHeader.replace('Bearer ', ''); + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const apiKey = await this.apiKeyRepository.findOne({ where: { token_hash: tokenHash } }); + + if (!apiKey || apiKey.revoked_at) { + throw new UnauthorizedException('Invalid or revoked token'); + } + + if (apiKey.expires_at && new Date(apiKey.expires_at) < new Date()) { + throw new UnauthorizedException('Token expired'); + } + + const storeIdParam = request.params.storeId; + if (storeIdParam && storeIdParam !== apiKey.store_id) { + throw new ForbiddenException('Store mismatch'); + } + + apiKey.last_used_at = new Date(); + await this.apiKeyRepository.save(apiKey); + + request.store_id = apiKey.store_id; + return true; + } +} +``` + +## ۵. تعریف کامل Endpointها + +| Method | Path | توضیح | +|---|---|---| +| GET | `/montact/customers` | لیست مشتریان (`page`, `limit`, `updated_after`) | +| GET | `/montact/customers/:id` | جزئیات مشتری | +| GET | `/montact/customers/:id/orders` | سفارش‌های مشتری (`updated_after`) | +| GET | `/montact/customers/:id/addresses` | آدرس‌های مشتری | +| GET | `/montact/customers/:id/transactions` | تراکنش‌های مشتری | +| GET | `/montact/customers/:id/engagement` | داده‌های تعاملی | +| GET | `/montact/orders/:orderId` | جزئیات یک سفارش | +| POST | `/montact/webhooks/register` | ثبت Webhook جدید | +| GET | `/montact/webhooks/:id` | مشاهده جزئیات Webhook | +| DELETE | `/montact/webhooks/:id` | حذف Webhook | + +### نمونه Query/ORM برای `GET /montact/customers` + +```typescript +async getCustomers(storeId: string, page: number, limit: number, updatedAfter?: string) { + const query = this.customerRepository + .createQueryBuilder('c') + .where('c.store_id = :storeId', { storeId }); + + if (updatedAfter) { + query.andWhere('c.updated_at > :updatedAfter', { updatedAfter }); + } + + const [data, total] = await query + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data: data.map(this.toCustomerDto), + meta: { current_page: page, per_page: limit, total, last_page: Math.ceil(total / limit) } + }; +} +``` + +### ساختار JSON خروجی مشتری/سفارش + +ساختار خروجی باید دقیقاً با فیلدهای تعریف‌شده در سند کلاینت (`MONTACT_CRM_INTEGRATION.md`, بخش ۵) یکسان باشد، شامل: + +- **Customer:** `id, name, first_name, last_name, phone, email, username, avatar, birthday, gender, type, store_id, created_at, updated_at, description, bio, job, cash_balance, gold_balance, orders, summary, addresses, transactions, engagement` +- **Order:** `id, user_id, store_id, code, sts, payment_sts, payment_type, total_price, discount_total_price, total_discounts, total_tax, total_qty, profit, percent_profit, sum_gold, sum_silver, address, address_id, note, cancel_reason, cancelled_at, closed_at, transaction_id, code_discount, discount_code_id, created_at, updated_at, deleted_at, order_items, user, store` +- **Engagement:** `saved, likes, comments` (دقیقاً مطابق نمونه JSON سند کلاینت بخش ۵.۶) + +## ۶. بهینه‌سازی + +### Caching (Redis) + +```typescript +async getCustomersCached(storeId: string, page: number, limit: number, updatedAfter?: string) { + const cacheKey = `customers:${storeId}:${page}:${limit}:${updatedAfter || 'all'}`; + const cached = await this.redis.get(cacheKey); + if (cached) return JSON.parse(cached); + + const result = await this.getCustomers(storeId, page, limit, updatedAfter); + await this.redis.set(cacheKey, JSON.stringify(result), 'EX', 45); // TTL 45s + return result; +} + +// Invalidation هنگام ایجاد/ویرایش سفارش +async invalidateCustomerCache(storeId: string) { + const keys = await this.redis.keys(`customers:${storeId}:*`); + if (keys.length) await this.redis.del(keys); +} +``` + +### Rate Limiting (Token Bucket) + +```typescript +import { Injectable, NestMiddleware, HttpException } from '@nestjs/common'; + +@Injectable() +export class RateLimitMiddleware implements NestMiddleware { + async use(req: any, res: any, next: () => void) { + const apiKeyId = req.apiKeyId; + const key = `ratelimit:${apiKeyId}`; + const current = await this.redis.incr(key); + + if (current === 1) { + await this.redis.expire(key, 60); // پنجره ۶۰ ثانیه‌ای + } + + if (current > 100) { // 100 req/min + res.setHeader('Retry-After', '60'); + throw new HttpException('Too Many Requests', 429); + } + + next(); + } +} +``` + +## ۷. فاز ۲ — Incremental Sync سمت Provider + +- فیلتر `updated_after` به شکل `WHERE updated_at > :updated_after` در تمام کوئری‌های لیست (customers, orders) اعمال می‌شود. +- برای پرفورمنس، باید ایندکس روی ستون `updated_at` ایجاد شود: + +```sql +CREATE INDEX idx_customers_updated_at ON customers(updated_at); +CREATE INDEX idx_orders_updated_at ON orders(updated_at); +``` + +## ۸. فاز ۳ — Webhook (Push) سمت Provider + +### ثبت Webhook + +```http +POST /montact/webhooks/register +Authorization: Bearer {token} +Content-Type: application/json + +{ + "url": "https://crm.montact.com/webhooks/montify", + "events": ["order.created", "order.paid"] +} +``` + +```typescript +async registerWebhook(storeId: string, url: string, events: string[]) { + if (!url.startsWith('https://')) { + throw new BadRequestException('Webhook URL must use HTTPS'); + } + // محافظت SSRF: منع IP های داخلی/لوکال + const hostname = new URL(url).hostname; + if (this.isPrivateOrLocalHost(hostname)) { + throw new BadRequestException('Private/local URLs are not allowed'); + } + + const secret = crypto.randomBytes(32).toString('hex'); + + const webhook = await this.webhookRepository.save({ + store_id: storeId, + url, + secret, // باید رمزنگاری‌شده در دیتابیس ذخیره شود (مثلاً با AES) + events, + is_active: true, + }); + + return { id: webhook.id, secret }; // secret فقط یک‌بار در پاسخ برگردانده می‌شود +} +``` + +### مدیریت Webhook + +```http +GET /montact/webhooks/:id +DELETE /montact/webhooks/:id +``` + +### رویدادهای پشتیبانی‌شده + +| رویداد | توضیح | +|---|---| +| customer.created | ایجاد مشتری جدید | +| customer.updated | بروزرسانی مشتری | +| order.created | ثبت سفارش جدید | +| order.updated | تغییر سفارش | +| order.paid | پرداخت سفارش | +| order.cancelled | لغو سفارش | + +### Event Dispatcher + +```javascript +const { Queue } = require('bullmq'); +const webhookQueue = new Queue('webhook-deliveries', { connection: redisConnection }); + +async function dispatchEvent(storeId, eventName, data) { + const endpoints = await WebhookEndpoint.findAll({ + where: { store_id: storeId, is_active: true } + }); + + for (const endpoint of endpoints) { + if (!endpoint.events.includes(eventName)) continue; + + const delivery = await WebhookDelivery.create({ + webhook_endpoint_id: endpoint.id, + event: eventName, + payload: { event: eventName, delivery_id: null, store_id: storeId, data }, + status: 'pending', + }); + + const payload = { + event: eventName, + delivery_id: delivery.id, + store_id: storeId, + data, + }; + + await webhookQueue.add('deliver', { + deliveryId: delivery.id, + endpointId: endpoint.id, + url: endpoint.url, + secret: endpoint.secret, + payload, + }); + } +} + +// مثال استفاده بعد از ثبت سفارش: +// await dispatchEvent(order.store_id, 'order.created', orderDto); +``` + +### امضای HMAC-SHA256 + +```javascript +const crypto = require('crypto'); + +function signPayload(payload, secret) { + const rawBody = JSON.stringify(payload); + return crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); +} +``` + +### نمونه Payload ارسالی برای `order.created` + +```json +{ + "event": "order.created", + "delivery_id": "d2f1c9e0-84a3-4b7f-9c1e-3a2b1c0d9e8f", + "store_id": "b2f1e4a0-...", + "data": { + "id": 4567, + "user_id": 123, + "code": "ORD-2026-4567", + "sts": "pending", + "payment_sts": "unpaid", + "total_price": 12500000, + "total_qty": 2, + "created_at": "2026-07-10T12:05:00Z" + } +} +``` + +### Retry Policy (Worker) + +```javascript +const { Worker } = require('bullmq'); +const axios = require('axios'); + +const RETRY_DELAYS = [1000, 5000, 15000, 60000, 300000]; // 1s,5s,15s,60s,300s + +const worker = new Worker('webhook-deliveries', async (job) => { + const { deliveryId, url, secret, payload } = job.data; + const signature = signPayload(payload, secret); + + try { + const response = await axios.post(url, payload, { + headers: { + 'X-Montact-Signature': `sha256=${signature}`, + 'X-Montact-Event': payload.event, + 'X-Montact-Delivery-Id': payload.delivery_id, + 'Content-Type': 'application/json', + }, + timeout: 10000, // 10s timeout + }); + + if (response.status >= 200 && response.status < 300) { + await WebhookDelivery.update( + { status: 'success', response_status: response.status, last_attempted_at: new Date() }, + { where: { id: deliveryId } } + ); + return; + } + throw new Error(`Unexpected status ${response.status}`); + } catch (err) { + const attempt = job.attemptsMade; + await WebhookDelivery.increment('attempt_count', { where: { id: deliveryId } }); + + if (attempt >= RETRY_DELAYS.length) { + await WebhookDelivery.update( + { status: 'failed', last_attempted_at: new Date() }, + { where: { id: deliveryId } } + ); + } else { + throw err; // BullMQ طبق backoff تعریف‌شده در صف، تلاش مجدد می‌کند + } + } +}, { + connection: redisConnection, + settings: { backoffStrategy: (attempt) => RETRY_DELAYS[attempt - 1] }, +}); +``` + +### نکات امنیتی ثبت Webhook + +- URL ثبت‌شده باید حتماً `https://` باشد. +- IPهای داخلی/لوکال (`127.0.0.1`, `10.x.x.x`, `192.168.x.x`, `169.254.x.x`) باید مسدود شوند (محافظت SSRF). +- `secret` باید در دیتابیس رمزنگاری‌شده ذخیره شود، نه به‌صورت plain-text. + +## ۹. مانیتورینگ و لاگ + +- تمام رکوردهای `webhook_deliveries` باید همراه با `attempt_count` و `response_status` لاگ شوند. +- در صورتی‌که نرخ `failed` برای یک Webhook Endpoint خاص از ۲۰٪ در بازه ۱ ساعته بیشتر شود، باید Alert ارسال شود (مثلاً از طریق Slack/Email) و در صورت ادامه، Endpoint به‌صورت موقت `is_active=false` شود. + +## ۱۰. چک‌لیست پیاده‌سازی برای تیم Provider + +- [ ] اجرای Migration جداول `api_keys`, `webhook_endpoints`, `webhook_deliveries` +- [ ] پیاده‌سازی Middleware احراز هویت (Guard) +- [ ] پیاده‌سازی تمام Endpointهای GET مطابق جدول بخش ۵ +- [ ] اعمال فیلتر `updated_after` و ایندکس‌گذاری `updated_at` +- [ ] پیاده‌سازی Caching با Redis و Invalidation مناسب +- [ ] پیاده‌سازی Rate Limiting (۱۰۰ req/min) +- [ ] پیاده‌سازی ثبت/حذف/مشاهده Webhook +- [ ] پیاده‌سازی Event Dispatcher و اتصال به Queue +- [ ] پیاده‌سازی Worker با Retry Policy و Backoff +- [ ] تست کامل امضای HMAC-SHA256 در سمت Worker و نمونه CRM + +## ۱۱. تست و چک‌لیست امنیتی + +- [ ] Token هرگز به‌صورت plain-text ذخیره نمی‌شود؛ تنها `token_hash` ذخیره می‌شود. +- [ ] `secret` وبهوک رمزنگاری‌شده در دیتابیس ذخیره می‌شود. +- [ ] HTTPS برای تمام Webhook URLها اجباری است. +- [ ] محافظت SSRF در برابر IPهای داخلی/لوکال فعال است. +- [ ] محدودسازی اختیاری بر اساس IP Whitelist برای توکن‌های حساس در نظر گرفته شده است. +- [ ] تست‌های Integration برای تمام Endpointها و سناریوهای خطا (۴۰۱, ۴۰۳, ۴۲۹) نوشته شده است. diff --git a/docs/MONTACT_CRM_INTEGRATION.md b/docs/MONTACT_CRM_INTEGRATION.md new file mode 100644 index 0000000..93b4954 --- /dev/null +++ b/docs/MONTACT_CRM_INTEGRATION.md @@ -0,0 +1,438 @@ +# راهنمای اتصال CRM (MONTACT) به Montify — نسخه کامل v2 + +## ۱. خلاصه و دیاگرام جریان + +این سند مشخصات اتصال سامانه **Montact CRM** به زیرسیستم‌های فروشگاهی (مانند Montify) را تعریف می‌کند. هر فروشگاه یک `Base URL` و یک `Token` اختصاصی دارد. + +``` +[فروشنده/Store] → [پنل Montify] → [صدور Token] → [سرور CRM] + │ + ▼ + GET {Base URL}/montact/customers + │ + ▼ + پاسخ JSON (لیست مشتریان) + │ + ▼ + GET {Base URL}/montact/customers/:id/orders + │ + ▼ + پاسخ JSON (سفارش‌های مشتری) +``` + +## ۲. دریافت اطلاعات اتصال از فروشنده + +1. فروشنده وارد پنل Montify می‌شود. +2. از مسیر «تنظیمات → یکپارچه‌سازی‌ها → CRM» وارد بخش Montact می‌شود. +3. دکمه «صدور Token» را می‌زند و مقادیر زیر را در اختیار تیم CRM قرار می‌دهد: + +| فیلد | مقدار نمونه | توضیح | +|---|---|---| +| Base URL | `https://store1.montify.com/api/v1` | آدرس پایه API فروشگاه | +| Token | `mk_live_8f3a1c9d2e...` | توکن احراز هویت Bearer | +| Store ID | `b2f1e4a0-...` | شناسه فروشگاه | + +> **نکته:** توکن‌ها می‌توانند منقضی شوند (`expires_at`). در صورت دریافت خطای ۴۰۱، باید توکن جدید از فروشنده درخواست شود. توکن هرگز نباید در فرانت‌اند ذخیره یا افشا شود؛ تنها باید در سرور CRM نگهداری گردد. + +## ۳. احراز هویت + +تمام درخواست‌ها باید هدرهای زیر را داشته باشند: + +```http +Authorization: Bearer {Token} +Content-Type: application/json +Accept: application/json +``` + +## ۴. فهرست کامل APIها + +| Method | Path | توضیح | +|---|---|---| +| GET | `/montact/customers` | لیست مشتریان (پشتیبانی از `page`, `limit`, `updated_after`) | +| POST | `/montact/customers` | ثبت/همگام‌سازی مشتری از سمت CRM (اختیاری) | +| GET | `/montact/customers/:id` | جزئیات کامل یک مشتری | +| GET | `/montact/customers/:id/orders` | لیست سفارش‌های مشتری (پشتیبانی از `updated_after`) | +| POST | `/montact/customers/:id/orders` | ثبت سفارش از CRM (اختیاری، در صورت فعال بودن) | +| GET | `/montact/customers/:id/addresses` | لیست آدرس‌های مشتری | +| GET | `/montact/customers/:id/transactions` | لیست تراکنش‌های مالی مشتری | +| GET | `/montact/customers/:id/engagement` | داده‌های تعاملی (`saved`, `likes`, `comments`) | +| GET | `/montact/orders/:orderId` | جزئیات یک سفارش خاص | +| POST | `/montact/webhooks/register` | ثبت Webhook برای رویدادهای بلادرنگ (فاز ۳) | +| GET | `/montact/webhooks/:id` | مشاهده جزئیات یک Webhook ثبت‌شده | +| DELETE | `/montact/webhooks/:id` | حذف Webhook | + +> پارامتر `updated_after` یک مقدار ISO 8601 است (مثلاً `2026-07-01T00:00:00Z`) و فقط رکوردهای تغییر یافته بعد از آن زمان را برمی‌گرداند (برای Incremental Sync). + +### نمونه‌های curl + +```bash +BASE="https://store1.montify.com/api/v1" +TOKEN="mk_live_8f3a1c9d2e..." + +# لیست مشتریان +curl -X GET "$BASE/montact/customers?page=1&limit=50" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" + +# لیست مشتریان تغییریافته بعد از یک زمان خاص (Incremental Sync) +curl -X GET "$BASE/montact/customers?updated_after=2026-07-01T00:00:00Z" \ + -H "Authorization: Bearer $TOKEN" + +# جزئیات یک مشتری +curl -X GET "$BASE/montact/customers/123" \ + -H "Authorization: Bearer $TOKEN" + +# سفارش‌های یک مشتری +curl -X GET "$BASE/montact/customers/123/orders?updated_after=2026-07-01T00:00:00Z" \ + -H "Authorization: Bearer $TOKEN" + +# آدرس‌های مشتری +curl -X GET "$BASE/montact/customers/123/addresses" \ + -H "Authorization: Bearer $TOKEN" + +# تراکنش‌های مشتری +curl -X GET "$BASE/montact/customers/123/transactions" \ + -H "Authorization: Bearer $TOKEN" + +# داده‌های تعاملی مشتری +curl -X GET "$BASE/montact/customers/123/engagement" \ + -H "Authorization: Bearer $TOKEN" + +# جزئیات یک سفارش خاص +curl -X GET "$BASE/montact/orders/4567" \ + -H "Authorization: Bearer $TOKEN" + +# ثبت Webhook (فاز ۳) +curl -X POST "$BASE/montact/webhooks/register" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://crm.montact.com/webhooks/montify","events":["order.created","order.paid"]}' +``` + +## ۵. ساختار پاسخ کامل + +### ۵.۱ فیلدهای مشتری (Customer) + +| فیلد | نوع | توضیح | +|---|---|---| +| id | integer | شناسه مشتری | +| name | string | نام کامل | +| first_name | string | نام | +| last_name | string | نام خانوادگی | +| phone | string | شماره موبایل | +| email | string\|null | ایمیل | +| username | string | نام کاربری | +| avatar | string\|null | URL آواتار | +| birthday | date\|null | تاریخ تولد | +| gender | string\|null | جنسیت | +| type | string | نوع مشتری (حقیقی/حقوقی) | +| store_id | integer | شناسه فروشگاه | +| created_at | datetime | تاریخ ایجاد | +| updated_at | datetime | آخرین بروزرسانی | +| description | string\|null | توضیحات | +| bio | string\|null | بیوگرافی | +| job | string\|null | شغل | +| cash_balance | number | موجودی نقدی | +| gold_balance | number | موجودی طلا (گرم) | +| orders | array | لیست سفارش‌ها | +| summary | object | خلاصه آماری مشتری | +| addresses | array | لیست آدرس‌ها | +| transactions | array | لیست تراکنش‌ها | +| engagement | object | داده‌های تعاملی | + +### ۵.۲ فیلدهای سفارش (Order) + +| فیلد | نوع | توضیح | +|---|---|---| +| id | integer | شناسه سفارش | +| user_id | integer | شناسه مشتری | +| store_id | integer | شناسه فروشگاه | +| code | string | کد پیگیری سفارش | +| sts | string | وضعیت سفارش | +| payment_sts | string | وضعیت پرداخت | +| payment_type | string | نوع پرداخت | +| total_price | number | قیمت کل | +| discount_total_price | number | قیمت پس از تخفیف | +| total_discounts | number | مجموع تخفیف‌ها | +| total_tax | number | مجموع مالیات | +| total_qty | integer | تعداد کل اقلام | +| profit | number | سود | +| percent_profit | number | درصد سود | +| sum_gold | number | مجموع وزن طلا | +| sum_silver | number | مجموع وزن نقره | +| address | object | آدرس ارسال | +| address_id | integer | شناسه آدرس | +| note | string\|null | یادداشت | +| cancel_reason | string\|null | دلیل لغو | +| cancelled_at | datetime\|null | تاریخ لغو | +| closed_at | datetime\|null | تاریخ بستن سفارش | +| transaction_id | integer\|null | شناسه تراکنش مرتبط | +| code_discount | string\|null | کد تخفیف | +| discount_code_id | integer\|null | شناسه کد تخفیف | +| created_at | datetime | تاریخ ایجاد | +| updated_at | datetime | آخرین بروزرسانی | +| deleted_at | datetime\|null | تاریخ حذف نرم | +| order_items | array | اقلام سفارش | +| user | object | اطلاعات خلاصه مشتری | +| store | object | اطلاعات خلاصه فروشگاه | + +### ۵.۳ اقلام سفارش (Order Items) + +| فیلد | نوع | توضیح | +|---|---|---| +| id | integer | شناسه قلم سفارش | +| order_id | integer | شناسه سفارش | +| product_id | integer | شناسه محصول | +| product_name | string | نام محصول | +| qty | integer | تعداد | +| price | number | قیمت واحد | +| weight | number | وزن (گرم) | +| total_price | number | قیمت کل قلم | + +### ۵.۴ آدرس‌ها (Addresses) + +| فیلد | نوع | توضیح | +|---|---|---| +| id | integer | شناسه آدرس | +| user_id | integer | شناسه مشتری | +| province | string | استان | +| city | string | شهر | +| address | string | متن آدرس | +| postal_code | string | کد پستی | +| receiver_name | string | نام گیرنده | +| receiver_phone | string | شماره گیرنده | +| is_default | boolean | آدرس پیش‌فرض | + +### ۵.۵ تراکنش‌ها (Transactions) + +| فیلد | نوع | توضیح | +|---|---|---| +| id | integer | شناسه تراکنش | +| user_id | integer | شناسه مشتری | +| type | string | نوع تراکنش (واریز/برداشت) | +| amount | number | مقدار | +| currency | string | واحد (ریال/گرم طلا) | +| status | string | وضعیت تراکنش | +| reference_code | string | کد پیگیری بانکی | +| created_at | datetime | تاریخ ثبت | + +### ۵.۶ داده‌های تعاملی (Engagement) + +مسیر: `GET /montact/customers/:id/engagement` + +```json +{ + "saved": [ + { "product_id": 55, "product_name": "سکه بهار آزادی", "saved_at": "2026-06-10T11:22:00Z" } + ], + "likes": [ + { "product_id": 78, "liked_at": "2026-06-15T09:00:00Z" } + ], + "comments": [ + { + "id": 301, + "product_id": 78, + "text": "کیفیت عالی بود", + "rating": 5, + "created_at": "2026-06-16T08:30:00Z" + } + ] +} +``` + +### ۵.۷ نمونه پاسخ JSON کامل لیست مشتریان + +```json +{ + "data": [ + { + "id": 123, + "name": "علی رضایی", + "first_name": "علی", + "last_name": "رضایی", + "phone": "09121234567", + "email": "ali@example.com", + "username": "ali_r", + "avatar": "https://cdn.montify.com/avatars/123.png", + "birthday": "1990-05-12", + "gender": "male", + "type": "individual", + "store_id": 10, + "created_at": "2025-01-10T08:00:00Z", + "updated_at": "2026-07-01T10:15:00Z", + "description": null, + "bio": null, + "job": "مهندس", + "cash_balance": 1500000, + "gold_balance": 2.35, + "orders": [], + "summary": { "total_orders": 12, "total_spent": 85000000 }, + "addresses": [], + "transactions": [], + "engagement": { "saved": [], "likes": [], "comments": [] } + } + ], + "meta": { + "current_page": 1, + "per_page": 50, + "total": 532, + "last_page": 11 + } +} +``` + +## ۶. قوانین نگاشت پیشنهادی در CRM + +| فیلد Montify | فیلد معادل در CRM | +|---|---| +| id | external_customer_id | +| name | full_name | +| phone | mobile | +| cash_balance | wallet_balance | +| gold_balance | gold_wallet_balance | +| orders[].code | order_reference | +| orders[].sts | order_status | + +**استراتژی Sync گام به گام:** +1. دریافت لیست مشتریان صفحه‌بندی‌شده و Upsert در جدول مشتریان CRM بر اساس `id`. +2. برای هر مشتری، دریافت سفارش‌ها، آدرس‌ها و تراکنش‌ها به‌صورت جداگانه و Upsert در جداول مرتبط. +3. ذخیره `updated_at` هر رکورد برای استفاده در Sync افزایشی بعدی. + +## ۷. استراتژی Sync سه‌فازی + +### فاز ۱ — Polling ساده + +- فراخوانی دوره‌ای `/montact/customers` با `page` و `limit`. +- فاصله پیشنهادی: هر ۵ تا ۱۵ دقیقه (بسته به حجم داده فروشگاه). +- در صورت دریافت `429 Too Many Requests`، باید بر اساس هدر `Retry-After` صبر و تلاش مجدد شود. + +### فاز ۲ — Incremental Sync + +- استفاده از `updated_after=` روی `/montact/customers` و `/montact/customers/:id/orders`. +- CRM باید مقدار `last_sync_timestamp` را پس از هر Sync موفق ذخیره کند و در فراخوانی بعدی به‌عنوان `updated_after` ارسال کند. + +```bash +curl -X GET "$BASE/montact/customers?updated_after=2026-07-10T12:00:00Z" \ + -H "Authorization: Bearer $TOKEN" +``` + +### فاز ۳ — Webhook (Push-based، بلادرنگ) + +برای دریافت رویدادها بدون تأخیر Polling، CRM باید یک Endpoint عمومی HTTPS در اختیار Montify قرار دهد: + +```bash +curl -X POST "$BASE/montact/webhooks/register" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://crm.montact.com/webhooks/montify", + "events": ["customer.created", "customer.updated", "order.created", "order.updated", "order.paid", "order.cancelled"] + }' +``` + +**رویدادهای پشتیبانی‌شده:** + +| رویداد | توضیح | +|---|---| +| customer.created | ایجاد مشتری جدید | +| customer.updated | بروزرسانی اطلاعات مشتری | +| order.created | ثبت سفارش جدید | +| order.updated | تغییر در سفارش | +| order.paid | پرداخت سفارش | +| order.cancelled | لغو سفارش | + +**نمونه Payload برای `order.created`:** + +```json +{ + "event": "order.created", + "delivery_id": "d2f1c9e0-84a3-4b7f-9c1e-3a2b1c0d9e8f", + "store_id": 10, + "data": { + "id": 4567, + "user_id": 123, + "code": "ORD-2026-4567", + "sts": "pending", + "payment_sts": "unpaid", + "total_price": 12500000, + "total_qty": 2, + "created_at": "2026-07-10T12:05:00Z" + } +} +``` + +**هدرهای ارسالی Webhook:** + +```http +X-Montact-Signature: sha256=3b4f9a1c7e8d2f... +X-Montact-Event: order.created +X-Montact-Delivery-Id: d2f1c9e0-84a3-4b7f-9c1e-3a2b1c0d9e8f +Content-Type: application/json +``` + +**وریفای امضا (Node.js):** + +```javascript +const crypto = require('crypto'); + +function verifySignature(rawBody, signatureHeader, secret) { + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + const received = signatureHeader.replace('sha256=', ''); + return crypto.timingSafeEqual( + Buffer.from(expected, 'hex'), + Buffer.from(received, 'hex') + ); +} +``` + +**Retry Policy:** + +- در صورت عدم دریافت پاسخ `2xx` ظرف ۱۰ ثانیه، تلاش مجدد با backoff نمایی انجام می‌شود. +- فاصله تلاش‌ها: `1s → 5s → 15s → 60s → 300s` (حداکثر ۵ بار). +- CRM باید سریعاً `200 OK` برگرداند و پردازش سنگین را به‌صورت async انجام دهد. +- Idempotency بر اساس `X-Montact-Delivery-Id` باید رعایت شود تا پردازش تکراری رخ ندهد. + +## ۸. کدهای خطا + +| کد HTTP | معنی | اقدام پیشنهادی | +|---|---|---| +| 200 | موفق | - | +| 400 | درخواست نامعتبر | بررسی پارامترها | +| 401 | توکن نامعتبر یا منقضی | درخواست توکن جدید از فروشنده | +| 403 | عدم دسترسی به منبع | بررسی scope توکن | +| 404 | منبع یافت نشد | بررسی شناسه ارسالی | +| 429 | تعداد درخواست بیش از حد مجاز | توجه به هدر `Retry-After` و کاهش نرخ درخواست | +| 500 | خطای سرور | تلاش مجدد بعد از فاصله زمانی | + +## ۹. محدودیت‌ها و رفتار فعلی + +- API برای داده‌های اصلی (مشتری، سفارش، آدرس، تراکنش) صرفاً **خواندنی (Read-only)** است؛ تنها استثنا ثبت/حذف Webhook است. +- فیلدهای حساس (مانند رمز عبور، هش‌های داخلی) از پاسخ‌ها حذف شده‌اند. +- CORS برای دامنه‌های مجاز CRM فعال است. +- نرخ مجاز درخواست پیشنهادی: **۱۰۰ درخواست در دقیقه به ازای هر Token**. + +## ۱۰. چک‌لیست اتصال برنامه‌نویس CRM + +- [ ] دریافت `Base URL`، `Token` و `Store ID` از فروشنده +- [ ] تست احراز هویت با `GET /montact/customers` +- [ ] پیاده‌سازی Polling فاز ۱ +- [ ] پیاده‌سازی Incremental Sync با `updated_after` (فاز ۲) +- [ ] ثبت Webhook Endpoint و تست دریافت رویداد (فاز ۳) +- [ ] پیاده‌سازی وریفای امضای HMAC-SHA256 +- [ ] تست Idempotency بر اساس `X-Montact-Delivery-Id` +- [ ] مدیریت خطاهای ۴۰۱/۴۲۹ و Retry مناسب +- [ ] ذخیره امن Token در سمت سرور (نه فرانت‌اند) + +## ۱۱. پشتیبانی + +برای ارسال تیکت پشتیبانی، اطلاعات زیر لازم است: + +- `Store ID` +- زمان دقیق درخواست (UTC) +- کد خطای دریافتی +- `X-Montact-Delivery-Id` (در صورت مرتبط بودن به Webhook) +- نمونه Request/Response (بدون افشای Token کامل) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 217265d..483a4fb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -134,6 +134,7 @@ export type OrgModuleKey = | "sms_campaigns" | "teams" | "woocommerce" + | "montify" | "voip" | "reservations" | "leads" @@ -172,6 +173,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [ "sms_campaigns", "teams", "woocommerce", + "montify", "voip", "reservations", "conversations", @@ -210,6 +212,7 @@ export const ORG_MODULE_LABELS: Record = { sms_campaigns: "پیامک گروهی", teams: "تیم‌ها و سطح دسترسی به پرونده‌ها", woocommerce: "ووکامرس", + montify: "فروشگاه‌های مونتیفای", voip: "تلفن سازمانی", reservations: "رزرو و نوبت‌دهی", conversations: "گفتگو", @@ -254,6 +257,7 @@ export const ORG_MODULE_GROUPS: Array<{ "reports", "saved_views", "woocommerce", + "montify", "voip", "reservations", ], @@ -803,6 +807,7 @@ export const PERSON_IMPORT_SOURCES = [ "excel", "phone", "lead_form", + "montify", "other", ] as const; export type PersonImportSource = (typeof PERSON_IMPORT_SOURCES)[number]; @@ -814,6 +819,7 @@ export const PERSON_IMPORT_SOURCE_LABELS: Record = { excel: "اکسل", phone: "مخاطبین گوشی", lead_form: "فرم لید", + montify: "فروشگاه مونتیفای", other: "سایر", }; @@ -4113,6 +4119,132 @@ export interface WooCommerceOrderLinkDto { syncedAt: string; } +/* ——— فروشگاه‌های Montify ——— */ + +export type MontifySyncStatus = "idle" | "running" | "ok" | "error"; + +export interface MontifySyncResultDto { + mode: "incremental" | "full"; + customersFetched: number; + customersCreated: number; + customersUpdated: number; + ordersFetched: number; + transactionsFetched: number; + errors: string[]; + startedAt: string; + finishedAt: string | null; +} + +export interface MontifyConnectionDto { + id: string; + label: string; + baseUrl: string; + storeId: string; + tokenMasked: string; + enabled: boolean; + webhookRegistered: boolean; + webhookUrl: string; + syncStatus: MontifySyncStatus; + syncStartedAt: string | null; + lastSyncAt: string | null; + lastSyncResult: MontifySyncResultDto | null; + lastHealthAt: string | null; + lastHealthOk: boolean | null; + lastError: string | null; + lastWebhookAt: string | null; + linkedCustomers: number; + orders: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateMontifyConnectionRequest { + label: string; + baseUrl: string; + token: string; + storeId?: string; + enabled?: boolean; +} + +export type UpdateMontifyConnectionRequest = Partial; + +export interface MontifyTestResultDto { + ok: boolean; + totalCustomers: number | null; +} + +export interface MontifyOrderItemDto { + productId: string | null; + productName: string | null; + qty: number | null; + price: number | null; + weight: number | null; + totalPrice: number | null; +} + +/** سفارش فروشگاه — orderReference = code، orderStatus = sts */ +export interface MontifyOrderDto { + id: string; + connectionId: string; + connectionLabel: string; + externalId: string; + customerExternalId: string | null; + personId: string | null; + personName: string | null; + orderReference: string | null; + orderStatus: string | null; + paymentStatus: string | null; + paymentType: string | null; + totalPrice: number | null; + discountTotalPrice: number | null; + totalDiscounts: number | null; + totalQty: number | null; + sumGold: number | null; + sumSilver: number | null; + note: string | null; + cancelReason: string | null; + cancelledAt: string | null; + closedAt: string | null; + items: MontifyOrderItemDto[]; + createdAt: string | null; + updatedAt: string | null; +} + +export interface MontifyTransactionDto { + id: string; + externalId: string; + type: string | null; + amount: number | null; + currency: string | null; + status: string | null; + referenceCode: string | null; + createdAt: string | null; +} + +export interface MontifyEngagementDto { + saved: number; + likes: number; + comments: number; +} + +/** پروفایل فروشگاهی یک شخص (wallet_balance / gold_wallet_balance طبق نگاشت مستند) */ +export interface MontifyPersonStoreDto { + connectionId: string; + connectionLabel: string; + externalCustomerId: string; + fullName: string | null; + username: string | null; + customerType: string | null; + walletBalance: number | null; + goldWalletBalance: number | null; + totalOrders: number | null; + totalSpent: number | null; + engagement: MontifyEngagementDto | null; + orders: MontifyOrderDto[]; + transactions: MontifyTransactionDto[]; + syncedAt: string; +} + /* ——— تلفن سازمانی / VoIP ——— */ export type VoipCallDirection = "inbound" | "outbound";