admin user and ui panel
This commit is contained in:
@@ -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;
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user