update admin panel, api, and shared-types

This commit is contained in:
mahmood
2026-10-25 11:43:45 +03:30
parent 2134de99e2
commit 5bd77c33d3
12 changed files with 150 additions and 91 deletions
@@ -2,19 +2,21 @@
<!-- <!--
Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a
tree — country > province > city > district — with the top level a heading tree — country > province > city > district — with the top level a heading
that cannot be picked. A top-level row with nothing under it stays pickable, that cannot be picked. Rendered as a plain a-select like every other picker
so a city entered without a parent is never unreachable. in the panel: each top-level row is an option group, everything under it a
normal option (deeper levels carry their path, e.g. «تهران › منطقه ۱»).
A top-level row with nothing under it stays pickable, so a city entered
without a parent is never unreachable.
`parentPicker` is the «والد» field of the city form: every row is pickable `parentPicker` is the «والد» field of the city form: every row is pickable
there except the city being edited and its own descendants. there (labelled with its full path) except the city being edited and its
own descendants.
--> -->
<a-tree-select <a-select
:value="value ?? undefined" :value="value ?? undefined"
:tree-data="treeData" :options="options"
show-search show-search
allow-clear allow-clear
tree-default-expand-all option-filter-prop="search"
tree-node-filter-prop="title"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
:placeholder="placeholder ?? 'شهر را انتخاب کنید'" :placeholder="placeholder ?? 'شهر را انتخاب کنید'"
class="w-full" class="w-full"
@update:value="onChange" @update:value="onChange"
@@ -31,11 +33,22 @@ export interface CityRow {
} }
interface Node { interface Node {
value: string; id: string;
key: string;
title: string; title: string;
disabled?: boolean; children: Node[];
children?: Node[]; }
interface Option {
value: string;
label: string;
/** What the search box matches: the row's path, its group included. */
search: string;
}
interface Group {
key: string;
label: string;
options: Option[];
} }
const props = defineProps<{ const props = defineProps<{
@@ -51,7 +64,7 @@ const emit = defineEmits<{
(e: 'change', v: string | undefined): void; (e: 'change', v: string | undefined): void;
}>(); }>();
const treeData = computed<Node[]>(() => { const tree = computed<Node[]>(() => {
const ids = new Set(props.cities.map((c) => c.id)); const ids = new Set(props.cities.map((c) => c.id));
const kids = new Map<string | null, CityRow[]>(); const kids = new Map<string | null, CityRow[]>();
for (const c of props.cities) { for (const c of props.cities) {
@@ -60,18 +73,37 @@ const treeData = computed<Node[]>(() => {
kids.get(p)!.push(c); kids.get(p)!.push(c);
} }
const seen = new Set<string>(); const seen = new Set<string>();
const build = (parent: string | null, depth: number): Node[] => const build = (parent: string | null): Node[] =>
(kids.get(parent) ?? []) (kids.get(parent) ?? [])
.filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId)) .filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId))
.map((c) => { .map((c) => {
seen.add(c.id); seen.add(c.id);
const children = build(c.id, depth + 1); return { id: c.id, title: c.title, children: build(c.id) };
const node: Node = { value: c.id, key: c.id, title: c.title };
if (children.length) node.children = children;
if (!props.parentPicker && depth === 0 && children.length) node.disabled = true;
return node;
}); });
return build(null, 0); return build(null);
});
const SEP = ' › ';
/** Every row under `nodes`, depth-first, labelled with its path from there. */
function flatten(nodes: Node[], prefix: string[], groupTitle = ''): Option[] {
return nodes.flatMap((n) => {
const path = [...prefix, n.title];
const label = path.join(SEP);
return [
{ value: n.id, label, search: groupTitle ? `${groupTitle}${SEP}${label}` : label },
...flatten(n.children, path, groupTitle),
];
});
}
const options = computed<(Option | Group)[]>(() => {
if (props.parentPicker) return flatten(tree.value, []);
return tree.value.map((top) =>
top.children.length
? { key: top.id, label: top.title, options: flatten(top.children, [], top.title) }
: { value: top.id, label: top.title, search: top.title },
);
}); });
function onChange(v: string | undefined) { function onChange(v: string | undefined) {
+4 -1
View File
@@ -75,7 +75,7 @@
</section> </section>
<!-- ── trend + channel ── --> <!-- ── trend + channel ── -->
<section class="row row--main"> <section class="row" :class="isSuper ? 'row--main' : 'row--single'">
<div class="panel"> <div class="panel">
<div class="panel__head"> <div class="panel__head">
<div> <div>
@@ -708,6 +708,9 @@ b {
.row--half { .row--half {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.row--single {
grid-template-columns: minmax(0, 1fr);
}
/* ── KPI cards ── */ /* ── KPI cards ── */
.kpi-grid { .kpi-grid {
@@ -77,12 +77,17 @@
<a-input v-model:value="form.title" placeholder="مثلاً: گردنبند طلا" :disabled="accountingLocked" /> <a-input v-model:value="form.title" placeholder="مثلاً: گردنبند طلا" :disabled="accountingLocked" />
</a-form-item> </a-form-item>
<div class="pf-grid-2"> <!-- site fields (نوع نمایش، شرح سایت، تصویر شاخص و گالری سایت) — super admin only -->
<a-form-item label="کد محصول" :help="accountingLocked ? 'از نرم‌افزار حسابداری' : undefined"> <div :class="{ 'pf-grid-2': users.isSuperAdmin }">
<a-form-item
label="کد محصول"
:class="{ 'mb-0': !users.isSuperAdmin }"
:help="accountingLocked ? 'از نرم‌افزار حسابداری' : undefined"
>
<a-input v-model:value="form.product_code" :disabled="accountingLocked" /> <a-input v-model:value="form.product_code" :disabled="accountingLocked" />
</a-form-item> </a-form-item>
<a-form-item label="نوع نمایش"> <a-form-item v-if="users.isSuperAdmin" label="نوع نمایش">
<a-select v-model:value="form.show_type"> <a-select v-model:value="form.show_type">
<a-select-option :value="1">سایت</a-select-option> <a-select-option :value="1">سایت</a-select-option>
<a-select-option :value="2">گوشی</a-select-option> <a-select-option :value="2">گوشی</a-select-option>
@@ -91,7 +96,7 @@
</a-form-item> </a-form-item>
</div> </div>
<a-form-item label="شرح محصول (سایت)" class="mb-0"> <a-form-item v-if="users.isSuperAdmin" label="شرح محصول (سایت)" class="mb-0">
<a-textarea <a-textarea
v-model:value="form.body_html" v-model:value="form.body_html"
:auto-size="{ minRows: 5, maxRows: 12 }" :auto-size="{ minRows: 5, maxRows: 12 }"
@@ -193,19 +198,21 @@
<span class="pf-section__icon"><PictureOutlined /></span> <span class="pf-section__icon"><PictureOutlined /></span>
<div> <div>
<h3>گالری تصاویر</h3> <h3>گالری تصاویر</h3>
<p>تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل</p> <p>{{ users.isSuperAdmin ? 'تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل' : 'تصاویر نمایش در اپلیکیشن موبایل' }}</p>
</div> </div>
</div> </div>
<a-form-item label="گالری تصاویر سایت"> <template v-if="users.isSuperAdmin">
<GalleryUploadInput <a-form-item label="گالری تصاویر سایت">
v-model="gallerySite" <GalleryUploadInput
purpose="product.gallery" v-model="gallerySite"
placeholder="افزودن تصاویر گالری سایت" purpose="product.gallery"
/> placeholder="افزودن تصاویر گالری سایت"
</a-form-item> />
</a-form-item>
<div class="pf-divider"></div> <div class="pf-divider"></div>
</template>
<a-form-item label="گالری تصاویر موبایل" class="mb-0"> <a-form-item label="گالری تصاویر موبایل" class="mb-0">
<GalleryUploadInput <GalleryUploadInput
@@ -286,7 +293,7 @@
</div> </div>
</div> </div>
<div class="pf-uploads"> <div class="pf-uploads">
<a-form-item label="سایت" class="mb-0"> <a-form-item v-if="users.isSuperAdmin" label="سایت" class="mb-0">
<ImageUploadInput v-model="form.indicator_id" purpose="product.indicator" placeholder="تصویر شاخص" /> <ImageUploadInput v-model="form.indicator_id" purpose="product.indicator" placeholder="تصویر شاخص" />
</a-form-item> </a-form-item>
<a-form-item label="موبایل" class="mb-0"> <a-form-item label="موبایل" class="mb-0">
@@ -383,7 +390,7 @@
</a-form-item> </a-form-item>
</div> </div>
</a-form> </a-form>
<p class="variant-form__hint">تصویر بارگذاری‌شده به گالری سایت محصول هم اضافه می‌شود.</p> <p v-if="users.isSuperAdmin" class="variant-form__hint">تصویر بارگذاری‌شده به گالری سایت محصول هم اضافه می‌شود.</p>
<template #footer> <template #footer>
<a-button @click="variantModal = false">انصراف</a-button> <a-button @click="variantModal = false">انصراف</a-button>
<a-button type="primary" @click="saveVariant">ثبت</a-button> <a-button type="primary" @click="saveVariant">ثبت</a-button>
@@ -82,7 +82,7 @@
<a-descriptions-item label="دسته بندی"> <a-descriptions-item label="دسته بندی">
{{ tempRow.category?.title || '-' }} {{ tempRow.category?.title || '-' }}
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item> <a-descriptions-item v-if="users.isSuperAdmin" label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
<a-descriptions-item label="تعداد تنوع"> <a-descriptions-item label="تعداد تنوع">
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }} {{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
</a-descriptions-item> </a-descriptions-item>
@@ -129,7 +129,7 @@ const router = useRouter();
const users = useUsersStore(); const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid)); const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildProductListModel(); const model = buildProductListModel(users.isSuperAdmin);
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null); const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const selectedRows = ref<Record<string, any>[]>([]); const selectedRows = ref<Record<string, any>[]>([]);
@@ -5,7 +5,8 @@
// ColCreator block above the live XTbl definition — not revived. // ColCreator block above the live XTbl definition — not revived.
import XTbl, { Xtc } from '../../../components/x-table/model'; import XTbl, { Xtc } from '../../../components/x-table/model';
export function buildProductListModel(): XTbl { // `withShowType` — the display channel is a site field, shown to the super admin only.
export function buildProductListModel(withShowType = true): XTbl {
const tbl = new XTbl('admin/products/list', 'لیست محصولات'); const tbl = new XTbl('admin/products/list', 'لیست محصولات');
tbl.add(new Xtc('id', 'کد').filterNumber()); tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort()); tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort());
@@ -14,15 +15,17 @@ export function buildProductListModel(): XTbl {
// legacy key is 'category.collection.title'; the mapping layer aliases it // legacy key is 'category.collection.title'; the mapping layer aliases it
tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText()); tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText());
tbl.add(new Xtc('product_code', 'کد محصول').filterText()); tbl.add(new Xtc('product_code', 'کد محصول').filterText());
tbl.add( if (withShowType) {
new Xtc('show_type', 'نوع نمایش') tbl.add(
.filterSelect({ new Xtc('show_type', 'نوع نمایش')
1: 'سایت', .filterSelect({
2: 'گوشی', 1: 'سایت',
3: 'سایت و گوشی', 2: 'گوشی',
}) 3: 'سایت و گوشی',
.renderSlot('show_type'), })
); .renderSlot('show_type'),
);
}
// D27 — stock in the accounting software, and whether the app shows it at all. // D27 — stock in the accounting software, and whether the app shows it at all.
tbl.add(new Xtc('accounting_stock', 'حسابداری / نمایش').renderSlot('accounting').noSort()); tbl.add(new Xtc('accounting_stock', 'حسابداری / نمایش').renderSlot('accounting').noSort());
tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort()); tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort());
@@ -125,13 +125,15 @@ import { CloseOutlined, DownOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue'; import XTable from '../../../components/x-table/XTable.vue';
import { buildProductListModel } from '../../apps/product/productListModel'; import { buildProductListModel } from '../../apps/product/productListModel';
import { useCartStore, type CartItem, type CartVariant } from '../../../stores/cart'; import { useCartStore, type CartItem, type CartVariant } from '../../../stores/cart';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client'; import api from '../../../api/client';
const router = useRouter(); const router = useRouter();
const cart = useCartStore(); const cart = useCartStore();
const users = useUsersStore();
const noImage = '/img/no-items.png'; const noImage = '/img/no-items.png';
const cols = buildProductListModel(); const cols = buildProductListModel(users.isSuperAdmin);
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null); const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false); const loading = ref(false);
const customers = ref<{ id: string; fname: string; lname: string }[]>([]); const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
+6 -17
View File
@@ -94,13 +94,8 @@
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button> <a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
</a-tab-pane> </a-tab-pane>
<!-- اسلایدر موبایل — دسترسی جدا از بقیه‌ی این صفحه، چون آپلود بنر عمومی <!-- اسلایدر موبایل — برای هر کسی که این صفحه را می‌بیند (config.statics) -->
سایت حساس‌تر از ویرایش برند/برچسب است (custom.config.statics.slider) --> <a-tab-pane key="slider" tab="اسلایدر موبایل">
<a-tab-pane
v-if="hasPermission('custom.config.statics.slider')"
key="slider"
tab="اسلایدر موبایل"
>
<div class="slider-toolbar"> <div class="slider-toolbar">
<span class="slider-count">{{ sliders.length }} اسلاید</span> <span class="slider-count">{{ sliders.length }} اسلاید</span>
<div class="row"> <div class="row">
@@ -309,11 +304,8 @@ function removeSlide(index: number) {
sliders.value.splice(index, 1); sliders.value.splice(index, 1);
} }
// Gated server-side by its own `custom.config.statics.slider` permission (not // Uses the dedicated `const/slider` route: the generic `const/:key` refuses the
// the broad `config.statics` that guards the rest of this screen) — see // slider's key (admin-misc.controller.ts).
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
// than the generic `const/:key`, which the server only accepts under that
// specific permission.
async function saveSliders() { async function saveSliders() {
try { try {
await api.put('const/slider', { val: sliders.value }); await api.put('const/slider', { val: sliders.value });
@@ -371,12 +363,9 @@ onMounted(async () => {
try { try {
loading.value = true; loading.value = true;
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]); await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
const [t, s] = await Promise.all([ const [t, s] = await Promise.all([api.get('const/__list__tags'), api.get('const/slider')]);
api.get('const/__list__tags'),
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
]);
tags.value = Array.isArray(t.data) ? t.data : []; tags.value = Array.isArray(t.data) ? t.data : [];
sliders.value = Array.isArray(s?.data) ? s.data : []; sliders.value = Array.isArray(s.data) ? s.data : [];
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} finally { } finally {
+1 -1
View File
@@ -216,7 +216,7 @@ const cart = useCartStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid)); const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const noImage = '/img/no-items.png'; const noImage = '/img/no-items.png';
const cols = buildProductListModel(); const cols = buildProductListModel(users.isSuperAdmin);
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null); const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false); const loading = ref(false);
@@ -176,27 +176,25 @@ export class AdminMiscController {
// The mobile home slider ("اسلایدر موبایل") is stored under the same // The mobile home slider ("اسلایدر موبایل") is stored under the same
// `Setting` table as every other `const/:key` entry (key: mobile_main_slider), // `Setting` table as every other `const/:key` entry (key: mobile_main_slider),
// but it gets its own permission (`custom.config.statics.slider`, per // and is open to everyone who can use the "مدیریت ثابت ها" screen
// acl.constants.ts / permissionConst.ts) rather than the broad `config.statics` // (`config.statics`). The older `custom.config.statics.slider` rid stays in
// that guards the rest of the "مدیریت ثابت ها" screen — uploading/replacing the // the catalogue so roles that still hold it keep saving, but gates nothing.
// 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` // Routes declared ahead of `const/:key` so Nest doesn't match `const/slider`
// (the legacy REST shape) to the generic key handler instead. // (the legacy REST shape) to the generic key handler instead.
@Get('const/slider') @Get('const/slider')
@RequirePermission('custom.config.statics.slider') @RequirePermission('config.statics')
getSlider() { getSlider() {
return this.misc.getConst('mobile_main_slider'); return this.misc.getConst('mobile_main_slider');
} }
@Put('const/slider') @Put('const/slider')
@RequirePermission('custom.config.statics.slider') @RequirePermission('config.statics')
setSlider(@Body() body: Record<string, unknown>) { setSlider(@Body() body: Record<string, unknown>) {
return this.misc.setConst('mobile_main_slider', body.val !== undefined ? body.val : body); return this.misc.setConst('mobile_main_slider', body.val !== undefined ? body.val : body);
} }
@Delete('const/slider') @Delete('const/slider')
@RequirePermission('custom.config.statics.slider') @RequirePermission('config.statics')
removeSlider() { removeSlider() {
return this.misc.removeConst('mobile_main_slider'); return this.misc.removeConst('mobile_main_slider');
} }
@@ -223,11 +221,8 @@ export class AdminMiscController {
return this.misc.removeConst(key); return this.misc.removeConst(key);
} }
// Blocks the generic `config.statics`-gated routes from reaching the slider // Keys with a dedicated route are refused here so each has exactly one way
// setting by its raw key — otherwise anyone with `config.statics` could // in: the slider (`const/slider`) and the accounting and pricing settings.
// 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) { private assertNotSliderKey(key: string) {
if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) { if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) {
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است'); throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
@@ -6,8 +6,8 @@ import { Public } from '../auth/guards';
* Public read of the admin-managed home slider ("اسلایدر موبایل" in * Public read of the admin-managed home slider ("اسلایدر موبایل" in
* manage/Const.vue), served at GET /api/home/slider. * manage/Const.vue), served at GET /api/home/slider.
* *
* The admin write path (const/slider, guarded by the dedicated * The admin write path (const/slider, guarded by `config.statics` — see
* `custom.config.statics.slider` permission — see admin-misc.controller.ts) * admin-misc.controller.ts)
* stores it under the same `Setting` row (key: mobile_main_slider) that this * 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 * 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 * `admin/misc`, so the shopper-facing site does not call an `/admin/...` path
@@ -5,6 +5,20 @@ import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'
import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isSuperAdmin } from '../auth/super-admin'; import { isSuperAdmin } from '../auth/super-admin';
/**
* The website side of a product — display channel, site description, site
* featured image and site gallery — is the super admin's alone. Everyone
* else's create/update reaches the service without these keys, so a create
* takes the defaults and an update leaves the stored values untouched.
*/
const SITE_FIELDS = ['show_type', 'body_html', 'description', 'indicator_id', 'images'] as const;
function withoutSiteFields(data: Record<string, unknown>, admin: AuthenticatedAdmin) {
if (isSuperAdmin(admin.mobile)) return data;
for (const key of SITE_FIELDS) delete data[key];
return data;
}
/** /**
* Legacy endpoints (ProductController): * Legacy endpoints (ProductController):
* POST products -> the product grid * POST products -> the product grid
@@ -107,7 +121,7 @@ export class AdminProductsController {
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) { create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
// Trays are super-admin-only; anyone else's product is created without them. // Trays are super-admin-only; anyone else's product is created without them.
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids; if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
return this.products.create(data); return this.products.create(withoutSiteFields(data, admin));
} }
@Post('delete/:id') @Post('delete/:id')
@@ -131,7 +145,11 @@ export class AdminProductsController {
@Put(':id') @Put(':id')
@RequirePermission('product.list.edit') @RequirePermission('product.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) { update(
return this.products.update(id, data); @Param('id') id: string,
@Body() data: Record<string, unknown>,
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.products.update(id, withoutSiteFields(data, admin));
} }
} }
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma, type ProductImageRole } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service'; import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto'; import type { TableQueryDto } from '../admin-table/table-query.dto';
@@ -571,15 +571,25 @@ export class AdminProductsService {
* their sortOrder refreshed, genuinely new URLs are inserted, and rows the * their sortOrder refreshed, genuinely new URLs are inserted, and rows the
* form dropped are deleted. * form dropped are deleted.
* *
* The form always posts all 4 image fields together (even as empty/absent), * Only the slots whose key is present are reconciled; an absent key leaves
* so any one of them being present means this is a real submit rather than a * that slot's rows alone. The form posts all 4 (empty as null / []), but the
* partial PATCH that happens to omit the others. * controller strips the site slots for anyone but the super admin, and
* those must survive the save.
*/ */
private async syncImages(productId: string, data: Record<string, unknown>) { private async syncImages(productId: string, data: Record<string, unknown>) {
if (!['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) return; const slotRoles: [string, ProductImageRole][] = [
['indicator_id', 'FEATURED'],
['mobile_pic', 'FEATURED_MOBILE'],
['images', 'GALLERY'],
['images_mobile', 'GALLERY_MOBILE'],
];
const roles = slotRoles.filter(([key]) => key in data).map(([, role]) => role);
if (!roles.length) return;
const desired = this.imageCreateData(data); const desired = this.imageCreateData(data);
const current = await this.prisma.productImage.findMany({ where: { productId } }); const current = await this.prisma.productImage.findMany({
where: { productId, role: { in: roles } },
});
// (url, role) identifies an image slot. Duplicates of the same pair are // (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 // consumed one at a time so a gallery listing the same URL twice keeps two