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
@@ -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