Add WooCommerce integration: sync orders to invoices/people with webhook, cron, and settings UI.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "WooCommerceConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"storeUrl" TEXT NOT NULL,
|
||||
"consumerKeyCiphertext" TEXT NOT NULL,
|
||||
"consumerKeyIv" TEXT NOT NULL,
|
||||
"consumerKeyTag" TEXT NOT NULL,
|
||||
"consumerKeyLast4" TEXT NOT NULL DEFAULT '',
|
||||
"consumerSecretCiphertext" TEXT NOT NULL,
|
||||
"consumerSecretIv" TEXT NOT NULL,
|
||||
"consumerSecretTag" TEXT NOT NULL,
|
||||
"webhookSecretCiphertext" TEXT,
|
||||
"webhookSecretIv" TEXT,
|
||||
"webhookSecretTag" TEXT,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"lastHealthAt" TIMESTAMP(3),
|
||||
"lastHealthOk" BOOLEAN,
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WooCommerceConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WooCommerceOrderLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"wooOrderId" TEXT NOT NULL,
|
||||
"wooOrderNumber" TEXT,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT '',
|
||||
"total" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"rawPayload" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WooCommerceOrderLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WooCommerceConnection_organizationId_enabled_idx" ON "WooCommerceConnection"("organizationId", "enabled");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WooCommerceOrderLink_organizationId_syncedAt_idx" ON "WooCommerceOrderLink"("organizationId", "syncedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WooCommerceOrderLink_invoiceId_idx" ON "WooCommerceOrderLink"("invoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WooCommerceOrderLink_connectionId_wooOrderId_key" ON "WooCommerceOrderLink"("connectionId", "wooOrderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WooCommerceConnection" ADD CONSTRAINT "WooCommerceConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WooCommerceOrderLink" ADD CONSTRAINT "WooCommerceOrderLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "WooCommerceConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -198,6 +198,7 @@ model Organization {
|
||||
apiKeys OrganizationApiKey[]
|
||||
webhookEndpoints WebhookEndpoint[]
|
||||
smsCampaigns SmsCampaign[]
|
||||
wooCommerceConnections WooCommerceConnection[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -1729,3 +1730,53 @@ model SmsCampaignRecipient {
|
||||
|
||||
@@index([campaignId, status])
|
||||
}
|
||||
|
||||
/// اتصال فروشگاه ووکامرس
|
||||
model WooCommerceConnection {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
storeUrl String
|
||||
consumerKeyCiphertext String
|
||||
consumerKeyIv String
|
||||
consumerKeyTag String
|
||||
consumerKeyLast4 String @default("")
|
||||
consumerSecretCiphertext String
|
||||
consumerSecretIv String
|
||||
consumerSecretTag String
|
||||
webhookSecretCiphertext String?
|
||||
webhookSecretIv String?
|
||||
webhookSecretTag String?
|
||||
enabled Boolean @default(true)
|
||||
lastSyncAt DateTime?
|
||||
lastHealthAt DateTime?
|
||||
lastHealthOk Boolean?
|
||||
lastError String?
|
||||
orders WooCommerceOrderLink[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, enabled])
|
||||
}
|
||||
|
||||
model WooCommerceOrderLink {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
connectionId String
|
||||
connection WooCommerceConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
wooOrderId String
|
||||
wooOrderNumber String?
|
||||
invoiceId String
|
||||
status String @default("")
|
||||
total Decimal @default(0) @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
rawPayload Json?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([connectionId, wooOrderId])
|
||||
@@index([organizationId, syncedAt])
|
||||
@@index([invoiceId])
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { SavedViewsModule } from './saved-views/saved-views.module';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -85,6 +86,7 @@ import { TeamsModule } from './teams/teams.module';
|
||||
PublicApiModule,
|
||||
SmsCampaignsModule,
|
||||
TeamsModule,
|
||||
WooCommerceModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateWooCommerceConnectionRequest,
|
||||
UpdateWooCommerceConnectionRequest,
|
||||
WooCommerceConnectionDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||
import { normalizeStoreUrl, WooCommerceClient } from './woo-client';
|
||||
|
||||
@Injectable()
|
||||
export class WooConnectionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: TahesabCryptoService,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<WooCommerceConnectionDto[]> {
|
||||
const rows = await this.prisma.wooCommerceConnection.findMany({
|
||||
where: { organizationId },
|
||||
include: { _count: { select: { orders: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((r) => this.toDto(r, r._count.orders));
|
||||
}
|
||||
|
||||
async getOrThrow(organizationId: string, id: string) {
|
||||
const row = await this.prisma.wooCommerceConnection.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('اتصال ووکامرس پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateWooCommerceConnectionRequest,
|
||||
): Promise<WooCommerceConnectionDto> {
|
||||
const label = dto.label?.trim();
|
||||
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
||||
let storeUrl: string;
|
||||
try {
|
||||
storeUrl = normalizeStoreUrl(dto.storeUrl);
|
||||
} catch (e) {
|
||||
throw new BadRequestException(
|
||||
e instanceof Error ? e.message : 'آدرس فروشگاه نامعتبر است',
|
||||
);
|
||||
}
|
||||
const key = dto.consumerKey?.trim();
|
||||
const secret = dto.consumerSecret?.trim();
|
||||
if (!key || !secret) {
|
||||
throw new BadRequestException('Consumer Key و Secret الزامی است');
|
||||
}
|
||||
|
||||
const keyEnc = this.crypto.encrypt(key);
|
||||
const secretEnc = this.crypto.encrypt(secret);
|
||||
const webhook = dto.webhookSecret?.trim();
|
||||
const webhookEnc = webhook ? this.crypto.encrypt(webhook) : null;
|
||||
|
||||
const row = await this.prisma.wooCommerceConnection.create({
|
||||
data: {
|
||||
organizationId,
|
||||
label,
|
||||
storeUrl,
|
||||
consumerKeyCiphertext: keyEnc.ciphertext,
|
||||
consumerKeyIv: keyEnc.iv,
|
||||
consumerKeyTag: keyEnc.tag,
|
||||
consumerKeyLast4: this.crypto.last4(key),
|
||||
consumerSecretCiphertext: secretEnc.ciphertext,
|
||||
consumerSecretIv: secretEnc.iv,
|
||||
consumerSecretTag: secretEnc.tag,
|
||||
webhookSecretCiphertext: webhookEnc?.ciphertext,
|
||||
webhookSecretIv: webhookEnc?.iv,
|
||||
webhookSecretTag: webhookEnc?.tag,
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
});
|
||||
return this.toDto(row, 0);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateWooCommerceConnectionRequest,
|
||||
): Promise<WooCommerceConnectionDto> {
|
||||
const existing = await this.getOrThrow(organizationId, id);
|
||||
const data: Prisma.WooCommerceConnectionUpdateInput = {};
|
||||
|
||||
if (dto.label !== undefined) {
|
||||
const label = dto.label.trim();
|
||||
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
||||
data.label = label;
|
||||
}
|
||||
if (dto.storeUrl !== undefined) {
|
||||
try {
|
||||
data.storeUrl = normalizeStoreUrl(dto.storeUrl);
|
||||
} catch (e) {
|
||||
throw new BadRequestException(
|
||||
e instanceof Error ? e.message : 'آدرس فروشگاه نامعتبر است',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (dto.consumerKey?.trim()) {
|
||||
const enc = this.crypto.encrypt(dto.consumerKey.trim());
|
||||
data.consumerKeyCiphertext = enc.ciphertext;
|
||||
data.consumerKeyIv = enc.iv;
|
||||
data.consumerKeyTag = enc.tag;
|
||||
data.consumerKeyLast4 = this.crypto.last4(dto.consumerKey.trim());
|
||||
}
|
||||
if (dto.consumerSecret?.trim()) {
|
||||
const enc = this.crypto.encrypt(dto.consumerSecret.trim());
|
||||
data.consumerSecretCiphertext = enc.ciphertext;
|
||||
data.consumerSecretIv = enc.iv;
|
||||
data.consumerSecretTag = enc.tag;
|
||||
}
|
||||
if (dto.webhookSecret !== undefined) {
|
||||
const wh = dto.webhookSecret?.trim();
|
||||
if (!wh) {
|
||||
data.webhookSecretCiphertext = null;
|
||||
data.webhookSecretIv = null;
|
||||
data.webhookSecretTag = null;
|
||||
} else {
|
||||
const enc = this.crypto.encrypt(wh);
|
||||
data.webhookSecretCiphertext = enc.ciphertext;
|
||||
data.webhookSecretIv = enc.iv;
|
||||
data.webhookSecretTag = enc.tag;
|
||||
}
|
||||
}
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
|
||||
const row = await this.prisma.wooCommerceConnection.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
const count = await this.prisma.wooCommerceOrderLink.count({
|
||||
where: { connectionId: id },
|
||||
});
|
||||
return this.toDto(row, count);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.getOrThrow(organizationId, id);
|
||||
await this.prisma.wooCommerceConnection.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async getClient(organizationId: string, id: string) {
|
||||
const row = await this.getOrThrow(organizationId, id);
|
||||
if (!row.enabled) throw new BadRequestException('این اتصال غیرفعال است');
|
||||
const consumerKey = this.crypto.decrypt({
|
||||
ciphertext: row.consumerKeyCiphertext,
|
||||
iv: row.consumerKeyIv,
|
||||
tag: row.consumerKeyTag,
|
||||
});
|
||||
const consumerSecret = this.crypto.decrypt({
|
||||
ciphertext: row.consumerSecretCiphertext,
|
||||
iv: row.consumerSecretIv,
|
||||
tag: row.consumerSecretTag,
|
||||
});
|
||||
return {
|
||||
row,
|
||||
client: new WooCommerceClient(row.storeUrl, consumerKey, consumerSecret),
|
||||
};
|
||||
}
|
||||
|
||||
async decryptWebhookSecret(
|
||||
row: {
|
||||
webhookSecretCiphertext: string | null;
|
||||
webhookSecretIv: string | null;
|
||||
webhookSecretTag: string | null;
|
||||
},
|
||||
): Promise<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) {
|
||||
const { row, client } = await this.getClient(organizationId, id);
|
||||
try {
|
||||
await client.test();
|
||||
await this.prisma.wooCommerceConnection.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: true,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'خطای ناشناخته';
|
||||
await this.prisma.wooCommerceConnection.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: false,
|
||||
lastError: msg.slice(0, 500),
|
||||
},
|
||||
});
|
||||
throw new BadRequestException(`اتصال ناموفق: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(
|
||||
row: {
|
||||
id: string;
|
||||
label: string;
|
||||
storeUrl: string;
|
||||
consumerKeyLast4: string;
|
||||
webhookSecretCiphertext: string | null;
|
||||
enabled: boolean;
|
||||
lastSyncAt: Date | null;
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
importedOrders: number,
|
||||
): WooCommerceConnectionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
storeUrl: row.storeUrl,
|
||||
consumerKeyMasked: row.consumerKeyLast4
|
||||
? `••••••${row.consumerKeyLast4}`
|
||||
: '••••',
|
||||
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
|
||||
enabled: row.enabled,
|
||||
lastSyncAt: row.lastSyncAt?.toISOString() ?? null,
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
importedOrders,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateWooConnectionDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(300)
|
||||
storeUrl!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(200)
|
||||
consumerKey!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(200)
|
||||
consumerSecret!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
webhookSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateWooConnectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(300)
|
||||
storeUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(200)
|
||||
consumerKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(200)
|
||||
consumerSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
webhookSecret?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
WooCommerceOrderLinkDto,
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import type { WooOrder } from './woo-client';
|
||||
|
||||
function mapInvoiceStatus(
|
||||
wooStatus: string | undefined,
|
||||
): 'issued' | 'paid' | 'canceled' {
|
||||
const s = (wooStatus ?? '').toLowerCase();
|
||||
if (s === 'completed' || s === 'processing') return 'paid';
|
||||
if (s === 'cancelled' || s === 'canceled' || s === 'refunded' || s === 'failed') {
|
||||
return 'canceled';
|
||||
}
|
||||
return 'issued';
|
||||
}
|
||||
|
||||
function isPaidStatus(wooStatus: string | undefined): boolean {
|
||||
const s = (wooStatus ?? '').toLowerCase();
|
||||
return s === 'completed' || s === 'processing';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WooSyncService {
|
||||
private readonly logger = new Logger(WooSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly connections: WooConnectionsService,
|
||||
) {}
|
||||
|
||||
async syncConnection(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
): Promise<WooCommerceSyncResultDto> {
|
||||
const { row, client } = await this.connections.getClient(
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
|
||||
const result: WooCommerceSyncResultDto = {
|
||||
fetched: 0,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const after = row.lastSyncAt
|
||||
? new Date(row.lastSyncAt.getTime() - 60_000).toISOString()
|
||||
: undefined;
|
||||
|
||||
let page = 1;
|
||||
let batch: WooOrder[] = [];
|
||||
do {
|
||||
batch = await client.listOrders({
|
||||
page,
|
||||
perPage: 50,
|
||||
modifiedAfter: after,
|
||||
});
|
||||
result.fetched += batch.length;
|
||||
for (const order of batch) {
|
||||
try {
|
||||
const outcome = await this.upsertOrder(
|
||||
organizationId,
|
||||
connectionId,
|
||||
order,
|
||||
);
|
||||
if (outcome === 'created') result.created += 1;
|
||||
else if (outcome === 'updated') result.updated += 1;
|
||||
else result.skipped += 1;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
result.errors.push(`سفارش ${order.id}: ${msg}`);
|
||||
this.logger.warn(`Woo sync order ${order.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
} while (batch.length >= 50 && page <= 20);
|
||||
|
||||
await this.prisma.wooCommerceConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: true,
|
||||
lastError: result.errors.length
|
||||
? result.errors.slice(0, 3).join(' | ').slice(0, 500)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
result.errors.push(msg);
|
||||
await this.prisma.wooCommerceConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
lastHealthAt: new Date(),
|
||||
lastHealthOk: false,
|
||||
lastError: msg.slice(0, 500),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async ingestWebhookOrder(
|
||||
connectionId: string,
|
||||
order: WooOrder,
|
||||
): Promise<{ invoiceId: string; outcome: string }> {
|
||||
const conn = await this.prisma.wooCommerceConnection.findFirst({
|
||||
where: { id: connectionId, enabled: true },
|
||||
});
|
||||
if (!conn) throw new NotFoundException('اتصال پیدا نشد');
|
||||
const outcome = await this.upsertOrder(
|
||||
conn.organizationId,
|
||||
connectionId,
|
||||
order,
|
||||
);
|
||||
const link = await this.prisma.wooCommerceOrderLink.findUnique({
|
||||
where: {
|
||||
connectionId_wooOrderId: {
|
||||
connectionId,
|
||||
wooOrderId: String(order.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.prisma.wooCommerceConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: { lastSyncAt: new Date() },
|
||||
});
|
||||
return { invoiceId: link!.invoiceId, outcome };
|
||||
}
|
||||
|
||||
async listRecentOrders(
|
||||
organizationId: string,
|
||||
connectionId?: string,
|
||||
): Promise<WooCommerceOrderLinkDto[]> {
|
||||
const rows = await this.prisma.wooCommerceOrderLink.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
},
|
||||
orderBy: { syncedAt: 'desc' },
|
||||
take: 40,
|
||||
});
|
||||
const invoiceIds = rows.map((r) => r.invoiceId);
|
||||
const invoices = invoiceIds.length
|
||||
? await this.prisma.invoice.findMany({
|
||||
where: { id: { in: invoiceIds } },
|
||||
select: { id: true, number: true },
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(invoices.map((i) => [i.id, i.number]));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
wooOrderId: r.wooOrderId,
|
||||
wooOrderNumber: r.wooOrderNumber,
|
||||
invoiceId: r.invoiceId,
|
||||
invoiceNumber: byId.get(r.invoiceId) ?? '—',
|
||||
status: r.status,
|
||||
total: Number(r.total),
|
||||
currency: r.currency,
|
||||
syncedAt: r.syncedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
@Cron('*/15 * * * *')
|
||||
async cronSyncAll(): Promise<void> {
|
||||
const connections = await this.prisma.wooCommerceConnection.findMany({
|
||||
where: { enabled: true },
|
||||
select: { id: true, organizationId: true },
|
||||
take: 30,
|
||||
});
|
||||
for (const c of connections) {
|
||||
try {
|
||||
await this.syncConnection(c.organizationId, c.id);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Woo cron sync ${c.id}: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertOrder(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
order: WooOrder,
|
||||
): Promise<'created' | 'updated' | 'skipped'> {
|
||||
if (!order?.id) return 'skipped';
|
||||
|
||||
const personId = await this.upsertPerson(organizationId, order);
|
||||
const currency = (order.currency ?? 'IRR').trim() || 'IRR';
|
||||
const total = Number(order.total ?? 0) || 0;
|
||||
const discount = Number(order.discount_total ?? 0) || 0;
|
||||
const status = mapInvoiceStatus(order.status);
|
||||
const title = `سفارش ووکامرس #${order.number ?? order.id}`;
|
||||
const number = `WOO-${order.id}`;
|
||||
const issuedAt = order.date_created
|
||||
? new Date(order.date_created)
|
||||
: new Date();
|
||||
const notes = [
|
||||
order.payment_method_title
|
||||
? `روش پرداخت: ${order.payment_method_title}`
|
||||
: null,
|
||||
`وضعیت ووکامرس: ${order.status ?? '—'}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const lineItems = (order.line_items ?? []).map((item, index) => ({
|
||||
name: (item.name ?? 'قلم').trim() || 'قلم',
|
||||
quantity: new Prisma.Decimal(item.quantity ?? 1),
|
||||
unitPrice: new Prisma.Decimal(
|
||||
item.price != null
|
||||
? item.price
|
||||
: item.quantity
|
||||
? Number(item.total ?? 0) / item.quantity
|
||||
: Number(item.total ?? 0),
|
||||
),
|
||||
currency,
|
||||
position: index,
|
||||
}));
|
||||
|
||||
const existing = await this.prisma.wooCommerceOrderLink.findUnique({
|
||||
where: {
|
||||
connectionId_wooOrderId: {
|
||||
connectionId,
|
||||
wooOrderId: String(order.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.invoice.update({
|
||||
where: { id: existing.invoiceId },
|
||||
data: {
|
||||
title,
|
||||
status,
|
||||
currency,
|
||||
notes,
|
||||
discountAmount: new Prisma.Decimal(discount),
|
||||
personId,
|
||||
issuedAt,
|
||||
lineItems: {
|
||||
deleteMany: {},
|
||||
create: lineItems.length
|
||||
? lineItems
|
||||
: [
|
||||
{
|
||||
name: title,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unitPrice: new Prisma.Decimal(total),
|
||||
currency,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.ensurePayment(
|
||||
existing.invoiceId,
|
||||
order,
|
||||
total,
|
||||
currency,
|
||||
);
|
||||
await this.prisma.wooCommerceOrderLink.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
wooOrderNumber: order.number ? String(order.number) : null,
|
||||
status: order.status ?? '',
|
||||
total: new Prisma.Decimal(total),
|
||||
currency,
|
||||
rawPayload: order as unknown as Prisma.InputJsonValue,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
const invoice = await this.prisma.invoice.create({
|
||||
data: {
|
||||
organizationId,
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
kind: 'invoice',
|
||||
currency,
|
||||
notes,
|
||||
discountAmount: new Prisma.Decimal(discount),
|
||||
taxPercent: new Prisma.Decimal(0),
|
||||
issuedAt,
|
||||
personId,
|
||||
lineItems: {
|
||||
create: lineItems.length
|
||||
? lineItems
|
||||
: [
|
||||
{
|
||||
name: title,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unitPrice: new Prisma.Decimal(total),
|
||||
currency,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.ensurePayment(invoice.id, order, total, currency);
|
||||
|
||||
await this.prisma.wooCommerceOrderLink.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId,
|
||||
wooOrderId: String(order.id),
|
||||
wooOrderNumber: order.number ? String(order.number) : null,
|
||||
invoiceId: invoice.id,
|
||||
status: order.status ?? '',
|
||||
total: new Prisma.Decimal(total),
|
||||
currency,
|
||||
rawPayload: order as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
private async ensurePayment(
|
||||
invoiceId: string,
|
||||
order: WooOrder,
|
||||
total: number,
|
||||
currency: string,
|
||||
) {
|
||||
if (!isPaidStatus(order.status) || total <= 0) return;
|
||||
const existing = await this.prisma.payment.findFirst({
|
||||
where: { invoiceId, notes: { contains: 'woocommerce' } },
|
||||
});
|
||||
if (existing) {
|
||||
await this.prisma.payment.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
amount: new Prisma.Decimal(total),
|
||||
currency,
|
||||
paidAt: order.date_paid ? new Date(order.date_paid) : new Date(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.prisma.payment.create({
|
||||
data: {
|
||||
invoiceId,
|
||||
amount: new Prisma.Decimal(total),
|
||||
currency,
|
||||
method: 'card',
|
||||
paidAt: order.date_paid ? new Date(order.date_paid) : new Date(),
|
||||
notes: `woocommerce order #${order.id}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertPerson(
|
||||
organizationId: string,
|
||||
order: WooOrder,
|
||||
): Promise<string | null> {
|
||||
const billing = order.billing ?? {};
|
||||
const email = normalizeEmail(billing.email ?? null);
|
||||
const mobile = normalizePhone(billing.phone ?? null);
|
||||
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
|
||||
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
|
||||
|
||||
if (!email && !mobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }, { phone: mobile }] : []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await this.prisma.person.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(email ? { email } : {}),
|
||||
...(mobile ? { mobile } : {}),
|
||||
firstName,
|
||||
lastName,
|
||||
},
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const created = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
mobile,
|
||||
phone: mobile,
|
||||
notes: order.customer_id
|
||||
? `ووکامرس customer_id=${order.customer_id}`
|
||||
: 'واردشده از ووکامرس',
|
||||
tags: ['woocommerce'],
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
export type WooOrderLine = {
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
total?: string;
|
||||
product_id?: number;
|
||||
};
|
||||
|
||||
export type WooBilling = {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
};
|
||||
|
||||
export type WooOrder = {
|
||||
id: number;
|
||||
number?: string;
|
||||
status?: string;
|
||||
currency?: string;
|
||||
total?: string;
|
||||
discount_total?: string;
|
||||
date_created?: string;
|
||||
date_paid?: string | null;
|
||||
customer_id?: number;
|
||||
billing?: WooBilling;
|
||||
line_items?: WooOrderLine[];
|
||||
payment_method_title?: string;
|
||||
};
|
||||
|
||||
function normalizeStoreUrl(url: string): string {
|
||||
let u = url.trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
throw new Error('آدرس فروشگاه باید با http یا https شروع شود');
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
export class WooCommerceClient {
|
||||
private readonly base: string;
|
||||
private readonly authHeader: string;
|
||||
|
||||
constructor(
|
||||
storeUrl: string,
|
||||
consumerKey: string,
|
||||
consumerSecret: string,
|
||||
) {
|
||||
this.base = `${normalizeStoreUrl(storeUrl)}/wp-json/wc/v3`;
|
||||
const token = Buffer.from(
|
||||
`${consumerKey.trim()}:${consumerSecret.trim()}`,
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
this.authHeader = `Basic ${token}`;
|
||||
}
|
||||
|
||||
async test(): Promise<void> {
|
||||
await this.request('GET', '/system_status?_fields=environment.home_url');
|
||||
}
|
||||
|
||||
async listOrders(opts: {
|
||||
after?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
modifiedAfter?: string;
|
||||
} = {}): Promise<WooOrder[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(opts.page ?? 1));
|
||||
params.set('per_page', String(opts.perPage ?? 50));
|
||||
params.set('orderby', 'modified');
|
||||
params.set('order', 'asc');
|
||||
if (opts.after) params.set('after', opts.after);
|
||||
if (opts.modifiedAfter) params.set('modified_after', opts.modifiedAfter);
|
||||
return this.request<WooOrder[]>('GET', `/orders?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getOrder(id: number | string): Promise<WooOrder> {
|
||||
return this.request<WooOrder>('GET', `/orders/${id}`);
|
||||
}
|
||||
|
||||
static verifyWebhookSignature(
|
||||
rawBody: string | Buffer,
|
||||
signature: string | undefined,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (!signature?.trim() || !secret.trim()) return false;
|
||||
const digest = createHmac('sha256', secret)
|
||||
.update(typeof rawBody === 'string' ? rawBody : rawBody)
|
||||
.digest('base64');
|
||||
return digest === signature.trim();
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string): Promise<T> {
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MentoCRM-WooCommerce/1.0',
|
||||
},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`WooCommerce HTTP ${res.status}: ${text.slice(0, 200) || res.statusText}`,
|
||||
);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
export { normalizeStoreUrl };
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
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 { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import { CreateWooConnectionDto, UpdateWooConnectionDto } from './dto';
|
||||
import { WooSyncService } from './sync.service';
|
||||
import { WooCommerceClient, type WooOrder } from './woo-client';
|
||||
|
||||
@Controller('integrations/woocommerce')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('woocommerce')
|
||||
@Roles('owner', 'admin')
|
||||
export class WooCommerceController {
|
||||
constructor(
|
||||
private readonly connections: WooConnectionsService,
|
||||
private readonly sync: WooSyncService,
|
||||
) {}
|
||||
|
||||
@Get('connections')
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.connections.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('connections')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateWooConnectionDto,
|
||||
) {
|
||||
return this.connections.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('connections/:id')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWooConnectionDto,
|
||||
) {
|
||||
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,
|
||||
) {
|
||||
return this.sync.syncConnection(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
recent(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
) {
|
||||
return this.sync.listRecentOrders(user.organizationId, connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('public/integrations/woocommerce')
|
||||
export class WooCommerceWebhookController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly connections: WooConnectionsService,
|
||||
private readonly sync: WooSyncService,
|
||||
) {}
|
||||
|
||||
@Post(':connectionId/webhook')
|
||||
async webhook(
|
||||
@Param('connectionId') connectionId: string,
|
||||
@Headers('x-wc-webhook-signature') signature: string | undefined,
|
||||
@Headers('x-wc-webhook-topic') topic: string | undefined,
|
||||
@Req() req: Request & { rawBody?: Buffer },
|
||||
@Body() body: WooOrder,
|
||||
) {
|
||||
const row = await this.prisma.wooCommerceConnection.findFirst({
|
||||
where: { id: connectionId, enabled: true },
|
||||
});
|
||||
if (!row) throw new UnauthorizedException('اتصال نامعتبر است');
|
||||
|
||||
const secret = await this.connections.decryptWebhookSecret(row);
|
||||
if (secret) {
|
||||
const raw =
|
||||
req.rawBody ??
|
||||
Buffer.from(JSON.stringify(body ?? {}), 'utf8');
|
||||
const ok = WooCommerceClient.verifyWebhookSignature(
|
||||
raw,
|
||||
signature,
|
||||
secret,
|
||||
);
|
||||
if (!ok) throw new UnauthorizedException('امضای وبهوک نامعتبر است');
|
||||
}
|
||||
|
||||
const topicOk =
|
||||
!topic ||
|
||||
topic.includes('order') ||
|
||||
topic === 'action.woocommerce_new_order';
|
||||
if (!topicOk) return { ok: true, skipped: true };
|
||||
|
||||
if (!body?.id) return { ok: true, skipped: true };
|
||||
|
||||
const result = await this.sync.ingestWebhookOrder(connectionId, body);
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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 { WooConnectionsService } from './connections.service';
|
||||
import { WooSyncService } from './sync.service';
|
||||
import {
|
||||
WooCommerceController,
|
||||
WooCommerceWebhookController,
|
||||
} from './woocommerce.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
controllers: [WooCommerceController, WooCommerceWebhookController],
|
||||
providers: [TahesabCryptoService, WooConnectionsService, WooSyncService],
|
||||
exports: [WooConnectionsService, WooSyncService],
|
||||
})
|
||||
export class WooCommerceModule {}
|
||||
@@ -5,7 +5,7 @@ import { brand } from '@mento/shared';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -68,6 +68,7 @@ const settingsActive = computed(() =>
|
||||
'/settings/custom-fields',
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
'/settings/woocommerce',
|
||||
'/settings/faq',
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -401,6 +401,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'amber',
|
||||
module: 'tahesab',
|
||||
},
|
||||
{
|
||||
to: '/settings/woocommerce',
|
||||
title: 'ووکامرس',
|
||||
desc: 'سفارش فروشگاه به فاکتور و مشتری',
|
||||
icon: 'box',
|
||||
tint: 'mint',
|
||||
module: 'woocommerce',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/faq',
|
||||
title: 'FAQ',
|
||||
|
||||
@@ -306,6 +306,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/TahesabSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/woocommerce',
|
||||
name: 'woocommerce-settings',
|
||||
component: () => import('@/views/WooCommerceSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/faq',
|
||||
name: 'faq-settings',
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
CreateWooCommerceConnectionRequest,
|
||||
WooCommerceConnectionDto,
|
||||
WooCommerceOrderLinkDto,
|
||||
WooCommerceSyncResultDto,
|
||||
} 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 syncing = ref(false);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const items = ref<WooCommerceConnectionDto[]>([]);
|
||||
const orders = ref<WooCommerceOrderLinkDto[]>([]);
|
||||
const lastSync = ref<WooCommerceSyncResultDto | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
label: 'فروشگاه اصلی',
|
||||
storeUrl: '',
|
||||
consumerKey: '',
|
||||
consumerSecret: '',
|
||||
webhookSecret: '',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const webhookUrl = computed(() => {
|
||||
if (!items.value[0]) return '';
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${origin}/api/public/integrations/woocommerce/${items.value[0].id}/webhook`;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [connRes, orderRes] = await Promise.all([
|
||||
api.get<WooCommerceConnectionDto[]>('/integrations/woocommerce/connections'),
|
||||
api.get<WooCommerceOrderLinkDto[]>('/integrations/woocommerce/orders'),
|
||||
]);
|
||||
items.value = connRes.data;
|
||||
orders.value = orderRes.data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول ووکامرس برای این محیط فعال نیست.';
|
||||
} else {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!form.storeUrl.trim() || !form.consumerKey.trim() || !form.consumerSecret.trim()) {
|
||||
error.value = 'آدرس فروشگاه و کلیدهای API را کامل کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const payload: CreateWooCommerceConnectionRequest = {
|
||||
label: form.label.trim() || 'فروشگاه',
|
||||
storeUrl: form.storeUrl.trim(),
|
||||
consumerKey: form.consumerKey.trim(),
|
||||
consumerSecret: form.consumerSecret.trim(),
|
||||
webhookSecret: form.webhookSecret.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
await api.post('/integrations/woocommerce/connections', payload);
|
||||
form.consumerKey = '';
|
||||
form.consumerSecret = '';
|
||||
form.webhookSecret = '';
|
||||
ok.value = 'اتصال ذخیره شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
|
||||
: 'ذخیره ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function test(id: string) {
|
||||
try {
|
||||
await api.post(`/integrations/woocommerce/connections/${id}/test`);
|
||||
ok.value = 'اتصال به فروشگاه موفق بود.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'تست ناموفق بود.')
|
||||
: 'تست ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function sync(id: string) {
|
||||
syncing.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<WooCommerceSyncResultDto>(
|
||||
`/integrations/woocommerce/connections/${id}/sync`,
|
||||
);
|
||||
lastSync.value = data;
|
||||
ok.value = `همگامسازی: ${toFaDigits(data.created)} جدید، ${toFaDigits(data.updated)} بهروز.`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'همگامسازی ناموفق بود.')
|
||||
: 'همگامسازی ناموفق بود.';
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('این اتصال حذف شود؟ لینک سفارشهای قبلی میماند ولی همگامسازی قطع میشود.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/integrations/woocommerce/connections/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyWebhook() {
|
||||
if (!webhookUrl.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl.value);
|
||||
ok.value = 'آدرس وبهوک کپی شد.';
|
||||
} catch {
|
||||
error.value = 'کپی ممکن نشد.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="ووکامرس"
|
||||
description="همگامسازی سفارشهای فروشگاه با فاکتور و مشتری در CRM"
|
||||
>
|
||||
<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="اتصال جدید">
|
||||
<p class="hint">
|
||||
در ووکامرس: ووکامرس ← تنظیمات ← پیشرفته ← REST API یک کلید بسازید.
|
||||
برای وبهوک: ووکامرس ← تنظیمات ← پیشرفته ← وبهوکها، موضوع «Order created/updated».
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="form.label" placeholder="فروشگاه اصلی" />
|
||||
</UiField>
|
||||
<UiField label="آدرس فروشگاه">
|
||||
<UiInput v-model="form.storeUrl" placeholder="https://shop.example.com" />
|
||||
</UiField>
|
||||
<UiField label="Consumer Key">
|
||||
<UiInput v-model="form.consumerKey" placeholder="ck_…" />
|
||||
</UiField>
|
||||
<UiField label="Consumer Secret">
|
||||
<UiInput v-model="form.consumerSecret" placeholder="cs_…" />
|
||||
</UiField>
|
||||
<UiField label="Webhook Secret (اختیاری)">
|
||||
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="اتصالهای ذخیرهشده">
|
||||
<UiEmptyState
|
||||
v-if="!loading && !items.length"
|
||||
title="اتصالی نیست"
|
||||
description="کلید REST API فروشگاه را وارد کنید."
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="c in items" :key="c.id" class="item">
|
||||
<div>
|
||||
<strong>{{ c.label }}</strong>
|
||||
<p class="muted">{{ c.storeUrl }}</p>
|
||||
<p class="muted">
|
||||
کلید {{ c.consumerKeyMasked }}
|
||||
· سفارشهای واردشده {{ toFaDigits(c.importedOrders) }}
|
||||
·
|
||||
{{
|
||||
c.lastSyncAt
|
||||
? `آخرین همگامسازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
|
||||
: 'هنوز همگام نشده'
|
||||
}}
|
||||
</p>
|
||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton variant="secondary" @click="test(c.id)">تست</UiButton>
|
||||
<UiButton :loading="syncing" @click="sync(c.id)">همگامسازی</UiButton>
|
||||
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookUrl" class="webhook">
|
||||
<p class="hint">آدرس وبهوک این اتصال:</p>
|
||||
<code>{{ webhookUrl }}</code>
|
||||
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orders" :key="o.id">
|
||||
<td>#{{ o.wooOrderNumber || o.wooOrderId }}</td>
|
||||
<td>{{ o.invoiceNumber }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ toFaDigits(o.total) }} {{ o.currency }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(o.syncedAt)) }}</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;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
.webhook {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.webhook code {
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.warn {
|
||||
color: #b45309;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.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,
|
||||
.item {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -121,7 +121,8 @@ export type OrgModuleKey =
|
||||
| "saved_views"
|
||||
| "public_api"
|
||||
| "sms_campaigns"
|
||||
| "teams";
|
||||
| "teams"
|
||||
| "woocommerce";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
@@ -153,6 +154,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"public_api",
|
||||
"sms_campaigns",
|
||||
"teams",
|
||||
"woocommerce",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
@@ -185,6 +187,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
public_api: "API و وبهوک",
|
||||
sms_campaigns: "پیامک گروهی",
|
||||
teams: "تیمها و دیدپذیری",
|
||||
woocommerce: "ووکامرس",
|
||||
};
|
||||
|
||||
export const ORG_MODULE_GROUPS: Array<{
|
||||
@@ -224,6 +227,7 @@ export const ORG_MODULE_GROUPS: Array<{
|
||||
"loyalty",
|
||||
"reports",
|
||||
"saved_views",
|
||||
"woocommerce",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -3143,3 +3147,59 @@ export interface UpdateTeamRequest {
|
||||
export interface SetTeamMembersRequest {
|
||||
members: Array<{ userId: string; role: TeamMemberRole }>;
|
||||
}
|
||||
|
||||
/* ——— ووکامرس ——— */
|
||||
|
||||
export interface WooCommerceConnectionDto {
|
||||
id: string;
|
||||
label: string;
|
||||
storeUrl: string;
|
||||
consumerKeyMasked: string;
|
||||
webhookSecretSet: boolean;
|
||||
enabled: boolean;
|
||||
lastSyncAt: string | null;
|
||||
lastHealthAt: string | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
importedOrders: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateWooCommerceConnectionRequest {
|
||||
label: string;
|
||||
storeUrl: string;
|
||||
consumerKey: string;
|
||||
consumerSecret: string;
|
||||
webhookSecret?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateWooCommerceConnectionRequest {
|
||||
label?: string;
|
||||
storeUrl?: string;
|
||||
consumerKey?: string;
|
||||
consumerSecret?: string;
|
||||
webhookSecret?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WooCommerceSyncResultDto {
|
||||
fetched: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface WooCommerceOrderLinkDto {
|
||||
id: string;
|
||||
wooOrderId: string;
|
||||
wooOrderNumber: string | null;
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user