Montify stores integration: multi-store connections, customer/order/transaction sync, webhooks
This commit is contained in:
@@ -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"));
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -96,6 +96,7 @@ const settingsActive = computed(() =>
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
'/settings/woocommerce',
|
||||
'/settings/montify',
|
||||
'/settings/voip',
|
||||
'/settings/faq',
|
||||
]),
|
||||
|
||||
@@ -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: 'تلفن سازمانی',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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ها و سناریوهای خطا (۴۰۱, ۴۰۳, ۴۲۹) نوشته شده است.
|
||||
@@ -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 کامل)
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user