feat: add scoreline dynamic filters

This commit is contained in:
Codex
2026-06-30 18:49:32 +08:00
parent 2f9763d352
commit ba737d41f5
14 changed files with 795 additions and 43 deletions

View File

@@ -1,23 +1,172 @@
import type { RequestContext } from '../../core/http.js';
import { HttpError } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
const SCORELINE_FIELD_QUERY_PREFIX = 'field.';
const SCORELINE_FIELD_MIN_QUERY_PREFIX = 'min.';
const SCORELINE_FIELD_MAX_QUERY_PREFIX = 'max.';
const SCORELINE_FIELD_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
interface ScorelineFilterField {
fieldKey: string;
fieldType?: string | null;
}
interface ScorelineDynamicFilter {
operator: 'eq' | 'min' | 'max';
fieldKey: string;
value: string;
}
function normalizeFieldType(value?: string | null) {
return (value || 'text').trim().toLowerCase();
}
function quoteJsonPathKey(key: string) {
return `'${key.replace(/'/g, "''")}'`;
}
function collectDynamicFilters(ctx: RequestContext) {
const filters: ScorelineDynamicFilter[] = [];
for (const [rawKey, rawValue] of ctx.url.searchParams.entries()) {
const value = rawValue.trim();
if (!value) continue;
if (rawKey.startsWith(SCORELINE_FIELD_QUERY_PREFIX)) {
filters.push({ operator: 'eq', fieldKey: rawKey.slice(SCORELINE_FIELD_QUERY_PREFIX.length), value });
} else if (rawKey.startsWith(SCORELINE_FIELD_MIN_QUERY_PREFIX)) {
filters.push({ operator: 'min', fieldKey: rawKey.slice(SCORELINE_FIELD_MIN_QUERY_PREFIX.length), value });
} else if (rawKey.startsWith(SCORELINE_FIELD_MAX_QUERY_PREFIX)) {
filters.push({ operator: 'max', fieldKey: rawKey.slice(SCORELINE_FIELD_MAX_QUERY_PREFIX.length), value });
}
}
return filters;
}
async function loadFilterableScorelineFields(tenantId: string, regionId: string) {
return query<ScorelineFilterField>(
`
select "fieldKey", "fieldType"
from (
select distinct on (field_key)
field_key as "fieldKey", field_type as "fieldType",
sort_order as "sortOrder"
from public.scoreline_fields
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid or region_id is null)
and is_filter = true
order by field_key asc,
case
when $2::uuid is not null and region_id = $2::uuid then 0
when region_id is null then 1
else 2
end asc,
sort_order asc
) fields
order by "sortOrder" asc, "fieldKey" asc
`,
[tenantId, regionId || null],
);
}
async function buildScorelineDynamicWhere(
ctx: RequestContext,
tenantId: string,
regionId: string,
params: unknown[],
) {
const filters = collectDynamicFilters(ctx);
if (!filters.length) return '';
const filterableFields = await loadFilterableScorelineFields(tenantId, regionId);
const fieldMap = new Map(filterableFields.map(item => [item.fieldKey, item]));
const clauses: string[] = [];
for (const filter of filters) {
if (!SCORELINE_FIELD_KEY_PATTERN.test(filter.fieldKey)) {
throw new HttpError(400, 'Scoreline field filter key is invalid', 'SCORELINE_FIELD_FILTER_KEY_INVALID');
}
const field = fieldMap.get(filter.fieldKey);
if (!field) {
throw new HttpError(400, 'Scoreline field filter is not enabled', 'SCORELINE_FIELD_FILTER_NOT_ALLOWED');
}
const type = normalizeFieldType(field.fieldType);
const jsonKey = quoteJsonPathKey(filter.fieldKey);
params.push(filter.value);
const paramIndex = params.length;
if (filter.operator === 'min' || filter.operator === 'max') {
if (!['number', 'integer', 'decimal', 'float'].includes(type)) {
throw new HttpError(400, 'Scoreline range filter requires a numeric field', 'SCORELINE_FIELD_RANGE_TYPE_INVALID');
}
const numericValue = Number(filter.value);
if (!Number.isFinite(numericValue)) {
throw new HttpError(400, 'Scoreline range filter value must be numeric', 'SCORELINE_FIELD_RANGE_VALUE_INVALID');
}
clauses.push(`
case
when jsonb_typeof(field_values -> ${jsonKey}) = 'number' then (field_values ->> ${jsonKey})::numeric
when field_values ->> ${jsonKey} ~ '^-?[0-9]+(\\.[0-9]+)?$' then (field_values ->> ${jsonKey})::numeric
else null
end ${filter.operator === 'min' ? '>=' : '<='} $${paramIndex}::numeric
`);
continue;
}
if (['number', 'integer', 'decimal', 'float'].includes(type)) {
const numericValue = Number(filter.value);
if (!Number.isFinite(numericValue)) {
throw new HttpError(400, 'Scoreline numeric filter value must be numeric', 'SCORELINE_FIELD_VALUE_INVALID');
}
clauses.push(`
case
when jsonb_typeof(field_values -> ${jsonKey}) = 'number' then (field_values ->> ${jsonKey})::numeric
when field_values ->> ${jsonKey} ~ '^-?[0-9]+(\\.[0-9]+)?$' then (field_values ->> ${jsonKey})::numeric
else null
end = $${paramIndex}::numeric
`);
} else if (['text', 'textarea', 'string'].includes(type)) {
clauses.push(`field_values ->> ${jsonKey} ilike '%' || $${paramIndex}::text || '%'`);
} else if (['boolean', 'bool'].includes(type)) {
clauses.push(`lower(field_values ->> ${jsonKey}) = lower($${paramIndex}::text)`);
} else {
clauses.push(`field_values ->> ${jsonKey} = $${paramIndex}::text`);
}
}
return clauses.length ? ` and ${clauses.join(' and ')}` : '';
}
export async function scorelineFieldsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
field_key as "fieldKey", field_name as "fieldName",
field_type as "fieldType", unit, is_filter as "isFilter",
is_required as "isRequired", is_visible as "isVisible",
is_trend as "isTrend", options, placeholder, description,
sort_order as "sortOrder", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc, field_name asc
select *
from (
select distinct on (field_key)
id, legacy_id as "legacyId", region_id as "regionId",
field_key as "fieldKey", field_name as "fieldName",
field_type as "fieldType", unit, is_filter as "isFilter",
is_required as "isRequired", is_visible as "isVisible",
is_trend as "isTrend", options, placeholder, description,
sort_order as "sortOrder", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid or region_id is null)
order by field_key asc,
case
when $2::uuid is not null and region_id = $2::uuid then 0
when region_id is null then 1
else 2
end asc,
sort_order asc,
field_name asc
) fields
order by "sortOrder" asc, "fieldName" asc
`,
[tenantId, regionId || null],
);
@@ -85,14 +234,18 @@ export async function scorelineRecordsRoute(ctx: RequestContext) {
const pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
const offset = (page - 1) * pageSize;
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
const dynamicWhere = await buildScorelineDynamicWhere(ctx, tenantId, regionId, params);
const where = `
tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid)
and ($5::integer = 0 or year = $5::integer)
${dynamicWhere}
`;
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
const limitParamIndex = params.length + 1;
const offsetParamIndex = params.length + 2;
const [countRow, items] = await Promise.all([
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params),
@@ -106,7 +259,7 @@ export async function scorelineRecordsRoute(ctx: RequestContext) {
from public.scoreline_records
where ${where}
order by year desc, school_name asc nulls last, major_name asc nulls last
limit $6 offset $7
limit $${limitParamIndex} offset $${offsetParamIndex}
`,
[...params, pageSize, offset],
),