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

This commit is contained in:
2026-10-10 15:59:12 +03:30
parent fa3fe0d364
commit e82bd2b2df
18 changed files with 3712 additions and 0 deletions
@@ -0,0 +1,162 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"storeId" TEXT NOT NULL DEFAULT '',
"tokenCiphertext" TEXT NOT NULL,
"tokenIv" TEXT NOT NULL,
"tokenTag" TEXT NOT NULL,
"tokenLast4" TEXT NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"webhookRemoteId" TEXT,
"webhookSecretCiphertext" TEXT,
"webhookSecretIv" TEXT,
"webhookSecretTag" TEXT,
"customersCursor" TIMESTAMP(3),
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
"syncStartedAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"lastSyncResult" JSONB,
"lastHealthAt" TIMESTAMP(3),
"lastHealthOk" BOOLEAN,
"lastError" TEXT,
"lastWebhookAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyLink" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"externalUpdatedAt" TIMESTAMP(3),
"ordersSyncedAt" TIMESTAMP(3),
"data" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyOrder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"customerExternalId" TEXT,
"personId" TEXT,
"orderReference" TEXT,
"orderStatus" TEXT,
"paymentStatus" TEXT,
"paymentType" TEXT,
"totalPrice" DECIMAL(20,2),
"discountTotalPrice" DECIMAL(20,2),
"totalDiscounts" DECIMAL(20,2),
"totalTax" DECIMAL(20,2),
"totalQty" DECIMAL(14,3),
"profit" DECIMAL(20,2),
"percentProfit" DECIMAL(10,3),
"sumGold" DECIMAL(14,3),
"sumSilver" DECIMAL(14,3),
"address" JSONB,
"note" TEXT,
"cancelReason" TEXT,
"cancelledAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"codeDiscount" TEXT,
"items" JSONB NOT NULL DEFAULT '[]',
"externalCreatedAt" TIMESTAMP(3),
"externalUpdatedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"raw" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyTransaction" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"customerExternalId" TEXT,
"personId" TEXT,
"type" TEXT,
"amount" DECIMAL(24,4),
"currency" TEXT,
"status" TEXT,
"referenceCode" TEXT,
"externalCreatedAt" TIMESTAMP(3),
"raw" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyWebhookDelivery" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"deliveryId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MontifyWebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MontifyConnection_organizationId_enabled_idx" ON "MontifyConnection"("organizationId", "enabled");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyLink_connectionId_entityType_externalId_key" ON "MontifyLink"("connectionId", "entityType", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyLink_organizationId_entityType_entityId_idx" ON "MontifyLink"("organizationId", "entityType", "entityId");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyOrder_connectionId_externalId_key" ON "MontifyOrder"("connectionId", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_personId_idx" ON "MontifyOrder"("organizationId", "personId");
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_externalCreatedAt_idx" ON "MontifyOrder"("organizationId", "externalCreatedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyTransaction_connectionId_externalId_key" ON "MontifyTransaction"("connectionId", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyTransaction_organizationId_personId_idx" ON "MontifyTransaction"("organizationId", "personId");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_deliveryId_key" ON "MontifyWebhookDelivery"("connectionId", "deliveryId");
CREATE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_createdAt_idx" ON "MontifyWebhookDelivery"("connectionId", "createdAt");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MontifyConnection" ADD CONSTRAINT "MontifyConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyLink" ADD CONSTRAINT "MontifyLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyOrder" ADD CONSTRAINT "MontifyOrder_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyTransaction" ADD CONSTRAINT "MontifyTransaction_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyWebhookDelivery" ADD CONSTRAINT "MontifyWebhookDelivery_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ماژول جدید برای محیط‌هایی که فهرست ماژول ذخیره‌شده دارند و ووکامرس فعال است
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'montify')
WHERE cardinality("enabledModules") > 0
AND 'woocommerce' = ANY("enabledModules")
AND NOT ('montify' = ANY("enabledModules"));
+142
View File
@@ -201,6 +201,7 @@ model Organization {
webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[]
wooCommerceConnections WooCommerceConnection[]
montifyConnections MontifyConnection[]
voipConnections VoipConnection[]
reservationServices ReservationService[]
reservationAppointments ReservationAppointment[]
@@ -2080,6 +2081,147 @@ model WooCommerceOrderLink {
@@index([invoiceId])
}
/// اتصال فروشگاه Montify (هر فروشگاه: Base URL + توکن اختصاصی)
model MontifyConnection {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String
baseUrl String
storeId String @default("")
tokenCiphertext String
tokenIv String
tokenTag String
tokenLast4 String @default("")
enabled Boolean @default(true)
webhookRemoteId String?
webhookSecretCiphertext String?
webhookSecretIv String?
webhookSecretTag String?
/// بیشترین updated_at مشتریان دریافت‌شده (برای updated_after)
customersCursor DateTime?
/// idle | running | ok | error
syncStatus String @default("idle")
syncStartedAt DateTime?
lastSyncAt DateTime?
lastSyncResult Json?
lastHealthAt DateTime?
lastHealthOk Boolean?
lastError String?
lastWebhookAt DateTime?
links MontifyLink[]
orders MontifyOrder[]
transactions MontifyTransaction[]
deliveries MontifyWebhookDelivery[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, enabled])
}
/// پیوند مشتری فروشگاه با شخص CRM (entityType = customer، entityId = Person.id)
model MontifyLink {
id String @id @default(cuid())
organizationId String
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
entityType String
externalId String
entityId String
externalUpdatedAt DateTime?
/// آخرین زمان دریافت سفارش‌های این مشتری (updated_after سفارش‌ها)
ordersSyncedAt DateTime?
/// پروفایل فروشگاهی مشتری: wallet_balance، gold_wallet_balance، summary، engagement، …
data Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, entityType, externalId])
@@index([organizationId, entityType, entityId])
}
/// سفارش فروشگاه (فقط‌خواندنی، نگاشت مستند: code → order_reference، sts → order_status)
model MontifyOrder {
id String @id @default(cuid())
organizationId String
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
externalId String
customerExternalId String?
personId String?
orderReference String?
orderStatus String?
paymentStatus String?
paymentType String?
totalPrice Decimal? @db.Decimal(20, 2)
discountTotalPrice Decimal? @db.Decimal(20, 2)
totalDiscounts Decimal? @db.Decimal(20, 2)
totalTax Decimal? @db.Decimal(20, 2)
totalQty Decimal? @db.Decimal(14, 3)
profit Decimal? @db.Decimal(20, 2)
percentProfit Decimal? @db.Decimal(10, 3)
sumGold Decimal? @db.Decimal(14, 3)
sumSilver Decimal? @db.Decimal(14, 3)
address Json?
note String?
cancelReason String?
cancelledAt DateTime?
closedAt DateTime?
codeDiscount String?
items Json @default("[]")
externalCreatedAt DateTime?
externalUpdatedAt DateTime?
deletedAt DateTime?
raw Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, externalId])
@@index([organizationId, personId])
@@index([organizationId, externalCreatedAt])
}
/// تراکنش مالی مشتری در فروشگاه (واریز/برداشت ریالی یا گرم طلا)
model MontifyTransaction {
id String @id @default(cuid())
organizationId String
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
externalId String
customerExternalId String?
personId String?
type String?
amount Decimal? @db.Decimal(24, 4)
currency String?
status String?
referenceCode String?
externalCreatedAt DateTime?
raw Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, externalId])
@@index([organizationId, personId])
}
model MontifyWebhookDelivery {
id String @id @default(cuid())
connectionId String
connection MontifyConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
deliveryId String
event String
/// received | processed | failed
status String @default("received")
error String?
createdAt DateTime @default(now())
@@unique([connectionId, deliveryId])
@@index([connectionId, createdAt])
}
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی)
model VoipConnection {
id String @id @default(cuid())
+2
View File
@@ -42,6 +42,7 @@ import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { MontifyModule } from './integrations/montify/montify.module';
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
@@ -97,6 +98,7 @@ import { RoutinesModule } from './routines/routines.module';
TrackingModule,
TeamsModule,
WooCommerceModule,
MontifyModule,
VoipModule,
ReservationsModule,
ConversationsModule,
@@ -0,0 +1,289 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
MontifySyncResultDto,
MontifySyncStatus,
MontifyTestResultDto,
UpdateMontifyConnectionRequest,
} from '@mento/shared';
import { Prisma, type MontifyConnection } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { MontifyClient, normalizeMontifyBaseUrl } from './montify-client';
export const MONTIFY_WEBHOOK_EVENTS = [
'customer.created',
'customer.updated',
'order.created',
'order.updated',
'order.paid',
'order.cancelled',
];
@Injectable()
export class MontifyConnectionsService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: TahesabCryptoService,
private readonly config: ConfigService,
) {}
async list(organizationId: string): Promise<MontifyConnectionDto[]> {
const rows = await this.prisma.montifyConnection.findMany({
where: { organizationId },
orderBy: { createdAt: 'asc' },
});
const [customerCounts, orderCounts] = await Promise.all([
this.prisma.montifyLink.groupBy({
by: ['connectionId'],
where: { organizationId, entityType: 'customer' },
_count: { _all: true },
}),
this.prisma.montifyOrder.groupBy({
by: ['connectionId'],
where: { organizationId },
_count: { _all: true },
}),
]);
const countIn = (list: { connectionId: string; _count: { _all: number } }[], id: string) =>
list.find((c) => c.connectionId === id)?._count._all ?? 0;
return rows.map((r) =>
this.toDto(r, countIn(customerCounts, r.id), countIn(orderCounts, r.id)),
);
}
async getDto(organizationId: string, id: string): Promise<MontifyConnectionDto> {
const row = await this.getOrThrow(organizationId, id);
const [customers, orders] = await Promise.all([
this.prisma.montifyLink.count({ where: { connectionId: id, entityType: 'customer' } }),
this.prisma.montifyOrder.count({ where: { connectionId: id } }),
]);
return this.toDto(row, customers, orders);
}
async getOrThrow(organizationId: string, id: string) {
const row = await this.prisma.montifyConnection.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('اتصال فروشگاه پیدا نشد');
return row;
}
async create(
organizationId: string,
dto: CreateMontifyConnectionRequest,
): Promise<MontifyConnectionDto> {
const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام فروشگاه الزامی است');
const baseUrl = this.parseBaseUrl(dto.baseUrl);
const token = dto.token?.trim();
if (!token) throw new BadRequestException('توکن الزامی است');
const enc = this.crypto.encrypt(token);
const row = await this.prisma.montifyConnection.create({
data: {
organizationId,
label,
baseUrl,
storeId: dto.storeId?.trim() ?? '',
tokenCiphertext: enc.ciphertext,
tokenIv: enc.iv,
tokenTag: enc.tag,
tokenLast4: this.crypto.last4(token),
enabled: dto.enabled ?? true,
},
});
return this.toDto(row, 0, 0);
}
async update(
organizationId: string,
id: string,
dto: UpdateMontifyConnectionRequest,
): Promise<MontifyConnectionDto> {
await this.getOrThrow(organizationId, id);
const data: Prisma.MontifyConnectionUpdateInput = {};
if (dto.label !== undefined) {
const label = dto.label.trim();
if (!label) throw new BadRequestException('نام فروشگاه الزامی است');
data.label = label;
}
if (dto.baseUrl !== undefined) data.baseUrl = this.parseBaseUrl(dto.baseUrl);
if (dto.token?.trim()) {
const token = dto.token.trim();
const enc = this.crypto.encrypt(token);
data.tokenCiphertext = enc.ciphertext;
data.tokenIv = enc.iv;
data.tokenTag = enc.tag;
data.tokenLast4 = this.crypto.last4(token);
data.lastError = null;
}
if (dto.storeId !== undefined) data.storeId = dto.storeId.trim();
if (dto.enabled !== undefined) data.enabled = dto.enabled;
await this.prisma.montifyConnection.update({ where: { id }, data });
return this.getDto(organizationId, id);
}
async remove(organizationId: string, id: string): Promise<void> {
const row = await this.getOrThrow(organizationId, id);
if (row.webhookRemoteId) {
try {
await this.clientFor(row).deleteWebhook(row.webhookRemoteId);
} catch {
// حذف اتصال نباید به در دسترس بودن فروشگاه وابسته باشد
}
}
await this.prisma.montifyConnection.delete({ where: { id } });
}
clientFor(row: Pick<MontifyConnection, 'baseUrl' | 'tokenCiphertext' | 'tokenIv' | 'tokenTag'>) {
const token = this.crypto.decrypt({
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
tag: row.tokenTag,
});
return new MontifyClient(row.baseUrl, token);
}
decryptWebhookSecret(row: MontifyConnection): string | null {
if (!row.webhookSecretCiphertext || !row.webhookSecretIv || !row.webhookSecretTag) {
return null;
}
return this.crypto.decrypt({
ciphertext: row.webhookSecretCiphertext,
iv: row.webhookSecretIv,
tag: row.webhookSecretTag,
});
}
async testConnection(organizationId: string, id: string): Promise<MontifyTestResultDto> {
const row = await this.getOrThrow(organizationId, id);
try {
const page = await this.clientFor(row).listCustomers({ page: 1, limit: 1 });
await this.prisma.montifyConnection.update({
where: { id },
data: { lastHealthAt: new Date(), lastHealthOk: true, lastError: null },
});
return { ok: true, totalCustomers: Number.isFinite(page.meta.total) ? page.meta.total : null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطای ناشناخته';
await this.prisma.montifyConnection.update({
where: { id },
data: { lastHealthAt: new Date(), lastHealthOk: false, lastError: msg.slice(0, 500) },
});
throw new BadRequestException(`اتصال ناموفق: ${msg}`);
}
}
webhookUrl(id: string): string {
return `${this.publicApiBaseUrl()}/public/integrations/montify/${id}/webhook`;
}
async registerWebhook(organizationId: string, id: string): Promise<MontifyConnectionDto> {
const row = await this.getOrThrow(organizationId, id);
const url = this.webhookUrl(id);
if (!/^https:\/\//i.test(url)) {
throw new BadRequestException(
`آدرس وب‌هوک باید HTTPS باشد (${url}). PUBLIC_API_BASE_URL را در تنظیمات سرور تعیین کنید.`,
);
}
const client = this.clientFor(row);
if (row.webhookRemoteId) {
try {
await client.deleteWebhook(row.webhookRemoteId);
} catch {
// وب‌هوک قبلی ممکن است در فروشگاه حذف شده باشد
}
}
let registered: { id: string; secret: string };
try {
registered = await client.registerWebhook(url, MONTIFY_WEBHOOK_EVENTS);
} catch (e) {
throw new BadRequestException(
`ثبت وب‌هوک ناموفق: ${e instanceof Error ? e.message : e}`,
);
}
const enc = this.crypto.encrypt(registered.secret);
await this.prisma.montifyConnection.update({
where: { id },
data: {
webhookRemoteId: registered.id,
webhookSecretCiphertext: enc.ciphertext,
webhookSecretIv: enc.iv,
webhookSecretTag: enc.tag,
},
});
return this.getDto(organizationId, id);
}
async unregisterWebhook(organizationId: string, id: string): Promise<MontifyConnectionDto> {
const row = await this.getOrThrow(organizationId, id);
if (row.webhookRemoteId) {
try {
await this.clientFor(row).deleteWebhook(row.webhookRemoteId);
} catch (e) {
throw new BadRequestException(
`حذف وب‌هوک در فروشگاه ناموفق: ${e instanceof Error ? e.message : e}`,
);
}
}
await this.prisma.montifyConnection.update({
where: { id },
data: {
webhookRemoteId: null,
webhookSecretCiphertext: null,
webhookSecretIv: null,
webhookSecretTag: null,
},
});
return this.getDto(organizationId, id);
}
private publicApiBaseUrl(): string {
const explicit = this.config.get<string>('PUBLIC_API_BASE_URL')?.trim();
if (explicit) return explicit.replace(/\/$/, '');
const cors = this.config.get<string>('CORS_ORIGIN')?.split(',')[0]?.trim();
if (cors) return `${cors.replace(/\/$/, '')}/api`;
return 'http://localhost:3000/api';
}
private parseBaseUrl(raw: string): string {
try {
return normalizeMontifyBaseUrl(raw ?? '');
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : 'Base URL نامعتبر است');
}
}
private toDto(row: MontifyConnection, customers: number, orders: number): MontifyConnectionDto {
return {
id: row.id,
label: row.label,
baseUrl: row.baseUrl,
storeId: row.storeId,
tokenMasked: row.tokenLast4 ? `••••••${row.tokenLast4}` : '••••',
enabled: row.enabled,
webhookRegistered: Boolean(row.webhookRemoteId && row.webhookSecretCiphertext),
webhookUrl: this.webhookUrl(row.id),
syncStatus: row.syncStatus as MontifySyncStatus,
syncStartedAt: row.syncStartedAt?.toISOString() ?? null,
lastSyncAt: row.lastSyncAt?.toISOString() ?? null,
lastSyncResult: (row.lastSyncResult as unknown as MontifySyncResultDto | null) ?? null,
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk,
lastError: row.lastError,
lastWebhookAt: row.lastWebhookAt?.toISOString() ?? null,
linkedCustomers: customers,
orders,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+61
View File
@@ -0,0 +1,61 @@
import {
IsBoolean,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateMontifyConnectionDto {
@IsString()
@MinLength(2)
@MaxLength(80)
label!: string;
@IsString()
@MinLength(8)
@MaxLength(300)
baseUrl!: string;
@IsString()
@MinLength(8)
@MaxLength(500)
token!: string;
@IsOptional()
@IsString()
@MaxLength(100)
storeId?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateMontifyConnectionDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
label?: string;
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(300)
baseUrl?: string;
@IsOptional()
@IsString()
@MaxLength(500)
token?: string;
@IsOptional()
@IsString()
@MaxLength(100)
storeId?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
@@ -0,0 +1,333 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type MontifyAddress = {
id?: number | string;
province?: string | null;
city?: string | null;
address?: string | null;
postal_code?: string | null;
receiver_name?: string | null;
receiver_phone?: string | null;
is_default?: boolean | null;
};
export type MontifyTransaction = {
id: number | string;
user_id?: number | string | null;
type?: string | null;
amount?: number | string | null;
currency?: string | null;
status?: string | null;
reference_code?: string | null;
created_at?: string | null;
};
export type MontifyEngagement = {
saved?: unknown[] | null;
likes?: unknown[] | null;
comments?: unknown[] | null;
};
export type MontifyCustomer = {
id: number | string;
name?: string | null;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
email?: string | null;
username?: string | null;
birthday?: string | null;
gender?: string | null;
type?: string | null;
store_id?: number | string | null;
created_at?: string | null;
updated_at?: string | null;
description?: string | null;
bio?: string | null;
job?: string | null;
cash_balance?: number | string | null;
gold_balance?: number | string | null;
avatar?: string | null;
summary?: { total_orders?: number | null; total_spent?: number | null } | null;
addresses?: MontifyAddress[] | null;
orders?: MontifyOrder[] | null;
transactions?: MontifyTransaction[] | null;
engagement?: MontifyEngagement | null;
};
export type MontifyOrderItem = {
id?: number | string;
product_id?: number | string | null;
product_name?: string | null;
qty?: number | string | null;
price?: number | string | null;
weight?: number | string | null;
total_price?: number | string | null;
};
export type MontifyOrder = {
id: number | string;
user_id?: number | string | null;
store_id?: number | string | null;
code?: string | null;
sts?: string | null;
payment_sts?: string | null;
payment_type?: string | null;
total_price?: number | string | null;
discount_total_price?: number | string | null;
total_discounts?: number | string | null;
total_tax?: number | string | null;
total_qty?: number | string | null;
profit?: number | string | null;
percent_profit?: number | string | null;
sum_gold?: number | string | null;
sum_silver?: number | string | null;
address?: unknown;
note?: string | null;
cancel_reason?: string | null;
cancelled_at?: string | null;
closed_at?: string | null;
code_discount?: string | null;
created_at?: string | null;
updated_at?: string | null;
deleted_at?: string | null;
order_items?: MontifyOrderItem[] | null;
};
export type MontifyPage<T> = {
data: T[];
meta: { current_page: number; per_page: number; total: number; last_page: number };
};
export class MontifyHttpError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
const MIN_INTERVAL_MS = 650;
const lastRequestAt = new Map<string, number>();
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export function normalizeMontifyBaseUrl(url: string): string {
const u = url.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(u)) {
throw new Error('Base URL باید با https شروع شود');
}
return u;
}
function unwrap<T>(body: unknown): T {
if (
body &&
typeof body === 'object' &&
'data' in body &&
!Array.isArray(body) &&
!('id' in body)
) {
return (body as { data: T }).data;
}
return body as T;
}
function toPage<T>(body: unknown): MontifyPage<T> {
if (Array.isArray(body)) {
return {
data: body as T[],
meta: { current_page: 1, per_page: body.length, total: body.length, last_page: 1 },
};
}
const b = (body ?? {}) as Partial<MontifyPage<T>>;
const data = Array.isArray(b.data) ? b.data : [];
return {
data,
meta: {
current_page: Number(b.meta?.current_page ?? 1),
per_page: Number(b.meta?.per_page ?? data.length),
total: Number(b.meta?.total ?? data.length),
last_page: Number(b.meta?.last_page ?? 1),
},
};
}
export class MontifyClient {
private readonly base: string;
private readonly throttleKey: string;
constructor(
baseUrl: string,
private readonly token: string,
) {
this.base = normalizeMontifyBaseUrl(baseUrl);
this.throttleKey = `${this.base}|${token.slice(-8)}`;
}
async listCustomers(opts: {
page?: number;
limit?: number;
updatedAfter?: string;
}): Promise<MontifyPage<MontifyCustomer>> {
const params = new URLSearchParams();
params.set('page', String(opts.page ?? 1));
params.set('limit', String(opts.limit ?? 50));
if (opts.updatedAfter) params.set('updated_after', opts.updatedAfter);
return toPage<MontifyCustomer>(
await this.request('GET', `/montact/customers?${params.toString()}`),
);
}
async getCustomer(id: string | number): Promise<MontifyCustomer> {
return unwrap<MontifyCustomer>(
await this.request('GET', `/montact/customers/${encodeURIComponent(String(id))}`),
);
}
async listCustomerOrders(
customerId: string | number,
opts: { page?: number; updatedAfter?: string } = {},
): Promise<MontifyPage<MontifyOrder>> {
const params = new URLSearchParams();
params.set('page', String(opts.page ?? 1));
params.set('limit', '50');
if (opts.updatedAfter) params.set('updated_after', opts.updatedAfter);
return toPage<MontifyOrder>(
await this.request(
'GET',
`/montact/customers/${encodeURIComponent(String(customerId))}/orders?${params.toString()}`,
),
);
}
async listCustomerAddresses(customerId: string | number): Promise<MontifyAddress[]> {
return this.allPages<MontifyAddress>(
`/montact/customers/${encodeURIComponent(String(customerId))}/addresses`,
);
}
async listCustomerTransactions(customerId: string | number): Promise<MontifyTransaction[]> {
return this.allPages<MontifyTransaction>(
`/montact/customers/${encodeURIComponent(String(customerId))}/transactions`,
);
}
/** این مسیرها صفحه‌بندی دارند ولی updated_after ندارند */
private async allPages<T>(path: string, maxPages = 100): Promise<T[]> {
const out: T[] = [];
for (let page = 1; page <= maxPages; page += 1) {
const res = toPage<T>(await this.request('GET', `${path}?page=${page}&limit=50`));
out.push(...res.data);
if (!res.data.length || page >= res.meta.last_page) break;
}
return out;
}
async getCustomerEngagement(customerId: string | number): Promise<MontifyEngagement> {
return (
unwrap<MontifyEngagement>(
await this.request(
'GET',
`/montact/customers/${encodeURIComponent(String(customerId))}/engagement`,
),
) ?? {}
);
}
async getOrder(id: string | number): Promise<MontifyOrder> {
return unwrap<MontifyOrder>(
await this.request('GET', `/montact/orders/${encodeURIComponent(String(id))}`),
);
}
async registerWebhook(
url: string,
events: string[],
): Promise<{ id: string; secret: string }> {
const res = unwrap<{ id?: string | number; secret?: string }>(
await this.request('POST', '/montact/webhooks/register', { url, events }),
);
if (!res?.id || !res.secret) {
throw new Error('پاسخ ثبت وب‌هوک شناسه یا secret ندارد');
}
return { id: String(res.id), secret: res.secret };
}
async deleteWebhook(id: string): Promise<void> {
await this.request('DELETE', `/montact/webhooks/${encodeURIComponent(id)}`);
}
static verifySignature(
rawBody: Buffer,
header: string | undefined,
secret: string,
): boolean {
const received = (header ?? '').trim().replace(/^sha256=/i, '');
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
}
private async throttle() {
const last = lastRequestAt.get(this.throttleKey) ?? 0;
const wait = last + MIN_INTERVAL_MS - Date.now();
if (wait > 0) await sleep(wait);
lastRequestAt.set(this.throttleKey, Date.now());
}
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
for (let attempt = 0; ; attempt += 1) {
await this.throttle();
let res: Response;
try {
res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'MontactCRM/1.0',
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
} catch (e) {
if (attempt < 2) {
await sleep(2000 * (attempt + 1));
continue;
}
throw new MontifyHttpError(0, `خطای شبکه: ${e instanceof Error ? e.message : e}`);
}
if (res.status === 429 && attempt < 3) {
const retryAfter = Number(res.headers.get('retry-after'));
await sleep((Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 30) * 1000);
continue;
}
if (res.status >= 500 && attempt < 2) {
await sleep(3000 * (attempt + 1));
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
if (res.status === 401) {
throw new MontifyHttpError(401, 'توکن نامعتبر یا منقضی است؛ توکن جدید از فروشگاه بگیرید');
}
if (res.status === 403) {
throw new MontifyHttpError(403, 'توکن به این بخش دسترسی ندارد (scope)');
}
throw new MontifyHttpError(
res.status,
`Montify HTTP ${res.status}: ${text.slice(0, 200) || res.statusText}`,
);
}
if (res.status === 204) return undefined;
const text = await res.text();
return text ? JSON.parse(text) : undefined;
}
}
}
@@ -0,0 +1,169 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { Prisma } from '@prisma/client';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { CurrentUser } from '../../auth/current-user.decorator';
import { Roles } from '../../auth/roles.decorator';
import { RolesGuard } from '../../auth/roles.guard';
import { OrgModuleGuard } from '../../billing/org-module.guard';
import { RequireOrgModule } from '../../billing/org-module.decorator';
import type { Actor } from '../../common/record-scope';
import { PrismaService } from '../../prisma/prisma.service';
import { MontifyConnectionsService } from './connections.service';
import { CreateMontifyConnectionDto, UpdateMontifyConnectionDto } from './dto';
import { MontifyClient } from './montify-client';
import { MontifySyncService } from './sync.service';
@Controller('integrations/montify')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('montify')
@Roles('owner', 'admin')
export class MontifyController {
constructor(
private readonly connections: MontifyConnectionsService,
private readonly sync: MontifySyncService,
) {}
@Get('connections')
list(@CurrentUser() user: { organizationId: string }) {
return this.connections.list(user.organizationId);
}
@Post('connections')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateMontifyConnectionDto,
) {
return this.connections.create(user.organizationId, dto);
}
@Patch('connections/:id')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateMontifyConnectionDto,
) {
return this.connections.update(user.organizationId, id, dto);
}
@Delete('connections/:id')
async remove(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
await this.connections.remove(user.organizationId, id);
return { ok: true };
}
@Post('connections/:id/test')
test(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
return this.connections.testConnection(user.organizationId, id);
}
@Post('connections/:id/sync')
syncNow(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Query('mode') mode?: string,
) {
return this.sync.startSync(user.organizationId, id, mode === 'full' ? 'full' : 'incremental');
}
@Post('connections/:id/webhook')
registerWebhook(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
return this.connections.registerWebhook(user.organizationId, id);
}
@Delete('connections/:id/webhook')
unregisterWebhook(@CurrentUser() user: { organizationId: string }, @Param('id') id: string) {
return this.connections.unregisterWebhook(user.organizationId, id);
}
@Get('orders')
recentOrders(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
) {
return this.sync.listOrders(user.organizationId, connectionId);
}
}
@Controller('integrations/montify/people')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('montify')
export class MontifyPeopleController {
constructor(private readonly sync: MontifySyncService) {}
@Get(':personId')
personStores(@CurrentUser() user: Actor, @Param('personId') personId: string) {
return this.sync.personStores(user, personId);
}
}
@Controller('public/integrations/montify')
export class MontifyWebhookController {
constructor(
private readonly prisma: PrismaService,
private readonly connections: MontifyConnectionsService,
private readonly sync: MontifySyncService,
) {}
@Post(':connectionId/webhook')
@HttpCode(200)
async webhook(
@Param('connectionId') connectionId: string,
@Headers('x-montact-signature') signature: string | undefined,
@Headers('x-montact-event') eventHeader: string | undefined,
@Headers('x-montact-delivery-id') deliveryHeader: string | undefined,
@Req() req: Request & { rawBody?: Buffer },
@Body() body: { event?: string; delivery_id?: string; data?: Record<string, unknown> },
) {
const row = await this.prisma.montifyConnection.findFirst({
where: { id: connectionId, enabled: true },
});
if (!row) throw new UnauthorizedException('اتصال نامعتبر است');
const secret = this.connections.decryptWebhookSecret(row);
if (!secret) throw new UnauthorizedException('وب‌هوک برای این اتصال ثبت نشده است');
const raw = req.rawBody ?? Buffer.from(JSON.stringify(body ?? {}), 'utf8');
if (!MontifyClient.verifySignature(raw, signature, secret)) {
throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
}
const event = (eventHeader ?? body?.event ?? '').trim();
const deliveryId = (deliveryHeader ?? body?.delivery_id ?? '').trim();
if (!event || !deliveryId) return { ok: true, skipped: true };
let deliveryRowId: string;
try {
const created = await this.prisma.montifyWebhookDelivery.create({
data: { connectionId, deliveryId, event },
});
deliveryRowId = created.id;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return { ok: true, duplicate: true };
}
throw e;
}
await this.prisma.montifyConnection.update({
where: { id: connectionId },
data: { lastWebhookAt: new Date() },
});
setImmediate(() => {
void this.sync.processWebhook(row, deliveryRowId, event, body);
});
return { ok: true };
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { BillingModule } from '../../billing/billing.module';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { MontifyConnectionsService } from './connections.service';
import {
MontifyController,
MontifyPeopleController,
MontifyWebhookController,
} from './montify.controller';
import { MontifySyncService } from './sync.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [MontifyController, MontifyPeopleController, MontifyWebhookController],
providers: [TahesabCryptoService, MontifyConnectionsService, MontifySyncService],
exports: [MontifyConnectionsService, MontifySyncService],
})
export class MontifyModule {}
@@ -0,0 +1,753 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import {
Prisma,
type MontifyConnection,
type MontifyOrder as MontifyOrderRow,
type MontifyTransaction as MontifyTransactionRow,
} from '@prisma/client';
import type {
MontifyConnectionDto,
MontifyEngagementDto,
MontifyOrderDto,
MontifyOrderItemDto,
MontifyPersonStoreDto,
MontifySyncResultDto,
MontifyTransactionDto,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service';
import {
MontifyClient,
MontifyHttpError,
type MontifyAddress,
type MontifyCustomer,
type MontifyEngagement,
type MontifyOrder,
type MontifyTransaction,
} from './montify-client';
const CUSTOMER_PAGE_SIZE = 50;
const ENGAGEMENT_KEEP = 50;
/** پروفایل فروشگاهی مشتری (MontifyLink.data) — نام‌ها طبق جدول نگاشت مستند */
type CustomerProfile = {
fullName: string | null;
username: string | null;
avatar: string | null;
birthday: string | null;
gender: string | null;
type: string | null;
job: string | null;
storeId: string | null;
walletBalance: number | null;
goldWalletBalance: number | null;
summary: { totalOrders: number | null; totalSpent: number | null };
addresses: MontifyAddress[];
engagement: { saved: unknown[]; likes: unknown[]; comments: unknown[] } | null;
};
function num(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function dec(v: unknown): Prisma.Decimal | null {
const n = num(v);
return n === null ? null : new Prisma.Decimal(n);
}
function decNum(v: Prisma.Decimal | null): number | null {
return v === null ? null : Number(v);
}
function str(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s ? s : null;
}
/** متن نمایشی فروشگاه (مثل sts_txt) اگر باشد، وگرنه مقدار خام */
function label(obj: unknown, key: string): string | null {
if (!obj || typeof obj !== 'object') return null;
const o = obj as Record<string, unknown>;
return str(o[`${key}_txt`]) ?? str(o[key]);
}
function parseDate(v: unknown): Date | null {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, '');
let core = digits;
if (core.startsWith('0098')) core = core.slice(4);
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
else if (core.startsWith('0')) core = core.slice(1);
if (!core) return [mobile];
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
}
function formatAddress(a: MontifyAddress): string | null {
const parts = [str(a.province), str(a.city), str(a.address)].filter(Boolean);
if (!parts.length) return null;
let line = parts.join('، ');
const postal = str(a.postal_code);
if (postal) line += ` — کدپستی ${postal}`;
const receiver = [str(a.receiver_name), str(a.receiver_phone)].filter(Boolean).join(' ');
if (receiver) line += ` — گیرنده: ${receiver}`;
return line;
}
function splitName(c: MontifyCustomer): { firstName: string; lastName: string } {
let firstName = str(c.first_name) ?? '';
let lastName = str(c.last_name) ?? '';
if (!firstName && !lastName) {
const parts = (str(c.name) ?? '').split(/\s+/).filter(Boolean);
if (parts.length > 1) {
firstName = parts[0]!;
lastName = parts.slice(1).join(' ');
} else {
lastName = parts[0] ?? '';
}
}
if (!firstName && !lastName) lastName = str(c.username) ?? `مشتری ${c.id}`;
return { firstName, lastName };
}
function customerLinkKey(connectionId: string, externalId: string) {
return {
connectionId_entityType_externalId: { connectionId, entityType: 'customer', externalId },
};
}
@Injectable()
export class MontifySyncService {
private readonly logger = new Logger(MontifySyncService.name);
private readonly running = new Set<string>();
constructor(
private readonly prisma: PrismaService,
private readonly connections: MontifyConnectionsService,
) {}
/* ——— فاز ۱ و ۲: Polling و Incremental Sync ——— */
async startSync(
organizationId: string,
connectionId: string,
mode: 'incremental' | 'full',
): Promise<MontifyConnectionDto> {
const row = await this.connections.getOrThrow(organizationId, connectionId);
if (!row.enabled) throw new BadRequestException('این اتصال غیرفعال است');
if (this.running.has(connectionId)) {
throw new BadRequestException('همگام‌سازی این فروشگاه در حال اجراست');
}
void this.runSync(row, mode);
return this.connections.getDto(organizationId, connectionId);
}
@Cron('*/10 * * * *')
async cronSyncAll(): Promise<void> {
const rows = await this.prisma.montifyConnection.findMany({
where: { enabled: true },
take: 50,
});
for (const row of rows) {
if (this.running.has(row.id)) continue;
await this.runSync(row, 'incremental');
}
}
private async runSync(row: MontifyConnection, mode: 'incremental' | 'full'): Promise<void> {
if (this.running.has(row.id)) return;
this.running.add(row.id);
const startedAt = new Date();
const result: MontifySyncResultDto = {
mode,
customersFetched: 0,
customersCreated: 0,
customersUpdated: 0,
ordersFetched: 0,
transactionsFetched: 0,
errors: [],
startedAt: startedAt.toISOString(),
finishedAt: null,
};
const saveProgress = () =>
this.prisma.montifyConnection.update({
where: { id: row.id },
data: { lastSyncResult: result as unknown as Prisma.InputJsonValue },
});
await this.prisma.montifyConnection.update({
where: { id: row.id },
data: {
syncStatus: 'running',
syncStartedAt: startedAt,
lastSyncResult: result as unknown as Prisma.InputJsonValue,
},
});
let cursor = row.customersCursor;
let fatal: string | null = null;
try {
const client = this.connections.clientFor(row);
const updatedAfter =
mode === 'incremental' && row.customersCursor
? row.customersCursor.toISOString()
: undefined;
let page = 1;
let lastPage = 1;
do {
const res = await client.listCustomers({ page, limit: CUSTOMER_PAGE_SIZE, updatedAfter });
lastPage = res.meta.last_page;
result.customersFetched += res.data.length;
for (const customer of res.data) {
try {
const outcome = await this.syncCustomerDeep(row, client, customer, mode, result);
if (outcome === 'created') result.customersCreated += 1;
else result.customersUpdated += 1;
const updatedAt = parseDate(customer.updated_at);
if (updatedAt && (!cursor || updatedAt > cursor)) cursor = updatedAt;
} catch (e) {
if (e instanceof MontifyHttpError && e.status === 401) throw e;
const msg = e instanceof Error ? e.message : String(e);
result.errors.push(`مشتری ${customer.id}: ${msg}`);
this.logger.warn(`Montify ${row.id} customer ${customer.id}: ${msg}`);
}
}
await saveProgress();
if (!res.data.length) break;
page += 1;
} while (page <= lastPage && page <= 2000);
} catch (e) {
fatal = e instanceof Error ? e.message : String(e);
result.errors.push(fatal);
this.logger.warn(`Montify sync ${row.id}: ${fatal}`);
} finally {
this.running.delete(row.id);
}
result.finishedAt = new Date().toISOString();
result.errors = result.errors.slice(0, 50);
await this.prisma.montifyConnection.update({
where: { id: row.id },
data: {
syncStatus: fatal ? 'error' : 'ok',
lastSyncAt: fatal ? undefined : new Date(),
customersCursor: cursor,
lastSyncResult: result as unknown as Prisma.InputJsonValue,
lastHealthAt: new Date(),
lastHealthOk: !fatal,
lastError: fatal
? fatal.slice(0, 500)
: result.errors.length
? result.errors.slice(0, 3).join(' | ').slice(0, 500)
: null,
},
});
}
/**
* گام ۲ استراتژی مستند: برای هر مشتری، آدرس‌ها، سفارش‌ها، تراکنش‌ها و داده‌های تعاملی
* جداگانه دریافت و Upsert می‌شوند.
*/
private async syncCustomerDeep(
row: MontifyConnection,
client: MontifyClient,
customer: MontifyCustomer,
mode: 'incremental' | 'full',
result: MontifySyncResultDto | null,
): Promise<'created' | 'updated'> {
const externalId = String(customer.id);
const step = async <T>(label: string, fn: () => Promise<T>): Promise<T | null> => {
try {
return await fn();
} catch (e) {
if (e instanceof MontifyHttpError && e.status === 401) throw e;
const msg = e instanceof Error ? e.message : String(e);
result?.errors.push(`مشتری ${externalId} (${label}): ${msg}`);
return null;
}
};
const addresses =
(await step('آدرس‌ها', () => client.listCustomerAddresses(externalId))) ??
customer.addresses ??
[];
const engagement =
(await step('تعاملات', () => client.getCustomerEngagement(externalId))) ??
customer.engagement ??
null;
const { personId, outcome, ordersSyncedAt } = await this.upsertCustomer(
row,
customer,
addresses,
engagement,
);
const ordersStartedAt = new Date();
const ordersOk = await step('سفارش‌ها', async () => {
const since = mode === 'full' ? null : ordersSyncedAt;
let page = 1;
let lastPage = 1;
do {
const res = await client.listCustomerOrders(externalId, {
page,
updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined,
});
lastPage = res.meta.last_page;
if (result) result.ordersFetched += res.data.length;
for (const order of res.data) await this.upsertOrder(row, order, personId, externalId);
if (!res.data.length) break;
page += 1;
} while (page <= lastPage && page <= 200);
return true;
});
if (ordersOk) {
await this.prisma.montifyLink.update({
where: customerLinkKey(row.id, externalId),
data: { ordersSyncedAt: ordersStartedAt },
});
}
await step('تراکنش‌ها', async () => {
const list = await client.listCustomerTransactions(externalId);
if (result) result.transactionsFetched += list.length;
for (const t of list) await this.upsertTransaction(row, t, personId, externalId);
});
return outcome;
}
/* ——— مشتری → شخص ——— */
private async upsertCustomer(
row: MontifyConnection,
c: MontifyCustomer,
addresses: MontifyAddress[],
engagement: MontifyEngagement | null,
): Promise<{ personId: string; outcome: 'created' | 'updated'; ordersSyncedAt: Date | null }> {
const externalId = String(c.id);
const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null);
const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = {
fullName: str(c.name) ?? (`${firstName} ${lastName}`.trim() || null),
username: str(c.username),
avatar: str(c.avatar),
birthday: str(c.birthday),
gender: label(c, 'gender'),
type: label(c, 'type'),
job: str(c.job),
storeId: str(c.store_id),
walletBalance: num(c.cash_balance),
goldWalletBalance: num(c.gold_balance),
summary: {
totalOrders: num(c.summary?.total_orders),
totalSpent: num(c.summary?.total_spent),
},
addresses,
engagement: engagement
? {
saved: arr(engagement.saved).slice(0, ENGAGEMENT_KEEP),
likes: arr(engagement.likes).slice(0, ENGAGEMENT_KEEP),
comments: arr(engagement.comments).slice(0, ENGAGEMENT_KEEP),
}
: null,
};
const existingLink = await this.prisma.montifyLink.findUnique({
where: customerLinkKey(row.id, externalId),
});
let person = existingLink
? await this.prisma.person.findFirst({
where: { id: existingLink.entityId, organizationId: row.organizationId },
})
: null;
if (!person && (mobile || email)) {
person = await this.prisma.person.findFirst({
where: {
organizationId: row.organizationId,
OR: [
...(mobile
? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }])
: []),
...(email ? [{ email }] : []),
],
},
orderBy: { createdAt: 'asc' },
});
}
let personId: string;
let outcome: 'created' | 'updated';
if (person) {
const ownedByStore = person.importSource === 'montify';
const current = Array.isArray(person.addresses)
? (person.addresses as unknown[]).map((a) => String(a))
: [];
const merged = [...current];
for (const a of addressLines) if (!merged.includes(a)) merged.push(a);
const tags = person.tags.includes(row.label) ? person.tags : [...person.tags, row.label];
await this.prisma.person.update({
where: { id: person.id },
data: {
...(ownedByStore ? { firstName, lastName } : {}),
...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}),
...(email && (ownedByStore || !person.email) ? { email } : {}),
...(str(c.job) && !person.position ? { position: str(c.job) } : {}),
addresses: merged as Prisma.InputJsonValue,
tags,
...(!existingLink && !ownedByStore && !person.previousImportSource
? carryPreviousSource(person, 'montify')
: {}),
},
});
personId = person.id;
outcome = 'updated';
} else {
const notes = [str(c.bio), str(c.description)].filter(Boolean).join('\n') || null;
const created = await this.prisma.person.create({
data: {
organizationId: row.organizationId,
firstName,
lastName,
mobile,
email,
position: str(c.job),
notes,
addresses: addressLines as Prisma.InputJsonValue,
tags: [row.label],
importSource: 'montify',
sourceRef: externalId,
},
});
personId = created.id;
outcome = 'created';
}
const link = await this.prisma.montifyLink.upsert({
where: customerLinkKey(row.id, externalId),
create: {
organizationId: row.organizationId,
connectionId: row.id,
entityType: 'customer',
externalId,
entityId: personId,
externalUpdatedAt: parseDate(c.updated_at),
data: profile as unknown as Prisma.InputJsonValue,
},
update: {
entityId: personId,
externalUpdatedAt: parseDate(c.updated_at),
data: profile as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
},
});
if (!existingLink || existingLink.entityId !== personId) {
await Promise.all([
this.prisma.montifyOrder.updateMany({
where: { connectionId: row.id, customerExternalId: externalId },
data: { personId },
}),
this.prisma.montifyTransaction.updateMany({
where: { connectionId: row.id, customerExternalId: externalId },
data: { personId },
}),
]);
}
return { personId, outcome, ordersSyncedAt: link.ordersSyncedAt };
}
/* ——— سفارش و تراکنش ——— */
private async personForCustomer(connectionId: string, customerExternalId: string | null) {
if (!customerExternalId) return null;
const link = await this.prisma.montifyLink.findUnique({
where: customerLinkKey(connectionId, customerExternalId),
select: { entityId: true },
});
return link?.entityId ?? null;
}
private async upsertOrder(
row: MontifyConnection,
o: MontifyOrder,
personIdHint: string | null,
customerHint: string | null,
): Promise<void> {
if (!o?.id) return;
const externalId = String(o.id);
const customerExternalId = str(o.user_id) ?? customerHint;
const personId =
personIdHint ?? (await this.personForCustomer(row.id, customerExternalId));
const items = (o.order_items ?? []).map((i) => ({
id: str(i.id),
productId: str(i.product_id),
productName: str(i.product_name),
qty: num(i.qty),
price: num(i.price),
weight: num(i.weight),
totalPrice: num(i.total_price),
}));
const data = {
customerExternalId,
personId,
orderReference: str(o.code),
orderStatus: str(o.sts),
paymentStatus: str(o.payment_sts),
paymentType: str(o.payment_type),
totalPrice: dec(o.total_price),
discountTotalPrice: dec(o.discount_total_price),
totalDiscounts: dec(o.total_discounts),
totalTax: dec(o.total_tax),
totalQty: dec(o.total_qty),
profit: dec(o.profit),
percentProfit: dec(o.percent_profit),
sumGold: dec(o.sum_gold),
sumSilver: dec(o.sum_silver),
address: (o.address ?? Prisma.JsonNull) as Prisma.InputJsonValue,
note: str(o.note),
cancelReason: str(o.cancel_reason),
cancelledAt: parseDate(o.cancelled_at),
closedAt: parseDate(o.closed_at),
codeDiscount: str(o.code_discount),
items: items as unknown as Prisma.InputJsonValue,
externalCreatedAt: parseDate(o.created_at),
externalUpdatedAt: parseDate(o.updated_at),
deletedAt: parseDate(o.deleted_at),
raw: o as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
};
await this.prisma.montifyOrder.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
update: data,
});
}
private async upsertTransaction(
row: MontifyConnection,
t: MontifyTransaction,
personId: string,
customerExternalId: string,
): Promise<void> {
if (!t?.id) return;
const externalId = String(t.id);
const data = {
customerExternalId: str(t.user_id) ?? customerExternalId,
personId,
type: str(t.type),
amount: dec(t.amount),
currency: str(t.currency),
status: str(t.status),
referenceCode: str(t.reference_code),
externalCreatedAt: parseDate(t.created_at),
raw: t as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
};
await this.prisma.montifyTransaction.upsert({
where: { connectionId_externalId: { connectionId: row.id, externalId } },
create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data },
update: data,
});
}
/* ——— فاز ۳: وب‌هوک ——— */
async processWebhook(
row: MontifyConnection,
deliveryRowId: string,
event: string,
payload: { data?: Record<string, unknown> } | null,
): Promise<void> {
try {
const data = payload?.data ?? {};
const id = str(data.id);
if (!id) throw new Error('شناسه رکورد در payload نیست');
const client = this.connections.clientFor(row);
if (event.startsWith('customer.')) {
const customer = await client.getCustomer(id);
await this.syncCustomerDeep(row, client, customer, 'incremental', null);
} else if (event.startsWith('order.')) {
let order: MontifyOrder;
try {
order = await client.getOrder(id);
} catch {
order = data as unknown as MontifyOrder;
}
const customerId = str(order.user_id);
const personId = await this.personForCustomer(row.id, customerId);
if (!personId && customerId) {
const customer = await client.getCustomer(customerId);
await this.syncCustomerDeep(row, client, customer, 'incremental', null);
}
await this.upsertOrder(row, order, null, customerId);
}
await this.prisma.montifyWebhookDelivery.update({
where: { id: deliveryRowId },
data: { status: 'processed' },
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(`Montify webhook ${row.id} ${event}: ${msg}`);
await this.prisma.montifyWebhookDelivery.update({
where: { id: deliveryRowId },
data: { status: 'failed', error: msg.slice(0, 500) },
});
}
}
/* ——— نمایش ——— */
async listOrders(organizationId: string, connectionId?: string): Promise<MontifyOrderDto[]> {
const rows = await this.prisma.montifyOrder.findMany({
where: { organizationId, ...(connectionId ? { connectionId } : {}) },
include: { connection: { select: { label: true } } },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 50,
});
const personIds = [...new Set(rows.map((r) => r.personId).filter((x): x is string => !!x))];
const people = personIds.length
? await this.prisma.person.findMany({
where: { id: { in: personIds } },
select: { id: true, firstName: true, lastName: true },
})
: [];
const nameOf = new Map(people.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]));
return rows.map((r) => this.orderDto(r, r.connection.label, nameOf));
}
async personStores(actor: Actor, personId: string): Promise<MontifyPersonStoreDto[]> {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const links = await this.prisma.montifyLink.findMany({
where: { organizationId: actor.organizationId, entityType: 'customer', entityId: personId },
include: { connection: { select: { label: true } } },
orderBy: { syncedAt: 'desc' },
});
const nameOf = new Map([[person.id, `${person.firstName} ${person.lastName}`.trim()]]);
const out: MontifyPersonStoreDto[] = [];
for (const l of links) {
const [orders, transactions] = await Promise.all([
this.prisma.montifyOrder.findMany({
where: { connectionId: l.connectionId, customerExternalId: l.externalId },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
this.prisma.montifyTransaction.findMany({
where: { connectionId: l.connectionId, customerExternalId: l.externalId },
orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }],
take: 20,
}),
]);
const p = (l.data ?? {}) as Partial<CustomerProfile>;
const engagement: MontifyEngagementDto | null = p.engagement
? {
saved: arr(p.engagement.saved).length,
likes: arr(p.engagement.likes).length,
comments: arr(p.engagement.comments).length,
}
: null;
out.push({
connectionId: l.connectionId,
connectionLabel: l.connection.label,
externalCustomerId: l.externalId,
fullName: p.fullName ?? null,
username: p.username ?? null,
customerType: p.type ?? null,
walletBalance: p.walletBalance ?? null,
goldWalletBalance: p.goldWalletBalance ?? null,
totalOrders: p.summary?.totalOrders ?? null,
totalSpent: p.summary?.totalSpent ?? null,
engagement,
orders: orders.map((o) => this.orderDto(o, l.connection.label, nameOf)),
transactions: transactions.map((t) => this.transactionDto(t)),
syncedAt: l.syncedAt.toISOString(),
});
}
return out;
}
private orderDto(
r: MontifyOrderRow,
connectionLabel: string,
nameOf: Map<string, string>,
): MontifyOrderDto {
const items = (Array.isArray(r.items) ? r.items : []) as unknown as MontifyOrderItemDto[];
const raw = r.raw;
return {
id: r.id,
connectionId: r.connectionId,
connectionLabel,
externalId: r.externalId,
customerExternalId: r.customerExternalId,
personId: r.personId,
personName: r.personId ? (nameOf.get(r.personId) ?? null) : null,
orderReference: r.orderReference,
orderStatus: label(raw, 'sts') ?? r.orderStatus,
paymentStatus: label(raw, 'payment_sts') ?? r.paymentStatus,
paymentType: label(raw, 'payment_type') ?? r.paymentType,
totalPrice: decNum(r.totalPrice),
discountTotalPrice: decNum(r.discountTotalPrice),
totalDiscounts: decNum(r.totalDiscounts),
totalQty: decNum(r.totalQty),
sumGold: decNum(r.sumGold),
sumSilver: decNum(r.sumSilver),
note: r.note,
cancelReason: r.cancelReason,
cancelledAt: r.cancelledAt?.toISOString() ?? null,
closedAt: r.closedAt?.toISOString() ?? null,
items: items.map((i) => ({
productId: i.productId ?? null,
productName: i.productName ?? null,
qty: i.qty ?? null,
price: i.price ?? null,
weight: i.weight ?? null,
totalPrice: i.totalPrice ?? null,
})),
createdAt: r.externalCreatedAt?.toISOString() ?? null,
updatedAt: r.externalUpdatedAt?.toISOString() ?? null,
};
}
private transactionDto(t: MontifyTransactionRow): MontifyTransactionDto {
return {
id: t.id,
externalId: t.externalId,
type: label(t.raw, 'type') ?? t.type,
amount: decNum(t.amount),
currency: label(t.raw, 'currency') ?? t.currency,
status: label(t.raw, 'status') ?? t.status,
referenceCode: t.referenceCode,
createdAt: t.externalCreatedAt?.toISOString() ?? null,
};
}
}
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { MontifyOrderDto, MontifyPersonStoreDto } from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{ personId: string }>();
const auth = useAuthStore();
const enabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('montify');
});
const stores = ref<MontifyPersonStoreDto[]>([]);
const openOrder = ref<string | null>(null);
async function load() {
stores.value = [];
if (!enabled.value || !props.personId) return;
try {
const { data } = await api.get<MontifyPersonStoreDto[]>(
`/integrations/montify/people/${props.personId}`,
);
stores.value = data;
} catch {
stores.value = [];
}
}
function amount(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : toFaDigits(v.toLocaleString('en-US'));
}
function date(iso: string | null): string {
return iso ? formatJalaliNumeric(new Date(iso)) : '—';
}
function orderTotal(o: MontifyOrderDto): number | null {
return o.discountTotalPrice ?? o.totalPrice;
}
watch(() => props.personId, load, { immediate: true });
</script>
<template>
<section v-if="stores.length" class="stores">
<h3>حساب در فروشگاه‌ها</h3>
<article v-for="s in stores" :key="s.connectionId + s.externalCustomerId" class="store">
<header>
<strong>{{ s.connectionLabel }}</strong>
<span class="muted">
شناسه مشتری {{ toFaDigits(s.externalCustomerId) }}
<template v-if="s.username"> · {{ s.username }}</template>
<template v-if="s.customerType"> · {{ s.customerType }}</template>
</span>
</header>
<dl>
<div>
<dt>موجودی کیف پول (ریال)</dt>
<dd>{{ amount(s.walletBalance) }}</dd>
</div>
<div>
<dt>موجودی طلا (گرم)</dt>
<dd>{{ amount(s.goldWalletBalance) }}</dd>
</div>
<div>
<dt>تعداد سفارش</dt>
<dd>{{ amount(s.totalOrders) }}</dd>
</div>
<div>
<dt>جمع خرید (ریال)</dt>
<dd>{{ amount(s.totalSpent) }}</dd>
</div>
<div v-if="s.engagement">
<dt>ذخیره / پسند / نظر</dt>
<dd>
{{ toFaDigits(s.engagement.saved) }} / {{ toFaDigits(s.engagement.likes) }} /
{{ toFaDigits(s.engagement.comments) }}
</dd>
</div>
</dl>
<div v-if="s.orders.length" class="block">
<h4>سفارش‌ها</h4>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>کد سفارش</th>
<th>وضعیت</th>
<th>پرداخت</th>
<th>مبلغ (ریال)</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<template v-for="o in s.orders" :key="o.id">
<tr class="clickable" @click="openOrder = openOrder === o.id ? null : o.id">
<td>{{ o.orderReference || `#${o.externalId}` }}</td>
<td>{{ o.orderStatus || '—' }}</td>
<td>{{ o.paymentStatus || '—' }}</td>
<td>{{ amount(orderTotal(o)) }}</td>
<td>{{ date(o.createdAt) }}</td>
</tr>
<tr v-if="openOrder === o.id" class="detail">
<td colspan="5">
<ul v-if="o.items.length">
<li v-for="(it, idx) in o.items" :key="idx">
{{ it.productName || 'قلم' }}
— {{ amount(it.qty) }} عدد
<template v-if="it.weight"> — {{ amount(it.weight) }} گرم</template>
— {{ amount(it.totalPrice ?? it.price) }}
</li>
</ul>
<p v-if="o.paymentType" class="muted">نوع پرداخت: {{ o.paymentType }}</p>
<p v-if="o.totalDiscounts" class="muted">تخفیف: {{ amount(o.totalDiscounts) }}</p>
<p v-if="o.note" class="muted">یادداشت: {{ o.note }}</p>
<p v-if="o.cancelReason" class="muted">دلیل لغو: {{ o.cancelReason }}</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div v-if="s.transactions.length" class="block">
<h4>تراکنش‌ها</h4>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>نوع</th>
<th>مقدار</th>
<th>واحد</th>
<th>وضعیت</th>
<th>کد پیگیری</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<tr v-for="t in s.transactions" :key="t.id">
<td>{{ t.type || '—' }}</td>
<td>{{ amount(t.amount) }}</td>
<td>{{ t.currency || '—' }}</td>
<td>{{ t.status || '—' }}</td>
<td dir="ltr">{{ t.referenceCode || '—' }}</td>
<td>{{ date(t.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="muted small">آخرین همگام‌سازی {{ date(s.syncedAt) }}</p>
</article>
</section>
</template>
<style scoped>
.stores {
display: grid;
gap: 0.6rem;
margin-bottom: 1rem;
}
h3 {
margin: 0;
font-size: 0.95rem;
}
h4 {
margin: 0 0 0.35rem;
font-size: 0.85rem;
color: #334155;
}
.store {
border: 1px solid #eef2f7;
border-radius: var(--radius);
padding: 0.75rem;
display: grid;
gap: 0.6rem;
}
.store header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
margin: 0;
}
dt {
color: #64748b;
font-size: 0.78rem;
}
dd {
margin: 0.15rem 0 0;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
text-align: right;
padding: 0.4rem;
border-bottom: 1px solid #f1f5f9;
}
th {
color: #64748b;
font-size: 0.76rem;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
background: #f8fafc;
}
.detail td {
background: #f8fafc;
}
.detail ul {
margin: 0;
padding-inline-start: 1.1rem;
}
.muted {
color: #64748b;
font-size: 0.82rem;
margin: 0.2rem 0 0;
}
.small {
font-size: 0.78rem;
margin: 0;
}
</style>
+1
View File
@@ -96,6 +96,7 @@ const settingsActive = computed(() =>
'/settings/currencies',
'/settings/tahesab',
'/settings/woocommerce',
'/settings/montify',
'/settings/voip',
'/settings/faq',
]),
+9
View File
@@ -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: 'تلفن سازمانی',
+6
View File
@@ -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',
+509
View File
@@ -0,0 +1,509 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
MontifyOrderDto,
MontifyTestResultDto,
UpdateMontifyConnectionRequest,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const busyId = ref('');
const error = ref('');
const ok = ref('');
const items = ref<MontifyConnectionDto[]>([]);
const orders = ref<MontifyOrderDto[]>([]);
const editingId = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const emptyForm = () => ({
label: '',
baseUrl: '',
token: '',
storeId: '',
});
const form = reactive(emptyForm());
const anyRunning = computed(() => items.value.some((c) => c.syncStatus === 'running'));
function errMsg(e: unknown, fallback: string): string {
if (axios.isAxiosError(e)) {
const m = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(m)) return m.join('، ');
if (m) return String(m);
}
return fallback;
}
async function load(silent = false) {
if (!silent) loading.value = true;
try {
const [connRes, orderRes] = await Promise.all([
api.get<MontifyConnectionDto[]>('/integrations/montify/connections'),
api.get<MontifyOrderDto[]>('/integrations/montify/orders'),
]);
items.value = connRes.data;
orders.value = orderRes.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول فروشگاه‌های مونتیفای برای این محیط فعال نیست.';
} else if (!silent) {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
if (anyRunning.value && !pollTimer) {
pollTimer = setInterval(() => void load(true), 4000);
} else if (!anyRunning.value && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function resetForm() {
Object.assign(form, emptyForm());
editingId.value = null;
}
function startEdit(c: MontifyConnectionDto) {
editingId.value = c.id;
Object.assign(form, {
label: c.label,
baseUrl: c.baseUrl,
token: '',
storeId: c.storeId,
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function save() {
error.value = '';
ok.value = '';
if (!form.label.trim() || !form.baseUrl.trim()) {
error.value = 'نام و Base URL فروشگاه الزامی است.';
return;
}
if (!editingId.value && !form.token.trim()) {
error.value = 'توکن فروشگاه الزامی است.';
return;
}
saving.value = true;
try {
if (editingId.value) {
const payload: UpdateMontifyConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
storeId: form.storeId.trim(),
...(form.token.trim() ? { token: form.token.trim() } : {}),
};
await api.patch(`/integrations/montify/connections/${editingId.value}`, payload);
ok.value = 'تغییرات ذخیره شد.';
} else {
const payload: CreateMontifyConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
token: form.token.trim(),
storeId: form.storeId.trim() || undefined,
};
await api.post('/integrations/montify/connections', payload);
ok.value = 'فروشگاه اضافه شد. حالا «تست اتصال» را بزنید.';
}
resetForm();
await load();
} catch (e) {
error.value = errMsg(e, 'ذخیره ناموفق بود.');
} finally {
saving.value = false;
}
}
async function run(id: string, fn: () => Promise<void>) {
busyId.value = id;
error.value = '';
ok.value = '';
try {
await fn();
} catch (e) {
error.value = errMsg(e, 'عملیات ناموفق بود.');
} finally {
busyId.value = '';
}
}
function test(c: MontifyConnectionDto) {
return run(c.id, async () => {
const { data } = await api.post<MontifyTestResultDto>(
`/integrations/montify/connections/${c.id}/test`,
);
ok.value =
data.totalCustomers !== null
? `اتصال «${c.label}» موفق بود — ${toFaDigits(data.totalCustomers)} مشتری در فروشگاه.`
: `اتصال «${c.label}» موفق بود.`;
await load(true);
});
}
function sync(c: MontifyConnectionDto, mode: 'incremental' | 'full') {
if (
mode === 'full' &&
!confirm('همگام‌سازی کامل همه مشتریان و سفارش‌ها را دوباره می‌خواند و ممکن است چند دقیقه طول بکشد. ادامه؟')
) {
return;
}
return run(c.id, async () => {
await api.post(`/integrations/montify/connections/${c.id}/sync`, null, { params: { mode } });
ok.value = 'همگام‌سازی در پس‌زمینه شروع شد.';
await load(true);
});
}
function toggleWebhook(c: MontifyConnectionDto) {
return run(c.id, async () => {
if (c.webhookRegistered) {
if (!confirm('وب‌هوک این فروشگاه حذف شود؟')) return;
await api.delete(`/integrations/montify/connections/${c.id}/webhook`);
ok.value = 'وب‌هوک حذف شد.';
} else {
await api.post(`/integrations/montify/connections/${c.id}/webhook`);
ok.value = 'وب‌هوک در فروشگاه ثبت شد؛ تغییرات از این به بعد لحظه‌ای می‌رسد.';
}
await load(true);
});
}
function toggleEnabled(c: MontifyConnectionDto) {
return run(c.id, async () => {
await api.patch(`/integrations/montify/connections/${c.id}`, { enabled: !c.enabled });
await load(true);
});
}
function remove(c: MontifyConnectionDto) {
if (!confirm(`اتصال «${c.label}» حذف شود؟ اشخاص و فاکتورهای واردشده می‌مانند.`)) return;
return run(c.id, async () => {
await api.delete(`/integrations/montify/connections/${c.id}`);
if (editingId.value === c.id) resetForm();
await load(true);
});
}
function formatAmount(v: number | null): string {
return v === null ? '—' : toFaDigits(v.toLocaleString('en-US'));
}
function statusLabel(c: MontifyConnectionDto): string {
if (!c.enabled) return 'غیرفعال';
if (c.syncStatus === 'running') return 'در حال همگام‌سازی…';
if (c.syncStatus === 'error') return 'خطا';
if (c.lastHealthOk === false) return 'اتصال ناموفق';
if (c.lastSyncAt) return 'فعال';
return 'هنوز همگام نشده';
}
function statusClass(c: MontifyConnectionDto): string {
if (!c.enabled) return 'off';
if (c.syncStatus === 'running') return 'run';
if (c.syncStatus === 'error' || c.lastHealthOk === false) return 'bad';
return 'good';
}
onMounted(() => void load());
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<template>
<div class="page">
<UiPageHeader
title="فروشگاه‌های مونتیفای"
description="اتصال فقط‌خواندنی چند فروشگاه؛ مشتری‌ها به اشخاص وصل می‌شوند و سفارش، آدرس، تراکنش و تعاملات هر مشتری در پرونده‌اش دیده می‌شود"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load()">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard :title="editingId ? 'ویرایش فروشگاه' : 'افزودن فروشگاه'">
<p class="hint">
فروشنده در پنل مونتیفای از «تنظیمات ← یکپارچه‌سازی‌ها ← CRM» توکن صادر می‌کند و
Base URL، Token و Store ID را به شما می‌دهد. توکن فقط روی سرور و به‌صورت رمزشده نگهداری می‌شود.
</p>
<div class="form-grid">
<UiField label="نام فروشگاه">
<UiInput v-model="form.label" placeholder="مثلاً فروشگاه شعبه ۱" />
</UiField>
<UiField label="Base URL">
<UiInput v-model="form.baseUrl" dir="ltr" placeholder="https://store1.montify.com/api/v1" />
</UiField>
<UiField :label="editingId ? 'توکن جدید (خالی = بدون تغییر)' : 'Token'">
<UiInput v-model="form.token" type="password" dir="ltr" placeholder="mk_live_…" />
</UiField>
<UiField label="Store ID">
<UiInput v-model="form.storeId" dir="ltr" placeholder="b2f1e4a0-…" />
</UiField>
</div>
<div class="form-actions">
<UiButton :loading="saving" @click="save">
{{ editingId ? 'ذخیره تغییرات' : 'افزودن فروشگاه' }}
</UiButton>
<UiButton v-if="editingId" variant="secondary" @click="resetForm">انصراف</UiButton>
</div>
</UiCard>
<UiCard title="فروشگاه‌های متصل">
<UiEmptyState
v-if="!loading && !items.length"
title="فروشگاهی وصل نشده"
description="اطلاعات اتصال هر فروشگاه را در فرم بالا وارد کنید."
/>
<div v-else class="list">
<article v-for="c in items" :key="c.id" class="item">
<div class="info">
<div class="title-row">
<strong>{{ c.label }}</strong>
<span class="badge" :class="statusClass(c)">{{ statusLabel(c) }}</span>
</div>
<p class="muted ltr">{{ c.baseUrl }}</p>
<p class="muted">
توکن {{ c.tokenMasked }}
<template v-if="c.storeId"> · Store ID {{ c.storeId }}</template>
· {{ toFaDigits(c.linkedCustomers) }} مشتری
· {{ toFaDigits(c.orders) }} سفارش
</p>
<p class="muted">
{{
c.lastSyncAt
? `آخرین همگام‌سازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
: 'هنوز همگام نشده'
}}
· وب‌هوک {{ c.webhookRegistered ? 'فعال' : 'ثبت نشده' }}
<template v-if="c.lastWebhookAt">
(آخرین رویداد {{ formatJalaliNumeric(new Date(c.lastWebhookAt)) }})
</template>
</p>
<p v-if="c.lastSyncResult" class="muted">
{{ c.syncStatus === 'running' ? 'پیشرفت' : 'نتیجه' }}:
{{ toFaDigits(c.lastSyncResult.customersFetched) }} مشتری خوانده شد
({{ toFaDigits(c.lastSyncResult.customersCreated) }} شخص جدید)،
{{ toFaDigits(c.lastSyncResult.ordersFetched) }} سفارش،
{{ toFaDigits(c.lastSyncResult.transactionsFetched) }} تراکنش
<template v-if="c.lastSyncResult.errors.length">
· {{ toFaDigits(c.lastSyncResult.errors.length) }} خطا
</template>
</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<div class="actions">
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
تست اتصال
</UiButton>
<UiButton
size="sm"
:disabled="!c.enabled || c.syncStatus === 'running'"
@click="sync(c, 'incremental')"
>
همگام‌سازی
</UiButton>
<UiButton
size="sm"
variant="secondary"
:disabled="!c.enabled || c.syncStatus === 'running'"
@click="sync(c, 'full')"
>
همگام‌سازی کامل
</UiButton>
<UiButton size="sm" variant="secondary" :disabled="!c.enabled" @click="toggleWebhook(c)">
{{ c.webhookRegistered ? 'حذف وب‌هوک' : 'ثبت وب‌هوک' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="startEdit(c)">ویرایش</UiButton>
<UiButton size="sm" variant="secondary" @click="toggleEnabled(c)">
{{ c.enabled ? 'غیرفعال' : 'فعال' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="remove(c)">حذف</UiButton>
</div>
</article>
</div>
</UiCard>
<UiCard title="آخرین سفارش‌های واردشده">
<UiEmptyState
v-if="!orders.length"
title="سفارشی نیست"
description="همگام‌سازی را بزنید یا وب‌هوک را ثبت کنید."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>فروشگاه</th>
<th>کد سفارش</th>
<th>مشتری</th>
<th>وضعیت سفارش</th>
<th>وضعیت پرداخت</th>
<th>مبلغ (ریال)</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<td>{{ o.connectionLabel }}</td>
<td>{{ o.orderReference || `#${o.externalId}` }}</td>
<td>
<RouterLink v-if="o.personId" :to="`/contacts/people/${o.personId}`">
{{ o.personName || 'مشاهده' }}
</RouterLink>
<span v-else>—</span>
</td>
<td>{{ o.orderStatus || '—' }}</td>
<td>{{ o.paymentStatus || '—' }}</td>
<td>{{ formatAmount(o.discountTotalPrice ?? o.totalPrice) }}</td>
<td>{{ o.createdAt ? formatJalaliNumeric(new Date(o.createdAt)) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.hint,
.muted {
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
margin: 0;
}
.ltr {
direction: ltr;
text-align: right;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.info {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
}
.badge.good {
background: #dcfce7;
color: #166534;
}
.badge.bad {
background: #fee2e2;
color: #991b1b;
}
.badge.run {
background: #e0f2fe;
color: #075985;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-content: start;
justify-content: end;
max-width: 340px;
}
.warn {
color: #b45309;
font-size: 0.85rem;
margin: 0;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: right;
padding: 0.55rem;
border-bottom: 1px solid #eef2f7;
}
th {
color: #64748b;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.form-grid {
grid-template-columns: 1fr;
}
.item {
flex-direction: column;
}
.actions {
max-width: none;
justify-content: start;
}
}
</style>
+2
View File
@@ -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 });
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<MontifyStoresCard :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>
+440
View File
@@ -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<boolean> {
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ها و سناریوهای خطا (۴۰۱, ۴۰۳, ۴۲۹) نوشته شده است.
+438
View File
@@ -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=<ISO8601>` روی `/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 کامل)
+132
View File
@@ -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<OrgModuleKey, string> = {
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<PersonImportSource, string> = {
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<CreateMontifyConnectionRequest>;
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";