Files
tehran_taj/apps/api/src/modules/admin-misc/admin-misc.controller.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30

236 lines
7.9 KiB
TypeScript

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';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isAccountingSettingKey } from '../accounting-sync/accounting-config';
/**
* Cities, SMS, notifications, newsletter, activity log and settings — the
* remaining admin screens. See AdminMiscService for the legacy endpoint map.
*/
@Controller('admin/misc')
export class AdminMiscController {
constructor(private readonly misc: AdminMiscService) {}
// ── cities ────────────────────────────────────────────────────────
@Post('cities/list')
@HttpCode(200)
@RequirePermission('cities.list')
listCities(@Body() query: TableQueryDto) {
return this.misc.listCities(query);
}
@Post('cities')
@HttpCode(200)
@RequirePermission('cities.create')
createCity(@Body() data: { title?: string; name?: string; province?: string }) {
return this.misc.createCity(data);
}
@Get('cities/percent/:id')
@RequirePermission('cities.list')
getCityPercent(@Param('id') id: string) {
return this.misc.getCityPercent(id, false);
}
@Get('cities/percent-aux/:id')
@RequirePermission('cities.list')
getCityPercentAux(@Param('id') id: string) {
return this.misc.getCityPercent(id, true);
}
@Put('cities/:id')
@RequirePermission('cities.list.view.edit')
updateCity(@Param('id') id: string, @Body() data: Record<string, never>) {
return this.misc.updateCity(id, data);
}
@Delete('cities/:id')
@RequirePermission('cities.list.remove')
removeCity(@Param('id') id: string) {
return this.misc.removeCity(id);
}
// ── sms ───────────────────────────────────────────────────────────
@Post('sms/list')
@HttpCode(200)
@RequirePermission('sms.list')
listSms(@Body() query: TableQueryDto) {
return this.misc.listSms(query);
}
@Post('sms')
@HttpCode(200)
@RequirePermission('sms.create')
sendSms(
@Body() data: { type?: string | number; mobile?: string; text?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.sendSms(data, admin.id);
}
// ── notifications ─────────────────────────────────────────────────
@Post('notify/list')
@HttpCode(200)
@RequirePermission('notify.me')
listNotifications(@Body() query: TableQueryDto) {
return this.misc.listNotifications(query);
}
@Post('notify')
@HttpCode(200)
@RequirePermission('notify.create')
createNotification(
@Body() data: { title?: string; text?: string; body?: string; type?: string | number },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.createNotification(data, admin.id);
}
@Post('notify/customer')
@HttpCode(200)
@RequirePermission('notify.create')
notifyCustomer(
@Body() data: { customer_id?: string; title?: string; text?: string; body?: string },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.misc.notifyCustomer(data, admin.id);
}
@Get('notify/read/:id')
@RequirePermission('notify.me')
markRead(@Param('id') id: string) {
return this.misc.markNotificationRead(id);
}
@Get('notify/:id')
@RequirePermission('notify.me')
findNotification(@Param('id') id: string) {
return this.misc.findNotification(id);
}
// ── newsletter ────────────────────────────────────────────────────
@Post('newsletter/list')
@HttpCode(200)
@RequirePermission('newsletters')
listNewsletter(@Body() query: TableQueryDto) {
return this.misc.listNewsletter(query);
}
// ── activity log ──────────────────────────────────────────────────
@Get('log/customers')
@RequirePermission('statics')
logCustomers() {
return this.misc.logCustomers();
}
@Get('log/customer/:id')
@RequirePermission('statics')
customerLog(@Param('id') id: string) {
return this.misc.customerLog(id);
}
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
@Get('dashboard-links')
@RequirePermission('config.report')
dashboardLinks() {
return this.misc.dashboardLinks();
}
@Get('const')
@RequirePermission('config.statics')
listConsts() {
return this.misc.listConsts();
}
@Post('brands')
@HttpCode(200)
@RequirePermission('config.statics.brand.create')
createBrand(@Body() body: { title: string }) {
return this.misc.createBrand(body.title);
}
@Put('brands/:id')
@RequirePermission('config.statics.brand.edit')
updateBrand(@Param('id') id: string, @Body() body: { title: string }) {
return this.misc.updateBrand(id, body.title);
}
@Delete('brands/:id')
@RequirePermission('config.statics.brand.remove')
removeBrand(@Param('id') id: string) {
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);
}
@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' || isAccountingSettingKey(key)) {
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
}
}
}