feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TableService } from './table.service';
/**
* Global so every admin grid module can inject TableService without re-importing.
* It is stateless — pure request-shape translation.
*/
@Global()
@Module({
providers: [TableService],
exports: [TableService],
})
export class AdminTableModule {}
@@ -0,0 +1,104 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
/**
* One row of the XTable filter bar.
*
* Mirrors the object legacy's XFilterRow pushed into `query.filters`:
* `{ id, field, op, value, value2? }`. `id` is a client-side react key
* (a random float) and is accepted but ignored.
*/
export class TableFilterDto {
@IsOptional()
id?: number | string;
@IsOptional()
@IsString()
field?: string;
/**
* Legacy operators, kept verbatim. `between` uses value + value2 and is what
* the date-range filter emits.
*/
@IsOptional()
@IsIn(['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'between', 'null', 'notnull'])
op?: string;
@IsOptional()
value?: unknown;
@IsOptional()
value2?: unknown;
}
/**
* The request body XTable POSTs, ported from its requestData():
*
* { ...query, select, titles, report_title, op? }
*
* where query = { search, filters, total, limit, page }.
*/
export class TableQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TableFilterDto)
filters?: TableFilterDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
/** Sort spec: '+field' ascending, '-field' descending (legacy `order`). */
@IsOptional()
@IsString()
order?: string;
/** Columns currently visible; drives the export payload. */
@IsOptional()
@IsArray()
select?: string[];
/** key -> Farsi column heading, used for export headers. */
@IsOptional()
titles?: Record<string, string>;
@IsOptional()
@IsString()
report_title?: string;
/**
* Export mode. Legacy expected `{ url }` back and did `window.open(data.url)`.
* Exports are not implemented yet; the controller rejects these explicitly
* rather than returning a broken url.
*/
@IsOptional()
@IsIn(['excel', 'json', 'print'])
op?: 'excel' | 'json' | 'print';
/** Echoed back inside `query`; the client owns this value. */
@IsOptional()
total?: number;
}
@@ -0,0 +1,158 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { TableFilterDto, TableQueryDto } from './table-query.dto';
/**
* Translates the legacy XTable request contract into Prisma query arguments.
*
* The response shape is dictated by legacy's getDataUsage() (x-table mixin):
*
* laravel mode -> { rows, query } <- what the admin panel used
* other mode -> { rows: [[...]], count, page }
*
* The panel's XTbl models all ran in 'laravel' mode, so that is the shape
* produced here: `{ rows, query }` with query carrying total/page/limit.
*/
export interface TableResult<T> {
rows: T[];
query: {
search?: string;
filters: TableFilterDto[];
total: number;
limit: number;
page: number;
};
}
export interface BuildOptions {
/** Columns the free-text `search` box scans (legacy initSearch([...])). */
searchable?: string[];
/** Whitelist of filterable/sortable fields; anything else is rejected. */
allowed?: string[];
/** Sort applied when the request specifies none. */
defaultOrderBy?: Record<string, 'asc' | 'desc'>;
}
@Injectable()
export class TableService {
/**
* Turns a dotted path into a nested Prisma object:
* 'customer.fullName', 'x' -> { customer: { fullName: 'x' } }
*/
private nest(path: string, leaf: unknown): Record<string, unknown> {
const parts = path.split('.');
return parts.reduceRight<unknown>((acc, key, i) => {
return i === parts.length - 1 ? { [key]: leaf } : { [key]: acc };
}, leaf) as Record<string, unknown>;
}
private assertAllowed(field: string, allowed?: string[]) {
if (allowed && !allowed.includes(field)) {
throw new BadRequestException(`Field not filterable: ${field}`);
}
}
/** Maps one legacy filter row to a Prisma condition. */
private toCondition(f: TableFilterDto, allowed?: string[]): Record<string, unknown> | null {
if (!f.field || !f.op) return null;
this.assertAllowed(f.field, allowed);
const v = f.value;
switch (f.op) {
case '=':
return this.nest(f.field, { equals: v });
case '!=':
return this.nest(f.field, { not: v });
case '>':
return this.nest(f.field, { gt: v });
case '>=':
return this.nest(f.field, { gte: v });
case '<':
return this.nest(f.field, { lt: v });
case '<=':
return this.nest(f.field, { lte: v });
case 'like':
// Legacy sent '%value%' to SQL LIKE; `contains` is the Prisma equivalent.
return this.nest(f.field, {
contains: String(v ?? '').replace(/^%|%$/g, ''),
mode: 'insensitive',
});
case 'in':
return this.nest(f.field, { in: Array.isArray(v) ? v : [v] });
case 'between':
return this.nest(f.field, { gte: v, lte: f.value2 });
case 'null':
return this.nest(f.field, { equals: null });
case 'notnull':
return this.nest(f.field, { not: null });
default:
return null;
}
}
/** Builds `where`, `orderBy`, `skip` and `take` from an XTable request. */
build(query: TableQueryDto, opts: BuildOptions = {}) {
const page = query.page && query.page > 0 ? query.page : 1;
const limit = query.limit && query.limit > 0 ? query.limit : 10;
const and: Record<string, unknown>[] = [];
for (const f of query.filters ?? []) {
const cond = this.toCondition(f, opts.allowed);
if (cond) and.push(cond);
}
// Free-text search across the declared columns (legacy: OR over initSearch).
const search = query.search?.trim();
if (search && opts.searchable?.length) {
and.push({
OR: opts.searchable.map((field) =>
this.nest(field, { contains: search, mode: 'insensitive' }),
),
});
}
// legacy `order`: '+field' asc, '-field' desc
let orderBy = opts.defaultOrderBy ?? { createdAt: 'desc' as const };
if (query.order) {
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
const field = query.order.replace(/^[+-]/, '');
if (field) {
this.assertAllowed(field, opts.allowed);
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
}
}
return {
where: and.length ? { AND: and } : {},
orderBy,
skip: (page - 1) * limit,
take: limit,
page,
limit,
};
}
/** Wraps rows + count into the exact envelope XTable expects. */
respond<T>(rows: T[], total: number, query: TableQueryDto, page: number, limit: number): TableResult<T> {
return {
rows,
query: {
search: query.search,
filters: query.filters ?? [],
total,
limit,
page,
},
};
}
/** Exports are not implemented; fail loudly instead of returning a dead url. */
assertNotExport(query: TableQueryDto) {
if (query.op) {
throw new BadRequestException(
`Export ('${query.op}') is not implemented yet in the new backend.`,
);
}
}
}