feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { LedgerService } from './ledger.service';
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
import { Public } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
@Controller('accounting')
export class AccountingController {
constructor(private readonly ledger: LedgerService) {}
@@ -0,0 +1,56 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from './admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (BranchController + WorkshopController):
* POST branch/table
* POST branch, workshop -> create
* GET/PUT branch/{id}, workshop/{id}
* GET branch/active/{id}, workshop/active/{id} -> toggle
* GET user/list -> the branch-manager picker
*/
@Controller('admin/branches')
export class AdminBranchesController {
constructor(private readonly branches: AdminBranchesService) {}
// ── branches ──────────────────────────────────────────────────────
@Post('list')
@HttpCode(200)
@RequirePermission('branch.list')
listBranches(@Body() query: TableQueryDto) {
return this.branches.listBranches(query);
}
@Get('user-list')
@RequirePermission('branch.create')
userList() {
return this.branches.userList();
}
@Post()
@HttpCode(200)
@RequirePermission('branch.create')
createBranch(@Body() data: Record<string, unknown>) {
return this.branches.createBranch(data);
}
@Get('active/:id')
@RequirePermission('branch.list')
toggleBranchActive(@Param('id') id: string) {
return this.branches.toggleBranchActive(id);
}
@Get(':id')
@RequirePermission('branch.list')
findBranch(@Param('id') id: string) {
return this.branches.findBranch(id);
}
@Put(':id')
@RequirePermission('branch.list')
updateBranch(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.branches.updateBranch(id, data);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminBranchesController } from './admin-branches.controller';
import { AdminBranchesService } from './admin-branches.service';
@Module({
imports: [PrismaModule],
controllers: [AdminBranchesController],
providers: [AdminBranchesService],
// The workshops module reuses this service — branches and workshops are
// near-identical screens and share their logic.
exports: [AdminBranchesService],
})
export class AdminBranchesModule {}
@@ -0,0 +1,361 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Branches and workshops — legacy views/branch/* and views/workshop/*, served by
* BranchController and WorkshopController:
* POST branch/table | workshop/table -> the grids
* POST branch | workshop -> create
* GET branch/{id} | workshop/{id} -> one row
* PUT branch/{id} | workshop/{id} -> update
* GET branch/active/{id} | workshop/active/{id} -> toggle active
*
* The two screens differ in how their manager is handled, and that difference is
* preserved: a BRANCH picks an existing admin (`user_id`), while a WORKSHOP
* creates its supervisor inline from three fields (user_fname/lname/phone).
*/
@Injectable()
export class AdminBranchesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
// ── Branches ───────────────────────────────────────────────────────
/** Legacy `POST branch/table`. */
async listBranches(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliases: Record<string, string> = { 'user.name': 'adminUsers.some.fullName' };
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name', 'address', 'phone'],
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
defaultOrderBy: { name: 'asc' },
});
void aliases;
const [rows, total] = await Promise.all([
this.prisma.branch.findMany({
where: where as Prisma.BranchWhereInput,
orderBy: orderBy as Prisma.BranchOrderByWithRelationInput,
skip,
take,
include: {
city: true,
adminUsers: { where: { isActive: true }, take: 1 },
_count: { select: { adminUsers: true, orders: true } },
},
}),
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
]);
return this.table.respond(
rows.map((b) => ({
id: b.id,
name: b.name,
address: b.address,
phone: b.phone,
active: b.isActive ? 1 : 0,
is_active: b.isActive,
city_id: b.cityId,
city: b.city ? { id: b.city.id, title: b.city.name } : null,
// legacy column key is `user.name` — the branch manager
user: b.adminUsers[0]
? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName }
: null,
users_count: b._count.adminUsers,
orders_count: b._count.orders,
})),
total,
query,
page,
limit,
);
}
async findBranch(id: string) {
const b = await this.prisma.branch.findUnique({
where: { id },
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } },
});
if (!b) throw new NotFoundException('شعبه یافت نشد');
return {
id: b.id,
name: b.name,
address: b.address,
phone: b.phone,
is_active: b.isActive,
city_id: b.cityId,
user_id: b.adminUsers[0]?.id ?? null,
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
};
}
/** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */
async createBranch(data: Record<string, unknown>) {
const name = String(data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شعبه الزامی است');
const branch = await this.prisma.branch.create({
data: {
name,
address: (data.address as string) || null,
phone: (data.phone as string) || null,
cityId: (data.city_id as string) || null,
},
});
if (data.user_id) {
await this.assignBranchManager(branch.id, String(data.user_id));
}
return this.findBranch(branch.id);
}
/** Legacy `PUT branch/{id}`. */
async updateBranch(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.branch.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('شعبه یافت نشد');
const patch: Prisma.BranchUpdateInput = {};
if (data.name !== undefined) patch.name = String(data.name);
if (data.address !== undefined) patch.address = (data.address as string) || null;
if (data.phone !== undefined) patch.phone = (data.phone as string) || null;
if (data.city_id !== undefined) {
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
}
await this.prisma.branch.update({ where: { id }, data: patch });
if (data.user_id !== undefined) {
await this.assignBranchManager(id, (data.user_id as string) || null);
}
return this.findBranch(id);
}
/**
* Moves the named admin onto this branch and clears whoever was on it before,
* so a branch keeps exactly one manager. Legacy did the same through its
* `->update(['branch_id' => null])` / `->update(['branch_id' => $id])` pair.
*/
private async assignBranchManager(branchId: string, userId: string | null) {
await this.prisma.$transaction(async (tx) => {
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
if (userId) {
const user = await tx.adminUser.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException('کاربر انتخاب‌شده یافت نشد');
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
}
});
}
/** Legacy `GET branch/active/{id}` — a toggle. */
async toggleBranchActive(id: string) {
const b = await this.prisma.branch.findUnique({ where: { id } });
if (!b) throw new NotFoundException('شعبه یافت نشد');
return this.prisma.branch.update({
where: { id },
data: { isActive: !b.isActive },
});
}
// ── Workshops ──────────────────────────────────────────────────────
/** Legacy `POST workshop/table`. */
async listWorkshops(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name', 'address', 'phone'],
allowed: ['id', 'name', 'address', 'phone', 'isActive'],
defaultOrderBy: { name: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.workshop.findMany({
where: where as Prisma.WorkshopWhereInput,
orderBy: orderBy as Prisma.WorkshopOrderByWithRelationInput,
skip,
take,
include: { supervisor: true, _count: { select: { preOrders: true } } },
}),
this.prisma.workshop.count({ where: where as Prisma.WorkshopWhereInput }),
]);
return this.table.respond(
rows.map((w) => this.workshopRow(w)),
total,
query,
page,
limit,
);
}
private workshopRow(
w: Prisma.WorkshopGetPayload<{
include: { supervisor: true; _count: { select: { preOrders: true } } };
}>,
) {
return {
id: w.id,
name: w.name,
address: w.address,
phone: w.phone,
active: w.isActive ? 1 : 0,
is_active: w.isActive,
user: w.supervisor ? { id: w.supervisor.id, name: w.supervisor.fullName } : null,
user_id: w.supervisorId,
preorders_count: w._count.preOrders,
};
}
async findWorkshop(id: string) {
const w = await this.prisma.workshop.findUnique({
where: { id },
include: { supervisor: true, _count: { select: { preOrders: true } } },
});
if (!w) throw new NotFoundException('کارگاه یافت نشد');
const row = this.workshopRow(w);
// The edit form splits the supervisor back into its three fields.
const parts = String(w.supervisor?.fullName ?? '').trim().split(' ');
return {
...row,
workshop_name: w.name,
workshop_address: w.address,
workshop_phone: w.phone,
user_fname: parts[0] ?? '',
user_lname: parts.slice(1).join(' '),
user_phone: w.supervisor?.mobile ?? '',
};
}
/**
* Legacy `POST workshop` — creates the workshop AND its supervisor together,
* from user_fname / user_lname / user_phone.
*/
async createWorkshop(data: Record<string, unknown>) {
const name = String(data.workshop_name ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام کارگاه الزامی است');
const supervisorId = await this.upsertSupervisor(data, null);
const w = await this.prisma.workshop.create({
data: {
name,
address: (data.workshop_address as string) || (data.address as string) || null,
phone: (data.workshop_phone as string) || (data.phone as string) || null,
supervisorId,
},
});
return this.findWorkshop(w.id);
}
/** Legacy `PUT workshop/{id}`. */
async updateWorkshop(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.workshop.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کارگاه یافت نشد');
const patch: Prisma.WorkshopUpdateInput = {};
if (data.workshop_name !== undefined || data.name !== undefined) {
patch.name = String(data.workshop_name ?? data.name);
}
if (data.workshop_address !== undefined || data.address !== undefined) {
patch.address = ((data.workshop_address ?? data.address) as string) || null;
}
if (data.workshop_phone !== undefined || data.phone !== undefined) {
patch.phone = ((data.workshop_phone ?? data.phone) as string) || null;
}
const supervisorId = await this.upsertSupervisor(data, existing.supervisorId);
if (supervisorId !== undefined) {
patch.supervisor = supervisorId
? { connect: { id: supervisorId } }
: { disconnect: true };
}
await this.prisma.workshop.update({ where: { id }, data: patch });
return this.findWorkshop(id);
}
/**
* Creates or updates the workshop's supervisor from the inline fields.
* Returns the id to link, or `undefined` when the form sent no supervisor data
* at all (so an update leaves the existing link alone).
*/
private async upsertSupervisor(
data: Record<string, unknown>,
currentId: string | null,
): Promise<string | null | undefined> {
const fname = String(data.user_fname ?? '').trim();
const lname = String(data.user_lname ?? '').trim();
const phone = String(data.user_phone ?? '').trim();
if (!fname && !lname && !phone) return undefined;
if (!phone) throw new BadRequestException('موبایل سرپرست کارگاه الزامی است');
const fullName = `${fname} ${lname}`.trim();
if (currentId) {
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
if (current) {
if (phone !== current.mobile) {
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
if (clash && clash.id !== currentId) {
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
}
}
await this.prisma.adminUser.update({
where: { id: currentId },
data: { fullName, mobile: phone },
});
return currentId;
}
}
// Reuse an existing admin with that mobile rather than colliding on the
// unique constraint.
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
if (existing) return existing.id;
const created = await this.prisma.adminUser.create({
data: {
fullName,
mobile: phone,
// No password is set: the supervisor cannot sign in until one is issued.
passwordHash: '',
role: AdminRole.WORKSHOP_SUPERVISOR,
},
});
return created.id;
}
/** Legacy `GET workshop/active/{id}` — a toggle. */
async toggleWorkshopActive(id: string) {
const w = await this.prisma.workshop.findUnique({ where: { id } });
if (!w) throw new NotFoundException('کارگاه یافت نشد');
return this.prisma.workshop.update({
where: { id },
data: { isActive: !w.isActive },
});
}
/** The admin picker the branch form uses — legacy `GET user/list`. */
async userList() {
const rows = await this.prisma.adminUser.findMany({
where: { isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true, branchId: true },
});
return rows.map((u) => ({
id: u.id,
name: u.fullName,
mobile: u.mobile,
branch_id: u.branchId,
}));
}
}
@@ -0,0 +1,92 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { AdminCategoriesService } from './admin-categories.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (CategoryController + the collection screens):
* POST category -> the grid (a read expressed as POST, legacy's shape)
* POST category/ -> create
* PUT category/{id} -> update
* DELETE category/{id} -> delete
* GET ciny -> collections ("سینی")
*/
@Controller('admin/categories')
export class AdminCategoriesController {
constructor(private readonly categories: AdminCategoriesService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('collection.list')
list(@Body() query: TableQueryDto) {
return this.categories.list(query);
}
@Get('tree')
@RequirePermission('collection.list')
tree(@Query('type') type?: string) {
return this.categories.tree(type ? Number(type) : undefined);
}
@Get('type-options')
@RequirePermission('collection.list')
typeOptions() {
return this.categories.typeOptions();
}
// ── collections ("سینی") ─────────────────────────────────────────
@Post('collections/list')
@HttpCode(200)
@RequirePermission('collection.list')
listCollections(@Body() query: TableQueryDto) {
return this.categories.listCollections(query);
}
@Post('collections')
@HttpCode(200)
@RequirePermission('collection.create')
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
return this.categories.createCollection(body);
}
@Put('collections/:id')
@RequirePermission('collection.list.edit')
updateCollection(
@Param('id') id: string,
@Body() body: { title?: string; description?: string; is_active?: boolean },
) {
return this.categories.updateCollection(id, body);
}
@Delete('collections/:id')
@RequirePermission('collection.list.edit')
removeCollection(@Param('id') id: string) {
return this.categories.removeCollection(id);
}
// ── categories ────────────────────────────────────────────────────
@Post()
@HttpCode(200)
@RequirePermission('collection.create')
create(@Body() data: Record<string, unknown>) {
return this.categories.create(data);
}
@Get(':id')
@RequirePermission('collection.list')
findOne(@Param('id') id: string) {
return this.categories.findOne(id);
}
@Put(':id')
@RequirePermission('collection.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.categories.update(id, data);
}
@Delete(':id')
@RequirePermission('collection.list.edit')
remove(@Param('id') id: string) {
return this.categories.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCategoriesController } from './admin-categories.controller';
import { AdminCategoriesService } from './admin-categories.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCategoriesController],
providers: [AdminCategoriesService],
})
export class AdminCategoriesModule {}
@@ -0,0 +1,287 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Category + Collection administration — legacy views/apps/category/*
* (CategoryController: `POST category`, `POST category/`, `PUT category/{id}`,
* `DELETE category/{id}`) and the "سینی" collection screens.
*
* Legacy Category.type: 1 سایت | 2 اپلیکشن موبایل.
*/
const TYPE_LABELS: Record<number, string> = {
1: 'سایت',
2: 'اپلیکشن موبایل',
};
@Injectable()
export class AdminCategoriesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'title',
'type',
'parentId',
'parent.title',
// legacy filter keys
'type_str',
];
/** Legacy `POST category` — the category grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) =>
f.field === 'type_str' ? { ...f, field: 'type', value: Number(f.value) } : f,
),
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['title'],
allowed: AdminCategoriesService.ALLOWED,
defaultOrderBy: { title: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.category.findMany({
where: where as Prisma.CategoryWhereInput,
orderBy: orderBy as Prisma.CategoryOrderByWithRelationInput,
skip,
take,
include: { parent: true, _count: { select: { products: true, children: true } } },
}),
this.prisma.category.count({ where: where as Prisma.CategoryWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
c: Prisma.CategoryGetPayload<{
include: { parent: true; _count: { select: { products: true; children: true } } };
}>,
) {
return {
id: c.id,
title: c.title,
type: c.type,
type_str: TYPE_LABELS[c.type] ?? '-',
image: c.imageUrl,
icon: c.iconUrl,
meta: c.metaTag,
body_html: c.description,
parent_id_id: c.parentId,
parent: c.parent ? { id: c.parent.id, title: c.parent.title } : null,
products_count: c._count.products,
children_count: c._count.children,
};
}
async findOne(id: string) {
const c = await this.prisma.category.findUnique({
where: { id },
include: { parent: true, _count: { select: { products: true, children: true } } },
});
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
return this.toRow(c);
}
/** The parent picker — legacy loaded the full list and built a tree client-side. */
async tree(type?: number) {
const rows = await this.prisma.category.findMany({
where: type ? { type } : {},
orderBy: { title: 'asc' },
include: { parent: true },
});
return rows.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
parent_id_id: c.parentId,
}));
}
/** Legacy `POST category/`. */
async create(data: Record<string, unknown>) {
const title = String(data.title ?? '').trim();
if (!title) throw new BadRequestException('عنوان دسته الزامی است');
return this.prisma.category.create({
data: {
title,
type: data.type !== undefined ? Number(data.type) : 1,
imageUrl: (data.image as string) || null,
iconUrl: (data.icon as string) || null,
metaTag: (data.meta as string) || null,
description: (data.body_html as string) || null,
parentId: (data.parent_id_id as string) || null,
},
});
}
/** Legacy `PUT category/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.category.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('دسته بندی یافت نشد');
// A category cannot be its own parent, nor a descendant's child — that would
// create a cycle in the tree and hang any recursive render. Legacy had no
// such guard.
const newParent = data.parent_id_id as string | undefined;
if (newParent) {
if (newParent === id) {
throw new BadRequestException('یک دسته نمی‌تواند پدر خودش باشد');
}
if (await this.isDescendant(newParent, id)) {
throw new BadRequestException('دسته‌ی انتخاب‌شده زیرمجموعه‌ی همین دسته است');
}
}
const patch: Prisma.CategoryUpdateInput = {};
if (data.title !== undefined) patch.title = String(data.title);
if (data.type !== undefined) patch.type = Number(data.type);
if (data.image !== undefined) patch.imageUrl = (data.image as string) || null;
if (data.icon !== undefined) patch.iconUrl = (data.icon as string) || null;
if (data.meta !== undefined) patch.metaTag = (data.meta as string) || null;
if (data.body_html !== undefined) patch.description = (data.body_html as string) || null;
if (data.parent_id_id !== undefined) {
patch.parent = newParent ? { connect: { id: newParent } } : { disconnect: true };
}
return this.prisma.category.update({ where: { id }, data: patch });
}
/** Walks up from `candidate` to see whether `ancestorId` is above it. */
private async isDescendant(candidate: string, ancestorId: string): Promise<boolean> {
let cursor: string | null = candidate;
const seen = new Set<string>();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const row: { parentId: string | null } | null = await this.prisma.category.findUnique({
where: { id: cursor },
select: { parentId: true },
});
if (!row?.parentId) return false;
if (row.parentId === ancestorId) return true;
cursor = row.parentId;
}
return false;
}
/** Legacy `DELETE category/{id}`. */
async remove(id: string) {
const c = await this.prisma.category.findUnique({
where: { id },
include: { _count: { select: { products: true, children: true } } },
});
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
// Refuses while anything still hangs off it. Legacy deleted regardless,
// which orphaned products and left child categories pointing at a missing
// parent. Recorded in docs/ADMIN-PORT-PLAN.md.
if (c._count.products > 0) {
throw new BadRequestException(
`حذف ممکن نیست: ${c._count.products} محصول در این دسته قرار دارد.`,
);
}
if (c._count.children > 0) {
throw new BadRequestException(
`حذف ممکن نیست: این دسته ${c._count.children} زیردسته دارد.`,
);
}
await this.prisma.category.delete({ where: { id } });
return { success: true };
}
typeOptions() {
return Object.entries(TYPE_LABELS).map(([value, title]) => ({ value: Number(value), title }));
}
// ── Collections ("سینی") ───────────────────────────────────────────
/** Legacy `GET ciny` / the collection grid. */
async listCollections(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title'],
allowed: ['id', 'title', 'isActive'],
defaultOrderBy: { title: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.collection.findMany({
where: where as Prisma.CollectionWhereInput,
orderBy: orderBy as Prisma.CollectionOrderByWithRelationInput,
skip,
take,
include: { _count: { select: { products: true } } },
}),
this.prisma.collection.count({ where: where as Prisma.CollectionWhereInput }),
]);
return this.table.respond(
rows.map((c) => ({
id: c.id,
title: c.title,
description: c.description,
is_active: c.isActive,
products_count: c._count.products,
})),
total,
query,
page,
limit,
);
}
async createCollection(data: { title: string; description?: string; is_active?: boolean }) {
if (!data.title?.trim()) throw new BadRequestException('عنوان سینی الزامی است');
return this.prisma.collection.create({
data: {
title: data.title.trim(),
description: data.description ?? null,
isActive: data.is_active ?? true,
},
});
}
async updateCollection(
id: string,
data: { title?: string; description?: string; is_active?: boolean },
) {
const existing = await this.prisma.collection.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سینی یافت نشد');
return this.prisma.collection.update({
where: { id },
data: {
...(data.title !== undefined ? { title: data.title } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.is_active !== undefined ? { isActive: data.is_active } : {}),
},
});
}
async removeCollection(id: string) {
const c = await this.prisma.collection.findUnique({ where: { id } });
if (!c) throw new NotFoundException('سینی یافت نشد');
// The join rows are this collection's own membership list, so clearing them
// removes no product — unlike the category case above.
await this.prisma.$transaction([
this.prisma.collectionProduct.deleteMany({ where: { collectionId: id } }),
this.prisma.collection.delete({ where: { id } }),
]);
return { success: true };
}
}
@@ -0,0 +1,22 @@
import { Controller, HttpCode, Post } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/**
* Legacy `POST cities/list` — the city lookup the table filters use.
* Returns `{ rows }`, matching what the users store's setCities() reads.
*
* Deliberately NOT permission-gated: legacy called this from table models at
* module load, before any rid check, and it exposes nothing sensitive.
*/
@Controller('admin/cities')
export class AdminCitiesController {
constructor(private readonly prisma: PrismaService) {}
@Post('list')
@HttpCode(200)
async list() {
const cities = await this.prisma.city.findMany({ orderBy: { name: 'asc' } });
// legacy shape: { id, title }
return { rows: cities.map((c) => ({ id: c.id, title: c.name, province: c.province })) };
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCitiesController } from './admin-cities.controller';
@Module({
imports: [PrismaModule],
controllers: [AdminCitiesController],
})
export class AdminCitiesModule {}
@@ -0,0 +1,59 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminCommentsService } from './admin-comments.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (CommentController, routes/admin.php:149-152):
* POST comment/table -> the moderation grid
* POST comment/sts -> approve / reject
* POST comment -> staff reply
* DELETE comment/{id}
*
* All gated on the legacy rid `comment`.
*/
@Controller('admin/comments')
export class AdminCommentsController {
constructor(private readonly comments: AdminCommentsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('comment')
list(@Body() query: TableQueryDto) {
return this.comments.list(query);
}
@Get('status-options')
@RequirePermission('comment')
statusOptions() {
return this.comments.statusOptions();
}
@Post('sts')
@HttpCode(200)
@RequirePermission('comment')
changeStatus(@Body() body: { id: string; sts: number }) {
return this.comments.changeStatus(body.id, body.sts);
}
@Post()
@HttpCode(200)
@RequirePermission('comment')
create(
@Body() body: { product_id: string; customer_id: string; comment: string; reply_to?: string },
) {
return this.comments.create(body);
}
@Get(':id')
@RequirePermission('comment')
findOne(@Param('id') id: string) {
return this.comments.findOne(id);
}
@Delete(':id')
@RequirePermission('comment')
remove(@Param('id') id: string) {
return this.comments.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCommentsController } from './admin-comments.controller';
import { AdminCommentsService } from './admin-comments.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCommentsController],
providers: [AdminCommentsService],
})
export class AdminCommentsModule {}
@@ -0,0 +1,203 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CommentStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Product comment moderation — legacy views/customer/Comments/Comments.vue,
* served by CommentController (`POST comment/table`, `POST comment/sts`,
* `POST comment`, `DELETE comment/{id}`).
*
* Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'].
* The numeric codes are preserved on the wire (`sts`) so the ported view's
* status dropdown posts unchanged, while the DB stores an enum.
*/
const STATUS_BY_CODE: Record<number, CommentStatus> = {
1: CommentStatus.PENDING,
2: CommentStatus.APPROVED,
3: CommentStatus.REJECTED,
};
const CODE_BY_STATUS: Record<CommentStatus, number> = {
PENDING: 1,
APPROVED: 2,
REJECTED: 3,
};
const STATUS_LABELS: Record<CommentStatus, string> = {
PENDING: 'در انتظار تایید',
APPROVED: 'تایید شده',
REJECTED: 'رد شده',
};
@Injectable()
export class AdminCommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'status',
'createdAt',
'updatedAt',
'body',
'customer.fullName',
'customer.mobile',
'product.title',
// legacy filter keys
'sts',
'comment',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
sts: 'status',
comment: 'body',
caj: 'createdAt',
uaj: 'updatedAt',
'customer.name': 'customer.fullName',
'product.name': 'product.title',
};
/** Legacy `POST comment/table`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) =>
f ? (AdminCommentsService.FIELD_ALIASES[f] ?? f) : f;
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
// legacy sent the numeric status code
if (mapped.field === 'status' && mapped.value !== undefined) {
mapped.value = STATUS_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['body', 'customer.fullName', 'product.title'],
allowed: AdminCommentsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.comment.findMany({
where: where as Prisma.CommentWhereInput,
orderBy: orderBy as Prisma.CommentOrderByWithRelationInput,
skip,
take,
include: { customer: true, product: true, replyTo: true, replies: true },
}),
this.prisma.comment.count({ where: where as Prisma.CommentWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
c: Prisma.CommentGetPayload<{
include: { customer: true; product: true; replyTo: true; replies?: true };
}> & { replies?: { id: string; body: string }[] },
) {
return {
id: c.id,
comment: c.body,
body: c.body,
sts: CODE_BY_STATUS[c.status],
sts_str: STATUS_LABELS[c.status],
status: c.status,
reply_to: c.replyToId,
// legacy view reads: text.length > 0 && text[0].comment !== null
reply: (c.replies ?? []).map((r) => ({ id: r.id, comment: r.body })),
product_id: c.productId,
customer_id: c.customerId,
product: { id: c.product.id, title: c.product.title, name: c.product.title },
customer: {
id: c.customer.id,
name: c.customer.fullName,
fname: c.customer.fullName,
lname: '',
mobile: c.customer.mobile,
},
caj: c.createdAt,
uaj: c.updatedAt,
};
}
async findOne(id: string) {
const c = await this.prisma.comment.findUnique({
where: { id },
include: { customer: true, product: true, replyTo: true, replies: true },
});
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
return this.toRow(c);
}
/**
* Legacy `POST comment/sts` — moderate a comment.
* Payload is `{ id, sts }` where sts is 1/2/3.
*/
async changeStatus(id: string, sts: number) {
const c = await this.prisma.comment.findUnique({ where: { id } });
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
const status = STATUS_BY_CODE[Number(sts)];
if (!status) {
throw new NotFoundException('وضعیت نامعتبر است');
}
return this.prisma.comment.update({ where: { id }, data: { status } });
}
/** Legacy `POST comment` — post a staff reply to a comment. */
async create(data: {
product_id: string;
customer_id: string;
comment: string;
reply_to?: string;
}) {
return this.prisma.comment.create({
data: {
productId: data.product_id,
customerId: data.customer_id,
body: data.comment,
replyToId: data.reply_to ?? null,
// A staff reply is visible immediately; legacy did the same.
status: CommentStatus.APPROVED,
},
});
}
/** Legacy `DELETE comment/{id}`. */
async remove(id: string) {
const c = await this.prisma.comment.findUnique({
where: { id },
include: { replies: true },
});
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
// Remove replies first so the self-relation does not block the delete.
await this.prisma.$transaction([
this.prisma.comment.deleteMany({ where: { replyToId: id } }),
this.prisma.comment.delete({ where: { id } }),
]);
return { success: true };
}
/** Farsi labels for the status dropdown, keyed by legacy's numeric codes. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([status, title]) => ({
value: CODE_BY_STATUS[status as CommentStatus],
title,
}));
}
}
@@ -0,0 +1,176 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Wholesale customer lists. Legacy endpoints, one per near-identical view:
* POST customer -> OmdeList
* POST banker -> BankerList
* POST shopkeeper -> ShopkeeperList
* POST major/verify/table -> OmdeNonVerifiedList
* POST major/archive/table -> OmdeArchivedList
* POST customer/toggle-active, customer/toggle-sts, major/verify
* GET major/archive/{id}, major/dashboard/{id}
*/
@Controller('admin/customers-omde')
export class AdminCustomersController {
constructor(private readonly customers: AdminCustomersService) {}
private list(query: TableQueryDto, scope: OmdeScope) {
return this.customers.list(query, scope);
}
@Post('list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listAll(@Body() query: TableQueryDto) {
return this.list(query, 'all');
}
@Post('banker/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listBanker(@Body() query: TableQueryDto) {
return this.list(query, 'banker');
}
@Post('shopkeeper/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listShopkeeper(@Body() query: TableQueryDto) {
return this.list(query, 'shopkeeper');
}
@Post('non-verified/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listNonVerified(@Body() query: TableQueryDto) {
return this.list(query, 'non_verified');
}
@Post('archived/list')
@HttpCode(200)
@RequirePermission('persons.list.client.omde')
listArchived(@Body() query: TableQueryDto) {
return this.list(query, 'archived');
}
/** Legacy POST single-customer — the retail customer list. */
@Post('single/list')
@HttpCode(200)
@RequirePermission('persons.list.client.single')
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.customers.listSingle(query, admin.branchId ?? undefined);
}
/**
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
* form. Returns a plain array of {id, name}, as legacy did.
*/
@Get('query/reagent')
@RequirePermission('persons.create')
queryReagents() {
return this.customers.queryReagents();
}
/** Legacy POST wallet/charge. */
@Post('wallet/charge')
@HttpCode(200)
@RequirePermission('wallet')
chargeWallet(@Body() body: { customer_id: string; value: string | number }) {
return this.customers.chargeWallet(body);
}
@Post('create')
@HttpCode(200)
@RequirePermission('persons.create')
create(@Body() data: Record<string, unknown>) {
return this.customers.create(data);
}
@Put(':id')
@RequirePermission('persons.list.client.omde.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.customers.update(id, data);
}
/** Legacy POST reagent — the sales-expert list. */
@Post('reagent/list')
@HttpCode(200)
@RequirePermission('persons.list.sellers')
listReagents(@Body() query: TableQueryDto) {
return this.customers.listReagents(query);
}
/** Legacy `GET single/order/{id}` — that customer's orders. */
@Get(':id/orders')
@RequirePermission('persons.list.client.omde.view.orders')
customerOrders(@Param('id') id: string) {
return this.customers.customerOrders(id);
}
/** Legacy `GET single/report/{id}` — that customer's ledger documents. */
@Get(':id/documents')
@RequirePermission('persons.list.client.omde.view.documents')
customerDocuments(@Param('id') id: string) {
return this.customers.customerDocuments(id);
}
/** Legacy `GET single/wallet/{id}` — that customer's wallet bonuses. */
@Get(':id/wallet')
@RequirePermission('wallet')
customerWallet(@Param('id') id: string) {
return this.customers.customerWallet(id);
}
@Get('dashboard/:id')
@RequirePermission('persons.list.client.omde.view')
dashboard(@Param('id') id: string) {
return this.customers.dashboard(id);
}
@Get('archive/:id')
@RequirePermission('persons.list.client.omde.edit')
toggleArchive(@Param('id') id: string) {
return this.customers.toggleArchive(id);
}
@Post('toggle-active')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
toggleActive(@Body() body: { id: string }) {
return this.customers.toggleActive(body.id);
}
@Post('toggle-sts')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
toggleSts(@Body() body: { id: string }) {
return this.customers.toggleSts(body.id);
}
@Post('verify')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
verify(
@Body()
body: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number },
) {
return this.customers.verify(body);
}
@Post('credit')
@HttpCode(200)
@RequirePermission('persons.list.client.omde.edit')
updateCredit(@Body() body: { id: string; gram_level?: string; percent?: string }) {
return this.customers.updateCredit(body.id, body);
}
@Get(':id')
@RequirePermission('persons.list.client.omde.view')
findOne(@Param('id') id: string) {
return this.customers.findOne(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCustomersController } from './admin-customers.controller';
import { AdminCustomersService } from './admin-customers.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCustomersController],
providers: [AdminCustomersService],
exports: [AdminCustomersService],
})
export class AdminCustomersModule {}
@@ -0,0 +1,719 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CustomerKind, Prisma, VerificationStep } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
*
* Legacy shipped FIVE near-identical 615-line views (OmdeList, BankerList,
* ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList) whose table models
* differed by exactly ONE line: the endpoint url. Verified by diff:
* ShopkeeperList vs OmdeList = 4 differing lines (title + model import only).
*
* They are served here by one method with a `scope`, matching the five legacy
* endpoints: customer, banker, shopkeeper, major/verify/table, major/archive/table.
*/
/**
* Legacy Customer::$TYPES, verbatim from henza_server app/Models/Customer.php:85
* (including its leading-space quirks). Kept so rows still carry the numeric
* `type` and Farsi `type_str` the ported grids bind to.
*/
export const CUSTOMER_TYPE_STR: Record<CustomerKind, string> = {
SALES_AGENT: ' کارشناس فروش',
APP_SHOPKEEPER: 'مغازه دار اپلیکیشن',
WALK_IN_SHOPKEEPER: ' مغازه دار حضوری',
BANKER: 'بنکدار',
WORKSHOP_SUPERVISOR: 'سرپرست کارگاه',
};
const LEGACY_TYPE_ID: Record<CustomerKind, number> = {
SALES_AGENT: 1,
APP_SHOPKEEPER: 2,
WALK_IN_SHOPKEEPER: 3,
BANKER: 4,
WORKSHOP_SUPERVISOR: 5,
};
/** Inverse of kindToLegacyType — legacy forms post the numeric `type`. */
export const legacyTypeToKind = (t: unknown): CustomerKind | undefined =>
(Object.keys(LEGACY_TYPE_ID) as CustomerKind[]).find(
(k) => LEGACY_TYPE_ID[k] === Number(t),
);
export const kindToLegacyType = (k: CustomerKind): number => LEGACY_TYPE_ID[k];
export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'archived';
/**
* Legacy exposed verification as two numeric steps. Confirmed from the view's
* own switch statements and its accept/reject payloads (OmdeList.vue:445-487):
*
* 1 = در انتظار (pending) -> HelpCircleIcon
* 2 = accepted -> posted by acceptStepOne/acceptStepTwo
* 3 = rejected -> posted by rejectStepOne/rejectStepTwo
*
* NOTE the template gates the second step and the order/account switches on
* `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them.
* The new schema models one ordered enum, so these numbers are derived from it.
*/
function verifyStepOne(step: VerificationStep): number {
switch (step) {
case VerificationStep.UNVERIFIED:
return 3; // rejected / not yet submitted
case VerificationStep.STEP_ONE_SUBMITTED:
return 1; // pending review
default:
// STEP_TWO_SUBMITTED and VERIFIED both imply step one was accepted
return 2;
}
}
function verifyStepTwo(step: VerificationStep): number {
switch (step) {
case VerificationStep.STEP_TWO_SUBMITTED:
return 1; // pending review
case VerificationStep.VERIFIED:
return 2; // accepted
default:
return 3; // rejected / not reached
}
}
@Injectable()
export class AdminCustomersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'fullName',
'mobile',
'nationalCode',
'cityId',
'isActive',
'createdAt',
'updatedAt',
'verificationStep',
'goldCreditLimitGrams',
'goldDebtGrams',
'fiatDebtRial',
'referredByAgent.fullName',
// legacy filter keys, accepted so the ported table models work unchanged
'name',
'city_id',
'active',
'gram_level',
'remain',
'rial_remain',
];
/** Translates legacy filter field names onto real schema paths. */
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName',
fname: 'fullName',
lname: 'fullName',
city_id: 'cityId',
active: 'isActive',
gram_level: 'goldCreditLimitGrams',
remain: 'goldDebtGrams',
rial_remain: 'fiatDebtRial',
caj: 'createdAt',
uaj: 'updatedAt',
'reagent.name': 'referredByAgent.fullName',
};
private applyAliases(query: TableQueryDto): TableQueryDto {
const mapField = (f?: string) =>
f ? (AdminCustomersService.FIELD_ALIASES[f] ?? f) : f;
return {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
// legacy sent active as '1' / '-1'; the schema stores a boolean
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
}
/** The per-scope WHERE clause, one per legacy endpoint. */
/**
* Verified against the legacy server (henza_server), not inferred from the
* views — AdminController::getBanker / getShopkeeper / getCustomer and
* MajorCustomerController::indexArchive / indexVerify:
*
* customer -> type=2 AND is_archive=0 (مغازه دار اپلیکیشن)
* banker -> type=4 AND is_archive=0 (بنکدار)
* shopkeeper -> type=3 AND is_archive=0 (مغازه دار حضوری)
* major/verify/table -> is_archive=0 AND (step_one=1 OR step_two=1)
* major/archive/table -> is_archive=1 (no type filter)
*
* NOTE every non-archive list also excludes archived rows, so archiving a
* customer removes them from ALL type lists at once.
*/
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
switch (scope) {
case 'banker':
return { kind: CustomerKind.BANKER, isArchived: false };
case 'shopkeeper':
// type 3 only — "لیست مغازه داران حضوری". App shopkeepers (type 2) are
// the main omde list, not this one.
return { kind: CustomerKind.WALK_IN_SHOPKEEPER, isArchived: false };
case 'non_verified':
return {
isArchived: false,
verificationStep: {
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
},
};
case 'archived':
return { isArchived: true };
case 'all':
default:
// legacy getCustomer is type=2 + is_archive=0 — not an unfiltered list.
return { kind: CustomerKind.APP_SHOPKEEPER, isArchived: false };
}
}
async list(query: TableQueryDto, scope: OmdeScope = 'all') {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile', 'nationalCode'],
allowed: AdminCustomersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.CustomerWhereInput = {
AND: [where as Prisma.CustomerWhereInput, this.scopeWhere(scope)],
};
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: { city: true, referredByAgent: true, avatarFile: true },
}),
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved so the ported table models bind unchanged. */
private toRow(
c: Prisma.CustomerGetPayload<{
include: { city: true; referredByAgent: true; avatarFile: true };
}>,
) {
return {
id: c.id,
// Legacy stored fname/lname separately; the new schema has one fullName.
name: c.fullName,
fname: c.fullName,
lname: '',
mobile: c.mobile,
national_code: c.nationalCode,
city_id: c.cityId,
city: c.city ? { id: c.city.id, title: c.city.name } : null,
reagent: c.referredByAgent
? { id: c.referredByAgent.id, name: c.referredByAgent.fullName }
: null,
gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial,
// legacy `percent` came from the referring agent's commission rate
percent: c.referredByAgent?.wagePercent ?? null,
// legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive
active: c.isActive ? 1 : -1,
sts: c.canPlaceOrders ? 1 : -1,
sts_str: c.canPlaceOrders ? 'مجاز' : 'غیر مجاز',
type: kindToLegacyType(c.kind),
type_str: CUSTOMER_TYPE_STR[c.kind],
is_archive: c.isArchived ? 1 : 0,
sts_verify_step_one: verifyStepOne(c.verificationStep),
sts_verify_step_two: verifyStepTwo(c.verificationStep),
verificationStep: c.verificationStep,
national_id_image: c.nationalIdImageUrl,
business_license: c.businessLicenseUrl,
shop_image: c.shopImageUrl,
caj: c.createdAt,
uaj: c.updatedAt,
// Legacy returned the avatar as a nested object and the form read
// `data.image.image` for the preview URL — kept in that exact shape.
image_id: c.avatarFileId,
image: c.avatarFile ? { id: c.avatarFile.id, image: c.avatarFile.url } : null,
phone: c.phone,
address: c.address,
store_name: c.storeName,
zone: c.zone,
pasazh_name: c.pasazhName,
mcode: c.memberCode,
};
}
async findOne(id: string) {
const c = await this.prisma.customer.findUnique({
where: { id },
include: {
city: true,
referredByAgent: true,
avatarFile: true,
addresses: { include: { city: true } },
},
});
if (!c) throw new NotFoundException('مشتری یافت نشد');
return {
...this.toRow(c),
addresses: c.addresses.map((a) => ({
id: a.id,
city: a.city ? { id: a.city.id, title: a.city.name } : null,
})),
};
}
/**
* Legacy `POST reagent` — the sales-expert ("کارشناس فروش") list.
*
* In legacy these lived in the same `customer` table under `type=1`, which is
* why the view reuses the customer column set. The redesigned schema models a
* sales expert as an AdminUser with role SALES_EXPERT, so this reads from there
* and maps onto the same legacy field names the ported table expects.
*/
async listReagents(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliases: Record<string, string> = {
name: 'fullName',
lname: 'fullName',
caj: 'createdAt',
active: 'isActive',
percent: 'wagePercent',
};
const mapField = (f?: string) => (f ? (aliases[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: ['id', 'fullName', 'mobile', 'isActive', 'createdAt', 'wagePercent', 'role'],
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.AdminUserWhereInput = {
AND: [where as Prisma.AdminUserWhereInput, { role: 'SALES_EXPERT' }],
};
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: scoped,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { branch: true, _count: { select: { referredCustomers: true } } },
}),
this.prisma.adminUser.count({ where: scoped }),
]);
// Total gold sold per agent, from the orders of the customers they referred.
const mapped = await Promise.all(
rows.map(async (r) => {
const agg = await this.prisma.order.aggregate({
where: { customer: { referredByAgentId: r.id } },
_sum: { totalGoldWeightGrams: true },
});
return {
id: r.id,
name: r.fullName,
fname: r.fullName,
lname: '',
mobile: r.mobile,
city_id: null,
city: null,
percent: r.wagePercent,
grams_sell: agg._sum.totalGoldWeightGrams ?? 0,
child: r._count.referredCustomers,
active: r.isActive ? 1 : -1,
sts: r.isActive ? 1 : -1,
sts_str: r.isActive ? 'مجاز' : 'غیر مجاز',
caj: r.createdAt,
uaj: r.createdAt,
branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null,
// Legacy columns an AdminUser has no counterpart for.
gram_level: null,
remain: null,
rial_remain: null,
phone: null,
address: null,
};
}),
);
return this.table.respond(mapped, total, query, page, limit);
}
/**
* Legacy `POST single-customer` — the retail ("تک") customer list.
*
* Legacy read this from a different table than the wholesale list
* (`orders_orders`-era SingleCustomerOrders lived alongside a separate customer
* record). The redesigned schema has one Customer model, so both lists read the
* same rows; only the column set and actions differ, exactly as the two legacy
* table models did. Same gap noted for orders in docs/ADMIN-PORT-PLAN.md.
*/
async listSingle(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: [...AdminCustomersService.ALLOWED, 'branch.name'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: where as Prisma.CustomerWhereInput,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: {
city: true,
avatarFile: true,
referredByAgent: { include: { branch: true } },
},
}),
this.prisma.customer.count({ where: where as Prisma.CustomerWhereInput }),
]);
return this.table.respond(
rows.map((r) => ({
...this.toRow(r),
// legacy single-customer columns: fname/lname/phone/email/branch.name
email: null,
branch: r.referredByAgent?.branch
? { id: r.referredByAgent.branch.id, name: r.referredByAgent.branch.name }
: null,
})),
total,
query,
page,
limit,
);
}
/** Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`. */
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? '').trim();
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
if (!fullName || !mobile) {
throw new BadRequestException('نام و موبایل الزامی است');
}
return this.prisma.customer.create({
data: {
fullName,
mobile,
nationalCode: nationalCode || mobile, // schema requires it and it is unique
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
// It used to be discarded and '' stored, so the operator typed a password
// that silently did nothing. Hashed here when supplied; '' still means
// "no password yet", which no bcrypt hash can ever match.
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '',
cityId: (data.city_id as string) ?? null,
goldCreditLimitGrams: data.gram_level
? new Prisma.Decimal(String(data.gram_level))
: new Prisma.Decimal(0),
// legacy `type` — defaults to 2 (مغازه دار اپلیکیشن), matching the
// main omde list, when the form omits it.
kind: legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER,
phone: (data.phone as string) ?? null,
address: (data.address as string) ?? null,
storeName: (data.store_name as string) ?? null,
pasazhName: (data.pasazh_name as string) ?? null,
zone: (data.zone as string) ?? null,
memberCode: (data.mcode as string) ?? null,
// legacy `image_id` from MyUpload's `ok` payload
avatarFileId: (data.image_id as string) || null,
// The sales agent who introduced this customer. The form's dropdown was
// populated from a working endpoint and the selection then thrown away.
referredByAgentId: (data.reagent_id as string) || null,
},
});
}
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.customer.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('مشتری یافت نشد');
const patch: Prisma.CustomerUpdateInput = {};
if (data.name !== undefined) patch.fullName = String(data.name);
if (data.mobile !== undefined) patch.mobile = String(data.mobile);
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
if (data.city_id !== undefined) {
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
}
if (data.gram_level !== undefined) {
patch.goldCreditLimitGrams = new Prisma.Decimal(String(data.gram_level));
}
if (data.type !== undefined) {
const kind = legacyTypeToKind(data.type);
if (!kind) throw new BadRequestException('نوع مشتری نامعتبر است');
patch.kind = kind;
}
if (data.phone !== undefined) patch.phone = String(data.phone);
if (data.address !== undefined) patch.address = String(data.address);
if (data.store_name !== undefined) patch.storeName = String(data.store_name);
if (data.pasazh_name !== undefined) patch.pasazhName = String(data.pasazh_name);
if (data.zone !== undefined) patch.zone = String(data.zone);
if (data.mcode !== undefined) patch.memberCode = String(data.mcode);
if (data.reagent_id !== undefined) {
patch.referredByAgent = data.reagent_id
? { connect: { id: String(data.reagent_id) } }
: { disconnect: true };
}
// Only when non-empty: an untouched password field must not clear the hash.
if (data.password) {
patch.passwordHash = await bcrypt.hash(String(data.password), 10);
}
if (data.image_id !== undefined) {
// legacy sent '' to mean "avatar removed"
patch.avatarFile = data.image_id
? { connect: { id: String(data.image_id) } }
: { disconnect: true };
}
return this.prisma.customer.update({ where: { id }, data: patch });
}
/**
* The three per-customer panels the "مشاهده اطلاعات" screens show, ported from
* legacy `single/order/{id}`, `single/report/{id}` and `single/wallet/{id}`.
*/
async customerOrders(customerId: string) {
const rows = await this.prisma.order.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
include: { items: true, branch: true },
});
return rows.map((o) => ({
id: o.id,
name: o.orderNumber,
w: o.totalGoldWeightGrams,
status: o.status,
items_count: o.items.length,
branch: o.branch?.name ?? null,
caj: o.createdAt,
}));
}
async customerDocuments(customerId: string) {
const rows = await this.prisma.ledgerEntry.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
});
return rows.map((e) => ({
id: e.id,
type: e.sourceType,
currency: e.currency,
direction: e.direction,
value: e.amount,
confirmed: e.confirmed ? 1 : 0,
note: e.note,
caj: e.createdAt,
}));
}
async customerWallet(customerId: string) {
const rows = await this.prisma.walletBonus.findMany({
where: { customerId },
orderBy: { grantedAt: 'desc' },
});
return rows.map((w) => ({
id: w.id,
value: w.goldGrams,
is_active: w.isActive,
consumed: Boolean(w.consumedAt),
caj: w.grantedAt,
expires_at: w.expiresAt,
}));
}
/** Legacy `GET query/reagent` — sales experts, for the customer form's picker. */
async queryReagents() {
const rows = await this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true },
});
return rows.map((r) => ({ id: r.id, name: r.fullName }));
}
/**
* Legacy `POST wallet/charge` — grant a wallet bonus to a customer.
*
* Legacy posted `{ customer_id, value }`. The redesigned schema models a
* wallet grant as a WalletBonus in GOLD GRAMS with an expiry, so `value` is
* read as grams. Legacy had no expiry column; a one-year window is applied
* here because `expiresAt` is required — flagged in docs/ADMIN-PORT-PLAN.md.
*/
async chargeWallet(data: { customer_id: string; value: string | number }) {
const customer = await this.prisma.customer.findUnique({ where: { id: data.customer_id } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const grams = new Prisma.Decimal(String(data.value ?? 0));
if (grams.lte(0)) {
throw new BadRequestException('میزان شارژ باید بزرگتر از صفر باشد');
}
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
return this.prisma.walletBonus.create({
data: { customerId: data.customer_id, goldGrams: grams, expiresAt },
});
}
/**
* Legacy `POST customer/toggle-active` — the ACCOUNT switch
* (AdminController::customerToggleActive).
*
* Legacy also nulled `api_token` (force-logout) and, when reactivating, sent
* the customer an SMS. The SMS is deliberately NOT fired inline here — it
* belongs to the notification module — and is noted in docs/ADMIN-PORT-PLAN.md.
*/
async toggleActive(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.customer.update({
where: { id },
data: { isActive: !c.isActive },
});
}
/**
* Legacy `POST customer/toggle-sts` — the ORDERING switch
* ("معلق کردن سفارش" / "فعال کردن سفارش"), AdminController::customerToggleSts.
* Independent of the account switch above.
*/
async toggleSts(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.customer.update({
where: { id },
data: { canPlaceOrders: !c.canPlaceOrders },
});
}
/**
* Legacy `POST major/verify`.
*
* Legacy's payload is `{ customer_id, sts_verify_step_one }` or
* `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or
* 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in
* exactly that shape so the ported view posts unchanged.
*/
async verify(payload: {
customer_id: string;
sts_verify_step_one?: number;
sts_verify_step_two?: number;
}) {
const id = payload.customer_id;
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
let next: VerificationStep;
if (payload.sts_verify_step_one !== undefined) {
next =
payload.sts_verify_step_one === 2
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
: VerificationStep.UNVERIFIED; // rejected
} else if (payload.sts_verify_step_two !== undefined) {
next =
payload.sts_verify_step_two === 2
? VerificationStep.VERIFIED
: VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted
} else {
throw new BadRequestException('مرحله‌ی تایید مشخص نشده است');
}
return this.prisma.customer.update({ where: { id }, data: { verificationStep: next } });
}
/** Legacy `GET major/archive/{id}` — MajorCustomerController::archive. */
async toggleArchive(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.customer.update({
where: { id },
data: { isArchived: !c.isArchived },
});
}
/** Legacy `GET major/dashboard/{id}` — the per-customer summary panel. */
async dashboard(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
const [orderCount, openOrders, ledgerCount] = await Promise.all([
this.prisma.order.count({ where: { customerId: id } }),
this.prisma.order.count({
where: { customerId: id, deliveredAt: null, status: { not: 'CANCELLED' } },
}),
this.prisma.ledgerEntry.count({ where: { customerId: id } }),
]);
return {
total_order: orderCount,
open_order: openOrders,
ledger_entries: ledgerCount,
gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial,
};
}
/** Legacy `PATCH` of the credit level / commission from the list row menu. */
async updateCredit(id: string, data: { gram_level?: string; percent?: string }) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
if (data.gram_level === undefined) {
throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است');
}
return this.prisma.customer.update({
where: { id },
data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) },
});
}
}
@@ -0,0 +1,39 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AdminDashboardService } from './admin-dashboard.service';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints:
* GET get-analytics -> views/DashboardECommerce.vue (wholesale)
* GET get-analytics-tak -> views/DashboardAnalytics.vue (retail)
*/
@Controller('admin/dashboard')
export class AdminDashboardController {
constructor(private readonly dashboard: AdminDashboardService) {}
@Get('omde')
@RequirePermission('dashboard.omde')
omde() {
return this.dashboard.omdeAnalytics();
}
@Get('tak')
@RequirePermission('dashboard.tak')
tak() {
return this.dashboard.takAnalytics();
}
/** Legacy views/report/order_item.vue. */
@Get('report/order-item')
@RequirePermission('report.order.item')
orderItemReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
return this.dashboard.orderItemReport(from, to);
}
/** Legacy views/report/product.vue. */
@Get('report/product')
@RequirePermission('report.product')
productReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
return this.dashboard.productReport(from, to);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminDashboardController } from './admin-dashboard.controller';
import { AdminDashboardService } from './admin-dashboard.service';
@Module({
imports: [PrismaModule],
controllers: [AdminDashboardController],
providers: [AdminDashboardService],
})
export class AdminDashboardModule {}
@@ -0,0 +1,269 @@
import { Injectable } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/**
* Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and
* views/DashboardAnalytics.vue (`GET get-analytics-tak`).
*
* Legacy's ECommerce page is a Vuexy template with most of its English demo
* cards COMMENTED OUT (Congratulations, Support Tracker, Product Orders, Sales
* Stats, Activity Timeline — verified line by line). What is actually live:
* - three 30-day stat cards: new customers, gold sold, order count
* - "عملکرد کارشناسان فروش": agents ranked by grams sold
* - "میزان فروش": sales volume
* - "سفارشات هفته جاری": this week's orders
*
* So this returns exactly those figures rather than the template's demo series.
*/
@Injectable()
export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {}
private daysAgo(n: number) {
const d = new Date();
d.setDate(d.getDate() - n);
return d;
}
/** Orders that have reached the debt-recognition point count as "sold". */
private static readonly SOLD: Prisma.OrderWhereInput = {
status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] },
};
/** Legacy `GET get-analytics` — the wholesale dashboard. */
async omdeAnalytics() {
const since30 = this.daysAgo(30);
const since7 = this.daysAgo(7);
const [customerCount, gramsAgg, orderCount, lastOrderRows, agents, monthAgg] =
await Promise.all([
// تعداد مشتریان ثبت نامی در 30 روز گذشته
this.prisma.customer.count({ where: { createdAt: { gte: since30 } } }),
// طلای فروش رفته در 30 روز گذشته
this.prisma.order.aggregate({
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
_sum: { totalGoldWeightGrams: true },
}),
// تعداد سفارشات 30 روز گذشته
this.prisma.order.count({ where: { createdAt: { gte: since30 } } }),
// سفارشات هفته جاری
this.prisma.order.findMany({
where: { createdAt: { gte: since7 } },
orderBy: { createdAt: 'desc' },
take: 50,
include: { customer: true, branch: true },
}),
// عملکرد کارشناسان فروش
this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
select: { id: true, fullName: true },
}),
// میزان فروش (same 30-day window, kept separate for clarity)
this.prisma.order.aggregate({
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
_sum: { totalGoldWeightGrams: true },
_count: true,
}),
]);
// Grams sold per agent, through the customers they referred. The view sorts
// this itself by grams_sell, so the shape matters more than the order.
const reagent = await Promise.all(
agents.map(async (a) => {
const agg = await this.prisma.order.aggregate({
where: {
AND: [
AdminDashboardService.SOLD,
{ customer: { referredByAgentId: a.id } },
{ createdAt: { gte: since30 } },
],
},
_sum: { totalGoldWeightGrams: true },
_count: true,
});
return {
id: a.id,
name: a.fullName,
grams_sell: agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
order_count: agg._count,
};
}),
);
return {
customerCount,
allgram: gramsAgg._sum.totalGoldWeightGrams ?? 0,
ordercount: orderCount,
reagent,
lastOrder: lastOrderRows.map((o) => this.orderRow(o)),
sumLastOrder: monthAgg._count,
sumLastOrderMonth: monthAgg._sum.totalGoldWeightGrams ?? 0,
};
}
/** Legacy `GET get-analytics-tak` — the retail dashboard. */
async takAnalytics() {
const since7 = this.daysAgo(7);
const rows = await this.prisma.order.findMany({
where: { createdAt: { gte: since7 } },
orderBy: { createdAt: 'desc' },
take: 50,
include: { customer: true, branch: true },
});
// The retail view reads only `model.lastOrder`; the rest of its cards are
// the Vuexy demo series and are not rendered from server data.
return { lastOrder: rows.map((o) => this.orderRow(o)) };
}
/**
* Legacy `report/order_item` — "گزارش تجمیعی سفارشات و اسناد مالی": order
* lines aggregated over a date range.
*/
async orderItemReport(from?: string, to?: string) {
const where = this.dateWhere(from, to);
const orders = await this.prisma.order.findMany({
where,
include: {
customer: true,
items: { include: { productVariant: { include: { product: true } } } },
},
orderBy: { createdAt: 'desc' },
});
const rows = orders.flatMap((o) =>
o.items.map((it) => ({
order_id: o.id,
order_number: o.orderNumber,
customer: o.customer.fullName,
product: it.productVariant.product.title,
variant: it.productVariant.title,
qty: it.quantity,
w: it.computedWeightGrams,
total_w: it.computedWeightGrams.mul(it.quantity),
caj: o.createdAt,
})),
);
const totalWeight = rows.reduce(
(s, r) => s.add(r.total_w),
new Prisma.Decimal(0),
);
return {
rows,
summary: {
order_count: orders.length,
item_count: rows.length,
total_weight: totalWeight,
},
};
}
/** Legacy `report/product` — per-product sales over a date range. */
async productReport(from?: string, to?: string) {
const where = this.dateWhere(from, to);
const orders = await this.prisma.order.findMany({
where,
include: { items: { include: { productVariant: { include: { product: true } } } } },
});
const byProduct = new Map<
string,
{ id: string; title: string; qty: number; weight: Prisma.Decimal; orders: Set<string> }
>();
for (const o of orders) {
for (const it of o.items) {
const p = it.productVariant.product;
const row = byProduct.get(p.id) ?? {
id: p.id,
title: p.title,
qty: 0,
weight: new Prisma.Decimal(0),
orders: new Set<string>(),
};
row.qty += it.quantity;
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
row.orders.add(o.id);
byProduct.set(p.id, row);
}
}
const rows = [...byProduct.values()]
.map((r) => ({
id: r.id,
title: r.title,
total: r.qty,
w: r.weight,
order_count: r.orders.size,
}))
.sort((a, b) => Number(b.w) - Number(a.w));
return { rows, summary: { product_count: rows.length } };
}
/** Shared date filter for both reports; an open end stays open. */
private dateWhere(from?: string, to?: string): Prisma.OrderWhereInput {
if (!from && !to) return {};
const createdAt: Prisma.DateTimeFilter = {};
if (from) createdAt.gte = new Date(from);
if (to) {
// Include the whole end day, not just its midnight.
const end = new Date(to);
end.setHours(23, 59, 59, 999);
createdAt.lte = end;
}
return { createdAt };
}
/** The row shape both dashboards' tables read. */
private orderRow(
o: Prisma.OrderGetPayload<{ include: { customer: true; branch: true } }>,
) {
const labels: Record<OrderStatus, string> = {
SUSPENDED: 'معلق (سقف اعتبار)',
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'در حال آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
CANCELLED: 'لغو شده',
};
// legacy reads `statusColor` straight off the row
const colors: Record<OrderStatus, string> = {
SUSPENDED: 'warning',
PLACED: 'primary',
ADMIN_APPROVED: 'primary',
SOURCING: 'warning',
PREPARING: 'warning',
DELIVERED_TO_AGENT: 'success',
DELIVERED_TO_CUSTOMER: 'success',
CANCELLED: 'danger',
};
return {
id: o.id,
name: o.orderNumber,
customer: o.customer.fullName,
w: o.totalGoldWeightGrams,
sts_str: labels[o.status],
statusColor: colors[o.status],
caj: o.createdAt,
// legacy rendered a parent/children tree for split orders; the redesigned
// schema has no such relation, so both are empty rather than faked.
parent: null,
children: [],
};
}
}
@@ -0,0 +1,106 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminDocumentsService } from './admin-documents.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (CashController + the wallet screens):
* POST cash/table -> the documents grid
* POST cash -> file a repayment document
* GET cash/{id} -> one document
* PUT cash/{id} -> edit
* DELETE cash/{id} -> delete
* GET query/customer -> the customer picker
* POST wallet -> granted wallet bonuses
*/
@Controller('admin/documents')
export class AdminDocumentsController {
constructor(private readonly documents: AdminDocumentsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('document.list')
list(@Body() query: TableQueryDto) {
return this.documents.list(query);
}
@Get('type-options')
@RequirePermission('document.list')
typeOptions() {
return this.documents.typeOptions();
}
@Get('query/customer')
@RequirePermission('document.create')
queryCustomers() {
return this.documents.queryCustomers();
}
@Post('wallet/list')
@HttpCode(200)
@RequirePermission('wallet')
listWallet(@Body() query: TableQueryDto) {
return this.documents.listWallet(query);
}
// ── wallet settings (views/manage/wallet.vue) ────────────────────
@Get('wallet/config')
@RequirePermission('config.wallet')
getWalletConfig() {
return this.documents.getSetting('wallet.config');
}
@Post('wallet/config')
@HttpCode(200)
@RequirePermission('config.wallet')
setWalletConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.config', body);
}
@Get('wallet/birthday/config')
@RequirePermission('config.wallet')
getBirthdayConfig() {
return this.documents.getSetting('wallet.birthday');
}
@Post('wallet/birthday/config')
@HttpCode(200)
@RequirePermission('config.wallet')
setBirthdayConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.birthday', body);
}
@Post()
@HttpCode(200)
@RequirePermission('document.create')
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.documents.create(data, admin.id);
}
/** Legacy POST cash/confirem/{id} — a toggle, not a one-way confirm. */
@Post(':id/confirm')
@HttpCode(200)
@RequirePermission('document.list.edit')
toggleConfirm(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.documents.toggleConfirm(id, admin.id);
}
@Get(':id')
@RequirePermission('document.list')
findOne(@Param('id') id: string) {
return this.documents.findOne(id);
}
@Put(':id')
@RequirePermission('document.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.documents.update(id, data);
}
@Delete(':id')
@RequirePermission('document.list.edit')
remove(@Param('id') id: string) {
return this.documents.remove(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AccountingModule } from '../accounting/accounting.module';
import { AdminDocumentsController } from './admin-documents.controller';
import { AdminDocumentsService } from './admin-documents.service';
@Module({
imports: [PrismaModule, AccountingModule],
controllers: [AdminDocumentsController],
providers: [AdminDocumentsService],
})
export class AdminDocumentsModule {}
@@ -0,0 +1,439 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { LedgerService } from '../accounting/ledger.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
* CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`,
* `GET cash/{id}`, `DELETE cash/{id}`).
*
* A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted
* gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the
* existing LedgerSourceType enum:
*
* 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750)
* 2 حواله -> REPAYMENT_TRANSFER
* 3 چک -> REPAYMENT_CHEQUE
* 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram)
*
* Posting delegates to LedgerService, which owns the karat and price-per-gram
* conversions and the confirm/balance rules. Re-implementing them here would let
* the admin path drift from the customer-facing one.
*/
const TYPE_BY_CODE: Record<number, LedgerSourceType> = {
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
2: LedgerSourceType.REPAYMENT_TRANSFER,
3: LedgerSourceType.REPAYMENT_CHEQUE,
4: LedgerSourceType.REPAYMENT_CASH,
};
/** The instrument name LedgerService.postGoldRepayment expects. */
const INSTRUMENT_BY_TYPE: Record<string, 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'> = {
REPAYMENT_MELTED_GOLD: 'MELTED_GOLD',
REPAYMENT_TRANSFER: 'TRANSFER',
REPAYMENT_CHEQUE: 'CHEQUE',
REPAYMENT_CASH: 'CASH',
};
const CODE_BY_TYPE: Partial<Record<LedgerSourceType, number>> = {
REPAYMENT_MELTED_GOLD: 1,
REPAYMENT_TRANSFER: 2,
REPAYMENT_CHEQUE: 3,
REPAYMENT_CASH: 4,
};
const TYPE_LABELS: Record<LedgerSourceType, string> = {
ORDER_STOCK: 'سفارش',
ORDER_PRE_ORDER: 'سفارش ساختنی',
REPAYMENT_MELTED_GOLD: 'آب شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
WALLET_BONUS: 'هدیه کیف پول',
ADJUSTMENT: 'اصلاح',
};
@Injectable()
export class AdminDocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly ledger: LedgerService,
) {}
private static readonly ALLOWED = [
'id',
'sourceType',
'currency',
'direction',
'amount',
'confirmed',
'createdAt',
'karatPerMille',
'labName',
'customer.fullName',
// legacy filter keys
'type_str',
'ayar',
'lab',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
type_str: 'sourceType',
ayar: 'karatPerMille',
lab: 'labName',
caj: 'createdAt',
uaj: 'createdAt',
'customer.name': 'customer.fullName',
value: 'amount',
price: 'priceRial',
original_value: 'originalValue',
account_name: 'accountName',
bank_name: 'bankName',
to: 'transferTo',
recive_date: 'receivedAt',
price_per_gram: 'pricePerGramRialAtEntry',
ang: 'hallmark',
};
/** Legacy `POST cash/table` — the documents grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'sourceType' && mapped.value !== undefined) {
mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
if (mapped.field === 'confirmed' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['note', 'customer.fullName', 'labName'],
allowed: AdminDocumentsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
// The grid is the REPAYMENT document list; order-generated debt entries are
// not documents an operator files, so they are excluded as legacy's separate
// `cash` table naturally did.
const scoped: Prisma.LedgerEntryWhereInput = {
AND: [
where as Prisma.LedgerEntryWhereInput,
{ sourceType: { in: Object.values(TYPE_BY_CODE) } },
],
};
const [rows, total] = await Promise.all([
this.prisma.ledgerEntry.findMany({
where: scoped,
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
skip,
take,
include: { customer: true, postedByAdminUser: true },
}),
this.prisma.ledgerEntry.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved so the ported table model binds unchanged. */
private toRow(
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
) {
return {
id: e.id,
type: CODE_BY_TYPE[e.sourceType] ?? null,
type_str: TYPE_LABELS[e.sourceType],
sourceType: e.sourceType,
currency: e.currency,
direction: e.direction,
// legacy `value` is the normalized final weight, `original_value` the raw one
value: e.amount,
original_value: e.originalValue,
price: e.priceRial,
price_per_gram: e.pricePerGramRialAtEntry,
ayar: e.karatPerMille,
lab: e.labName,
ang: e.hallmark,
account_name: e.accountName,
bank_name: e.bankName,
to: e.transferTo,
recive_date: e.receivedAt,
note: e.note,
// legacy grid renders `confirmed` through a slot using 0/1
confirmed: e.confirmed ? 1 : 0,
confirmedAt: e.confirmedAt,
customer: {
id: e.customer.id,
name: e.customer.fullName,
fname: e.customer.fullName,
lname: '',
mobile: e.customer.mobile,
},
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
caj: e.createdAt,
uaj: e.createdAt,
};
}
/** Legacy `GET cash/{id}`. */
async findOne(id: string) {
const e = await this.prisma.ledgerEntry.findUnique({
where: { id },
include: { customer: true, postedByAdminUser: true },
});
if (!e) throw new NotFoundException('سند یافت نشد');
return this.toRow(e);
}
/**
* Legacy `POST cash` — file a repayment document.
*
* The gram/karat/price conversions are LedgerService's job; this method only
* translates the legacy form shape and attaches the instrument metadata.
*/
async create(data: Record<string, unknown>, actorAdminUserId: string) {
const typeCode = Number(data.type ?? 2);
const sourceType = TYPE_BY_CODE[typeCode];
if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است');
const customerId = String(data.customer_id ?? '');
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
// LedgerService takes the legacy instrument name and the RAW figure, then
// applies the karat / price-per-gram conversion itself.
const instrument = INSTRUMENT_BY_TYPE[sourceType];
// Which field carries the raw figure depends on the instrument, exactly as
// the legacy form did: cash posts a Rial price, melted gold posts the
// pre-normalization weight, transfer/cheque post grams directly.
const rawValue =
instrument === 'CASH'
? String(data.price ?? 0)
: instrument === 'MELTED_GOLD'
? String(data.original_value ?? data.value ?? 0)
: String(data.value ?? 0);
const entry = await this.ledger.postGoldRepayment({
customerId,
instrument,
rawValue,
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
note: (data.note as string) ?? undefined,
postedByAdminUserId: actorAdminUserId,
});
// Attach the instrument metadata the ledger service does not model.
await this.prisma.ledgerEntry.update({
where: { id: entry.id },
data: this.metadataPatch(data),
});
return this.findOne(entry.id);
}
private metadataPatch(data: Record<string, unknown>): Prisma.LedgerEntryUpdateInput {
const patch: Prisma.LedgerEntryUpdateInput = {};
if (data.lab !== undefined) patch.labName = (data.lab as string) || null;
if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null;
if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null;
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
if (data.original_value !== undefined && data.original_value !== null) {
patch.originalValue = new Prisma.Decimal(String(data.original_value));
}
if (data.price !== undefined && data.price !== null) {
patch.priceRial = new Prisma.Decimal(String(data.price));
}
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
return patch;
}
/**
* Legacy `PUT cash/{id}` — edit a document.
*
* Only metadata and the note are editable. Changing the AMOUNT of a confirmed
* entry would silently rewrite a customer's debt, so that is refused: the
* correct accounting action is a reversing adjustment, not an edit. Legacy
* allowed it. Recorded in docs/ADMIN-PORT-PLAN.md.
*/
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سند یافت نشد');
const amountChanged =
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
if (existing.confirmed && amountChanged) {
throw new BadRequestException(
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
);
}
const patch = this.metadataPatch(data);
if (data.note !== undefined) patch.note = (data.note as string) || null;
if (!existing.confirmed && amountChanged) {
patch.amount = new Prisma.Decimal(String(data.value));
}
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
return this.findOne(id);
}
/**
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
* calling this same endpoint.
*
* Confirming delegates to LedgerService so the customer balance moves through
* the one code path that owns it. Un-confirming reverses that same amount
* rather than editing the balance directly, so the two stay consistent.
*/
async toggleConfirm(id: string, adminUserId: string) {
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!e) throw new NotFoundException('سند یافت نشد');
if (!e.confirmed) {
await this.ledger.confirmEntry(id, adminUserId);
return this.findOne(id);
}
// Un-confirm: undo this entry's effect on the cached balance, then clear the
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
// amount back.
await this.prisma.$transaction(async (tx) => {
const delta =
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
const field =
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
await tx.customer.update({
where: { id: e.customerId },
data: { [field]: { increment: delta } },
});
await tx.ledgerEntry.update({
where: { id },
data: { confirmed: false, confirmedAt: null },
});
});
return this.findOne(id);
}
/**
* Legacy `DELETE cash/{id}`.
*
* A confirmed entry has already moved the customer's balance; deleting it
* would leave that balance overstated with no trace. Refused — legacy allowed
* it. Unconfirmed entries never touched a balance, so they delete cleanly.
*/
async remove(id: string) {
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!e) throw new NotFoundException('سند یافت نشد');
if (e.confirmed) {
throw new BadRequestException(
'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.',
);
}
await this.prisma.ledgerEntry.delete({ where: { id } });
return { success: true };
}
typeOptions() {
return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({
value: Number(code),
title: TYPE_LABELS[sourceType],
}));
}
// ── Wallet ─────────────────────────────────────────────────────────
/** Legacy `POST wallet` grid — the list of granted wallet bonuses. */
async listWallet(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['customer.fullName'],
allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'],
defaultOrderBy: { grantedAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.walletBonus.findMany({
where: where as Prisma.WalletBonusWhereInput,
orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput,
skip,
take,
include: { customer: true },
}),
this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }),
]);
return this.table.respond(
rows.map((w) => ({
id: w.id,
value: w.goldGrams,
grams: w.goldGrams,
is_active: w.isActive,
consumed: Boolean(w.consumedAt),
caj: w.grantedAt,
expires_at: w.expiresAt,
customer: {
id: w.customer.id,
name: w.customer.fullName,
mobile: w.customer.mobile,
},
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST
* counterparts — the wallet settings screen (views/manage/wallet.vue).
* Stored in the Setting key/value table that replaces legacy `config_static`.
*/
async getSetting(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
return row?.value ?? {};
}
async setSetting(key: string, value: Record<string, unknown>) {
const row = await this.prisma.setting.upsert({
where: { key },
update: { value: value as Prisma.InputJsonValue },
create: { key, value: value as Prisma.InputJsonValue },
});
return row.value;
}
/** The customer picker used by the document form — legacy `GET query/customer`. */
async queryCustomers() {
const rows = await this.prisma.customer.findMany({
where: { isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true },
});
return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.fullName, lname: '', mobile: c.mobile }));
}
}
@@ -0,0 +1,62 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminInvoicesService } from './admin-invoices.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (BranchController's `branch/product` family):
* POST branch/product/table
* POST branch/product
* GET branch/product/{id}
* DELETE branch/product/{id}
* GET branch/list -> the branch picker
*/
@Controller('admin/invoices')
export class AdminInvoicesController {
constructor(private readonly invoices: AdminInvoicesService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('invoice.list')
list(@Body() query: TableQueryDto) {
return this.invoices.list(query);
}
@Get('branch-list')
@RequirePermission('invoice.create')
branchList() {
return this.invoices.branchList();
}
@Post()
@HttpCode(200)
@RequirePermission('invoice.create')
create(
@Body() data: { branch_id?: string; products?: string[]; note?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.invoices.create(data, admin.id);
}
@Get(':id')
@RequirePermission('invoice.list')
findOne(@Param('id') id: string) {
return this.invoices.findOne(id);
}
@Put(':id')
@RequirePermission('invoice.list')
update(
@Param('id') id: string,
@Body() data: { branch_id?: string; products?: string[]; note?: string },
) {
return this.invoices.update(id, data);
}
@Delete(':id')
@RequirePermission('invoice.list')
remove(@Param('id') id: string) {
return this.invoices.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminInvoicesController } from './admin-invoices.controller';
import { AdminInvoicesService } from './admin-invoices.service';
@Module({
imports: [PrismaModule],
controllers: [AdminInvoicesController],
providers: [AdminInvoicesService],
})
export class AdminInvoicesModule {}
@@ -0,0 +1,245 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Branch stock allocations — legacy views/invoice/* ("فاکتور انتقال"), served by
* BranchController as `branch/product`:
* POST branch/product/table -> the grid
* POST branch/product -> create { branch_id, products: [id...] }
* GET branch/product/{id} -> one allocation
* DELETE branch/product/{id} -> delete
*
* Despite the screen being labelled an invoice, it is not a sales document: it
* records which products head office allocated to a branch, and who did it.
*/
@Injectable()
export class AdminInvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'note',
'createdAt',
'branchId',
'branch.name',
'allocatorAdminUser.fullName',
// legacy filter keys
'name',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'branch.name',
caj: 'createdAt',
'allocator_user.name': 'allocatorAdminUser.fullName',
};
/** Legacy `POST branch/product/table`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminInvoicesService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => ({ ...f, field: mapField(f.field) })),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['note', 'branch.name'],
allowed: AdminInvoicesService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.branchAllocation.findMany({
where: where as Prisma.BranchAllocationWhereInput,
orderBy: orderBy as Prisma.BranchAllocationOrderByWithRelationInput,
skip,
take,
include: {
branch: true,
allocatorAdminUser: true,
items: { include: { product: true } },
},
}),
this.prisma.branchAllocation.count({ where: where as Prisma.BranchAllocationWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
a: Prisma.BranchAllocationGetPayload<{
include: {
branch: true;
allocatorAdminUser: true;
items: { include: { product: true } };
};
}>,
) {
const sumWeight = a.items.reduce(
(sum, it) => sum.add(it.weightGrams ?? new Prisma.Decimal(0)),
new Prisma.Decimal(0),
);
return {
id: a.id,
// legacy column key is `name` — the receiving branch
name: a.branch.name,
branch_id: a.branchId,
branch: { id: a.branch.id, name: a.branch.name },
sum_weight: sumWeight,
count_item: a.items.length,
note: a.note,
allocator_user: a.allocatorAdminUser
? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName }
: null,
caj: a.createdAt,
products: a.items.map((it) => ({
id: it.productId,
pid: it.productId,
title: it.product.title,
product_code: it.product.productCode,
grams: it.weightGrams,
})),
};
}
/** Legacy `GET branch/product/{id}`. */
async findOne(id: string) {
const a = await this.prisma.branchAllocation.findUnique({
where: { id },
include: {
branch: true,
allocatorAdminUser: true,
items: { include: { product: true } },
},
});
if (!a) throw new NotFoundException('فاکتور یافت نشد');
return this.toRow(a);
}
/**
* Legacy `POST branch/product` with `{ branch_id, products: [id...] }`.
*
* Each product's current lightest variant weight is snapshotted, so a later
* catalog edit does not silently rewrite what this document says was moved.
*/
async create(data: { branch_id?: string; products?: string[]; note?: string }, actorId: string) {
const branchId = String(data.branch_id ?? '');
if (!branchId) throw new BadRequestException('شعبه انتخاب نشده است');
const branch = await this.prisma.branch.findUnique({ where: { id: branchId } });
if (!branch) throw new NotFoundException('شعبه یافت نشد');
const productIds = [...new Set((data.products ?? []).map((p) => String(p)).filter(Boolean))];
if (productIds.length === 0) throw new BadRequestException('هیچ محصولی انتخاب نشده است');
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { variants: { where: { isActive: true } } },
});
if (products.length !== productIds.length) {
throw new BadRequestException('برخی از محصولات انتخاب‌شده یافت نشدند');
}
const allocation = await this.prisma.branchAllocation.create({
data: {
branchId,
allocatorAdminUserId: actorId,
note: data.note ?? null,
items: {
create: products.map((p) => ({
productId: p.id,
weightGrams: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
})),
},
},
});
return this.findOne(allocation.id);
}
/** Legacy `PUT branch/product/{id}` — the edit screen replaces the product set. */
async update(id: string, data: { branch_id?: string; products?: string[]; note?: string }) {
const existing = await this.prisma.branchAllocation.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('فاکتور یافت نشد');
if (data.branch_id) {
const branch = await this.prisma.branch.findUnique({
where: { id: String(data.branch_id) },
});
if (!branch) throw new NotFoundException('شعبه یافت نشد');
}
if (Array.isArray(data.products)) {
const productIds = [...new Set(data.products.map((p) => String(p)).filter(Boolean))];
if (productIds.length === 0) {
throw new BadRequestException('هیچ محصولی انتخاب نشده است');
}
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { variants: { where: { isActive: true } } },
});
await this.prisma.$transaction([
this.prisma.branchAllocationItem.deleteMany({ where: { allocationId: id } }),
this.prisma.branchAllocationItem.createMany({
data: products.map((p) => ({
allocationId: id,
productId: p.id,
weightGrams: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
})),
skipDuplicates: true,
}),
]);
}
await this.prisma.branchAllocation.update({
where: { id },
data: {
...(data.branch_id ? { branchId: String(data.branch_id) } : {}),
...(data.note !== undefined ? { note: data.note } : {}),
},
});
return this.findOne(id);
}
/** Legacy `DELETE branch/product/{id}`. Items cascade. */
async remove(id: string) {
const a = await this.prisma.branchAllocation.findUnique({ where: { id } });
if (!a) throw new NotFoundException('فاکتور یافت نشد');
await this.prisma.branchAllocation.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET branch/list` — the branch picker for the invoice form. */
async branchList() {
const rows = await this.prisma.branch.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true },
});
return rows;
}
}
@@ -0,0 +1,183 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminMiscService } from './admin-misc.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Cities, SMS, notifications, newsletter, activity log and settings — the
* remaining admin screens. See AdminMiscService for the legacy endpoint map.
*/
@Controller('admin/misc')
export class AdminMiscController {
constructor(private readonly misc: AdminMiscService) {}
// ── cities ────────────────────────────────────────────────────────
@Post('cities/list')
@HttpCode(200)
@RequirePermission('cities.list')
listCities(@Body() query: TableQueryDto) {
return this.misc.listCities(query);
}
@Post('cities')
@HttpCode(200)
@RequirePermission('cities.create')
createCity(@Body() data: { title?: string; name?: string; province?: string }) {
return this.misc.createCity(data);
}
@Get('cities/percent/:id')
@RequirePermission('cities.list')
getCityPercent(@Param('id') id: string) {
return this.misc.getCityPercent(id, false);
}
@Get('cities/percent-aux/:id')
@RequirePermission('cities.list')
getCityPercentAux(@Param('id') id: string) {
return this.misc.getCityPercent(id, true);
}
@Put('cities/:id')
@RequirePermission('cities.list.view.edit')
updateCity(@Param('id') id: string, @Body() data: Record<string, never>) {
return this.misc.updateCity(id, data);
}
@Delete('cities/:id')
@RequirePermission('cities.list.remove')
removeCity(@Param('id') id: string) {
return this.misc.removeCity(id);
}
// ── sms ───────────────────────────────────────────────────────────
@Post('sms/list')
@HttpCode(200)
@RequirePermission('sms.list')
listSms(@Body() query: TableQueryDto) {
return this.misc.listSms(query);
}
@Post('sms')
@HttpCode(200)
@RequirePermission('sms.create')
sendSms(
@Body() data: { type?: string | number; mobile?: string; text?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.sendSms(data, admin.id);
}
// ── notifications ─────────────────────────────────────────────────
@Post('notify/list')
@HttpCode(200)
@RequirePermission('notify.me')
listNotifications(@Body() query: TableQueryDto) {
return this.misc.listNotifications(query);
}
@Post('notify')
@HttpCode(200)
@RequirePermission('notify.create')
createNotification(
@Body() data: { title?: string; text?: string; body?: string; type?: string | number },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.createNotification(data, admin.id);
}
@Post('notify/customer')
@HttpCode(200)
@RequirePermission('notify.create')
notifyCustomer(
@Body() data: { customer_id?: string; title?: string; text?: string; body?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.notifyCustomer(data, admin.id);
}
@Get('notify/read/:id')
@RequirePermission('notify.me')
markRead(@Param('id') id: string) {
return this.misc.markNotificationRead(id);
}
@Get('notify/:id')
@RequirePermission('notify.me')
findNotification(@Param('id') id: string) {
return this.misc.findNotification(id);
}
// ── newsletter ────────────────────────────────────────────────────
@Post('newsletter/list')
@HttpCode(200)
@RequirePermission('newsletters')
listNewsletter(@Body() query: TableQueryDto) {
return this.misc.listNewsletter(query);
}
// ── activity log ──────────────────────────────────────────────────
@Get('log/customers')
@RequirePermission('statics')
logCustomers() {
return this.misc.logCustomers();
}
@Get('log/customer/:id')
@RequirePermission('statics')
customerLog(@Param('id') id: string) {
return this.misc.customerLog(id);
}
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
@Get('dashboard-links')
@RequirePermission('config.report')
dashboardLinks() {
return this.misc.dashboardLinks();
}
@Get('const')
@RequirePermission('config.statics')
listConsts() {
return this.misc.listConsts();
}
@Post('brands')
@HttpCode(200)
@RequirePermission('config.statics.brand.create')
createBrand(@Body() body: { title: string }) {
return this.misc.createBrand(body.title);
}
@Put('brands/:id')
@RequirePermission('config.statics.brand.edit')
updateBrand(@Param('id') id: string, @Body() body: { title: string }) {
return this.misc.updateBrand(id, body.title);
}
@Delete('brands/:id')
@RequirePermission('config.statics.brand.remove')
removeBrand(@Param('id') id: string) {
return this.misc.removeBrand(id);
}
@Get('const/:key')
@RequirePermission('config.statics')
getConst(@Param('key') key: string) {
return this.misc.getConst(key);
}
@Put('const/:key')
@RequirePermission('config.statics')
setConst(@Param('key') key: string, @Body() body: Record<string, unknown>) {
// Legacy posts either a bare value under `val` or a whole object.
return this.misc.setConst(key, body.val !== undefined ? body.val : body);
}
@Delete('const/:key')
@RequirePermission('config.statics')
removeConst(@Param('key') key: string) {
return this.misc.removeConst(key);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminMiscController } from './admin-misc.controller';
import { AdminMiscService } from './admin-misc.service';
@Module({
imports: [PrismaModule],
controllers: [AdminMiscController],
providers: [AdminMiscService],
})
export class AdminMiscModule {}
@@ -0,0 +1,524 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { MessageAudience, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* The remaining admin screens — legacy views/city, views/sms, views/notify,
* views/newsletters, views/statics and views/manage/*:
*
* cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id}
* sms/table, sms
* notify, notify/customer, notify/{id}, notify/read/{id}
* ddf -> the newsletter list (legacy's own opaque name)
* log/list/customer, log/ago/{id}
* const, const/{key}, const/by-id/{id}
* dashboard/link
*/
@Injectable()
export class AdminMiscService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
// ── Cities ─────────────────────────────────────────────────────────
/** Legacy `POST cities/list` grid. */
async listCities(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['name', 'province'],
allowed: ['id', 'name', 'province', 'percent', 'percentAux'],
defaultOrderBy: { name: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.city.findMany({
where: where as Prisma.CityWhereInput,
orderBy: orderBy as Prisma.CityOrderByWithRelationInput,
skip,
take,
include: { _count: { select: { customers: true, branches: true } } },
}),
this.prisma.city.count({ where: where as Prisma.CityWhereInput }),
]);
return this.table.respond(
rows.map((c) => ({
id: c.id,
title: c.name,
name: c.name,
province: c.province,
percent: c.percent,
percent_aux: c.percentAux,
customers_count: c._count.customers,
branches_count: c._count.branches,
})),
total,
query,
page,
limit,
);
}
/** Legacy `POST cities`. */
async createCity(data: { title?: string; name?: string; province?: string }) {
const name = String(data.title ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شهر الزامی است');
return this.prisma.city.create({ data: { name, province: data.province ?? null } });
}
/** Legacy `PUT cities/{id}` — the grid's inline edit also sets the percents. */
async updateCity(
id: string,
data: { title?: string; name?: string; province?: string; percent?: unknown; percent_aux?: unknown },
) {
const existing = await this.prisma.city.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('شهر یافت نشد');
const patch: Prisma.CityUpdateInput = {};
if (data.title !== undefined || data.name !== undefined) {
patch.name = String(data.title ?? data.name);
}
if (data.province !== undefined) patch.province = data.province ?? null;
if (data.percent !== undefined) {
patch.percent = data.percent === null ? null : new Prisma.Decimal(String(data.percent));
}
if (data.percent_aux !== undefined) {
patch.percentAux =
data.percent_aux === null ? null : new Prisma.Decimal(String(data.percent_aux));
}
return this.prisma.city.update({ where: { id }, data: patch });
}
/**
* Legacy `DELETE cities/{id}`.
*
* Refuses while customers or branches still point at the city — legacy deleted
* regardless, orphaning those rows.
*/
async removeCity(id: string) {
const c = await this.prisma.city.findUnique({
where: { id },
include: { _count: { select: { customers: true, branches: true, addresses: true } } },
});
if (!c) throw new NotFoundException('شهر یافت نشد');
const refs = c._count.customers + c._count.branches + c._count.addresses;
if (refs > 0) {
throw new BadRequestException(
`حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`,
);
}
await this.prisma.city.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET cities/get-percent/{id}` — returns the bare number. */
async getCityPercent(id: string, aux: boolean) {
const c = await this.prisma.city.findUnique({ where: { id } });
if (!c) throw new NotFoundException('شهر یافت نشد');
return aux ? c.percentAux : c.percent;
}
// ── SMS ────────────────────────────────────────────────────────────
/** Legacy `POST sms/table` — the sent-message log. */
async listSms(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['body', 'recipients'],
allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.smsMessage.findMany({
where: where as Prisma.SmsMessageWhereInput,
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
skip,
take,
include: { sentByAdminUser: true },
}),
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
]);
return this.table.respond(
rows.map((s) => ({
id: s.id,
type: s.audience,
text: s.body,
mobile: s.recipients,
sent_count: s.sentCount,
sender: s.sentByAdminUser?.fullName ?? null,
caj: s.createdAt,
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST sms` — records the send.
*
* NOTE no SMS gateway is wired up: legacy handed the message to an external
* provider, and this port has no credentials for one. The message is stored
* with the audience it resolved to, so the log is accurate, but nothing is
* actually transmitted. Flagged in docs/ADMIN-PORT-PLAN.md.
*/
async sendSms(
data: { type?: string | number; mobile?: string; text?: string },
actorId: string,
) {
const body = String(data.text ?? '').trim();
if (!body) throw new BadRequestException('متن پیامک الزامی است');
const audience = this.resolveAudience(data.type);
const recipients = await this.resolveRecipients(audience, data.mobile);
return this.prisma.smsMessage.create({
data: {
audience,
recipients: recipients.join(','),
body,
sentCount: recipients.length,
sentByAdminUserId: actorId,
},
});
}
/**
* Legacy's numeric audience codes, read off views/sms/create.vue where each
* option is gated by its own rid:
* -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent
* 2 sms.create.omde | 9 sms.create.multiple
* (there is no option for sms.create.single-customer "tak" in the legacy
* markup even though the rid exists — preserved as unreachable.)
*/
private resolveAudience(type?: string | number): MessageAudience {
const byCode: Record<string, MessageAudience> = {
'-1': MessageAudience.SINGLE_NUMBER,
'0': MessageAudience.ALL_CUSTOMERS,
'1': MessageAudience.SALES_AGENTS,
'2': MessageAudience.WHOLESALE_CUSTOMERS,
'9': MessageAudience.MULTIPLE_NUMBERS,
};
const byName: Record<string, MessageAudience> = {
single: MessageAudience.SINGLE_NUMBER,
multiple: MessageAudience.MULTIPLE_NUMBERS,
all: MessageAudience.ALL_CUSTOMERS,
omde: MessageAudience.WHOLESALE_CUSTOMERS,
tak: MessageAudience.RETAIL_CUSTOMERS,
reagent: MessageAudience.SALES_AGENTS,
};
const key = String(type ?? '');
return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER;
}
/** Expands an audience into the actual phone numbers it covers. */
private async resolveRecipients(
audience: MessageAudience,
explicit?: string,
): Promise<string[]> {
if (
audience === MessageAudience.SINGLE_NUMBER ||
audience === MessageAudience.MULTIPLE_NUMBERS
) {
const list = String(explicit ?? '')
.split(/[,\s\n]+/)
.map((x) => x.trim())
.filter(Boolean);
if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است');
return list;
}
if (audience === MessageAudience.SALES_AGENTS) {
const rows = await this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
select: { mobile: true },
});
return rows.map((r) => r.mobile);
}
// The customer audiences. WHOLESALE/RETAIL had a Customer.type column in
// legacy that the schema redesign removed, so both currently resolve to all
// active customers — the same gap recorded for the omde/tak lists.
const rows = await this.prisma.customer.findMany({
where: { isActive: true },
select: { mobile: true },
});
return rows.map((r) => r.mobile);
}
// ── Notifications ──────────────────────────────────────────────────
/** Legacy `POST notify` — broadcast to an audience. */
async createNotification(
data: { title?: string; text?: string; body?: string; type?: string | number },
actorId: string,
) {
const title = String(data.title ?? '').trim();
const body = String(data.body ?? data.text ?? '').trim();
if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است');
if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است');
const audience = this.resolveAudience(data.type);
const customers = await this.prisma.customer.findMany({
where: { isActive: true },
select: { id: true },
});
return this.prisma.notification.create({
data: {
title,
body,
audience,
sentByAdminUserId: actorId,
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
},
include: { _count: { select: { recipients: true } } },
});
}
/** Legacy `POST notify/customer` — send to one named customer. */
async notifyCustomer(
data: { customer_id?: string; title?: string; text?: string; body?: string },
actorId: string,
) {
const customerId = String(data.customer_id ?? '');
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const title = String(data.title ?? '').trim();
const body = String(data.body ?? data.text ?? '').trim();
if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است');
return this.prisma.notification.create({
data: {
title,
body,
audience: MessageAudience.SINGLE_NUMBER,
sentByAdminUserId: actorId,
recipients: { create: [{ customerId }] },
},
});
}
/** Legacy `GET notify/{id}` — one notification with its delivery state. */
async findNotification(id: string) {
const n = await this.prisma.notification.findUnique({
where: { id },
include: {
sentByAdminUser: true,
recipients: { include: { customer: true } },
},
});
if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد');
return {
id: n.id,
title: n.title,
body: n.body,
text: n.body,
audience: n.audience,
sender: n.sentByAdminUser?.fullName ?? null,
caj: n.createdAt,
recipients: n.recipients.map((r) => ({
id: r.id,
customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile },
read_at: r.readAt,
is_read: Boolean(r.readAt),
})),
read_count: n.recipients.filter((r) => r.readAt).length,
total_count: n.recipients.length,
};
}
/** Legacy `POST notify/list` — the notification log. */
async listNotifications(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'body'],
allowed: ['id', 'title', 'body', 'audience', 'createdAt'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.notification.findMany({
where: where as Prisma.NotificationWhereInput,
orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput,
skip,
take,
include: {
sentByAdminUser: true,
_count: { select: { recipients: true } },
},
}),
this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }),
]);
return this.table.respond(
rows.map((n) => ({
id: n.id,
title: n.title,
text: n.body,
audience: n.audience,
sender: n.sentByAdminUser?.fullName ?? null,
total_count: n._count.recipients,
caj: n.createdAt,
})),
total,
query,
page,
limit,
);
}
/** Legacy `GET notify/read/{id}` — mark one recipient row read. */
async markNotificationRead(recipientId: string) {
const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
if (!r) throw new NotFoundException('یافت نشد');
if (r.readAt) return r;
return this.prisma.notificationRecipient.update({
where: { id: recipientId },
data: { readAt: new Date() },
});
}
// ── Newsletter ─────────────────────────────────────────────────────
/** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */
async listNewsletter(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['email', 'mobile'],
allowed: ['id', 'email', 'mobile', 'createdAt'],
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.newsletterSubscriber.findMany({
where: where as Prisma.NewsletterSubscriberWhereInput,
orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput,
skip,
take,
}),
this.prisma.newsletterSubscriber.count({
where: where as Prisma.NewsletterSubscriberWhereInput,
}),
]);
return this.table.respond(
rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })),
total,
query,
page,
limit,
);
}
// ── Activity log ("آمار") ─────────────────────────────────────────
/** Legacy `GET log/list/customer` — customers that have any activity. */
async logCustomers() {
const rows = await this.prisma.customer.findMany({
where: { activityLogs: { some: {} } },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true },
});
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
}
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
async customerLog(customerId: string) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const rows = await this.prisma.customerActivityLog.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
take: 200,
});
return rows.map((l) => ({
id: l.id,
action: l.action,
detail: l.detail,
caj: l.createdAt,
}));
}
// ── Settings ("مدیریت ثابت ها") ───────────────────────────────────
/** Legacy `GET const/{key}`. */
async getConst(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
return row?.value ?? null;
}
/** Legacy `POST const` / `PUT const/{key}`. */
async setConst(key: string, value: unknown) {
const row = await this.prisma.setting.upsert({
where: { key },
update: { value: value as Prisma.InputJsonValue },
create: { key, value: value as Prisma.InputJsonValue },
});
return row.value;
}
async listConsts() {
const rows = await this.prisma.setting.findMany({ orderBy: { key: 'asc' } });
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
}
async removeConst(key: string) {
const row = await this.prisma.setting.findUnique({ where: { key } });
if (!row) throw new NotFoundException('تنظیم یافت نشد');
await this.prisma.setting.delete({ where: { key } });
return { success: true };
}
// ── Brands (managed from the same "ثابت ها" screen) ───────────────
async createBrand(title: string) {
if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است');
return this.prisma.brand.create({ data: { title: title.trim() } });
}
async updateBrand(id: string, title: string) {
const b = await this.prisma.brand.findUnique({ where: { id } });
if (!b) throw new NotFoundException('برند یافت نشد');
return this.prisma.brand.update({ where: { id }, data: { title } });
}
async removeBrand(id: string) {
const b = await this.prisma.brand.findUnique({
where: { id },
include: { _count: { select: { products: true } } },
});
if (!b) throw new NotFoundException('برند یافت نشد');
if (b._count.products > 0) {
throw new BadRequestException(
`حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`,
);
}
await this.prisma.brand.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */
async dashboardLinks() {
const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } });
return row?.value ?? [];
}
}
@@ -0,0 +1,109 @@
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminOrdersService } from './admin-orders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { UpdateOrderStatusDto } from './update-status.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (henza_server routes/admin.php):
* POST single-order -> the single-sale order grid
* GET order/{id} -> order detail + status timeline
*
* Each route is gated by the same rid the legacy router used in `meta.role`,
* so the API enforces what the menu already hides.
*/
@Controller('admin/orders')
export class AdminOrdersController {
constructor(private readonly orders: AdminOrdersService) {}
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
@Post('list')
@HttpCode(200)
@RequirePermission('order.list')
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
/**
* Branch scoping, ported from legacy AdminController::getSingleOrders:
*
* if (!is_null(auth()->user()->branch_id)) {
* $records->where('branch_id', auth()->user()->branch_id);
* }
*
* i.e. ANY user carrying a branch_id sees only that branch. Faithful to legacy.
*
* Consequence worth knowing: the seed gives head-office admins a home branch,
* so under this rule they see only that branch's orders. That is legacy's real
* behaviour, not a defect introduced here — head office was expected to have a
* null branch_id. Recorded in docs/ADMIN-PORT-PLAN.md.
*/
return this.orders.list(query, admin.branchId ?? undefined);
}
/** Legacy POST major/order/table — the wholesale grid. */
@Post('wholesale/list')
@HttpCode(200)
@RequirePermission('order.omde')
listWholesale(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.listWholesale(query, admin.branchId ?? undefined);
}
/** Legacy POST order-tamin — orders that reached the sourcing stage. */
@Post('tamin/list')
@HttpCode(200)
@RequirePermission('order.tamin')
listTamin(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.listTamin(query, admin.branchId ?? undefined);
}
/** Legacy POST delete-order. */
@Post('delete')
@HttpCode(200)
@RequirePermission('order.omde')
remove(@Body() body: { id: string }) {
return this.orders.remove(body.id);
}
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
@Post('create')
@HttpCode(200)
@RequirePermission('order.create')
createFromAdmin(
@Body()
body: {
customer_id: string;
order_items: { vid: string; qty: number; percent?: number; discount?: number }[];
sts?: number | string;
note?: string;
},
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.orders.createFromAdmin(body, admin.id);
}
/** Legacy GET order/type. */
@Get('type-options')
@RequirePermission('order.create')
typeOptions() {
return this.orders.typeOptions();
}
@Get('status-options')
@RequirePermission('order.list')
statusOptions() {
return this.orders.statusOptions();
}
/** Legacy POST single/order-sts/edit. */
@Post('status')
@HttpCode(200)
@RequirePermission('order.list')
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.setStatus(dto, admin.id);
}
@Get(':id')
@RequirePermission('order.list')
findOne(@Param('id') id: string) {
return this.orders.findOne(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { OrdersModule } from '../orders/orders.module';
import { AdminOrdersController } from './admin-orders.controller';
import { AdminOrdersService } from './admin-orders.service';
@Module({
imports: [PrismaModule, OrdersModule],
controllers: [AdminOrdersController],
providers: [AdminOrdersService],
})
export class AdminOrdersModule {}
@@ -0,0 +1,406 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { OrdersService } from '../orders/orders.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
/**
* Farsi status labels, matching legacy's `sts_str` column.
*
* Legacy stored status as an integer `sts` and the server derived the string via
* Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit.
* Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes.
*/
const STATUS_LABELS: Record<OrderStatus, string> = {
SUSPENDED: 'معلق (سقف اعتبار)',
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'در حال آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
CANCELLED: 'لغو شده',
};
/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */
const STATUS_FLOW: OrderStatus[] = [
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_AGENT,
OrderStatus.DELIVERED_TO_CUSTOMER,
];
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly orders: OrdersService,
) {}
/** Fields the XTable filter bar and sorter may reference. */
private static readonly ALLOWED = [
'id',
'orderNumber',
'status',
'createdAt',
'updatedAt',
'totalGoldWeightGrams',
'flatRialCharge',
'isSuspendedOverCreditLimit',
'customer.fullName',
'customer.mobile',
'branch.name',
];
/**
* Legacy `POST single-order` — the order list grid.
*
* `branchId` narrows the result to one branch, which is how the branch-mode
* build of the panel scopes its list.
*/
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
allowed: AdminOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.OrderWhereInput = {
AND: [
where as Prisma.OrderWhereInput,
{ channel },
...(branchId ? [{ branchId }] : []),
],
};
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where: scoped,
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
}),
this.prisma.order.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/**
* Shapes a row the way the ported list view reads it.
*
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
* so the ported template bindings keep working unchanged.
*/
private toRow(o: Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: { productVariant: { include: { product: true } } } };
};
}>) {
return {
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
sts_str: STATUS_LABELS[o.status],
caj: o.createdAt,
uaj: o.updatedAt,
total_price: o.flatRialCharge,
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
deliveredAt: o.deliveredAt,
branch_id: o.branchId,
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
customer: {
id: o.customer.id,
// Legacy split names into fname/lname; the new schema has one fullName.
// Both are exposed so ported templates binding either keep working.
fname: o.customer.fullName,
lname: '',
fullName: o.customer.fullName,
phone: o.customer.mobile,
},
items: o.items.map((it) => ({
id: it.id,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
rash: it.rashWeightGramsSnapshot,
})),
};
}
/**
* Legacy `POST major/order/table` — the wholesale (عمده) order grid.
*
* Legacy kept the two sales channels in SEPARATE tables: `orders_orders`
* (SingleCustomerOrders, served by AdminController::getSingleOrders) and
* `orders` (Order, served by MajorOrderController::index). Neither controller
* applied a type filter because the table itself was the discriminator, so the
* two lists never overlapped.
*
* The redesigned schema has one Order model, so `Order.channel` carries that
* distinction instead and each list filters on it.
*/
async listWholesale(query: TableQueryDto, branchId?: string) {
return this.list(query, branchId, OrderChannel.WHOLESALE);
}
/**
* Legacy `POST order-tamin` — MajorOrderController::getTaminOrders.
*
* Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those
* that have reached the sourcing stage. The equivalent here is a status event
* recording a transition into SOURCING.
*/
async listTamin(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
allowed: AdminOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const conditions: Prisma.OrderWhereInput[] = [
where as Prisma.OrderWhereInput,
{ statusEvents: { some: { toStatus: OrderStatus.SOURCING } } },
];
if (branchId) conditions.push({ branchId });
const scoped: Prisma.OrderWhereInput = { AND: conditions };
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where: scoped,
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
}),
this.prisma.order.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy `POST delete-order` — remove a wholesale order. */
async remove(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: { ledgerEntries: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
// Refuses once debt has been posted: deleting the order would orphan the
// ledger entries and leave the customer permanently in debt. Legacy had no
// such guard. Recorded in docs/ADMIN-PORT-PLAN.md.
if (order.ledgerEntries.length > 0) {
throw new BadRequestException(
'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.',
);
}
await this.prisma.$transaction([
this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }),
this.prisma.orderItem.deleteMany({ where: { orderId: id } }),
this.prisma.order.delete({ where: { id } }),
]);
return { success: true };
}
/** Legacy `GET order/:id` detail, including the status timeline. */
async findOne(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
},
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
return {
...this.toRow(order),
// legacy `activity`: one entry per step, `proceed` marking those reached
activity: STATUS_FLOW.map((s) => {
const event = order.statusEvents.find((e) => e.toStatus === s);
return {
status: s,
title: STATUS_LABELS[s],
proceed: reached.has(s),
uaj: event?.createdAt ?? null,
note: event?.note ?? '',
};
}),
};
}
/** Farsi labels for the status dropdowns. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
}
/**
* Legacy `GET order/type` — the "نوع فروش" dropdown.
* Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری'].
*/
typeOptions() {
return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' };
}
/**
* Legacy `POST /order` from views/order/create.vue — the admin order builder.
*
* Delegates to OrdersService.createOrder, which owns the weight formula
* w = rash * (1 + (wagePercent - discountPercent)/100)
* and the credit-limit gate that suspends rather than rejects an over-limit
* order. Reimplementing either here would let the two paths drift.
*
* Legacy also posted total_price / total_weight / gold_price_original computed
* in the BROWSER. Those are ignored: the server recomputes them from the
* variant snapshots, so a tampered or stale client total cannot alter a debt.
*/
async createFromAdmin(
payload: {
customer_id: string;
order_items: {
vid: string;
qty: number;
percent?: number;
discount?: number;
rial_input?: number;
}[];
sts?: number | string;
note?: string;
},
actorAdminUserId: string,
) {
if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است');
if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) {
throw new BadRequestException('سبد سفارش خالی است');
}
const items = await Promise.all(
payload.order_items.map(async (it) => {
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
return {
productVariantId: it.vid,
quantity: Number(it.qty) || 1,
// Fall back to the variant's own default wage when the builder did not
// negotiate a different one.
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
discountPercent: Number(it.discount ?? 0),
flatRialCharge: Number(it.rial_input ?? 0),
};
}),
);
const order = await this.orders.createOrder({
customerId: payload.customer_id,
items,
// The admin builder serves the omde (major/*) flow, so orders it creates
// belong to the wholesale channel — legacy wrote them to the `orders`
// table, which MajorOrderController::index listed.
channel: OrderChannel.WHOLESALE,
});
// legacy `sts`: advance the new order to the chosen delivery status.
const target = this.statusFromLegacyCode(payload.sts);
if (target && target !== OrderStatus.PLACED) {
await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note);
}
return this.findOne(order.id);
}
/**
* Legacy delivery-status codes from create.vue's `sts` map:
* 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین
* 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری
*/
private statusFromLegacyCode(code?: number | string): OrderStatus | undefined {
const map: Record<number, OrderStatus> = {
1: OrderStatus.PLACED,
2: OrderStatus.ADMIN_APPROVED,
3: OrderStatus.SOURCING,
4: OrderStatus.PREPARING,
5: OrderStatus.DELIVERED_TO_AGENT,
6: OrderStatus.DELIVERED_TO_CUSTOMER,
};
return code === undefined ? undefined : map[Number(code)];
}
/**
* Legacy `POST single/order-sts/edit` — advance or remove a status.
*
* Setting a status delegates to OrdersService.updateStatus rather than writing
* the row here, because that method also posts the gold debt to the ledger at
* DELIVERED_TO_AGENT. Duplicating the update would silently skip that.
*/
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
if (dto.remove) {
return this.removeStatusEvent(dto.orderId, dto.status);
}
return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note);
}
/**
* Legacy's `remove: true` branch: drop a recorded status event.
*
* Deliberately refuses to remove DELIVERED_TO_AGENT. In legacy, deleting that
* row left the posted debt ledger entries behind, so the customer stayed in
* debt for an order no longer marked delivered. Rather than reproduce a
* data-corrupting path, this rejects and explains — recorded in
* docs/ADMIN-PORT-PLAN.md.
*/
private async removeStatusEvent(orderId: string, status: OrderStatus) {
if (status === OrderStatus.DELIVERED_TO_AGENT) {
throw new BadRequestException(
'حذف وضعیت «تحویل به نماینده» ممکن نیست، چون بدهی طلا در همین مرحله ثبت شده است.',
);
}
const event = await this.prisma.orderStatusEvent.findFirst({
where: { orderId, toStatus: status },
orderBy: { createdAt: 'desc' },
});
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
await this.prisma.orderStatusEvent.delete({ where: { id: event.id } });
// Roll the order back to whatever status is now the latest recorded one.
const latest = await this.prisma.orderStatusEvent.findFirst({
where: { orderId },
orderBy: { createdAt: 'desc' },
});
return this.prisma.order.update({
where: { id: orderId },
data: { status: latest?.toStatus ?? OrderStatus.PLACED },
});
}
}
@@ -0,0 +1,26 @@
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { OrderStatus } from '@prisma/client';
/**
* Body of legacy `POST single/order-sts/edit`.
*
* Legacy posted the whole timeline row back, so it carried `status`, an optional
* `note`, and `remove: true` when the operator was deleting a status instead of
* setting one. The order id travelled on the row as `order_id`/`orderId`.
*/
export class UpdateOrderStatusDto {
@IsString()
orderId!: string;
@IsEnum(OrderStatus)
status!: OrderStatus;
@IsOptional()
@IsString()
note?: string;
/** legacy: row.remove = true -> delete this status event rather than add one */
@IsOptional()
@IsBoolean()
remove?: boolean;
}
@@ -0,0 +1,47 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminPreOrdersService } from './admin-preorders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (CustomOrderController):
* POST get-custom-orders -> the "سفارش های ساختنی" grid
* GET get-custom-order/{id}
* PUT custom-order/{id}
* DELETE custom-order/{id}
*/
@Controller('admin/pre-orders')
export class AdminPreOrdersController {
constructor(private readonly preOrders: AdminPreOrdersService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('order.makeOrder')
list(@Body() query: TableQueryDto) {
return this.preOrders.list(query);
}
@Get('status-options')
@RequirePermission('order.makeOrder')
statusOptions() {
return this.preOrders.statusOptions();
}
@Get(':id')
@RequirePermission('order.makeOrder')
findOne(@Param('id') id: string) {
return this.preOrders.findOne(id);
}
@Put(':id')
@RequirePermission('order.makeOrder')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.preOrders.update(id, data);
}
@Delete(':id')
@RequirePermission('order.makeOrder')
remove(@Param('id') id: string) {
return this.preOrders.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminPreOrdersController } from './admin-preorders.controller';
import { AdminPreOrdersService } from './admin-preorders.service';
@Module({
imports: [PrismaModule],
controllers: [AdminPreOrdersController],
providers: [AdminPreOrdersService],
})
export class AdminPreOrdersModule {}
@@ -0,0 +1,158 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PreOrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Custom / made-to-order requests — legacy "سفارش های ساختنی".
*
* Legacy served these from CustomOrderController (`POST get-custom-orders`,
* `PUT custom-order/{id}`, `DELETE custom-order/{id}`). The redesigned schema
* models them as PreOrder: a design request that an admin prices and later
* converts into a real, debt-bearing Order.
*/
const PREORDER_STATUS_LABELS: Record<PreOrderStatus, string> = {
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
WORKSHOP_APPROVED: 'تایید کارگاه',
READY_TO_SHIP: 'آماده ارسال',
DELIVERED: 'تحویل شده',
CANCELLED: 'لغو شده',
};
@Injectable()
export class AdminPreOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'status',
'createdAt',
'updatedAt',
'colorName',
'sizeSpec',
'stoneColor',
'minaColor',
'estimatedWeightGrams',
'estimatedPriceRial',
'customer.fullName',
'customer.mobile',
];
/** Legacy `POST get-custom-orders`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['customer.fullName', 'customer.mobile'],
allowed: AdminPreOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.preOrder.findMany({
where: where as Prisma.PreOrderWhereInput,
orderBy: orderBy as Prisma.PreOrderOrderByWithRelationInput,
skip,
take,
include: { customer: true, city: true, workshop: true },
}),
this.prisma.preOrder.count({ where: where as Prisma.PreOrderWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names (color, size, color_mina, color_stone, ...) preserved. */
private toRow(
p: Prisma.PreOrderGetPayload<{ include: { customer: true; city: true; workshop: true } }>,
) {
return {
id: p.id,
status: p.status,
sts_str_fa: PREORDER_STATUS_LABELS[p.status],
color: p.colorName,
size: p.sizeSpec,
color_mina: p.minaColor,
color_stone: p.stoneColor,
// legacy 'sum' (quantity); a PreOrder models a single design request
sum: 1,
description: p.description,
designImageUrl: p.designImageUrl,
estimatedWeightGrams: p.estimatedWeightGrams,
estimatedPriceRial: p.estimatedPriceRial,
time_creat_fa: p.createdAt,
time_update_fa: p.updatedAt,
convertedOrderId: p.convertedOrderId,
city: p.city ? { id: p.city.id, title: p.city.name } : null,
workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null,
customer: {
id: p.customer.id,
fname: p.customer.fullName,
lname: '',
name: p.customer.fullName,
fullName: p.customer.fullName,
mobile: p.customer.mobile,
},
};
}
async findOne(id: string) {
const p = await this.prisma.preOrder.findUnique({
where: { id },
include: { customer: true, city: true, workshop: true },
});
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
return this.toRow(p);
}
/** Legacy `PUT custom-order/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.preOrder.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سفارش ساختنی یافت نشد');
const patch: Prisma.PreOrderUpdateInput = {};
if (data.status !== undefined) patch.status = data.status as PreOrderStatus;
if (data.color !== undefined) patch.colorName = data.color as string;
if (data.size !== undefined) patch.sizeSpec = data.size as string;
if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string;
if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string;
if (data.description !== undefined) patch.description = data.description as string;
if (data.estimatedWeightGrams !== undefined) {
patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string);
}
if (data.estimatedPriceRial !== undefined) {
patch.estimatedPriceRial = new Prisma.Decimal(data.estimatedPriceRial as string);
}
return this.prisma.preOrder.update({ where: { id }, data: patch });
}
/** Legacy `DELETE custom-order/{id}`. */
async remove(id: string) {
const p = await this.prisma.preOrder.findUnique({ where: { id } });
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
// Once converted into a real Order the design request is part of an
// accounting trail; deleting it would orphan that link. Legacy had no guard.
if (p.convertedOrderId) {
throw new BadRequestException(
'حذف ممکن نیست: این سفارش ساختنی قبلاً به سفارش قطعی تبدیل شده است.',
);
}
await this.prisma.$transaction([
this.prisma.preOrderStatusEvent.deleteMany({ where: { preOrderId: id } }),
this.prisma.preOrder.delete({ where: { id } }),
]);
return { success: true };
}
statusOptions() {
return Object.entries(PREORDER_STATUS_LABELS).map(([value, title]) => ({ value, title }));
}
}
@@ -0,0 +1,131 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminProductsService } from './admin-products.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (ProductController):
* POST products -> the product grid
* GET product/show/{id}
* POST product/create
* PUT product/update/{id}
* POST delete/product/{id} (a delete expressed as POST — legacy's shape)
* POST get/variants (a read expressed as POST — legacy's shape)
* GET product/{id}/variant
* POST change/percent/count, POST change/all
* GET brand, POST category
*/
@Controller('admin/products')
export class AdminProductsController {
constructor(private readonly products: AdminProductsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('product.list')
list(@Body() query: TableQueryDto) {
return this.products.list(query);
}
@Post('variants/list')
@HttpCode(200)
@RequirePermission('product.list')
listVariants(@Body() query: TableQueryDto & { product_id?: string }) {
return this.products.listVariants(query);
}
/**
* Legacy `POST product/variant` — the cart's rehydrate call. Given a list of
* product ids it returns each product with `variants_all`, so the basket can
* refresh prices/stock and drop anything that went out of stock.
*/
@Post('by-ids')
@HttpCode(200)
@RequirePermission('product.list')
byIds(@Body() body: { products_id: string[] }) {
return this.products.byIds(body.products_id ?? []);
}
/**
* Legacy `POST add/variant` — creates a bespoke weight variant from the order
* builder's inline weight editor.
*/
@Post('variants/create')
@HttpCode(200)
@RequirePermission('order.create')
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
return this.products.createVariant(body);
}
@Get('show-type-options')
@RequirePermission('product.list')
showTypeOptions() {
return this.products.showTypeOptions();
}
@Get('brands')
@RequirePermission('product.list')
brands() {
return this.products.brands();
}
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
@Post('brands-table')
@HttpCode(200)
@RequirePermission('product.list')
brandsTable(@Body() query: TableQueryDto) {
return this.products.brandsTable(query);
}
@Get('collections')
@RequirePermission('product.list')
collections() {
return this.products.collections();
}
@Post('change-percent/count')
@HttpCode(200)
@RequirePermission('product.percent')
changePercentCount(@Body() body: { brand_id?: string; category_id?: string }) {
return this.products.changePercentCount(body);
}
@Post('change-percent/apply')
@HttpCode(200)
@RequirePermission('product.percent')
changePercent(@Body() body: { percent: number; brand_id?: string; category_id?: string }) {
return this.products.changePercent(body);
}
@Post('create')
@HttpCode(200)
@RequirePermission('product.create')
create(@Body() data: Record<string, unknown>) {
return this.products.create(data);
}
@Post('delete/:id')
@HttpCode(200)
@RequirePermission('product.list.edit')
remove(@Param('id') id: string) {
return this.products.remove(id);
}
@Get(':id/variants')
@RequirePermission('product.list')
variantsOf(@Param('id') id: string) {
return this.products.variantsOf(id);
}
@Get(':id')
@RequirePermission('product.list')
findOne(@Param('id') id: string) {
return this.products.findOne(id);
}
@Put(':id')
@RequirePermission('product.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.products.update(id, data);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminProductsController } from './admin-products.controller';
import { AdminProductsService } from './admin-products.service';
@Module({
imports: [PrismaModule],
controllers: [AdminProductsController],
providers: [AdminProductsService],
})
export class AdminProductsModule {}
@@ -0,0 +1,481 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Product administration — legacy views/apps/product/* served by
* ProductController (`POST products`, `POST product/create`,
* `PUT product/update/{id}`, `GET product/show/{id}`,
* `POST delete/product/{id}`, `POST get/variants`, `POST change/percent/count`).
*
* Legacy `show_type` values, preserved as integers:
* 1 سایت | 2 گوشی | 3 سایت و گوشی
*/
const SHOW_TYPE_LABELS: Record<number, string> = {
1: 'سایت',
2: 'گوشی',
3: 'سایت و گوشی',
};
@Injectable()
export class AdminProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'title',
'productCode',
'showType',
'isActive',
'createdAt',
'brand.title',
'category.title',
// legacy filter keys
'product_code',
'show_type',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
product_code: 'productCode',
show_type: 'showType',
'category.collection.title': 'category.title',
caj: 'createdAt',
};
private applyAliases(query: TableQueryDto): TableQueryDto {
const mapField = (f?: string) => (f ? (AdminProductsService.FIELD_ALIASES[f] ?? f) : f);
return {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'showType' && mapped.value !== undefined) {
mapped.value = Number(mapped.value);
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
}
/** Legacy `POST products` — the product grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['title', 'productCode'],
allowed: AdminProductsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.product.findMany({
where: where as Prisma.ProductWhereInput,
orderBy: orderBy as Prisma.ProductOrderByWithRelationInput,
skip,
take,
include: {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: true,
},
}),
this.prisma.product.count({ where: where as Prisma.ProductWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved so ported table models bind unchanged. */
private toRow(
p: Prisma.ProductGetPayload<{
include: { brand: true; category: true; images: true; variants: true };
}>,
) {
return {
id: p.id,
title: p.title,
product_code: p.productCode,
show_type: p.showType,
show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-',
is_active: p.isActive,
description: p.description,
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
// Legacy nested this as category.collection.title in the grid model.
category: p.category
? { id: p.category.id, title: p.category.title, collection: { title: p.category.title } }
: null,
// legacy grid slot reads `ProductImage`
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder })),
// The order builder's grid reads `thumb.full` and `variants_all` directly
// off each row, so both travel with the list as they did in legacy.
thumb: p.images[0] ? { full: p.images[0].url } : null,
variants_all: p.variants.map((v) => this.variantRow(v)),
variants_count: p.variants.length,
// legacy min_gram / max_gram: the product's lightest and heaviest variant,
// shown by the storefront and by the branch-allocation picker.
min_gram: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
max_gram: p.variants.length
? p.variants.reduce(
(max, v) => (v.rashWeightGrams.gt(max) ? v.rashWeightGrams : max),
p.variants[0].rashWeightGrams,
)
: null,
caj: p.createdAt,
};
}
/** Legacy `GET product/show/{id}`. */
async findOne(id: string) {
const p = await this.prisma.product.findUnique({
where: { id },
include: {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: true,
collections: { include: { collection: true } },
},
});
if (!p) throw new NotFoundException('محصول یافت نشد');
return {
...this.toRow(p),
body_html: p.description,
brand_id: p.brandId,
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
variants_all: p.variants.map((v) => this.variantRow(v)),
};
}
private variantRow(v: Prisma.ProductVariantGetPayload<object>) {
return {
id: v.id,
product_id_id: v.productId,
title: v.title,
sku: v.sku,
grams: v.rashWeightGrams,
percentage_pay: v.defaultWagePercent,
qty: v.qty,
is_pre_order: v.isPreOrder,
is_active: v.isActive,
// Legacy had a separate rial making-charge column; the schema models wage
// as a percentage only. Returned as null rather than faked.
rial_pay: null,
color_str: null,
};
}
/** Legacy `POST get/variants` — a POST that reads, per legacy's own routing. */
async listVariants(query: TableQueryDto & { product_id?: string }) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'sku'],
allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'],
defaultOrderBy: { title: 'asc' },
});
const scoped: Prisma.ProductVariantWhereInput = query.product_id
? { AND: [where as Prisma.ProductVariantWhereInput, { productId: query.product_id }] }
: (where as Prisma.ProductVariantWhereInput);
const [rows, total] = await Promise.all([
this.prisma.productVariant.findMany({
where: scoped,
orderBy: orderBy as Prisma.ProductVariantOrderByWithRelationInput,
skip,
take,
include: { product: true },
}),
this.prisma.productVariant.count({ where: scoped }),
]);
return this.table.respond(
rows.map((v) => ({ ...this.variantRow(v), product: { id: v.product.id, title: v.product.title } })),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST product/variant` — used by the cart to refresh the products it
* holds. Returns each product with `variants_all` attached, the shape
* stores/cart.ts reads.
*/
async byIds(ids: string[]) {
if (!ids.length) return [];
const rows = await this.prisma.product.findMany({
where: { id: { in: ids } },
include: {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: true,
},
});
return rows.map((p) => ({
...this.toRow(p),
variants_all: p.variants.map((v) => this.variantRow(v)),
// The cart reads `promotion.id`; there is no Promotion model yet (Batch 9).
promotion: null,
thumb: p.images[0] ? { full: p.images[0].url } : null,
}));
}
/**
* Legacy `POST add/variant` — the order builder's inline weight editor creates
* a bespoke variant rather than mutating the shared catalog one, so other
* orders keep the weight they were placed with.
*/
async createVariant(body: { product_id: string; title: string; grams: number }) {
if (!body.product_id) throw new BadRequestException('محصول مشخص نشده است');
const product = await this.prisma.product.findUnique({
where: { id: body.product_id },
include: { variants: { take: 1 } },
});
if (!product) throw new NotFoundException('محصول یافت نشد');
return this.prisma.productVariant.create({
data: {
productId: body.product_id,
title: body.title,
rashWeightGrams: new Prisma.Decimal(String(body.grams ?? 0)),
// Inherit the product's existing wage rate so the bespoke weight prices
// the same way as its siblings.
defaultWagePercent:
product.variants[0]?.defaultWagePercent ?? new Prisma.Decimal(0),
qty: 1,
},
});
}
/** Legacy `GET product/{id}/variant`. */
async variantsOf(productId: string) {
const rows = await this.prisma.productVariant.findMany({ where: { productId } });
return rows.map((v) => this.variantRow(v));
}
/** Shape of one inline variant row posted by the product form. */
private variantCreateData(v: Record<string, unknown>) {
return {
title: String(v.title ?? ''),
rashWeightGrams: new Prisma.Decimal(String(v.grams ?? 0)),
defaultWagePercent: new Prisma.Decimal(String(v.percentage_pay ?? 0)),
qty: Number(v.qty ?? 0),
isPreOrder: Boolean(v.is_pre_order),
isActive: v.is_active !== undefined ? Boolean(v.is_active) : true,
};
}
/** Legacy `POST product/create`. */
async create(data: Record<string, unknown>) {
const title = String(data.title ?? '').trim();
if (!title) throw new BadRequestException('عنوان محصول الزامی است');
const cats = Array.isArray(data.cats) ? (data.cats as string[]) : [];
const cinyIds = Array.isArray(data.ciny_ids) ? (data.ciny_ids as string[]) : [];
const variants = Array.isArray(data.variants)
? (data.variants as Record<string, unknown>[])
: [];
return this.prisma.product.create({
data: {
title,
description: (data.body_html as string) ?? (data.description as string) ?? null,
productCode: (data.product_code as string) || null,
showType: data.show_type !== undefined ? Number(data.show_type) : 3,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
brandId: (data.brand_id as string) || null,
categoryId: cats[0] ?? null,
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
variants: variants.length
? { create: variants.map((v) => this.variantCreateData(v)) }
: undefined,
},
include: { variants: true },
});
}
/** Legacy `PUT product/update/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('محصول یافت نشد');
const patch: Prisma.ProductUpdateInput = {};
if (data.title !== undefined) patch.title = String(data.title);
if (data.body_html !== undefined) patch.description = String(data.body_html);
if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null;
if (data.show_type !== undefined) patch.showType = Number(data.show_type);
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
if (data.brand_id !== undefined) {
patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true };
}
if (Array.isArray(data.cats)) {
const catId = (data.cats as string[])[0];
patch.category = catId ? { connect: { id: catId } } : { disconnect: true };
}
await this.prisma.product.update({ where: { id }, data: patch });
// Variants are edited inline in the product form, so the posted array is the
// desired end state: update the ones that still carry an id, create the new
// ones, and deactivate (never delete) the ones that disappeared — deleting
// would break the OrderItem snapshots that reference them.
if (Array.isArray(data.variants)) {
const posted = data.variants as Record<string, unknown>[];
// Collect ids as we go: a NEWLY created variant must count as kept, or the
// sweep below would deactivate the row we just added.
const keptIds: string[] = [];
for (const v of posted) {
const payload = this.variantCreateData(v);
if (v.id) {
await this.prisma.productVariant.update({
where: { id: String(v.id) },
data: payload,
});
keptIds.push(String(v.id));
} else {
const created = await this.prisma.productVariant.create({
data: { ...payload, productId: id },
});
keptIds.push(created.id);
}
}
// Anything no longer posted is deactivated, never deleted — OrderItem
// snapshots reference these rows.
await this.prisma.productVariant.updateMany({
where: { productId: id, id: { notIn: keptIds.length ? keptIds : ['__none__'] } },
data: { isActive: false },
});
}
return this.findOne(id);
}
/**
* Legacy `POST delete/product/{id}` — a DELETE expressed as POST.
*
* Refuses when the product has ever been ordered: its variants are referenced
* by OrderItem snapshots, and removing them would break order history.
* Legacy had no such guard. Deactivation is offered instead.
*/
async remove(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: { variants: { include: { orderItems: { take: 1 } } } },
});
if (!product) throw new NotFoundException('محصول یافت نشد');
const hasOrders = product.variants.some((v) => v.orderItems.length > 0);
if (hasOrders) {
throw new BadRequestException(
'حذف این محصول ممکن نیست، چون در سفارش‌های ثبت‌شده استفاده شده است. می‌توانید آن را غیرفعال کنید.',
);
}
await this.prisma.$transaction([
this.prisma.collectionProduct.deleteMany({ where: { productId: id } }),
this.prisma.comment.deleteMany({ where: { productId: id } }),
this.prisma.productImage.deleteMany({ where: { productId: id } }),
this.prisma.productVariant.deleteMany({ where: { productId: id } }),
this.prisma.product.delete({ where: { id } }),
]);
return { success: true };
}
/**
* Legacy `POST change/percent/count` — how many variants a bulk wage-percent
* change would affect, used by ChangePercent.vue before applying.
*/
async changePercentCount(filter: { brand_id?: string; category_id?: string }) {
const where: Prisma.ProductVariantWhereInput = {
product: {
...(filter.brand_id ? { brandId: filter.brand_id } : {}),
...(filter.category_id ? { categoryId: filter.category_id } : {}),
},
};
return { count: await this.prisma.productVariant.count({ where }) };
}
/** Legacy `POST change/all` — apply a bulk wage-percent change. */
async changePercent(data: { percent: number; brand_id?: string; category_id?: string }) {
if (data.percent === undefined || Number.isNaN(Number(data.percent))) {
throw new BadRequestException('درصد نامعتبر است');
}
const where: Prisma.ProductVariantWhereInput = {
product: {
...(data.brand_id ? { brandId: data.brand_id } : {}),
...(data.category_id ? { categoryId: data.category_id } : {}),
},
};
const result = await this.prisma.productVariant.updateMany({
where,
data: { defaultWagePercent: new Prisma.Decimal(String(data.percent)) },
});
return { updated: result.count };
}
/** Legacy `GET brand` and `POST category` — pickers for the product form. */
async brands() {
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
}
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
async brandsTable(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title'],
allowed: ['id', 'title'],
defaultOrderBy: { title: 'asc' },
});
const [rows, total] = await Promise.all([
this.prisma.brand.findMany({
where: where as Prisma.BrandWhereInput,
orderBy: orderBy as Prisma.BrandOrderByWithRelationInput,
skip,
take,
}),
this.prisma.brand.count({ where: where as Prisma.BrandWhereInput }),
]);
return this.table.respond(rows, total, query, page, limit);
}
async collections() {
return this.prisma.collection.findMany({
where: { isActive: true },
orderBy: { title: 'asc' },
});
}
showTypeOptions() {
return Object.entries(SHOW_TYPE_LABELS).map(([value, title]) => ({
value: Number(value),
title,
}));
}
}
@@ -0,0 +1,93 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminPromotionsService } from './admin-promotions.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints:
* GET/POST/PUT/DELETE product/variant/promotion -> PromotionController
* GET /autopromotion -> the auto-discount screen
* GET/POST/DELETE /ignore -> views/offersIgnore
*/
@Controller('admin/promotions')
export class AdminPromotionsController {
constructor(private readonly promotions: AdminPromotionsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('offers.list')
list(@Body() query: TableQueryDto) {
return this.promotions.list(query);
}
@Get('scope-options')
@RequirePermission('offers.list')
scopeOptions() {
return this.promotions.scopeOptions();
}
@Get('usage-options')
@RequirePermission('offers.list')
usageOptions() {
return this.promotions.usageOptions();
}
@Get('auto')
@RequirePermission('offers.autopromotion')
getAutoPromotion() {
return this.promotions.getAutoPromotion();
}
@Post('auto')
@HttpCode(200)
@RequirePermission('offers.autopromotion')
setAutoPromotion(@Body() body: { state?: boolean; discount_percent?: number | string }) {
return this.promotions.setAutoPromotion(body);
}
// ── exclusions ────────────────────────────────────────────────────
@Get('exclusions')
@RequirePermission('offersIgnore')
listExclusions() {
return this.promotions.listExclusions();
}
@Post('exclusions')
@HttpCode(200)
@RequirePermission('offersIgnore')
createExclusion(@Body() body: { type: number; item_id?: string[] }) {
return this.promotions.createExclusion(body);
}
@Delete('exclusions/:id')
@RequirePermission('offersIgnore')
removeExclusion(@Param('id') id: string) {
return this.promotions.removeExclusion(id);
}
// ── promotions ────────────────────────────────────────────────────
@Post()
@HttpCode(200)
@RequirePermission('offers.create')
create(@Body() data: Record<string, unknown>) {
return this.promotions.create(data);
}
@Get(':id')
@RequirePermission('offers.list')
findOne(@Param('id') id: string) {
return this.promotions.findOne(id);
}
@Put(':id')
@RequirePermission('offers.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.promotions.update(id, data);
}
@Delete(':id')
@RequirePermission('offers.list.edit')
remove(@Param('id') id: string) {
return this.promotions.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminPromotionsController } from './admin-promotions.controller';
import { AdminPromotionsService } from './admin-promotions.service';
@Module({
imports: [PrismaModule],
controllers: [AdminPromotionsController],
providers: [AdminPromotionsService],
})
export class AdminPromotionsModule {}
@@ -0,0 +1,334 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, PromotionScope, PromotionUsage } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Promotions — legacy views/promotion/*, served by PromotionController
* (`GET/POST/PUT/DELETE product/variant/promotion`) plus the exclusion list
* (`/ignore`) behind views/offersIgnore.
*
* Legacy `type` (scope): 1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند
* Legacy `use_only` (usage): 1 فقط یکبار | 2 تا تاریخ پایان
*/
const SCOPE_BY_CODE: Record<number, PromotionScope> = {
1: PromotionScope.PRODUCT,
2: PromotionScope.CATEGORY,
3: PromotionScope.CUSTOMER,
4: PromotionScope.BRAND,
};
const CODE_BY_SCOPE: Record<PromotionScope, number> = {
PRODUCT: 1,
CATEGORY: 2,
CUSTOMER: 3,
BRAND: 4,
};
const SCOPE_LABELS: Record<PromotionScope, string> = {
PRODUCT: 'محصولات',
CATEGORY: 'دسته بندی',
CUSTOMER: 'مشتری',
BRAND: 'برند',
};
const USAGE_BY_CODE: Record<number, PromotionUsage> = {
1: PromotionUsage.ONCE,
2: PromotionUsage.UNTIL_END_DATE,
};
const CODE_BY_USAGE: Record<PromotionUsage, number> = {
ONCE: 1,
UNTIL_END_DATE: 2,
};
@Injectable()
export class AdminPromotionsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'title',
'scope',
'usage',
'discountPercent',
'isActive',
'platform',
'startsAt',
'endsAt',
'createdAt',
// legacy filter keys
'promotion_type_str',
'status',
'start',
'end',
'discount_percent',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
promotion_type_str: 'scope',
type: 'scope',
use_only: 'usage',
status: 'isActive',
start: 'startsAt',
end: 'endsAt',
discount_percent: 'discountPercent',
caj: 'createdAt',
};
/** Legacy `GET product/variant/promotion` — the promotions grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminPromotionsService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'scope' && mapped.value !== undefined) {
mapped.value = SCOPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['title', 'description'],
allowed: AdminPromotionsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.promotion.findMany({
where: where as Prisma.PromotionWhereInput,
orderBy: orderBy as Prisma.PromotionOrderByWithRelationInput,
skip,
take,
include: { product: true, category: true, customer: true, brand: true },
}),
this.prisma.promotion.count({ where: where as Prisma.PromotionWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
p: Prisma.PromotionGetPayload<{
include: { product: true; category: true; customer: true; brand: true };
}>,
) {
return {
id: p.id,
title: p.title,
description: p.description,
type: CODE_BY_SCOPE[p.scope],
promotion_type_str: SCOPE_LABELS[p.scope],
use_only: CODE_BY_USAGE[p.usage],
discount_percent: p.discountPercent,
// legacy grid renders `status` through a slot
status: p.isActive ? 1 : 0,
is_active: p.isActive,
/**
* Legacy `type_customer`: 1 = تک (retail), 2 = عمده (wholesale). The grid
* uses it BOTH to pick which permission gates the row's edit/delete icons
* AND to choose which edit route opens. A customer-scoped promotion is the
* retail ("tak") kind; everything else is the wholesale kind.
*/
type_customer: p.scope === PromotionScope.CUSTOMER ? 1 : 2,
platform: p.platform,
start: p.startsAt,
end: p.endsAt,
product_id: p.productId,
category_id: p.categoryId,
customer_id: p.customerId,
brand_id: p.brandId,
product: p.product ? { id: p.product.id, title: p.product.title } : null,
category: p.category ? { id: p.category.id, title: p.category.title } : null,
customer: p.customer ? { id: p.customer.id, name: p.customer.fullName } : null,
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
caj: p.createdAt,
};
}
async findOne(id: string) {
const p = await this.prisma.promotion.findUnique({
where: { id },
include: { product: true, category: true, customer: true, brand: true },
});
if (!p) throw new NotFoundException('تخفیف یافت نشد');
return this.toRow(p);
}
/**
* Maps the legacy form payload onto the model, attaching the ONE target that
* matches the chosen scope and clearing the others — so a promotion can never
* carry, say, both a product and a customer.
*/
private buildData(data: Record<string, unknown>) {
const scope = SCOPE_BY_CODE[Number(data.type ?? 1)];
if (!scope) throw new BadRequestException('نوع تخفیف نامعتبر است');
const usage = USAGE_BY_CODE[Number(data.use_only ?? 1)] ?? PromotionUsage.ONCE;
const percent = new Prisma.Decimal(String(data.discount_percent ?? 0));
if (percent.lt(0) || percent.gt(100)) {
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
}
const startsAt = data.start ? new Date(String(data.start)) : null;
const endsAt = data.end ? new Date(String(data.end)) : null;
if (startsAt && endsAt && endsAt < startsAt) {
throw new BadRequestException('تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد');
}
return {
title: String(data.title ?? '').trim(),
description: (data.description as string) || null,
scope,
usage,
discountPercent: percent,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
platform: data.platform !== undefined ? Number(data.platform) : 3,
startsAt,
endsAt,
productId: scope === PromotionScope.PRODUCT ? ((data.product_id as string) ?? null) : null,
categoryId: scope === PromotionScope.CATEGORY ? ((data.category_id as string) ?? null) : null,
customerId: scope === PromotionScope.CUSTOMER ? ((data.customer_id as string) ?? null) : null,
brandId: scope === PromotionScope.BRAND ? ((data.brand_id as string) ?? null) : null,
};
}
/** Legacy `POST product/variant/promotion`. */
async create(data: Record<string, unknown>) {
const payload = this.buildData(data);
if (!payload.title) throw new BadRequestException('عنوان تخفیف الزامی است');
return this.prisma.promotion.create({ data: payload });
}
/** Legacy `PUT product/variant/promotion/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.promotion.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('تخفیف یافت نشد');
// legacy disabled the type selector on edit (`:disabled="this.id"`), so the
// scope is not re-read here either.
const payload = this.buildData({ ...data, type: CODE_BY_SCOPE[existing.scope] });
return this.prisma.promotion.update({ where: { id }, data: payload });
}
/** Legacy `DELETE product/variant/promotion/{id}`. */
async remove(id: string) {
const p = await this.prisma.promotion.findUnique({ where: { id } });
if (!p) throw new NotFoundException('تخفیف یافت نشد');
await this.prisma.promotion.delete({ where: { id } });
return { success: true };
}
scopeOptions() {
return Object.entries(SCOPE_BY_CODE).map(([code, scope]) => ({
value: Number(code),
title: SCOPE_LABELS[scope],
}));
}
usageOptions() {
return [
{ value: 1, title: ' فقط یکبار' },
{ value: 2, title: 'تا تاریخ پایان' },
];
}
/**
* Legacy `GET /autopromotion` and its POST counterpart — the settings screen
* for "تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید": a one-shot welcome
* discount for new customers, configured as an on/off state plus a percentage.
*
* Stored in the Setting key/value table, like the other config screens.
*/
async getAutoPromotion() {
const row = await this.prisma.setting.findUnique({ where: { key: 'promotion.auto' } });
return row?.value ?? { state: false, discount_percent: null };
}
async setAutoPromotion(data: { state?: boolean; discount_percent?: number | string }) {
const percent = Number(data.discount_percent ?? 0);
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
}
const value = { state: Boolean(data.state), discount_percent: percent };
const row = await this.prisma.setting.upsert({
where: { key: 'promotion.auto' },
update: { value },
create: { key: 'promotion.auto', value },
});
return row.value;
}
// ── Exclusions (views/offersIgnore) ────────────────────────────────
/**
* Legacy `GET /ignore` — the full exclusion list.
*
* The view reads it as a flat array and filters client-side:
* items_id(type) { return this.ignoreList.filter(i => i.type == type).map(x => x.item_id) }
* so the flat shape with `item_id` is preserved.
*/
async listExclusions() {
const rows = await this.prisma.promotionExclusion.findMany({
orderBy: [{ type: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((e) => ({ id: e.id, type: e.type, item_id: e.itemId }));
}
/**
* Legacy `POST /ignore` with `{ type, item_id: [...] }`.
*
* The posted array is the COMPLETE selection for that type — the view binds it
* to a checkbox table — so this replaces that type's rows rather than adding
* to them. Anything unchecked is therefore removed.
*/
async createExclusion(data: { type: number; item_id?: string[] }) {
const type = Number(data.type);
if (!type) throw new BadRequestException('نوع استثناء الزامی است');
const itemIds = Array.isArray(data.item_id)
? data.item_id.map((x) => String(x)).filter(Boolean)
: [];
await this.prisma.$transaction([
this.prisma.promotionExclusion.deleteMany({ where: { type } }),
this.prisma.promotionExclusion.createMany({
data: itemIds.map((itemId) => ({ type, itemId })),
skipDuplicates: true,
}),
]);
return this.listExclusions();
}
/**
* Legacy `DELETE /ignore/{id}` — the view's "حذف نوع استثناء" card removes a
* whole TYPE, so a numeric id is treated as a type and a cuid as one row.
*/
async removeExclusion(id: string) {
if (/^\d+$/.test(id)) {
const type = Number(id);
const { count } = await this.prisma.promotionExclusion.deleteMany({ where: { type } });
if (count === 0) throw new NotFoundException('استثنایی برای این نوع یافت نشد');
return { success: true, removed: count };
}
const e = await this.prisma.promotionExclusion.findUnique({ where: { id } });
if (!e) throw new NotFoundException('استثناء یافت نشد');
await this.prisma.promotionExclusion.delete({ where: { id } });
return { success: true, removed: 1 };
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TableService } from './table.service';
/**
* Global so every admin grid module can inject TableService without re-importing.
* It is stateless — pure request-shape translation.
*/
@Global()
@Module({
providers: [TableService],
exports: [TableService],
})
export class AdminTableModule {}
@@ -0,0 +1,104 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
/**
* One row of the XTable filter bar.
*
* Mirrors the object legacy's XFilterRow pushed into `query.filters`:
* `{ id, field, op, value, value2? }`. `id` is a client-side react key
* (a random float) and is accepted but ignored.
*/
export class TableFilterDto {
@IsOptional()
id?: number | string;
@IsOptional()
@IsString()
field?: string;
/**
* Legacy operators, kept verbatim. `between` uses value + value2 and is what
* the date-range filter emits.
*/
@IsOptional()
@IsIn(['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'between', 'null', 'notnull'])
op?: string;
@IsOptional()
value?: unknown;
@IsOptional()
value2?: unknown;
}
/**
* The request body XTable POSTs, ported from its requestData():
*
* { ...query, select, titles, report_title, op? }
*
* where query = { search, filters, total, limit, page }.
*/
export class TableQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TableFilterDto)
filters?: TableFilterDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
/** Sort spec: '+field' ascending, '-field' descending (legacy `order`). */
@IsOptional()
@IsString()
order?: string;
/** Columns currently visible; drives the export payload. */
@IsOptional()
@IsArray()
select?: string[];
/** key -> Farsi column heading, used for export headers. */
@IsOptional()
titles?: Record<string, string>;
@IsOptional()
@IsString()
report_title?: string;
/**
* Export mode. Legacy expected `{ url }` back and did `window.open(data.url)`.
* Exports are not implemented yet; the controller rejects these explicitly
* rather than returning a broken url.
*/
@IsOptional()
@IsIn(['excel', 'json', 'print'])
op?: 'excel' | 'json' | 'print';
/** Echoed back inside `query`; the client owns this value. */
@IsOptional()
total?: number;
}
@@ -0,0 +1,158 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { TableFilterDto, TableQueryDto } from './table-query.dto';
/**
* Translates the legacy XTable request contract into Prisma query arguments.
*
* The response shape is dictated by legacy's getDataUsage() (x-table mixin):
*
* laravel mode -> { rows, query } <- what the admin panel used
* other mode -> { rows: [[...]], count, page }
*
* The panel's XTbl models all ran in 'laravel' mode, so that is the shape
* produced here: `{ rows, query }` with query carrying total/page/limit.
*/
export interface TableResult<T> {
rows: T[];
query: {
search?: string;
filters: TableFilterDto[];
total: number;
limit: number;
page: number;
};
}
export interface BuildOptions {
/** Columns the free-text `search` box scans (legacy initSearch([...])). */
searchable?: string[];
/** Whitelist of filterable/sortable fields; anything else is rejected. */
allowed?: string[];
/** Sort applied when the request specifies none. */
defaultOrderBy?: Record<string, 'asc' | 'desc'>;
}
@Injectable()
export class TableService {
/**
* Turns a dotted path into a nested Prisma object:
* 'customer.fullName', 'x' -> { customer: { fullName: 'x' } }
*/
private nest(path: string, leaf: unknown): Record<string, unknown> {
const parts = path.split('.');
return parts.reduceRight<unknown>((acc, key, i) => {
return i === parts.length - 1 ? { [key]: leaf } : { [key]: acc };
}, leaf) as Record<string, unknown>;
}
private assertAllowed(field: string, allowed?: string[]) {
if (allowed && !allowed.includes(field)) {
throw new BadRequestException(`Field not filterable: ${field}`);
}
}
/** Maps one legacy filter row to a Prisma condition. */
private toCondition(f: TableFilterDto, allowed?: string[]): Record<string, unknown> | null {
if (!f.field || !f.op) return null;
this.assertAllowed(f.field, allowed);
const v = f.value;
switch (f.op) {
case '=':
return this.nest(f.field, { equals: v });
case '!=':
return this.nest(f.field, { not: v });
case '>':
return this.nest(f.field, { gt: v });
case '>=':
return this.nest(f.field, { gte: v });
case '<':
return this.nest(f.field, { lt: v });
case '<=':
return this.nest(f.field, { lte: v });
case 'like':
// Legacy sent '%value%' to SQL LIKE; `contains` is the Prisma equivalent.
return this.nest(f.field, {
contains: String(v ?? '').replace(/^%|%$/g, ''),
mode: 'insensitive',
});
case 'in':
return this.nest(f.field, { in: Array.isArray(v) ? v : [v] });
case 'between':
return this.nest(f.field, { gte: v, lte: f.value2 });
case 'null':
return this.nest(f.field, { equals: null });
case 'notnull':
return this.nest(f.field, { not: null });
default:
return null;
}
}
/** Builds `where`, `orderBy`, `skip` and `take` from an XTable request. */
build(query: TableQueryDto, opts: BuildOptions = {}) {
const page = query.page && query.page > 0 ? query.page : 1;
const limit = query.limit && query.limit > 0 ? query.limit : 10;
const and: Record<string, unknown>[] = [];
for (const f of query.filters ?? []) {
const cond = this.toCondition(f, opts.allowed);
if (cond) and.push(cond);
}
// Free-text search across the declared columns (legacy: OR over initSearch).
const search = query.search?.trim();
if (search && opts.searchable?.length) {
and.push({
OR: opts.searchable.map((field) =>
this.nest(field, { contains: search, mode: 'insensitive' }),
),
});
}
// legacy `order`: '+field' asc, '-field' desc
let orderBy = opts.defaultOrderBy ?? { createdAt: 'desc' as const };
if (query.order) {
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
const field = query.order.replace(/^[+-]/, '');
if (field) {
this.assertAllowed(field, opts.allowed);
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
}
}
return {
where: and.length ? { AND: and } : {},
orderBy,
skip: (page - 1) * limit,
take: limit,
page,
limit,
};
}
/** Wraps rows + count into the exact envelope XTable expects. */
respond<T>(rows: T[], total: number, query: TableQueryDto, page: number, limit: number): TableResult<T> {
return {
rows,
query: {
search: query.search,
filters: query.filters ?? [],
total,
limit,
page,
},
};
}
/** Exports are not implemented; fail loudly instead of returning a dead url. */
assertNotExport(query: TableQueryDto) {
if (query.op) {
throw new BadRequestException(
`Export ('${query.op}') is not implemented yet in the new backend.`,
);
}
}
}
@@ -0,0 +1,64 @@
import {
Controller,
Get,
HttpCode,
Param,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AdminUploadsService } from './admin-uploads.service';
import { CurrentAdmin } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { Body } from '@nestjs/common';
/**
* The subset of multer's file object this controller uses. Declared locally
* rather than pulling in @types/multer for one interface — multer itself ships
* with @nestjs/platform-express, but its types do not.
*/
interface UploadedMulterFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
/**
* Legacy endpoints:
* POST file/create -> storeWithoutData
* PUT file/update/{id} -> updateBody (metadata only; not ported — nothing
* in henza_admin calls it)
*
* The multipart field name `file` matches what every legacy uploader posts
* (MyUpload.vue, SingleUploadInput.vue, UploadInput.vue, PodcastUploadInput.vue).
*
* PERMISSIONS. Legacy gated this on nothing at all: `file/create` sat outside
* any middleware, and henza_server's `auth:api` group is itself commented out
* (routes/admin.php:12), so the endpoint accepted anonymous uploads. That is a
* security bug, not a feature, so it is NOT reproduced — these routes require a
* signed-in admin. No specific rid is demanded, since legacy's permission
* vocabulary has no media/file entry to port and inventing one would let it
* drift from the 144 real rids.
*/
@Controller('admin/files')
export class AdminUploadsController {
constructor(private readonly uploads: AdminUploadsService) {}
@Post('create')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file'))
create(
@UploadedFile() file: UploadedMulterFile | undefined,
@Body('purpose') purpose: string | undefined,
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.uploads.store(file, purpose, admin.id ?? null);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.uploads.findOne(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUploadsController } from './admin-uploads.controller';
import { AdminUploadsService } from './admin-uploads.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUploadsController],
providers: [AdminUploadsService],
exports: [AdminUploadsService],
})
export class AdminUploadsModule {}
@@ -0,0 +1,80 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { PrismaService } from '../../prisma/prisma.service';
/**
* File uploads — ports legacy `POST file/create`
* (henza_server Adm/FileController::storeWithoutData).
*
* Legacy behaviour, reproduced here:
* - the multipart field is named `file`
* - the stored name is md5(time + original name) + the original extension
* - files land in one flat public directory (legacy: public/storage)
* - the JSON response carries an `id`, which the ant-design Upload wrappers
* (SingleUploadInput.vue:87, MyUpload.vue) require before emitting `ok`
*
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
* and thumbnailing. That code is dead in legacy too — every branch of it is
* inside a `/* ... *​/` block, leaving only the plain `$file->move(...)` live.
*/
@Injectable()
export class AdminUploadsService {
/** Matches legacy's flat public/storage directory. */
private readonly dir = process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads');
/** Mirrors legacy's `type` field: 1 = image, 2 = any other document. */
private static readonly IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
private static readonly ALLOWED = /^(image\/(jpeg|png|gif|webp|svg\+xml)|application\/pdf)$/;
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
private static readonly MAX_BYTES = 8 * 1024 * 1024;
constructor(private readonly prisma: PrismaService) {}
async store(
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
purpose: string | undefined,
actorAdminUserId: string | null,
) {
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
if (file.size > AdminUploadsService.MAX_BYTES) {
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
}
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
throw new BadRequestException('نوع فایل مجاز نیست');
}
// legacy: md5(time() . original_name) . '.' . ext
const ext = extname(file.originalname).toLowerCase();
const stored =
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
ext;
await mkdir(this.dir, { recursive: true });
await writeFile(join(this.dir, stored), file.buffer);
return this.prisma.uploadedFile.create({
data: {
url: `/uploads/${stored}`,
originalName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
purpose: purpose ?? null,
uploadedByAdminUserId: actorAdminUserId,
},
});
}
async findOne(id: string) {
const f = await this.prisma.uploadedFile.findUnique({ where: { id } });
if (!f) throw new NotFoundException('فایل یافت نشد');
return f;
}
/** True when the stored file is an image, for callers that care. */
static isImage(mime: string) {
return AdminUploadsService.IMAGE_MIME.test(mime);
}
}
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminUsersService } from './admin-users.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (UserController + RoleController):
* GET/POST/PUT/DELETE users
* GET const/__list__ut -> access-level picker
* POST const -> create an access level
* GET roles/{ut} -> rids of one level
* POST roles/create -> set rids
* DELETE roles/{ut}/{rid} -> revoke one rid
*/
@Controller('admin/users')
export class AdminUsersController {
constructor(private readonly users: AdminUsersService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('user.list')
list(@Body() query: TableQueryDto) {
return this.users.list(query);
}
@Get('role-options')
@RequirePermission('user.list')
roleOptions() {
return this.users.roleOptions();
}
/** Legacy GET const/__list__ut. */
@Get('types')
@RequirePermission('user.list')
listUserTypes() {
return this.users.listUserTypes();
}
/** Legacy POST const {key:"__list__ut", val:"<title>"}. */
@Post('types')
@HttpCode(200)
@RequirePermission('user.permission.create')
createUserType(@Body() body: { val?: string; title?: string }) {
return this.users.createUserType(body.val ?? body.title ?? '');
}
/** Legacy GET roles/{ut}. */
@Get('types/:ut/roles')
@RequirePermission('user.permission')
getRoles(@Param('ut') ut: string) {
return this.users.getRoles(Number(ut));
}
/** Legacy POST roles/create {ut, roles}. */
@Post('roles')
@HttpCode(200)
@RequirePermission('user.permission.edit')
setRoles(@Body() body: { ut: number | string; roles: string[] }) {
return this.users.setRoles(Number(body.ut), body.roles ?? []);
}
/** Legacy DELETE roles/{ut}/{rid}. */
@Delete('types/:ut/roles/:rid')
@RequirePermission('user.permission.edit')
revokeRole(@Param('ut') ut: string, @Param('rid') rid: string) {
return this.users.revokeRole(Number(ut), rid);
}
/** The "اطلاعات من" screen. */
@Get('me')
myData(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.users.myData(admin.id);
}
@Post()
@HttpCode(200)
@RequirePermission('user.create')
create(@Body() data: Record<string, unknown>) {
return this.users.create(data);
}
@Get(':id')
@RequirePermission('user.list')
findOne(@Param('id') id: string) {
return this.users.findOne(id);
}
@Put(':id')
@RequirePermission('user.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.users.update(id, data);
}
@Delete(':id')
@RequirePermission('user.list.remove')
remove(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.users.remove(id, admin.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUsersController],
providers: [AdminUsersService],
})
export class AdminUsersModule {}
@@ -0,0 +1,323 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Admin users and the permission matrix — legacy views/user/*, served by
* UserController and RoleController:
* POST/GET/PUT/DELETE users -> the admin user list and form
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
* POST const -> create a new user type
* GET roles/{ut} -> the rids granted to a user type
* POST roles/create -> grant rids
* DELETE roles/{ut}/{rid} -> revoke one rid
*
* The permission matrix writes the `permission(rid, userTypeId)` rows that
* Phase 1 modelled, so this screen is what actually administers the ACL.
*/
@Injectable()
export class AdminUsersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'fullName',
'mobile',
'role',
'isActive',
'createdAt',
'userTypeId',
'userType.title',
'branch.name',
// legacy filter keys
'name',
'ut',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName',
ut: 'userTypeId',
caj: 'createdAt',
active: 'isActive',
};
/** Legacy `GET users` — the admin user grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminUsersService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
if (mapped.field === 'userTypeId' && mapped.value !== undefined) {
mapped.value = Number(mapped.value);
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: AdminUsersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: where as Prisma.AdminUserWhereInput,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { userType: true, branch: true },
}),
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved; the password hash is never exposed. */
private toRow(u: Prisma.AdminUserGetPayload<{ include: { userType: true; branch: true } }>) {
return {
id: u.id,
name: u.fullName,
fname: u.fullName,
lname: '',
mobile: u.mobile,
role: u.role,
ut: u.userTypeId,
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
branch_id: u.branchId,
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
percent: u.wagePercent,
active: u.isActive ? 1 : -1,
is_active: u.isActive,
caj: u.createdAt,
};
}
async findOne(id: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: { userType: true, branch: true },
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
}
/** Legacy `POST users`. */
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? '').trim();
const password = String(data.password ?? '');
if (!fullName) throw new BadRequestException('نام الزامی است');
if (!mobile) throw new BadRequestException('موبایل الزامی است');
if (!password) throw new BadRequestException('رمز عبور الزامی است');
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
return this.prisma.adminUser.create({
data: {
fullName,
mobile,
passwordHash: await bcrypt.hash(password, 10),
role: (data.role as AdminRole) ?? AdminRole.SALES_EXPERT,
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
branchId: (data.branch_id as string) || null,
wagePercent:
data.percent !== undefined && data.percent !== null
? new Prisma.Decimal(String(data.percent))
: null,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
},
include: { userType: true, branch: true },
});
}
/** Legacy `PUT users/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کاربر یافت نشد');
const patch: Prisma.AdminUserUpdateInput = {};
if (data.name !== undefined) patch.fullName = String(data.name);
if (data.mobile !== undefined) {
const mobile = String(data.mobile);
if (mobile !== existing.mobile) {
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
}
patch.mobile = mobile;
}
// A blank password field means "leave it unchanged" — legacy did the same.
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
if (data.role !== undefined) patch.role = data.role as AdminRole;
if (data.ut !== undefined) {
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
}
if (data.branch_id !== undefined) {
patch.branch = data.branch_id
? { connect: { id: String(data.branch_id) } }
: { disconnect: true };
}
if (data.percent !== undefined) {
patch.wagePercent =
data.percent === null ? null : new Prisma.Decimal(String(data.percent));
}
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
return this.prisma.adminUser.update({
where: { id },
data: patch,
include: { userType: true, branch: true },
});
}
/**
* Legacy `DELETE users/{id}`.
*
* Refuses to delete an admin who has posted ledger entries or changed order
* statuses — those rows reference them, and losing the actor would erase the
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
*/
async remove(id: string, actorId: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: {
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید');
}
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
if (refs > 0) {
throw new BadRequestException(
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. می‌توانید غیرفعالش کنید.`,
);
}
await this.prisma.adminUser.delete({ where: { id } });
return { success: true };
}
// ── User types + the permission matrix ─────────────────────────────
/** Legacy `GET const/__list__ut` — the access-level picker. */
async listUserTypes() {
const rows = await this.prisma.userType.findMany({
orderBy: { id: 'asc' },
include: { _count: { select: { adminUsers: true, permissions: true } } },
});
// legacy returned {id, title} pairs; the counts are additive, not breaking.
return rows.map((t) => ({
id: t.id,
title: t.title,
users_count: t._count.adminUsers,
permissions_count: t._count.permissions,
}));
}
/**
* Legacy `POST const` with `{key: "__list__ut", val: "<title>"}` — creates a
* new access level. Legacy's `config_static` used an auto-increment id; here
* the next free integer is taken explicitly since UserType.id is not generated.
*/
async createUserType(title: string) {
if (!title?.trim()) throw new BadRequestException('نام سطح دسترسی الزامی است');
const last = await this.prisma.userType.findFirst({ orderBy: { id: 'desc' } });
const nextId = (last?.id ?? 84) + 1; // 85 is legacy's first, per Permission.vue
return this.prisma.userType.create({
data: { id: nextId, title: title.trim() },
});
}
/** Legacy `GET roles/{ut}` — the rids granted to one access level. */
async getRoles(userTypeId: number) {
const type = await this.prisma.userType.findUnique({
where: { id: userTypeId },
include: { permissions: true },
});
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
// legacy's tree binds to a flat array of rid strings
return type.permissions.map((p) => p.rid);
}
/**
* Legacy `POST roles/create` with `{ut, roles: [...]}`.
*
* The posted array is the complete tick-state of the permission tree, so it
* REPLACES that type's rows — which is what legacy's live code does too: it
* posts the whole `this.rid` array and nothing else.
*
* (Legacy also carries a COMMENTED-OUT block that diffed the old and new sets
* and fired one DELETE per removed rid on a 500ms setTimeout. That path is
* dead in legacy and is not revived here; the transaction below achieves the
* same end atomically.)
*/
async setRoles(userTypeId: number, rids: string[]) {
const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } });
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
const clean = [...new Set((rids ?? []).map((r) => String(r)).filter(Boolean))];
await this.prisma.$transaction([
this.prisma.permission.deleteMany({ where: { userTypeId } }),
this.prisma.permission.createMany({
data: clean.map((rid) => ({ rid, userTypeId })),
skipDuplicates: true,
}),
]);
return this.getRoles(userTypeId);
}
/** Legacy `DELETE roles/{ut}/{rid}` — revoke a single rid. */
async revokeRole(userTypeId: number, rid: string) {
const existing = await this.prisma.permission.findUnique({
where: { rid_userTypeId: { rid, userTypeId } },
});
if (!existing) throw new NotFoundException('این دسترسی وجود ندارد');
await this.prisma.permission.delete({
where: { rid_userTypeId: { rid, userTypeId } },
});
return { success: true };
}
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
async myData(adminId: string) {
return this.findOne(adminId);
}
/** The AdminRole picker for the user form. */
roleOptions() {
return [
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
];
}
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Workshops — legacy views/workshop/*, served by WorkshopController:
* POST workshop/table -> the grid (also the picker the PreOrder detail uses)
* POST workshop -> create (workshop + its supervisor, together)
* GET/PUT workshop/{id}
* GET workshop/active/{id} -> toggle
*
* The list route predates the rest (Batch 3 added it for the made-to-order
* screen's workshop picker); the CRUD arrived with Batch 11. Both live here so
* there is one owner of /admin/workshops, and the logic sits in
* AdminBranchesService alongside the branch code it mirrors.
*/
@Controller('admin/workshops')
export class AdminWorkshopsController {
constructor(private readonly branches: AdminBranchesService) {}
@Post('list')
@HttpCode(200)
list(@Body() query: TableQueryDto) {
return this.branches.listWorkshops(query);
}
@Post()
@HttpCode(200)
@RequirePermission('workshop.create')
create(@Body() data: Record<string, unknown>) {
return this.branches.createWorkshop(data);
}
@Get('active/:id')
@RequirePermission('workshop.list')
toggleActive(@Param('id') id: string) {
return this.branches.toggleWorkshopActive(id);
}
@Get(':id')
@RequirePermission('workshop.list')
findOne(@Param('id') id: string) {
return this.branches.findWorkshop(id);
}
@Put(':id')
@RequirePermission('workshop.list')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.branches.updateWorkshop(id, data);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminBranchesModule } from '../admin-branches/admin-branches.module';
import { AdminWorkshopsController } from './admin-workshops.controller';
@Module({
imports: [PrismaModule, AdminBranchesModule],
controllers: [AdminWorkshopsController],
})
export class AdminWorkshopsModule {}
+204
View File
@@ -0,0 +1,204 @@
/**
* Every permission id (`rid`) the admin panel actually enforces.
*
* Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the
* union of two enforcement points:
* - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js)
* - 100 gated inside views via `$hasPermission('...')`
* = 144 distinct rids (the two sets overlap)
*
* An earlier revision of this file listed only the 58 route-gated ones, which
* left the seeded super-admin unable to use the in-view actions. Corrected.
*
* These are LEAVES as written. Ancestors ("order" from "order.list", "persons"
* from "persons.list.client.omde", ...) are NOT stored — they are derived at
* runtime by expandWithParents(), exactly as legacy's checkParent() did.
*/
export const ALL_RIDS = [
'branch.create',
'branch.list',
'buy.order.list',
'cities.create',
'cities.list',
'cities.list.remove',
'cities.list.view',
'cities.list.view.edit',
'collection.create',
'collection.list',
'collection.list.edit',
'collection.list.remove',
'comment',
'comment.remove',
'comment.state',
'comment.view',
'comment.view.edit',
'config.report',
'config.statics',
'config.statics.brand',
'config.statics.brand.create',
'config.statics.brand.edit',
'config.statics.brand.remove',
'config.statics.tags',
'config.statics.tags.create',
'config.statics.tags.edit',
'config.statics.tags.remove',
'config.wallet',
'custom.config.statics.slider',
'custom.config.statics.tags.banner.ids',
'dashboard.omde',
'dashboard.tak',
'document.create',
'document.list',
'document.list.confirm',
'document.list.deny',
'document.list.edit',
'document.list.get',
'document.list.remove',
'invoice.create',
'invoice.list',
'newsletters',
'notify.create',
'notify.me',
'offers.autopromotion',
'offers.create',
'offers.create.tak',
'offers.list',
'offers.list.edit',
'offers.list.remove',
'offers.list.tak.edit',
'offers.list.tak.remove',
'offersIgnore',
'order.create',
'order.list',
'order.list.pdf',
'order.list.sts',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view',
'order.list.view.edit',
'order.makeOrder',
'order.makeOrder.get',
'order.makeOrder.remove',
'order.makeOrder.state',
'order.makeOrder.state.remove',
'order.makeOrder.state.submit',
'order.makeOrder.view',
'order.makeOrder.view.edit',
'order.omde',
'order.omde.get',
'order.omde.remove',
'order.omde.state',
'order.omde.state.remove',
'order.omde.state.submit',
'order.omde.view',
'order.omde.view.edit',
'order.omde.view.remove',
'order.tamin',
'order.tamin.state',
'order.tamin.state.remove',
'order.tamin.state.submit',
'order.tamin.view',
'order.tamin.view.edit',
'persons.create',
'persons.create.client',
'persons.create.customer.tak',
'persons.create.seller',
'persons.list.client.omde',
'persons.list.client.omde.able',
'persons.list.client.omde.able.order',
'persons.list.client.omde.edit',
'persons.list.client.omde.unable',
'persons.list.client.omde.unable.order',
'persons.list.client.omde.view',
'persons.list.client.omde.view.documents',
'persons.list.client.omde.view.orders',
'persons.list.client.single',
'persons.list.client.single.edit',
'persons.list.client.single.view',
'persons.list.sellers',
'persons.list.sellers.able',
'persons.list.sellers.able.order',
'persons.list.sellers.edit',
'persons.list.sellers.unable',
'persons.list.sellers.unable.order',
'persons.list.sellers.view',
'persons.list.sellers.view.documents',
'persons.list.sellers.view.orders',
'product.create',
'product.list',
'product.list.edit',
'product.list.group.edit',
'product.list.remove',
'product.list.variants',
'product.list.variants.add',
'product.list.variants.edit',
'product.list.variants.remove',
'product.list.view',
'product.percent',
'report.order.item',
'report.product',
'sms.create',
'sms.create.all',
'sms.create.multiple',
'sms.create.omde',
'sms.create.reagent',
'sms.create.single',
'sms.list',
'statics',
'user.create',
'user.list',
'user.list.create',
'user.list.edit',
'user.list.remove',
'user.permission',
'user.permission.create',
'user.permission.edit',
'wallet',
'wallet.charge',
'wallet.list',
'wallet.percent',
'workshop.create',
'workshop.list',
] as const;
export type Rid = (typeof ALL_RIDS)[number];
/**
* Port of henza_admin/src/store/users/index.js getParents() — returns every
* ancestor of a dotted rid, longest first.
*
* getParents(['a','b','c']) -> ['a.b', 'a']
*/
function getParents(keys: string[]): string[] {
const work = [...keys];
const parents: string[] = [];
while (work.length > 1) {
work.pop();
parents.push(work.join('.'));
}
return parents;
}
/**
* Port of henza_admin/src/store/users/index.js checkParent().
*
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
* "persons.list" and "persons", so parent menu nodes stay visible. The legacy
* version mutated a module-scoped `rid` variable; this one is pure, which changes
* nothing observable but makes it safe to call concurrently on the server.
*
* Note this only walks UP. A rid never implies its children — which is why the
* legacy nav item guarded by "wallet.list" stays hidden for a user granted only
* "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md,
* and is preserved here rather than silently repaired.
*/
export function expandWithParents(values: string[]): string[] {
let rid: string[] = [...values];
for (const x of values) {
const keys = x.split('.');
if (keys.length > 1) {
rid = [...new Set([...rid, ...getParents(keys)])];
}
}
return rid;
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { CurrentAdmin, Public } from './guards';
import type { AuthenticatedAdmin } from './jwt.strategy';
/**
* Legacy endpoints (henza_server routes/admin.php):
* POST admin-login -> AdminController::login
* GET users/show -> UserController::showData
*
* Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping
* layer translates the legacy paths onto these.
*/
@Controller('admin/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
/** Legacy `users/show` — the router guard's permission refresh. */
@Get('me')
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.authService.showData(admin.id);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../../prisma/prisma.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard, PermissionsGuard } from './guards';
@Module({
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: (process.env.JWT_EXPIRES_IN ?? '12h') as `${number}h`,
},
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
// Global: authenticate first, then check the rid. Routes opt out with @Public().
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
exports: [AuthService],
})
export class AuthModule {}
+89
View File
@@ -0,0 +1,89 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import type { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
/**
* Port of legacy AdminController::login (henza_server).
*
* Legacy issued a random 60-char string as `api_token` and persisted it on the
* user row; this issues a signed JWT instead. The response keeps the legacy
* shape the admin panel's auth store reads:
* - `api_token` -> stored in localStorage under token/branch_token
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
* through checkParent()
*
* Deliberate deviation from legacy: a bad phone and a bad password both return
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
* a wrong password, which let anyone enumerate valid admin phone numbers.
*/
async login(dto: LoginDto) {
const user = await this.prisma.adminUser.findUnique({
where: { mobile: dto.phone },
include: { userType: { include: { permissions: true } } },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const passwordOk = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordOk) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile });
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
};
}
/**
* Port of legacy UserController::showData, which the router guard calls on every
* cold load to refresh the permission list:
*
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
*
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
*/
async showData(adminId: string) {
const user = await this.prisma.adminUser.findUnique({
where: { id: adminId },
include: { userType: { include: { permissions: true } } },
});
if (!user) {
throw new UnauthorizedException();
}
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
permission: (user.userType?.permissions ?? []).map((p) => p.rid),
};
}
}
@@ -0,0 +1,18 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Mirrors the legacy contract in AdminController::login:
* $this->validate($request, ['phone' => 'required', 'password' => 'required']);
*
* The field is named `phone` (not `mobile`) even though it is matched against
* `users.mobile` — kept as-is so the ported login form posts an unchanged body.
*/
export class LoginDto {
@IsString()
@IsNotEmpty()
phone!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+71
View File
@@ -0,0 +1,71 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
createParamDecorator,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedAdmin } from './jwt.strategy';
/** Marks a route as reachable without a token (login, health checks). */
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/** Requires the caller to hold this rid (ancestors count — see expandWithParents). */
export const REQUIRED_RID_KEY = 'requiredRid';
export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid);
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
export const CurrentAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin =>
ctx.switchToHttp().getRequest().user,
);
/**
* JWT guard that honours @Public().
*
* Registered globally in AuthModule, so every route is authenticated by default
* and opting out is explicit. The legacy app had the inverse default — its router
* guard skipped a hardcoded `noneAuthUrl` list — which meant any route added
* without a `meta.role` was silently unprotected.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
/** Enforces @RequirePermission(rid) against the expanded permission list. */
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRid = this.reflector.getAllAndOverride<string>(REQUIRED_RID_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRid) return true;
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
if (!user) throw new ForbiddenException();
if (!user.permissions.includes(requiredRid)) {
throw new ForbiddenException(`Missing permission: ${requiredRid}`);
}
return true;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { expandWithParents } from './acl.constants';
export interface JwtPayload {
sub: string;
mobile: string;
}
/**
* The authenticated principal attached to `req.user`.
*
* `permissions` is already expanded with ancestors, so guards can test a rid with
* a plain `includes()` — the same shape the legacy frontend held in its Vuex store
* after checkParent().
*/
export interface AuthenticatedAdmin {
id: string;
fullName: string;
mobile: string;
role: string;
userTypeId: number | null;
branchId: string | null;
permissions: string[];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private readonly prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
});
}
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
const user = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
include: { userType: { include: { permissions: true } } },
});
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
// Permissions are re-read on every request rather than baked into the token,
// so a permission change takes effect immediately instead of at next login.
const rids = user.userType?.permissions.map((p) => p.rid) ?? [];
return {
id: user.id,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
userTypeId: user.userTypeId,
branchId: user.branchId,
permissions: expandWithParents(rids),
};
}
}
@@ -1,6 +1,8 @@
import { Controller, Get } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { Public } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
@Controller('catalog/products')
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@@ -1,6 +1,8 @@
import { Controller, Get, Param } from '@nestjs/common';
import { CustomersService } from './customers.service';
import { Public } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
@Controller('customers')
export class CustomersController {
constructor(private readonly customers: CustomersService) {}
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
import { OrderChannel } from '@prisma/client';
import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
export class CreateOrderItemInputDto {
@IsString()
@@ -31,6 +32,15 @@ export class CreateOrderDto {
@IsString()
branchId?: string;
/**
* Which sales channel placed the order. Legacy had no such field because the
* two channels lived in separate tables (orders vs orders_orders); the one
* Order model carries the distinction here. Defaults to RETAIL.
*/
@IsOptional()
@IsEnum(OrderChannel)
channel?: OrderChannel;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
import { Public } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@@ -1,5 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CatalogService } from '../catalog/catalog.service';
import { LedgerService } from '../accounting/ledger.service';
@@ -80,6 +80,7 @@ export class OrdersService {
orderNumber: this.generateOrderNumber(),
customerId: dto.customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
@@ -1,6 +1,8 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PricingService } from './pricing.service';
import { Public } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
@Controller('pricing')
export class PricingController {
constructor(private readonly pricing: PricingService) {}