Enable Montact CRM integration and admin CRM APIs.

This commit is contained in:
2026-10-10 17:44:30 +03:30
parent 7481ac5243
commit 68752f0403
11 changed files with 1158 additions and 2 deletions
+2
View File
@@ -25,6 +25,7 @@ import { BannersModule } from './banners/banners.module';
import { AddressesModule } from './addresses/addresses.module';
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
import { CrmModule } from './crm/crm.module';
import { MontactModule } from './montact/montact.module';
@Module({
imports: [
@@ -52,6 +53,7 @@ import { CrmModule } from './crm/crm.module';
AddressesModule,
InternalMessagesModule,
CrmModule,
MontactModule,
],
providers: [
{
+5
View File
@@ -7,6 +7,7 @@ import {
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AssignmentRequestStatus } from '@prisma/client';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -18,6 +19,8 @@ import {
UpdateAdminAgentDto,
} from './dto/crm.dto';
@ApiTags('AdminAgents')
@ApiBearerAuth()
@Controller('admin/agents')
@AuthType('admin')
@Roles('admin', 'sales')
@@ -45,6 +48,8 @@ export class AdminAgentsController {
}
}
@ApiTags('AdminAgents')
@ApiBearerAuth()
@Controller('admin/assignment-requests')
@AuthType('admin')
@Roles('admin', 'sales')
+3
View File
@@ -1,9 +1,12 @@
import { Controller, Get, Param, Patch, Body, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SalesPipelineStage } from '@prisma/client';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CrmService } from './crm.service';
import { UpdateOpportunityDto } from './dto/crm.dto';
@ApiTags('Crm')
@ApiBearerAuth()
@Controller('admin/crm')
@AuthType('admin')
@Roles('admin', 'sales')
+5
View File
@@ -6,6 +6,7 @@ import {
Patch,
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
@@ -18,6 +19,8 @@ import {
UpdateNextActionDto,
} from './dto/crm.dto';
@ApiTags('CustomerCrm')
@ApiBearerAuth()
@Controller('admin/customers')
@AuthType('admin')
@Roles('admin', 'sales')
@@ -68,6 +71,8 @@ export class CustomerCrmController {
}
}
@ApiTags('CustomerCrm')
@ApiBearerAuth()
@Controller('admin/next-actions')
@AuthType('admin')
@Roles('admin', 'sales')
+3
View File
@@ -56,8 +56,11 @@ async function bootstrap() {
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
// تا دکمه Authorize توکن را به همه endpointها (مثل admin/crm) بفرستد
document.security = [{ bearer: [] }];
SwaggerModule.setup(`${globalPrefix}/docs`, app, document, {
customSwaggerUiPath: swaggerUiAssetsPath(),
swaggerOptions: { persistAuthorization: true },
});
const port = Number(process.env.PORT) || 3000;
+43
View File
@@ -0,0 +1,43 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { resolveJwtSecret } from '../common/utils/jwt-secret';
@Injectable()
export class MontactAuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | undefined>;
}>();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('توکن نامعتبر یا منقضی شده است');
}
const token = authHeader.slice(7).trim();
const montactToken = this.config.get<string>('MONTACT_API_TOKEN')?.trim();
if (montactToken && token === montactToken) {
return true;
}
try {
const payload = await this.jwt.verifyAsync<{ type?: string }>(token, {
secret: resolveJwtSecret(this.config),
});
if (payload.type === 'admin') {
return true;
}
} catch {
// fall through
}
throw new UnauthorizedException('توکن نامعتبر یا منقضی شده است');
}
}
+92
View File
@@ -0,0 +1,92 @@
import {
Controller,
Delete,
Get,
NotImplementedException,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/auth.decorators';
import { MontactAuthGuard } from './montact-auth.guard';
import { MontactService } from './montact.service';
@Public()
@ApiTags('Montact')
@ApiBearerAuth()
@UseGuards(MontactAuthGuard)
@Controller('montact')
export class MontactController {
constructor(private readonly montact: MontactService) {}
@Get('customers')
listCustomers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('updated_after') updatedAfter?: string,
) {
const p = page ? Number.parseInt(page, 10) : 1;
const l = limit ? Number.parseInt(limit, 10) : 50;
return this.montact.listCustomers(p, l, updatedAfter);
}
@Post('customers')
createCustomer() {
throw new NotImplementedException('ثبت مشتری از CRM در این فاز فعال نیست');
}
@Get('customers/:id')
getCustomer(@Param('id') id: string) {
return this.montact.getCustomer(id);
}
@Get('customers/:id/orders')
customerOrders(
@Param('id') id: string,
@Query('updated_after') updatedAfter?: string,
) {
return this.montact.listCustomerOrders(id, updatedAfter);
}
@Post('customers/:id/orders')
createOrder() {
throw new NotImplementedException('ثبت سفارش از CRM در این فاز فعال نیست');
}
@Get('customers/:id/addresses')
customerAddresses(@Param('id') id: string) {
return this.montact.listCustomerAddresses(id);
}
@Get('customers/:id/transactions')
customerTransactions(@Param('id') id: string) {
return this.montact.listCustomerTransactions(id);
}
@Get('customers/:id/engagement')
customerEngagement(@Param('id') id: string) {
return this.montact.getCustomerEngagement(id);
}
@Get('orders/:orderId')
getOrder(@Param('orderId') orderId: string) {
return this.montact.getOrder(orderId);
}
@Post('webhooks/register')
registerWebhook() {
throw new NotImplementedException('Webhook Montact در فاز ۳ پیاده می‌شود');
}
@Get('webhooks/:id')
getWebhook() {
throw new NotImplementedException('Webhook Montact در فاز ۳ پیاده می‌شود');
}
@Delete('webhooks/:id')
deleteWebhook() {
throw new NotImplementedException('Webhook Montact در فاز ۳ پیاده می‌شود');
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MontactController } from './montact.controller';
import { MontactService } from './montact.service';
import { MontactAuthGuard } from './montact-auth.guard';
@Module({
imports: [AuthModule],
controllers: [MontactController],
providers: [MontactService, MontactAuthGuard],
})
export class MontactModule {}
+397
View File
@@ -0,0 +1,397 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AppUserRole, OrderStatus, Prisma } from '@prisma/client';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { joinFullName } from '../common/utils/name';
type MontactCustomer = Record<string, unknown>;
type MontactOrder = Record<string, unknown>;
@Injectable()
export class MontactService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
private storeId(): number {
const raw = this.config.get<string>('MONTACT_STORE_ID');
const n = raw ? Number.parseInt(raw, 10) : 1;
return Number.isFinite(n) ? n : 1;
}
async listCustomers(
page = 1,
limit = 50,
updatedAfter?: string,
) {
const safePage = Math.max(1, page);
const safeLimit = Math.min(100, Math.max(1, limit));
const skip = (safePage - 1) * safeLimit;
const updatedFilter = updatedAfter
? { updatedAt: { gte: new Date(updatedAfter) } }
: {};
const where: Prisma.CustomerWhereInput = {
appRole: AppUserRole.customer,
...updatedFilter,
};
const [total, rows] = await Promise.all([
this.prisma.customer.count({ where }),
this.prisma.customer.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip,
take: safeLimit,
include: {
orders: { select: { finalAmount: true } },
},
}),
]);
const data = rows.map((c) =>
this.mapCustomerListItem(c, c.orders),
);
const lastPage = Math.max(1, Math.ceil(total / safeLimit));
return {
data,
meta: {
current_page: safePage,
per_page: safeLimit,
total,
last_page: lastPage,
},
};
}
async getCustomer(id: string) {
const customer = await this.prisma.customer.findFirst({
where: { id, appRole: AppUserRole.customer },
include: {
orders: {
orderBy: { createdAt: 'desc' },
include: { items: true },
},
addresses: true,
favorites: { include: { product: { select: { id: true, name: true } } } },
},
});
if (!customer) throw new NotFoundException('منبع یافت نشد');
const orders = customer.orders.map((o) => this.mapOrder(o, false));
const addresses = customer.addresses.map((a) => this.mapAddress(a, customer.id));
const summary = this.buildSummary(customer.orders);
return {
...this.mapCustomerBase(customer),
orders,
summary,
addresses,
transactions: [],
engagement: this.mapEngagement(customer.favorites),
};
}
async listCustomerOrders(
customerId: string,
updatedAfter?: string,
) {
await this.assertCustomer(customerId);
const orders = await this.prisma.order.findMany({
where: {
userId: customerId,
...(updatedAfter
? { updatedAt: { gte: new Date(updatedAfter) } }
: {}),
},
orderBy: { createdAt: 'desc' },
include: { items: true },
});
return { data: orders.map((o) => this.mapOrder(o, true)) };
}
async listCustomerAddresses(customerId: string) {
await this.assertCustomer(customerId);
const rows = await this.prisma.customerAddress.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
});
return { data: rows.map((a) => this.mapAddress(a, customerId)) };
}
async listCustomerTransactions(_customerId: string) {
await this.assertCustomer(_customerId);
return { data: [] as unknown[] };
}
async getCustomerEngagement(customerId: string) {
await this.assertCustomer(customerId);
const favorites = await this.prisma.productFavorite.findMany({
where: { customerId },
include: { product: { select: { id: true, name: true } } },
orderBy: { createdAt: 'desc' },
});
return this.mapEngagement(favorites);
}
async getOrder(orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: {
items: true,
user: {
select: {
id: true,
firstName: true,
lastName: true,
phone: true,
},
},
},
});
if (!order) throw new NotFoundException('منبع یافت نشد');
return this.mapOrder(order, true);
}
private async assertCustomer(id: string) {
const c = await this.prisma.customer.findFirst({
where: { id, appRole: AppUserRole.customer },
select: { id: true },
});
if (!c) throw new NotFoundException('منبع یافت نشد');
}
private mapCustomerListItem(
c: {
id: string;
firstName: string;
lastName: string;
phone: string;
customerType: string;
avatarUrl: string | null;
createdAt: Date;
updatedAt: Date;
businessName: string | null;
},
orders: Array<{ finalAmount: number | null }>,
): MontactCustomer {
const summary = this.buildSummary(orders);
return {
...this.mapCustomerBase(c),
orders: [],
summary,
addresses: [],
transactions: [],
engagement: { saved: [], likes: [], comments: [] },
};
}
private mapCustomerBase(c: {
id: string;
firstName: string;
lastName: string;
phone: string;
customerType: string;
avatarUrl: string | null;
createdAt: Date;
updatedAt: Date;
businessName: string | null;
}): MontactCustomer {
const name = joinFullName(c.firstName, c.lastName);
return {
id: c.id,
name,
first_name: c.firstName,
last_name: c.lastName,
phone: c.phone,
email: null,
username: c.phone,
avatar: c.avatarUrl,
birthday: null,
gender: null,
type: c.customerType === 'retail' ? 'individual' : 'business',
store_id: this.storeId(),
created_at: c.createdAt.toISOString(),
updated_at: c.updatedAt.toISOString(),
description: c.businessName,
bio: null,
job: null,
cash_balance: 0,
gold_balance: 0,
};
}
private buildSummary(
orders: Array<{ finalAmount: number | null }>,
) {
let totalSpent = 0;
for (const o of orders) {
totalSpent += o.finalAmount ?? 0;
}
return {
total_orders: orders.length,
total_spent: Math.round(totalSpent),
};
}
private mapOrder(
o: {
id: string;
userId: string;
orderNumber: string;
status: OrderStatus;
finalAmount: number | null;
totalAmount: number | null;
totalDiscount: number | null;
totalMeterage: number;
notes: string | null;
createdAt: Date;
updatedAt: Date;
items: Array<{
id: string;
orderId: string;
productId: string | null;
productName: string;
unitPrice: number | null;
lineTotal: number | null;
confirmedMeterage: number | null;
requestedMeterage: number;
}>;
user?: {
id: string;
firstName: string;
lastName: string;
phone: string;
};
},
withNested: boolean,
): MontactOrder {
const totalPrice = o.finalAmount ?? o.totalAmount ?? 0;
const qty = o.items.length;
const base: MontactOrder = {
id: o.id,
user_id: o.userId,
store_id: this.storeId(),
code: o.orderNumber,
sts: this.mapOrderStatus(o.status),
payment_sts: this.mapPaymentStatus(o.status),
payment_type: 'unknown',
total_price: Math.round(totalPrice),
discount_total_price: Math.round(totalPrice),
total_discounts: Math.round(o.totalDiscount ?? 0),
total_tax: 0,
total_qty: qty,
profit: 0,
percent_profit: 0,
sum_gold: 0,
sum_silver: 0,
address: null,
address_id: null,
note: o.notes,
cancel_reason: null,
cancelled_at: null,
closed_at: null,
transaction_id: null,
code_discount: null,
discount_code_id: null,
created_at: o.createdAt.toISOString(),
updated_at: o.updatedAt.toISOString(),
deleted_at: null,
order_items: o.items.map((item) => ({
id: item.id,
order_id: item.orderId,
product_id: item.productId ?? '',
product_name: item.productName,
qty: 1,
price: Math.round(item.unitPrice ?? 0),
weight: item.confirmedMeterage ?? item.requestedMeterage,
total_price: Math.round(item.lineTotal ?? 0),
})),
};
if (withNested && o.user) {
base.user = {
id: o.user.id,
name: joinFullName(o.user.firstName, o.user.lastName),
phone: o.user.phone,
};
base.store = { id: this.storeId() };
}
return base;
}
private mapOrderStatus(status: OrderStatus): string {
if (status === OrderStatus.cancelled || status === OrderStatus.rejected) {
return 'cancelled';
}
if (status === OrderStatus.delivered) return 'delivered';
if (
status === OrderStatus.shipped ||
status === OrderStatus.ready_to_ship
) {
return 'shipped';
}
if (
status === OrderStatus.final_approved ||
status === OrderStatus.preparing
) {
return 'processing';
}
return 'pending';
}
private mapPaymentStatus(status: OrderStatus): string {
if (
status === OrderStatus.cash_confirmed ||
status === OrderStatus.checks_approved ||
status === OrderStatus.delivered
) {
return 'paid';
}
if (status === OrderStatus.cancelled || status === OrderStatus.rejected) {
return 'cancelled';
}
return 'unpaid';
}
private mapAddress(
a: {
id: string;
label: string | null;
text: string;
isDefault: boolean;
},
userId: string,
) {
return {
id: a.id,
user_id: userId,
province: '',
city: '',
address: a.text,
postal_code: '',
receiver_name: a.label ?? '',
receiver_phone: '',
is_default: a.isDefault,
};
}
private mapEngagement(
favorites: Array<{
createdAt: Date;
product: { id: string; name: string };
}>,
) {
return {
saved: favorites.map((f) => ({
product_id: f.product.id,
product_name: f.product.name,
saved_at: f.createdAt.toISOString(),
})),
likes: [] as unknown[],
comments: [] as unknown[],
};
}
}