feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user