Add agent level progression and extend backend services.
Introduce agent level utilities/service, expand auth and notification flows, update category definitions and seed data, and wire level-aware order and suggestion handling.
This commit is contained in:
+50
-2
@@ -42,6 +42,8 @@ async function main() {
|
||||
await prisma.product.deleteMany();
|
||||
await prisma.catalog.deleteMany();
|
||||
await prisma.bankAccount.deleteMany();
|
||||
await prisma.levelBenefit.deleteMany();
|
||||
await prisma.customerLevelDef.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
await prisma.company.deleteMany();
|
||||
await prisma.adminUser.deleteMany();
|
||||
@@ -332,6 +334,51 @@ async function main() {
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.customerLevelDef.create({
|
||||
data: {
|
||||
code: 'bronze',
|
||||
label: 'برنزی',
|
||||
sortOrder: 1,
|
||||
criteriaJson: { minSalesToman: 0 },
|
||||
benefits: {
|
||||
create: [{ title: 'دسترسی به کاتالوگ پایه' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.customerLevelDef.create({
|
||||
data: {
|
||||
code: 'silver',
|
||||
label: 'نقرهای',
|
||||
sortOrder: 2,
|
||||
criteriaJson: { minSalesToman: 500_000_000 },
|
||||
benefits: {
|
||||
create: [{ title: 'پشتیبانی اولویتدار' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const goldLevel = await prisma.customerLevelDef.create({
|
||||
data: {
|
||||
code: 'gold',
|
||||
label: 'طلایی',
|
||||
sortOrder: 3,
|
||||
criteriaJson: { minSalesToman: 1_500_000_000 },
|
||||
benefits: {
|
||||
create: [{ title: 'تخفیف ویژه سفارشهای عمده' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.customerLevelDef.create({
|
||||
data: {
|
||||
code: 'vip',
|
||||
label: 'VIP',
|
||||
sortOrder: 4,
|
||||
criteriaJson: { minSalesToman: 3_000_000_000 },
|
||||
benefits: {
|
||||
create: [{ title: 'مشاوره تخصصی و ارسال سریع' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.customer.create({
|
||||
data: {
|
||||
id: 'agent-1',
|
||||
@@ -342,10 +389,11 @@ async function main() {
|
||||
customerType: CustomerType.wholesale,
|
||||
appRole: AppUserRole.agent,
|
||||
agentCode: 'W-1247',
|
||||
levelDefId: goldLevel.id,
|
||||
levelLabel: 'طلایی',
|
||||
nextLevelLabel: 'VIP',
|
||||
pointsToNext: 550_000_000,
|
||||
levelProgress: 72,
|
||||
pointsToNext: 0,
|
||||
levelProgress: 0,
|
||||
companyId: 'company-par',
|
||||
isActive: true,
|
||||
avatarLetter: 'ع',
|
||||
|
||||
+39
-2
@@ -133240,6 +133240,25 @@ var require_notifications_service = __commonJS({
|
||||
dispatchAdmin(input) {
|
||||
void this.notifyAdmin(input);
|
||||
}
|
||||
async notifyCustomer(input) {
|
||||
try {
|
||||
await this.prisma.customerNotification.create({
|
||||
data: {
|
||||
customerId: input.customerId,
|
||||
event: input.event,
|
||||
title: input.title.trim(),
|
||||
body: input.body.trim(),
|
||||
tone: input.tone ?? client_1.NotificationTone.info,
|
||||
link: input.link ?? null
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`notifyCustomer(${input.event}) failed for customer ${input.customerId}`, err instanceof Error ? err.stack : err);
|
||||
}
|
||||
}
|
||||
dispatchCustomer(input) {
|
||||
void this.notifyCustomer(input);
|
||||
}
|
||||
async listForCustomer(customerId, limit = 50) {
|
||||
const rows = await this.prisma.customerNotification.findMany({
|
||||
where: { customerId },
|
||||
@@ -139500,14 +139519,18 @@ var require_suggestions_service = __commonJS({
|
||||
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||
exports2.SuggestionsService = void 0;
|
||||
var common_12 = require_common();
|
||||
var client_1 = require_default2();
|
||||
var notifications_service_1 = require_notifications_service();
|
||||
var prisma_service_1 = require_prisma_service();
|
||||
var SuggestionsService = class SuggestionsService {
|
||||
static {
|
||||
__name(this, "SuggestionsService");
|
||||
}
|
||||
prisma;
|
||||
constructor(prisma) {
|
||||
notifications;
|
||||
constructor(prisma, notifications) {
|
||||
this.prisma = prisma;
|
||||
this.notifications = notifications;
|
||||
}
|
||||
async listAdmin(customerId) {
|
||||
const rows = await this.prisma.productSuggestion.findMany({
|
||||
@@ -139549,6 +139572,15 @@ var require_suggestions_service = __commonJS({
|
||||
product: { select: { name: true, code: true, color: true } }
|
||||
}
|
||||
});
|
||||
const note = row.note?.trim();
|
||||
this.notifications.dispatchCustomer({
|
||||
customerId: input.customerId,
|
||||
event: "product_suggestion",
|
||||
title: "\u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u0645\u062D\u0635\u0648\u0644 \u0628\u0631\u0627\u06CC \u0634\u0645\u0627",
|
||||
body: note ? `\u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0645\u0627 \u0645\u062D\u0635\u0648\u0644 \xAB${row.product.name}\xBB \u0631\u0627 \u0628\u0647 \u0634\u0645\u0627 \u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u062F\u0627\u062F\u0647\u200C\u0627\u0646\u062F: ${note}` : `\u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0645\u0627 \u0645\u062D\u0635\u0648\u0644 \xAB${row.product.name}\xBB \u0631\u0627 \u0628\u0647 \u0634\u0645\u0627 \u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u062F\u0627\u062F\u0647\u200C\u0627\u0646\u062F.`,
|
||||
tone: client_1.NotificationTone.info,
|
||||
link: `/product?id=${input.productId}`
|
||||
});
|
||||
return this.map(row);
|
||||
}
|
||||
async setActive(id, isActive) {
|
||||
@@ -139596,7 +139628,10 @@ var require_suggestions_service = __commonJS({
|
||||
exports2.SuggestionsService = SuggestionsService;
|
||||
exports2.SuggestionsService = SuggestionsService = __decorate2([
|
||||
(0, common_12.Injectable)(),
|
||||
__metadata2("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
__metadata2("design:paramtypes", [
|
||||
prisma_service_1.PrismaService,
|
||||
notifications_service_1.NotificationsService
|
||||
])
|
||||
], SuggestionsService);
|
||||
}
|
||||
});
|
||||
@@ -139738,6 +139773,7 @@ var require_suggestions_module = __commonJS({
|
||||
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||
exports2.SuggestionsModule = void 0;
|
||||
var common_12 = require_common();
|
||||
var notifications_module_1 = require_notifications_module();
|
||||
var suggestions_controller_1 = require_suggestions_controller();
|
||||
var suggestions_service_1 = require_suggestions_service();
|
||||
var SuggestionsModule = class SuggestionsModule {
|
||||
@@ -139748,6 +139784,7 @@ var require_suggestions_module = __commonJS({
|
||||
exports2.SuggestionsModule = SuggestionsModule;
|
||||
exports2.SuggestionsModule = SuggestionsModule = __decorate2([
|
||||
(0, common_12.Module)({
|
||||
imports: [notifications_module_1.NotificationsModule],
|
||||
controllers: [suggestions_controller_1.SuggestionsController],
|
||||
providers: [suggestions_service_1.SuggestionsService],
|
||||
exports: [suggestions_service_1.SuggestionsService]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppUserRole } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AgentLevelSnapshot,
|
||||
DEFAULT_AGENT_LEVEL_TIERS,
|
||||
LevelTier,
|
||||
computeAgentLevel,
|
||||
parseLevelMinSales,
|
||||
} from './agent-level.util';
|
||||
|
||||
@Injectable()
|
||||
export class AgentLevelService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getLevelTiers(): Promise<LevelTier[]> {
|
||||
const rows = await this.prisma.customerLevelDef.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { label: 'asc' }],
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
return DEFAULT_AGENT_LEVEL_TIERS;
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
label: row.label,
|
||||
sortOrder: row.sortOrder,
|
||||
minSalesToman: parseLevelMinSales(row.criteriaJson),
|
||||
}));
|
||||
}
|
||||
|
||||
async getAgentSalesVolume(agentId: string): Promise<number> {
|
||||
const sales = await this.prisma.order.aggregate({
|
||||
where: {
|
||||
user: { assignedAgentId: agentId },
|
||||
finalAmount: { not: null },
|
||||
},
|
||||
_sum: { finalAmount: true },
|
||||
});
|
||||
|
||||
return sales._sum.finalAmount ?? 0;
|
||||
}
|
||||
|
||||
async syncAgentLevel(agentId: string): Promise<AgentLevelSnapshot> {
|
||||
const agent = await this.prisma.customer.findUnique({
|
||||
where: { id: agentId },
|
||||
select: { id: true, appRole: true },
|
||||
});
|
||||
|
||||
if (!agent || agent.appRole !== AppUserRole.agent) {
|
||||
return {
|
||||
levelLabel: 'برنزی',
|
||||
nextLevelLabel: 'نقرهای',
|
||||
pointsToNext: 0,
|
||||
levelProgress: 0,
|
||||
levelDefId: null,
|
||||
salesVolumeToman: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [salesVolumeToman, tiers] = await Promise.all([
|
||||
this.getAgentSalesVolume(agentId),
|
||||
this.getLevelTiers(),
|
||||
]);
|
||||
|
||||
const computed = computeAgentLevel(salesVolumeToman, tiers);
|
||||
|
||||
await this.prisma.customer.update({
|
||||
where: { id: agentId },
|
||||
data: {
|
||||
levelLabel: computed.levelLabel,
|
||||
nextLevelLabel: computed.nextLevelLabel,
|
||||
pointsToNext: computed.pointsToNext,
|
||||
levelProgress: computed.levelProgress,
|
||||
levelDefId: computed.levelDefId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...computed,
|
||||
salesVolumeToman,
|
||||
};
|
||||
}
|
||||
|
||||
async syncAgentForOrder(orderId: string): Promise<void> {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
user: {
|
||||
select: { assignedAgentId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const agentId = order?.user?.assignedAgentId;
|
||||
if (!agentId) return;
|
||||
|
||||
await this.syncAgentLevel(agentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { computeAgentLevel, DEFAULT_AGENT_LEVEL_TIERS } from './agent-level.util';
|
||||
|
||||
describe('computeAgentLevel', () => {
|
||||
it('starts at bronze with empty progress', () => {
|
||||
const result = computeAgentLevel(0, DEFAULT_AGENT_LEVEL_TIERS);
|
||||
expect(result.levelLabel).toBe('برنزی');
|
||||
expect(result.nextLevelLabel).toBe('نقرهای');
|
||||
expect(result.pointsToNext).toBe(500_000_000);
|
||||
expect(result.levelProgress).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates progress between silver and gold', () => {
|
||||
const result = computeAgentLevel(1_000_000_000, DEFAULT_AGENT_LEVEL_TIERS);
|
||||
expect(result.levelLabel).toBe('نقرهای');
|
||||
expect(result.nextLevelLabel).toBe('طلایی');
|
||||
expect(result.pointsToNext).toBe(500_000_000);
|
||||
expect(result.levelProgress).toBe(50);
|
||||
});
|
||||
|
||||
it('caps at vip with full progress', () => {
|
||||
const result = computeAgentLevel(4_000_000_000, DEFAULT_AGENT_LEVEL_TIERS);
|
||||
expect(result.levelLabel).toBe('VIP');
|
||||
expect(result.nextLevelLabel).toBe('VIP');
|
||||
expect(result.pointsToNext).toBe(0);
|
||||
expect(result.levelProgress).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
export type LevelTier = {
|
||||
id?: string;
|
||||
code: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
minSalesToman: number;
|
||||
};
|
||||
|
||||
export type AgentLevelSnapshot = {
|
||||
levelLabel: string;
|
||||
nextLevelLabel: string;
|
||||
pointsToNext: number;
|
||||
levelProgress: number;
|
||||
levelDefId: string | null;
|
||||
salesVolumeToman: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_AGENT_LEVEL_TIERS: LevelTier[] = [
|
||||
{ code: 'bronze', label: 'برنزی', sortOrder: 1, minSalesToman: 0 },
|
||||
{ code: 'silver', label: 'نقرهای', sortOrder: 2, minSalesToman: 500_000_000 },
|
||||
{ code: 'gold', label: 'طلایی', sortOrder: 3, minSalesToman: 1_500_000_000 },
|
||||
{ code: 'vip', label: 'VIP', sortOrder: 4, minSalesToman: 3_000_000_000 },
|
||||
];
|
||||
|
||||
export function parseLevelMinSales(criteriaJson: unknown): number {
|
||||
if (!criteriaJson || typeof criteriaJson !== 'object') return 0;
|
||||
const value =
|
||||
(criteriaJson as Record<string, unknown>).minSalesToman ??
|
||||
(criteriaJson as Record<string, unknown>).minSales ??
|
||||
(criteriaJson as Record<string, unknown>).salesVolumeToman ??
|
||||
0;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : 0;
|
||||
}
|
||||
|
||||
export function sortLevelTiers(tiers: LevelTier[]): LevelTier[] {
|
||||
return [...tiers].sort(
|
||||
(a, b) => a.minSalesToman - b.minSalesToman || a.sortOrder - b.sortOrder,
|
||||
);
|
||||
}
|
||||
|
||||
export function computeAgentLevel(
|
||||
salesVolumeToman: number,
|
||||
tiers: LevelTier[],
|
||||
): Omit<AgentLevelSnapshot, 'salesVolumeToman'> {
|
||||
const sorted = sortLevelTiers(tiers);
|
||||
const safeSales = Math.max(0, Math.round(salesVolumeToman));
|
||||
|
||||
if (!sorted.length) {
|
||||
return {
|
||||
levelLabel: 'برنزی',
|
||||
nextLevelLabel: 'نقرهای',
|
||||
pointsToNext: 0,
|
||||
levelProgress: 0,
|
||||
levelDefId: null,
|
||||
};
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
if (safeSales >= sorted[index].minSalesToman) {
|
||||
currentIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
const current = sorted[currentIndex];
|
||||
const next = sorted[currentIndex + 1];
|
||||
|
||||
if (!next) {
|
||||
return {
|
||||
levelLabel: current.label,
|
||||
nextLevelLabel: current.label,
|
||||
pointsToNext: 0,
|
||||
levelProgress: 100,
|
||||
levelDefId: current.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const range = next.minSalesToman - current.minSalesToman;
|
||||
const progressInRange = safeSales - current.minSalesToman;
|
||||
const levelProgress =
|
||||
range <= 0
|
||||
? 100
|
||||
: Math.min(100, Math.max(0, Math.round((progressInRange / range) * 100)));
|
||||
|
||||
return {
|
||||
levelLabel: current.label,
|
||||
nextLevelLabel: next.label,
|
||||
pointsToNext: Math.max(0, next.minSalesToman - safeSales),
|
||||
levelProgress,
|
||||
levelDefId: current.id ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentController } from './agent.controller';
|
||||
import { AgentLevelService } from './agent-level.service';
|
||||
import { AgentService } from './agent.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AgentController],
|
||||
providers: [AgentService],
|
||||
exports: [AgentService],
|
||||
providers: [AgentService, AgentLevelService],
|
||||
exports: [AgentService, AgentLevelService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
|
||||
+12
-15
@@ -19,12 +19,16 @@ import {
|
||||
CreateReminderDto,
|
||||
} from './dto/agent.dto';
|
||||
import { loadCatalogCategories } from '../catalog/category-defs';
|
||||
import { AgentLevelService } from './agent-level.service';
|
||||
|
||||
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
|
||||
|
||||
@Injectable()
|
||||
export class AgentService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly agentLevelService: AgentLevelService,
|
||||
) {}
|
||||
|
||||
private async requireAgent(agentId: string) {
|
||||
const agent = await this.prisma.customer.findUnique({
|
||||
@@ -38,7 +42,7 @@ export class AgentService {
|
||||
|
||||
async getProfile(agentId: string) {
|
||||
const agent = await this.requireAgent(agentId);
|
||||
const [customersCount, ordersCount] = await Promise.all([
|
||||
const [customersCount, ordersCount, levelSnapshot] = await Promise.all([
|
||||
this.prisma.customer.count({
|
||||
where: { assignedAgentId: agentId, isActive: true },
|
||||
}),
|
||||
@@ -47,27 +51,20 @@ export class AgentService {
|
||||
user: { assignedAgentId: agentId },
|
||||
},
|
||||
}),
|
||||
this.agentLevelService.syncAgentLevel(agentId),
|
||||
]);
|
||||
|
||||
const sales = await this.prisma.order.aggregate({
|
||||
where: {
|
||||
user: { assignedAgentId: agentId },
|
||||
finalAmount: { not: null },
|
||||
},
|
||||
_sum: { finalAmount: true },
|
||||
});
|
||||
|
||||
const salesVolume = sales._sum.finalAmount ?? 0;
|
||||
const salesVolume = levelSnapshot.salesVolumeToman;
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
firstName: agent.firstName,
|
||||
lastName: agent.lastName,
|
||||
code: agent.agentCode ?? '',
|
||||
levelLabel: agent.levelLabel ?? 'برنزی',
|
||||
nextLevelLabel: agent.nextLevelLabel ?? 'نقرهای',
|
||||
pointsToNext: agent.pointsToNext ?? 0,
|
||||
levelProgress: agent.levelProgress ?? 0,
|
||||
levelLabel: levelSnapshot.levelLabel,
|
||||
nextLevelLabel: levelSnapshot.nextLevelLabel,
|
||||
pointsToNext: levelSnapshot.pointsToNext,
|
||||
levelProgress: levelSnapshot.levelProgress,
|
||||
phone: agent.phone,
|
||||
customersCount,
|
||||
ordersCount,
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { AgentModule } from '../agent/agent.module';
|
||||
import { resolveJwtSecret } from '../common/utils/jwt-secret';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
AgentModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AppUserRole } from '@prisma/client';
|
||||
import { AgentLevelService } from '../agent/agent-level.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import {
|
||||
@@ -31,6 +33,7 @@ export class AuthService {
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly agentLevelService: AgentLevelService,
|
||||
) {}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
@@ -153,6 +156,13 @@ export class AuthService {
|
||||
|
||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||
|
||||
if (user.appRole === AppUserRole.agent) {
|
||||
await this.agentLevelService.syncAgentLevel(user.id);
|
||||
user =
|
||||
(await this.prisma.customer.findUnique({ where: { id: user.id } })) ??
|
||||
user;
|
||||
}
|
||||
|
||||
const accessToken = await this.signCustomerToken(
|
||||
user.id,
|
||||
user.phone,
|
||||
@@ -215,10 +225,18 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.prisma.customer.findUnique({
|
||||
let user = await this.prisma.customer.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
if (user.appRole === AppUserRole.agent) {
|
||||
await this.agentLevelService.syncAgentLevel(user.id);
|
||||
user =
|
||||
(await this.prisma.customer.findUnique({ where: { id: userId } })) ??
|
||||
user;
|
||||
}
|
||||
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ export const CATEGORY_ICONS = [
|
||||
'catalog',
|
||||
'headset',
|
||||
'home',
|
||||
'shirt',
|
||||
'suit',
|
||||
'trousers',
|
||||
] as const
|
||||
|
||||
export type CategoryIconId = (typeof CATEGORY_ICONS)[number]
|
||||
@@ -46,7 +49,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
||||
id: 'trousers',
|
||||
title: 'پارچههای شلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'grid',
|
||||
icon: 'trousers',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -55,7 +58,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
||||
id: 'suit',
|
||||
title: 'پارچههای کتشلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'bag',
|
||||
icon: 'suit',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -64,7 +67,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
||||
id: 'shirt',
|
||||
title: 'پارچههای پیراهنی',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'id-card',
|
||||
icon: 'shirt',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
@@ -140,11 +143,19 @@ function parseIcon(value: unknown, fallback: CategoryIconId): CategoryIconId {
|
||||
}
|
||||
|
||||
function defaultUsageIcon(id: string): CategoryIconId {
|
||||
if (id === 'shirt') return 'id-card'
|
||||
if (id === 'suit') return 'bag'
|
||||
if (id === 'shirt') return 'shirt'
|
||||
if (id === 'suit') return 'suit'
|
||||
if (id === 'trousers') return 'trousers'
|
||||
return 'grid'
|
||||
}
|
||||
|
||||
function resolveUsageIconForApi(item: UsageCategoryDef): CategoryIconId {
|
||||
if (item.id === 'shirt' || item.id === 'suit' || item.id === 'trousers') {
|
||||
return defaultUsageIcon(item.id)
|
||||
}
|
||||
return item.icon
|
||||
}
|
||||
|
||||
function defaultSeasonIcon(id: string): CategoryIconId {
|
||||
if (id === 'summer') return 'bolt'
|
||||
if (id === 'winter') return 'feather'
|
||||
@@ -229,6 +240,7 @@ export async function loadCatalogCategories(prisma: PrismaService) {
|
||||
return {
|
||||
usage: defs.usage.map((item) => ({
|
||||
...item,
|
||||
icon: resolveUsageIconForApi(item),
|
||||
imageUrl: images[item.id] || undefined,
|
||||
})),
|
||||
seasons: defs.seasons.map((item) => ({
|
||||
|
||||
@@ -133,6 +133,44 @@ export class NotificationsService {
|
||||
void this.notifyAdmin(input);
|
||||
}
|
||||
|
||||
async notifyCustomer(input: {
|
||||
customerId: string;
|
||||
event: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tone?: NotificationTone;
|
||||
link?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.prisma.customerNotification.create({
|
||||
data: {
|
||||
customerId: input.customerId,
|
||||
event: input.event,
|
||||
title: input.title.trim(),
|
||||
body: input.body.trim(),
|
||||
tone: input.tone ?? NotificationTone.info,
|
||||
link: input.link ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`notifyCustomer(${input.event}) failed for customer ${input.customerId}`,
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatchCustomer(input: {
|
||||
customerId: string;
|
||||
event: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tone?: NotificationTone;
|
||||
link?: string;
|
||||
}): void {
|
||||
void this.notifyCustomer(input);
|
||||
}
|
||||
|
||||
async listForCustomer(customerId: string, limit = 50) {
|
||||
const rows = await this.prisma.customerNotification.findMany({
|
||||
where: { customerId },
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AgentModule } from '../agent/agent.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
imports: [NotificationsModule, AgentModule],
|
||||
controllers: [CustomerOrdersController, AdminOrdersController],
|
||||
providers: [OrdersService, CashInvoiceSchedulerService],
|
||||
exports: [OrdersService],
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
assertTransition,
|
||||
} from '../common/utils/order-transitions';
|
||||
import { resolveMaxMeterage } from '../common/utils/meterage';
|
||||
import { AgentLevelService } from '../agent/agent-level.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { eventForStatus } from '../notifications/notification-events';
|
||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||
@@ -81,6 +82,7 @@ export class OrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly agentLevelService: AgentLevelService,
|
||||
) {}
|
||||
|
||||
async findMyOrders(userId: string) {
|
||||
@@ -498,6 +500,8 @@ export class OrdersService {
|
||||
}
|
||||
});
|
||||
|
||||
await this.agentLevelService.syncAgentForOrder(id);
|
||||
|
||||
return this.findAdminOrder(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SuggestionsController } from './suggestions.controller';
|
||||
import { SuggestionsService } from './suggestions.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [SuggestionsController],
|
||||
providers: [SuggestionsService],
|
||||
exports: [SuggestionsService],
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { NotificationTone } from '@prisma/client';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SuggestionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async listAdmin(customerId?: string) {
|
||||
const rows = await this.prisma.productSuggestion.findMany({
|
||||
@@ -52,6 +57,19 @@ export class SuggestionsService {
|
||||
product: { select: { name: true, code: true, color: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const note = row.note?.trim();
|
||||
this.notifications.dispatchCustomer({
|
||||
customerId: input.customerId,
|
||||
event: 'product_suggestion',
|
||||
title: 'پیشنهاد محصول برای شما',
|
||||
body: note
|
||||
? `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند: ${note}`
|
||||
: `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند.`,
|
||||
tone: NotificationTone.info,
|
||||
link: `/product?id=${input.productId}`,
|
||||
});
|
||||
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user