Add WooCommerce integration: sync orders to invoices/people with webhook, cron, and settings UI.

This commit is contained in:
2026-09-26 17:59:16 +03:30
parent 1b160c8cd3
commit 26085b54a9
15 changed files with 1571 additions and 2 deletions
+2
View File
@@ -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 {}
+1 -1
View File
@@ -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');