update admin panel, api, and shared-types
This commit is contained in:
@@ -2,19 +2,21 @@
|
||||
<!--
|
||||
Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a
|
||||
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,
|
||||
so a city entered without a parent is never unreachable.
|
||||
that cannot be picked. Rendered as a plain a-select like every other picker
|
||||
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
|
||||
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"
|
||||
:tree-data="treeData"
|
||||
:options="options"
|
||||
show-search
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
tree-node-filter-prop="title"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
option-filter-prop="search"
|
||||
:placeholder="placeholder ?? 'شهر را انتخاب کنید'"
|
||||
class="w-full"
|
||||
@update:value="onChange"
|
||||
@@ -31,11 +33,22 @@ export interface CityRow {
|
||||
}
|
||||
|
||||
interface Node {
|
||||
value: string;
|
||||
key: string;
|
||||
id: 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<{
|
||||
@@ -51,7 +64,7 @@ const emit = defineEmits<{
|
||||
(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 kids = new Map<string | null, CityRow[]>();
|
||||
for (const c of props.cities) {
|
||||
@@ -60,18 +73,37 @@ const treeData = computed<Node[]>(() => {
|
||||
kids.get(p)!.push(c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const build = (parent: string | null, depth: number): Node[] =>
|
||||
const build = (parent: string | null): Node[] =>
|
||||
(kids.get(parent) ?? [])
|
||||
.filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId))
|
||||
.map((c) => {
|
||||
seen.add(c.id);
|
||||
const children = build(c.id, depth + 1);
|
||||
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 { id: c.id, title: c.title, children: build(c.id) };
|
||||
});
|
||||
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) {
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</section>
|
||||
|
||||
<!-- ── trend + channel ── -->
|
||||
<section class="row row--main">
|
||||
<section class="row" :class="isSuper ? 'row--main' : 'row--single'">
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
@@ -708,6 +708,9 @@ b {
|
||||
.row--half {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.row--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* ── KPI cards ── */
|
||||
.kpi-grid {
|
||||
|
||||
@@ -77,12 +77,17 @@
|
||||
<a-input v-model:value="form.title" placeholder="مثلاً: گردنبند طلا" :disabled="accountingLocked" />
|
||||
</a-form-item>
|
||||
|
||||
<div class="pf-grid-2">
|
||||
<a-form-item label="کد محصول" :help="accountingLocked ? 'از نرمافزار حسابداری' : undefined">
|
||||
<!-- site fields (نوع نمایش، شرح سایت، تصویر شاخص و گالری سایت) — super admin only -->
|
||||
<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-form-item>
|
||||
|
||||
<a-form-item label="نوع نمایش">
|
||||
<a-form-item v-if="users.isSuperAdmin" label="نوع نمایش">
|
||||
<a-select v-model:value="form.show_type">
|
||||
<a-select-option :value="1">سایت</a-select-option>
|
||||
<a-select-option :value="2">گوشی</a-select-option>
|
||||
@@ -91,7 +96,7 @@
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="شرح محصول (سایت)" class="mb-0">
|
||||
<a-form-item v-if="users.isSuperAdmin" label="شرح محصول (سایت)" class="mb-0">
|
||||
<a-textarea
|
||||
v-model:value="form.body_html"
|
||||
:auto-size="{ minRows: 5, maxRows: 12 }"
|
||||
@@ -193,19 +198,21 @@
|
||||
<span class="pf-section__icon"><PictureOutlined /></span>
|
||||
<div>
|
||||
<h3>گالری تصاویر</h3>
|
||||
<p>تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل</p>
|
||||
<p>{{ users.isSuperAdmin ? 'تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل' : 'تصاویر نمایش در اپلیکیشن موبایل' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form-item label="گالری تصاویر سایت">
|
||||
<GalleryUploadInput
|
||||
v-model="gallerySite"
|
||||
purpose="product.gallery"
|
||||
placeholder="افزودن تصاویر گالری سایت"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-if="users.isSuperAdmin">
|
||||
<a-form-item label="گالری تصاویر سایت">
|
||||
<GalleryUploadInput
|
||||
v-model="gallerySite"
|
||||
purpose="product.gallery"
|
||||
placeholder="افزودن تصاویر گالری سایت"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<div class="pf-divider"></div>
|
||||
<div class="pf-divider"></div>
|
||||
</template>
|
||||
|
||||
<a-form-item label="گالری تصاویر موبایل" class="mb-0">
|
||||
<GalleryUploadInput
|
||||
@@ -286,7 +293,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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="تصویر شاخص" />
|
||||
</a-form-item>
|
||||
<a-form-item label="موبایل" class="mb-0">
|
||||
@@ -383,7 +390,7 @@
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
<p class="variant-form__hint">تصویر بارگذاریشده به گالری سایت محصول هم اضافه میشود.</p>
|
||||
<p v-if="users.isSuperAdmin" class="variant-form__hint">تصویر بارگذاریشده به گالری سایت محصول هم اضافه میشود.</p>
|
||||
<template #footer>
|
||||
<a-button @click="variantModal = false">انصراف</a-button>
|
||||
<a-button type="primary" @click="saveVariant">ثبت</a-button>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<a-descriptions-item label="دسته بندی">
|
||||
{{ tempRow.category?.title || '-' }}
|
||||
</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="تعداد تنوع">
|
||||
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
|
||||
</a-descriptions-item>
|
||||
@@ -129,7 +129,7 @@ const router = useRouter();
|
||||
const users = useUsersStore();
|
||||
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 selectedRows = ref<Record<string, any>[]>([]);
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// ColCreator block above the live XTbl definition — not revived.
|
||||
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', 'لیست محصولات');
|
||||
tbl.add(new Xtc('id', 'کد').filterNumber());
|
||||
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
|
||||
tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText());
|
||||
tbl.add(new Xtc('product_code', 'کد محصول').filterText());
|
||||
tbl.add(
|
||||
new Xtc('show_type', 'نوع نمایش')
|
||||
.filterSelect({
|
||||
1: 'سایت',
|
||||
2: 'گوشی',
|
||||
3: 'سایت و گوشی',
|
||||
})
|
||||
.renderSlot('show_type'),
|
||||
);
|
||||
if (withShowType) {
|
||||
tbl.add(
|
||||
new Xtc('show_type', 'نوع نمایش')
|
||||
.filterSelect({
|
||||
1: 'سایت',
|
||||
2: 'گوشی',
|
||||
3: 'سایت و گوشی',
|
||||
})
|
||||
.renderSlot('show_type'),
|
||||
);
|
||||
}
|
||||
// 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('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 { buildProductListModel } from '../../apps/product/productListModel';
|
||||
import { useCartStore, type CartItem, type CartVariant } from '../../../stores/cart';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
|
||||
const router = useRouter();
|
||||
const cart = useCartStore();
|
||||
const users = useUsersStore();
|
||||
|
||||
const noImage = '/img/no-items.png';
|
||||
const cols = buildProductListModel();
|
||||
const cols = buildProductListModel(users.isSuperAdmin);
|
||||
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const loading = ref(false);
|
||||
const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
|
||||
|
||||
@@ -94,13 +94,8 @@
|
||||
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- اسلایدر موبایل — دسترسی جدا از بقیهی این صفحه، چون آپلود بنر عمومی
|
||||
سایت حساستر از ویرایش برند/برچسب است (custom.config.statics.slider) -->
|
||||
<a-tab-pane
|
||||
v-if="hasPermission('custom.config.statics.slider')"
|
||||
key="slider"
|
||||
tab="اسلایدر موبایل"
|
||||
>
|
||||
<!-- اسلایدر موبایل — برای هر کسی که این صفحه را میبیند (config.statics) -->
|
||||
<a-tab-pane key="slider" tab="اسلایدر موبایل">
|
||||
<div class="slider-toolbar">
|
||||
<span class="slider-count">{{ sliders.length }} اسلاید</span>
|
||||
<div class="row">
|
||||
@@ -309,11 +304,8 @@ function removeSlide(index: number) {
|
||||
sliders.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// Gated server-side by its own `custom.config.statics.slider` permission (not
|
||||
// the broad `config.statics` that guards the rest of this screen) — see
|
||||
// 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.
|
||||
// Uses the dedicated `const/slider` route: the generic `const/:key` refuses the
|
||||
// slider's key (admin-misc.controller.ts).
|
||||
async function saveSliders() {
|
||||
try {
|
||||
await api.put('const/slider', { val: sliders.value });
|
||||
@@ -371,12 +363,9 @@ onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
|
||||
const [t, s] = await Promise.all([
|
||||
api.get('const/__list__tags'),
|
||||
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
|
||||
]);
|
||||
const [t, s] = await Promise.all([api.get('const/__list__tags'), api.get('const/slider')]);
|
||||
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) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
|
||||
@@ -216,7 +216,7 @@ const cart = useCartStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const noImage = '/img/no-items.png';
|
||||
const cols = buildProductListModel();
|
||||
const cols = buildProductListModel(users.isSuperAdmin);
|
||||
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
|
||||
@@ -176,27 +176,25 @@ export class AdminMiscController {
|
||||
|
||||
// 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.
|
||||
// and is open to everyone who can use the "مدیریت ثابت ها" screen
|
||||
// (`config.statics`). The older `custom.config.statics.slider` rid stays in
|
||||
// the catalogue so roles that still hold it keep saving, but gates nothing.
|
||||
// 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')
|
||||
@RequirePermission('config.statics')
|
||||
getSlider() {
|
||||
return this.misc.getConst('mobile_main_slider');
|
||||
}
|
||||
|
||||
@Put('const/slider')
|
||||
@RequirePermission('custom.config.statics.slider')
|
||||
@RequirePermission('config.statics')
|
||||
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')
|
||||
@RequirePermission('config.statics')
|
||||
removeSlider() {
|
||||
return this.misc.removeConst('mobile_main_slider');
|
||||
}
|
||||
@@ -223,11 +221,8 @@ export class AdminMiscController {
|
||||
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.
|
||||
// Keys with a dedicated route are refused here so each has exactly one way
|
||||
// in: the slider (`const/slider`) and the accounting and pricing settings.
|
||||
private assertNotSliderKey(key: string) {
|
||||
if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) {
|
||||
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
|
||||
|
||||
@@ -6,8 +6,8 @@ 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)
|
||||
* The admin write path (const/slider, guarded by `config.statics` — 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
|
||||
|
||||
@@ -5,6 +5,20 @@ import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
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):
|
||||
* POST products -> the product grid
|
||||
@@ -107,7 +121,7 @@ export class AdminProductsController {
|
||||
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
// Trays are super-admin-only; anyone else's product is created without them.
|
||||
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
|
||||
return this.products.create(data);
|
||||
return this.products.create(withoutSiteFields(data, admin));
|
||||
}
|
||||
|
||||
@Post('delete/:id')
|
||||
@@ -131,7 +145,11 @@ export class AdminProductsController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('product.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.products.update(id, data);
|
||||
update(
|
||||
@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 { Prisma } from '@prisma/client';
|
||||
import { Prisma, type ProductImageRole } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
@@ -571,15 +571,25 @@ export class AdminProductsService {
|
||||
* 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.
|
||||
* Only the slots whose key is present are reconciled; an absent key leaves
|
||||
* that slot's rows alone. The form posts all 4 (empty as null / []), but the
|
||||
* 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>) {
|
||||
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 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
|
||||
// consumed one at a time so a gallery listing the same URL twice keeps two
|
||||
|
||||
Reference in New Issue
Block a user