admin user and ui panel

This commit is contained in:
s.esmaeili
2026-09-21 08:32:31 +03:30
parent 79226c9f13
commit ef1d4638cc
341 changed files with 2270 additions and 657 deletions
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "ProductVariant" ADD COLUMN "imageId" TEXT;
-- CreateIndex
CREATE INDEX "ProductVariant_imageId_idx" ON "ProductVariant"("imageId");
-- AddForeignKey
-- ON DELETE SET NULL: removing a gallery image must never cascade into the
-- variant, because OrderItem rows reference variants and losing one would
-- break order history. The variant simply loses its picture.
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- Sales agents ("کارشناس فروش") are modelled as AdminUser rows
-- (role=SALES_EXPERT), not Customer rows — see admin-users.service.ts and
-- admin-customers.service.ts's listReagents(). ReagentCustomer.vue used to
-- post agent data at the Customer endpoints instead, so agents ended up as
-- Customer rows (kind SALES_AGENT), which then leaked into every
-- Customer-backed list, including "لیست مشتریان تک" (listSingle had no kind
-- filter at all). Fixed at the application layer alongside this migration;
-- this migration only adds what that fix needs:
--
-- 1. CustomerKind.RETAIL — legacy kept retail ("تک") customers in an
-- entirely separate table (SingleCustomer/`login`) that could never
-- contain a wholesale/agent/shopkeeper/banker row. The redesigned
-- schema folded everything into one Customer model, so listSingle()
-- needs an explicit kind to filter on to reproduce that separation.
-- 2. AdminUser gets the handful of Customer-only columns
-- (ReagentCustomer.vue) that a sales agent's form actually collects:
-- avatar, address, store/pasazh name, member code. Only ever set when
-- role=SALES_EXPERT; every other AdminUser leaves them null.
-- AlterEnum
ALTER TYPE "CustomerKind" ADD VALUE IF NOT EXISTS 'RETAIL';
-- AlterTable
ALTER TABLE "AdminUser"
ADD COLUMN "avatarFileId" TEXT,
ADD COLUMN "address" TEXT,
ADD COLUMN "storeName" TEXT,
ADD COLUMN "pasazhName" TEXT,
ADD COLUMN "memberCode" TEXT;
-- CreateIndex
CREATE INDEX "AdminUser_avatarFileId_idx" ON "AdminUser"("avatarFileId");
-- AddForeignKey
ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_avatarFileId_fkey"
FOREIGN KEY ("avatarFileId") REFERENCES "UploadedFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+47 -4
View File
@@ -4,7 +4,7 @@
// All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math.
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
// native = local Windows/macOS; debian-openssl-3.0.x = node:20-bookworm-slim (Arvan)
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
@@ -28,12 +28,27 @@ enum AdminRole {
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
/// labels taken verbatim from bulkCustomer.vue's userTypes list.
///
/// SALES_AGENT is a LEGACY value only: legacy stored a sales agent as a
/// Customer row with type=1, but the redesigned system models a sales agent
/// as an AdminUser (role SALES_EXPERT) instead — see admin-users.service.ts
/// and admin-customers.service.ts's listReagents()/create(). No Customer row
/// should be created with kind SALES_AGENT going forward; the value is kept
/// only so legacyTypeToKind()/kindToLegacyType() stay total functions and any
/// already-migrated data keeps a readable label.
///
/// RETAIL marks a "تک" (single/retail) customer — legacy kept these in an
/// entirely separate table (SingleCustomer/`login`) that could never contain
/// a wholesale/agent/shopkeeper/banker row. The redesigned schema folded both
/// into one Customer model, so this value is what listSingle() now filters on
/// to reproduce that same separation.
enum CustomerKind {
SALES_AGENT // 1 کارشناس فروش
SALES_AGENT // legacy 1 کارشناس فروش — superseded by AdminRole.SALES_EXPERT, do not use for new rows
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
BANKER // 4 بنکدار
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
RETAIL // مشتری تک — legacy SingleCustomer
}
enum VerificationStep {
@@ -127,6 +142,18 @@ model AdminUser {
isActive Boolean @default(true)
createdAt DateTime @default(now())
/// Legacy stored a sales agent as a `customers` row with type=1, so
/// ReagentCustomer.vue's shared form collected these alongside the
/// wholesale-customer fields. A sales agent is now an AdminUser
/// (role=SALES_EXPERT), so these live here instead — only ever set for that
/// role; every other AdminUser leaves them null.
avatarFileId String?
avatarFile UploadedFile? @relation("AdminUserAvatar", fields: [avatarFileId], references: [id])
address String?
storeName String? // legacy store_name
pasazhName String? // legacy pasazh_name
memberCode String? // legacy mcode
referredCustomers Customer[] @relation("ReferralAgent")
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
@@ -139,6 +166,7 @@ model AdminUser {
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
}
model Customer {
@@ -329,6 +357,11 @@ model ProductImage {
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
/// existing rows and any generic "just attach an image" caller keep working.
role ProductImageRole @default(GALLERY)
/// Variants that display this image. A gallery image may back several
/// variants (one photo, several weights), which is why this is a plain
/// one-to-many and not a join table.
variants ProductVariant[]
}
enum ProductImageRole {
@@ -356,7 +389,16 @@ model ProductVariant {
isPreOrder Boolean @default(false)
isActive Boolean @default(true)
/// The gallery image shown for this variant, picked from its own product's
/// images — never a re-uploaded copy. onDelete: SetNull because dropping an
/// image must never cascade into the variant: OrderItem rows reference
/// variants, so losing one would break order history.
imageId String?
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
orderItems OrderItem[]
@@index([imageId])
}
// ─────────────────────────────────────────────────────────────────────────
@@ -861,8 +903,9 @@ model UploadedFile {
uploadedByAdminUserId String?
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
createdAt DateTime @default(now())
customerAvatars Customer[] @relation("CustomerAvatar")
createdAt DateTime @default(now())
customerAvatars Customer[] @relation("CustomerAvatar")
adminUserAvatars AdminUser[] @relation("AdminUserAvatar")
@@index([purpose])
}
@@ -65,6 +65,14 @@ export class AdminCustomersController {
return this.customers.listSingle(query, admin.branchId ?? undefined);
}
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
@Post('single')
@HttpCode(200)
@RequirePermission('persons.create')
createSingle(@Body() data: Record<string, unknown>) {
return this.customers.createSingle(data);
}
/**
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
* form. Returns a plain array of {id, name}, as legacy did.
@@ -104,6 +112,13 @@ export class AdminCustomersController {
return this.customers.listReagents(query);
}
/** A sales agent's own detail screen — see reagentDetail()'s header comment. */
@Get('reagent/:id')
@RequirePermission('persons.list.sellers.view')
reagentDetail(@Param('id') id: string) {
return this.customers.reagentDetail(id);
}
/** Legacy `GET single/order/{id}` — that customer's orders. */
@Get(':id/orders')
@RequirePermission('persons.list.client.omde.view.orders')
@@ -22,14 +22,19 @@ import type { TableQueryDto } from '../admin-table/table-query.dto';
* `type` and Farsi `type_str` the ported grids bind to.
*/
export const CUSTOMER_TYPE_STR: Record<CustomerKind, string> = {
SALES_AGENT: ' کارشناس فروش',
SALES_AGENT: ' کارشناس فروش', // legacy value only — see CustomerKind's schema comment
APP_SHOPKEEPER: 'مغازه دار اپلیکیشن',
WALK_IN_SHOPKEEPER: ' مغازه دار حضوری',
BANKER: 'بنکدار',
WORKSHOP_SUPERVISOR: 'سرپرست کارگاه',
RETAIL: 'مشتری تک',
};
const LEGACY_TYPE_ID: Record<CustomerKind, number> = {
// legacy had no numeric type for RETAIL (it lived in a separate table/model
// entirely — see listSingle()'s header comment), so it is deliberately absent
// here; legacyTypeToKind() returns undefined for it and kindToLegacyType()
// is never called with it.
const LEGACY_TYPE_ID: Partial<Record<CustomerKind, number>> = {
SALES_AGENT: 1,
APP_SHOPKEEPER: 2,
WALK_IN_SHOPKEEPER: 3,
@@ -43,7 +48,7 @@ export const legacyTypeToKind = (t: unknown): CustomerKind | undefined =>
(k) => LEGACY_TYPE_ID[k] === Number(t),
);
export const kindToLegacyType = (k: CustomerKind): number => LEGACY_TYPE_ID[k];
export const kindToLegacyType = (k: CustomerKind): number | null => LEGACY_TYPE_ID[k] ?? null;
export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'archived';
@@ -378,14 +383,85 @@ export class AdminCustomersService {
return this.table.respond(mapped, total, query, page, limit);
}
/**
* The sales agent's own detail screen ("مشاهده اطلاعات" on ReagentList.vue).
*
* A sales agent is an AdminUser now, not a Customer, so it has no debt/
* ledger of its own — "سند های مالی" doesn't apply the way it does for a
* wholesale customer. This shows the agent's profile plus the customers
* they referred and the orders those customers placed instead.
*/
async reagentDetail(id: string) {
const agent = await this.prisma.adminUser.findUnique({
where: { id, role: 'SALES_EXPERT' },
include: { branch: true, avatarFile: true },
});
if (!agent) throw new NotFoundException('کارشناس فروش یافت نشد');
const referred = await this.prisma.customer.findMany({
where: { referredByAgentId: id },
orderBy: { createdAt: 'desc' },
include: { city: true },
});
const orders = await this.prisma.order.findMany({
where: { customer: { referredByAgentId: id } },
orderBy: { createdAt: 'desc' },
take: 100,
include: { customer: true },
});
const gramsSoldAgg = await this.prisma.order.aggregate({
where: { customer: { referredByAgentId: id }, ...AdminCustomersService.SOLD },
_sum: { totalGoldWeightGrams: true },
});
return {
id: agent.id,
name: agent.fullName,
mobile: agent.mobile,
percent: agent.wagePercent,
active: agent.isActive ? 1 : -1,
branch: agent.branch ? { id: agent.branch.id, name: agent.branch.name } : null,
image: agent.avatarFile ? { id: agent.avatarFile.id, image: agent.avatarFile.url } : null,
caj: agent.createdAt,
grams_sell: gramsSoldAgg._sum.totalGoldWeightGrams ?? 0,
referred_customers: referred.map((c) => ({
id: c.id,
name: c.fullName,
mobile: c.mobile,
city: c.city ? { id: c.city.id, title: c.city.name } : null,
type_str: CUSTOMER_TYPE_STR[c.kind],
active: c.isActive ? 1 : -1,
caj: c.createdAt,
})),
orders: orders.map((o) => ({
id: o.id,
name: o.orderNumber,
customer: o.customer.fullName,
w: o.totalGoldWeightGrams,
status: o.status,
caj: o.createdAt,
})),
};
}
/** Orders that reached debt-recognition, reused by reagentDetail(). */
private static readonly SOLD: Prisma.OrderWhereInput = {
status: { in: ['DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER'] },
};
/**
* 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.
* Legacy read this from a COMPLETELY SEPARATE table (SingleCustomer / `login`)
* that could never contain a wholesale/agent/shopkeeper/banker row — the
* separation was structural, not a WHERE-clause discipline. The redesigned
* schema folded every kind into one Customer model, so this list MUST filter
* on `kind: RETAIL` to reproduce that same separation; without it, every
* Customer row (including a mis-created sales agent — see admin-users
* module's header comment) leaked in here. Filtered explicitly rather than
* inferred, so a future creation-path bug can't quietly widen this list again.
*/
async listSingle(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
@@ -397,9 +473,13 @@ export class AdminCustomersService {
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.CustomerWhereInput = {
AND: [where as Prisma.CustomerWhereInput, { kind: CustomerKind.RETAIL }],
};
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: where as Prisma.CustomerWhereInput,
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
@@ -409,7 +489,7 @@ export class AdminCustomersService {
referredByAgent: { include: { branch: true } },
},
}),
this.prisma.customer.count({ where: where as Prisma.CustomerWhereInput }),
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(
@@ -428,7 +508,42 @@ export class AdminCustomersService {
);
}
/** Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`. */
/**
* Legacy `POST single` — singleCustomer.vue, the retail ("تک") customer
* form. This endpoint never existed before (singleCustomer.vue posted to
* a path nothing served — see this method's git history / the bug report
* that led here); its edit path happened to work only because `update()`
* is generic enough to patch any Customer row, agent or otherwise.
*/
async createSingle(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? data.phone ?? '').trim();
if (!fullName || !mobile) {
throw new BadRequestException('نام و موبایل الزامی است');
}
return this.prisma.customer.create({
data: {
fullName,
mobile,
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
passwordHash: '',
cityId: (data.city_id as string) || null,
kind: CustomerKind.RETAIL,
},
});
}
/**
* Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`.
*
* Sales agents (`type`/`kind` SALES_AGENT) are REJECTED here — a sales
* agent is an AdminUser (role SALES_EXPERT) now, created via
* `POST admin/users`, not a Customer row. bulkCustomer.vue no longer offers
* that option; this guard is defense-in-depth against any other caller
* (e.g. reg.vue, or a stale client) still sending type=1.
*/
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? '').trim();
@@ -438,6 +553,13 @@ export class AdminCustomersService {
throw new BadRequestException('نام و موبایل الزامی است');
}
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
'کارشناس فروش از این فرم ثبت نمی‌شود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
);
}
return this.prisma.customer.create({
data: {
fullName,
@@ -454,7 +576,7 @@ export class AdminCustomersService {
: 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,
kind,
phone: (data.phone as string) ?? null,
address: (data.address as string) ?? null,
storeName: (data.store_name as string) ?? null,
@@ -463,6 +585,12 @@ export class AdminCustomersService {
memberCode: (data.mcode as string) ?? null,
// legacy `image_id` from MyUpload's `ok` payload
avatarFileId: (data.image_id as string) || null,
// The three document photos the wholesale form collects. These were
// already read back in toRow() but never persisted, so every upload was
// silently discarded on save.
nationalIdImageUrl: (data.national_id_image as string) || null,
businessLicenseUrl: (data.business_license as string) || null,
shopImageUrl: (data.shop_image 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,
@@ -487,6 +615,13 @@ export class AdminCustomersService {
if (data.type !== undefined) {
const kind = legacyTypeToKind(data.type);
if (!kind) throw new BadRequestException('نوع مشتری نامعتبر است');
// See create()'s header comment — a Customer row must never become a
// sales agent; that's an AdminUser (role SALES_EXPERT) now.
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
'کارشناس فروش از این فرم ویرایش نمی‌شود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
);
}
patch.kind = kind;
}
if (data.phone !== undefined) patch.phone = String(data.phone);
@@ -510,6 +645,17 @@ export class AdminCustomersService {
? { connect: { id: String(data.image_id) } }
: { disconnect: true };
}
// Same three document photos as in create(). '' means "removed", so these
// normalise to null rather than storing an empty string.
if (data.national_id_image !== undefined) {
patch.nationalIdImageUrl = (data.national_id_image as string) || null;
}
if (data.business_license !== undefined) {
patch.businessLicenseUrl = (data.business_license as string) || null;
}
if (data.shop_image !== undefined) {
patch.shopImageUrl = (data.shop_image as string) || null;
}
return this.prisma.customer.update({ where: { id }, data: patch });
}
@@ -1,4 +1,14 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import {
Body,
Controller,
Delete,
ForbiddenException,
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';
@@ -162,15 +172,44 @@ export class AdminMiscController {
return this.misc.removeBrand(id);
}
// The mobile home slider ("اسلایدر موبایل") is stored under the same
// `Setting` table as every other `const/:key` entry (key: mobile_main_slider),
// but it gets its own permission (`custom.config.statics.slider`, per
// acl.constants.ts / permissionConst.ts) rather than the broad `config.statics`
// that guards the rest of the "مدیریت ثابت ها" screen — uploading/replacing the
// public site banner is more sensitive than editing brand/tag lists, so it
// should not be reachable by everyone who can merely open that screen.
// Routes declared ahead of `const/:key` so Nest doesn't match `const/slider`
// (the legacy REST shape) to the generic key handler instead.
@Get('const/slider')
@RequirePermission('custom.config.statics.slider')
getSlider() {
return this.misc.getConst('mobile_main_slider');
}
@Put('const/slider')
@RequirePermission('custom.config.statics.slider')
setSlider(@Body() body: Record<string, unknown>) {
return this.misc.setConst('mobile_main_slider', body.val !== undefined ? body.val : body);
}
@Delete('const/slider')
@RequirePermission('custom.config.statics.slider')
removeSlider() {
return this.misc.removeConst('mobile_main_slider');
}
@Get('const/:key')
@RequirePermission('config.statics')
getConst(@Param('key') key: string) {
this.assertNotSliderKey(key);
return this.misc.getConst(key);
}
@Put('const/:key')
@RequirePermission('config.statics')
setConst(@Param('key') key: string, @Body() body: Record<string, unknown>) {
this.assertNotSliderKey(key);
// Legacy posts either a bare value under `val` or a whole object.
return this.misc.setConst(key, body.val !== undefined ? body.val : body);
}
@@ -178,6 +217,18 @@ export class AdminMiscController {
@Delete('const/:key')
@RequirePermission('config.statics')
removeConst(@Param('key') key: string) {
this.assertNotSliderKey(key);
return this.misc.removeConst(key);
}
// Blocks the generic `config.statics`-gated routes from reaching the slider
// setting by its raw key — otherwise anyone with `config.statics` could
// bypass the dedicated `const/slider` routes (guarded by the stricter
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
// directly, since the underlying Setting row is keyed the same either way.
private assertNotSliderKey(key: string) {
if (key === 'mobile_main_slider') {
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
}
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminMiscController } from './admin-misc.controller';
import { PublicSliderController } from './public-slider.controller';
import { AdminMiscService } from './admin-misc.service';
@Module({
imports: [PrismaModule],
controllers: [AdminMiscController],
controllers: [AdminMiscController, PublicSliderController],
providers: [AdminMiscService],
})
export class AdminMiscModule {}
@@ -0,0 +1,26 @@
import { Controller, Get } from '@nestjs/common';
import { AdminMiscService } from './admin-misc.service';
import { Public } from '../auth/guards';
/**
* Public read of the admin-managed home slider ("اسلایدر موبایل" in
* manage/Const.vue), served at GET /api/home/slider.
*
* The admin write path (const/slider, guarded by the dedicated
* `custom.config.statics.slider` permission — see admin-misc.controller.ts)
* stores it under the same `Setting` row (key: mobile_main_slider) that this
* reads. Split into its own controller/prefix, rather than exposing it under
* `admin/misc`, so the shopper-facing site does not call an `/admin/...` path
* unauthenticated.
*/
@Public()
@Controller('home')
export class PublicSliderController {
constructor(private readonly misc: AdminMiscService) {}
@Get('slider')
async getSlider() {
const value = await this.misc.getConst('mobile_main_slider');
return Array.isArray(value) ? value : [];
}
}
@@ -153,7 +153,7 @@ export class AdminProductsService {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: true,
variants: { include: { image: true } },
collections: { include: { collection: true } },
},
});
@@ -173,10 +173,19 @@ export class AdminProductsService {
mobile_pic: p.images.find((i) => i.role === 'FEATURED_MOBILE')?.url ?? null,
images_gallery: p.images.filter((i) => i.role === 'GALLERY').map((i) => i.url),
images_mobile: p.images.filter((i) => i.role === 'GALLERY_MOBILE').map((i) => i.url),
// The same images again, but carrying their ids — the variant image
// picker needs something to select. The four URL-only fields above stay
// exactly as they were so the form's existing bindings keep working.
gallery: p.images.map((i) => ({
id: i.id,
url: i.url,
role: i.role,
sortOrder: i.sortOrder,
})),
};
}
private variantRow(v: Prisma.ProductVariantGetPayload<object>) {
private variantRow(v: Prisma.ProductVariantGetPayload<object> & { image?: { id: string; url: string } | null }) {
return {
id: v.id,
product_id_id: v.productId,
@@ -187,6 +196,11 @@ export class AdminProductsService {
qty: v.qty,
is_pre_order: v.isPreOrder,
is_active: v.isActive,
// The gallery image picked for this variant. `image_id` is what the form
// posts back; `image` is denormalised so the table can render a thumbnail
// without a second lookup. Only populated where the query included it.
image_id: v.imageId,
image: v.image ? { id: v.image.id, url: v.image.url } : null,
// 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,
@@ -214,7 +228,7 @@ export class AdminProductsService {
orderBy: orderBy as Prisma.ProductVariantOrderByWithRelationInput,
skip,
take,
include: { product: true },
include: { product: true, image: true },
}),
this.prisma.productVariant.count({ where: scoped }),
]);
@@ -241,7 +255,7 @@ export class AdminProductsService {
brand: true,
category: true,
images: { orderBy: { sortOrder: 'asc' } },
variants: true,
variants: { include: { image: true } },
},
});
return rows.map((p) => ({
@@ -282,12 +296,27 @@ export class AdminProductsService {
/** Legacy `GET product/{id}/variant`. */
async variantsOf(productId: string) {
const rows = await this.prisma.productVariant.findMany({ where: { productId } });
const rows = await this.prisma.productVariant.findMany({
where: { productId },
include: { image: true },
});
return rows.map((v) => this.variantRow(v));
}
/** Shape of one inline variant row posted by the product form. */
private variantCreateData(v: Record<string, unknown>) {
/**
* Shape of one inline variant row posted by the product form.
*
* `image_id` is resolved against `allowedImageIds` — the ids of the images
* belonging to *this* product — so a payload can never point a variant at
* another product's image. An absent/empty value clears the picture.
*/
private variantCreateData(v: Record<string, unknown>, allowedImageIds: Set<string>) {
const rawImageId = v.image_id ?? v.imageId;
const imageId = rawImageId ? String(rawImageId) : null;
if (imageId && !allowedImageIds.has(imageId)) {
throw new BadRequestException('تصویر انتخاب‌شده متعلق به این محصول نیست');
}
return {
title: String(v.title ?? ''),
rashWeightGrams: new Prisma.Decimal(String(v.grams ?? 0)),
@@ -295,9 +324,47 @@ export class AdminProductsService {
qty: Number(v.qty ?? 0),
isPreOrder: Boolean(v.is_pre_order),
isActive: v.is_active !== undefined ? Boolean(v.is_active) : true,
imageId,
};
}
/**
* Builds a commercial SKU for a newly created variant.
*
* Derived from the product's own code (falling back to a slice of its id when
* no code was entered) plus a running number, e.g. `RING-001-01`. Only ever
* called for variants that have no SKU yet — an existing one is never
* rewritten, because it may already be in use in stock/sales systems.
*
* `taken` carries the SKUs handed out earlier in the same request so two new
* variants in one submit cannot collide before either is written.
*/
private async generateSku(
productCode: string | null,
productId: string,
taken: Set<string>,
): Promise<string> {
const base = (productCode?.trim() || productId.slice(-6)).toUpperCase().replace(/\s+/g, '-');
// Start past the highest suffix already stored for this prefix, so deleting
// and re-adding variants does not reuse a retired code.
const existing = await this.prisma.productVariant.findMany({
where: { sku: { startsWith: `${base}-` } },
select: { sku: true },
});
for (const row of existing) if (row.sku) taken.add(row.sku);
for (let n = 1; n <= 9999; n++) {
const candidate = `${base}-${String(n).padStart(2, '0')}`;
if (!taken.has(candidate)) {
taken.add(candidate);
return candidate;
}
}
// Practically unreachable; keeps the return type honest.
return `${base}-${Date.now()}`;
}
/**
* Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
* گالری تصاویر سایت / گالری تصاویر موبایل — AddProduct.vue's `indicator_id`,
@@ -340,11 +407,15 @@ export class AdminProductsService {
: [];
const images = this.imageCreateData(data);
return this.prisma.product.create({
const productCode = (data.product_code as string) || null;
// Images are created with the product so their ids exist before variants
// are attached — a variant's `image_id` has to reference a real row.
const created = await this.prisma.product.create({
data: {
title,
description: (data.body_html as string) ?? (data.description as string) ?? null,
productCode: (data.product_code as string) || null,
productCode,
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,
@@ -352,13 +423,30 @@ export class AdminProductsService {
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
variants: variants.length
? { create: variants.map((v) => this.variantCreateData(v)) }
: undefined,
images: images.length ? { create: images } : undefined,
},
include: { variants: true, images: true },
include: { images: true },
});
// On create the form works from freshly uploaded URLs and has no image ids
// to post yet, so in practice `image_id` is absent here — but a client that
// does send one is still validated against this product's own images.
if (variants.length) {
const allowed = new Set(created.images.map((i) => i.id));
const takenSkus = new Set<string>();
for (const v of variants) {
const payload = this.variantCreateData(v, allowed);
await this.prisma.productVariant.create({
data: {
...payload,
productId: created.id,
sku: await this.generateSku(productCode, created.id, takenSkus),
},
});
}
}
return this.findOne(created.id);
}
/** Legacy `PUT product/update/{id}`. */
@@ -382,6 +470,10 @@ export class AdminProductsService {
await this.prisma.product.update({ where: { id }, data: patch });
// Images are reconciled BEFORE variants, because a variant's `image_id`
// must resolve against the image rows this submit leaves behind.
await this.syncImages(id, data);
// 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
@@ -392,8 +484,19 @@ export class AdminProductsService {
// sweep below would deactivate the row we just added.
const keptIds: string[] = [];
const currentImages = await this.prisma.productImage.findMany({
where: { productId: id },
select: { id: true },
});
const allowed = new Set(currentImages.map((i) => i.id));
const takenSkus = new Set<string>();
const productCode =
data.product_code !== undefined
? (data.product_code as string) || null
: existing.productCode;
for (const v of posted) {
const payload = this.variantCreateData(v);
const payload = this.variantCreateData(v, allowed);
if (v.id) {
await this.prisma.productVariant.update({
where: { id: String(v.id) },
@@ -402,7 +505,11 @@ export class AdminProductsService {
keptIds.push(String(v.id));
} else {
const created = await this.prisma.productVariant.create({
data: { ...payload, productId: id },
data: {
...payload,
productId: id,
sku: await this.generateSku(productCode, id, takenSkus),
},
});
keptIds.push(created.id);
}
@@ -416,22 +523,70 @@ export class AdminProductsService {
});
}
// Images carry no downstream reference (unlike variants/OrderItem), so all
// 4 slots are applied as a straight replace: clear and recreate from
// whatever the form posted. The product form always posts all 4 image
// fields together (even as empty/absent), so any one of them present means
// this is a real submit, not a partial PATCH missing the others.
if (['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) {
const images = this.imageCreateData(data);
await this.prisma.productImage.deleteMany({ where: { productId: id } });
if (images.length) {
await this.prisma.productImage.createMany({
data: images.map((img) => ({ ...img, productId: id })),
});
return this.findOne(id);
}
/**
* Applies the 4 posted image slots to a product WITHOUT churning row ids.
*
* This used to be a delete-everything-and-recreate, which was fine while
* nothing referenced an image. ProductVariant.imageId now does, so a rewrite
* would silently blank every variant's picture on each save (the FK is
* ON DELETE SET NULL). Instead each posted (url, role) pair is matched
* against the rows already stored: survivors keep their id and only have
* their sortOrder refreshed, genuinely new URLs are inserted, and rows the
* form dropped are deleted.
*
* The form always posts all 4 image fields together (even as empty/absent),
* so any one of them being present means this is a real submit rather than a
* partial PATCH that happens to omit the others.
*/
private async syncImages(productId: string, data: Record<string, unknown>) {
if (!['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) return;
const desired = this.imageCreateData(data);
const current = await this.prisma.productImage.findMany({ where: { productId } });
// (url, role) identifies an image slot. Duplicates of the same pair are
// consumed one at a time so a gallery listing the same URL twice keeps two
// distinct rows instead of collapsing them.
const pools = new Map<string, typeof current>();
for (const row of current) {
const key = `${row.role}\u0000${row.url}`;
const pool = pools.get(key);
if (pool) pool.push(row);
else pools.set(key, [row]);
}
const survivingIds = new Set<string>();
for (const img of desired) {
const key = `${img.role}\u0000${img.url}`;
const match = pools.get(key)?.shift();
if (match) {
survivingIds.add(match.id);
if (match.sortOrder !== img.sortOrder) {
await this.prisma.productImage.update({
where: { id: match.id },
data: { sortOrder: img.sortOrder },
});
}
} else {
await this.prisma.productImage.create({ data: { ...img, productId } });
}
}
return this.findOne(id);
const removedIds = current.filter((row) => !survivingIds.has(row.id)).map((row) => row.id);
if (removedIds.length) {
// Any variant pointing at a removed image is cleared first. The FK would
// do this anyway, but doing it explicitly keeps the behaviour identical
// on databases where the constraint is not enforced the same way.
await this.prisma.productVariant.updateMany({
where: { imageId: { in: removedIds } },
data: { imageId: null },
});
await this.prisma.productImage.deleteMany({ where: { id: { in: removedIds } } });
}
}
/**
@@ -458,8 +613,11 @@ export class AdminProductsService {
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 } }),
// Variants reference images (ProductVariant.imageId), so they go first —
// the FK is ON DELETE SET NULL, but deleting in dependency order keeps
// this correct regardless of how the constraint is enforced.
this.prisma.productVariant.deleteMany({ where: { productId: id } }),
this.prisma.productImage.deleteMany({ where: { productId: id } }),
this.prisma.product.delete({ where: { id } }),
]);
return { success: true };
@@ -81,7 +81,7 @@ export class AdminUsersService {
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { userType: true, branch: true },
include: { userType: true, branch: true, avatarFile: true },
}),
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
]);
@@ -90,7 +90,11 @@ export class AdminUsersService {
}
/** Legacy field names preserved; the password hash is never exposed. */
private toRow(u: Prisma.AdminUserGetPayload<{ include: { userType: true; branch: true } }>) {
private toRow(
u: Prisma.AdminUserGetPayload<{
include: { userType: true; branch: true; avatarFile: true };
}>,
) {
return {
id: u.id,
name: u.fullName,
@@ -106,27 +110,45 @@ export class AdminUsersService {
active: u.isActive ? 1 : -1,
is_active: u.isActive,
caj: u.createdAt,
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
// null for every other admin. Same shapes admin-customers.service.ts used
// when this data still lived on Customer, so the form binds unchanged.
address: u.address,
store_name: u.storeName,
pasazh_name: u.pasazhName,
mcode: u.memberCode,
image_id: u.avatarFileId,
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
};
}
async findOne(id: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: { userType: true, branch: true },
include: { userType: true, branch: true, avatarFile: true },
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
}
/** Legacy `POST users`. */
/**
* Legacy `POST users`.
*
* `role` is REQUIRED — it used to default to SALES_EXPERT whenever the
* caller omitted it, which silently turned every admin created without an
* explicit role into a sales agent regardless of the access level ("نوع
* کاربری") actually chosen. UserCreate.vue now always sends one.
*/
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 ?? '');
const role = data.role as AdminRole | undefined;
if (!fullName) throw new BadRequestException('نام الزامی است');
if (!mobile) throw new BadRequestException('موبایل الزامی است');
if (!password) throw new BadRequestException('رمز عبور الزامی است');
if (!role) throw new BadRequestException('نقش کاربر الزامی است');
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
@@ -136,7 +158,7 @@ export class AdminUsersService {
fullName,
mobile,
passwordHash: await bcrypt.hash(password, 10),
role: (data.role as AdminRole) ?? AdminRole.SALES_EXPERT,
role,
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
branchId: (data.branch_id as string) || null,
wagePercent:
@@ -144,8 +166,15 @@ export class AdminUsersService {
? new Prisma.Decimal(String(data.percent))
: null,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
// Sales-agent-only fields (ReagentCustomer.vue); undefined for every
// other role, which Prisma leaves at column default (null).
address: (data.address as string) || null,
storeName: (data.store_name as string) || null,
pasazhName: (data.pasazh_name as string) || null,
memberCode: (data.mcode as string) || null,
avatarFileId: (data.image_id as string) || null,
},
include: { userType: true, branch: true },
include: { userType: true, branch: true, avatarFile: true },
});
}
@@ -180,11 +209,21 @@ export class AdminUsersService {
data.percent === null ? null : new Prisma.Decimal(String(data.percent));
}
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
if (data.address !== undefined) patch.address = (data.address as string) || null;
if (data.store_name !== undefined) patch.storeName = (data.store_name as string) || null;
if (data.pasazh_name !== undefined) patch.pasazhName = (data.pasazh_name as string) || null;
if (data.mcode !== undefined) patch.memberCode = (data.mcode as string) || null;
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.adminUser.update({
where: { id },
data: patch,
include: { userType: true, branch: true },
include: { userType: true, branch: true, avatarFile: true },
});
}
@@ -9,8 +9,18 @@ export class CatalogService {
return this.prisma.product.findMany({
where: { isActive: true },
include: {
variants: true,
images: true,
// Only orderable variants reach the storefront. An inactive one used to
// be listed and then rejected by getVariantOrThrow at checkout, so the
// customer could pick a chip and get a 404 on the order.
variants: {
where: { isActive: true },
// Each variant carries its own gallery picture, so the app can render
// the chip without a second request per variant.
include: { image: true },
},
// Explicit order: the UI treats images[0] as the product's main photo,
// which is only meaningful if the rows come back sorted.
images: { orderBy: { sortOrder: 'asc' } },
category: true,
brand: true,
collections: { include: { collection: true } },