Enable Montact CRM integration and admin CRM APIs.
This commit is contained in:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('توکن نامعتبر یا منقضی شده است');
|
||||
}
|
||||
}
|
||||
@@ -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 در فاز ۳ پیاده میشود');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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[],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user