using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Scoreline; using Tiku.Domain.Catalog; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Scoreline; public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : IScorelineQueryService { public async Task> GetFieldsAsync( ScorelineFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.ScorelineFields.AsNoTracking() .Where(field => field.TenantId == filter.TenantId); if (filter.RegionId.HasValue) { query = query.Where(field => field.RegionId == filter.RegionId.Value || field.RegionId == null); } var fields = await query .OrderBy(field => field.SortOrder) .ThenBy(field => field.FieldName) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); return new CatalogList(fields.Select(ToFieldItem).ToArray()); } public async Task GetRecordsAsync( ScorelineFilter filter, CancellationToken cancellationToken = default) { var page = Math.Max(filter.Page ?? 1, 1); var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200); var records = await BuildRecordQuery(filter) .OrderByDescending(record => record.Year) .ThenBy(record => record.SchoolName) .ThenBy(record => record.MajorName) .ToArrayAsync(cancellationToken); var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken); var items = filtered .Skip((page - 1) * pageSize) .Take(pageSize) .Select(ToRecordItem) .ToArray(); return new ScorelineRecordPage(page, pageSize, filtered.Count, items); } public async Task> GetTrendAsync( ScorelineFilter filter, CancellationToken cancellationToken = default) { var records = await BuildRecordQuery(filter) .OrderBy(record => record.Year) .ThenBy(record => record.MajorName) .Take(ResolveLimit(filter.Limit, 100)) .ToArrayAsync(cancellationToken); var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken); return new CatalogList(filtered.Select(ToRecordItem).ToArray()); } public async Task> GetYearsAsync( ScorelineFilter filter, CancellationToken cancellationToken = default) { var years = await BuildRecordQuery(filter) .Select(record => record.Year) .Distinct() .OrderByDescending(year => year) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); return new CatalogList(years); } private IQueryable BuildRecordQuery(ScorelineFilter filter) { var query = dbContext.ScorelineRecords.AsNoTracking() .Where(record => record.TenantId == filter.TenantId); if (filter.RegionId.HasValue) { query = query.Where(record => record.RegionId == filter.RegionId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(record => record.SchoolId == filter.SchoolId.Value); } if (filter.MajorId.HasValue) { query = query.Where(record => record.MajorId == filter.MajorId.Value); } if (filter.Year.HasValue) { query = query.Where(record => record.Year == filter.Year.Value); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(record => (record.SchoolName != null && record.SchoolName.Contains(keyword)) || (record.MajorName != null && record.MajorName.Contains(keyword))); } return query; } private async Task> ApplyDynamicFiltersAsync( ScorelineFilter filter, IReadOnlyCollection records, CancellationToken cancellationToken) { if (filter.DynamicFilters is null || filter.DynamicFilters.Count == 0) { return records.ToArray(); } var allowedFields = await dbContext.ScorelineFields.AsNoTracking() .Where(field => field.TenantId == filter.TenantId && field.IsFilter && (filter.RegionId == null || field.RegionId == filter.RegionId || field.RegionId == null)) .ToArrayAsync(cancellationToken); var allowed = allowedFields .GroupBy(field => field.FieldKey, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.OrderBy(field => field.SortOrder).First(), StringComparer.Ordinal); foreach (var dynamicFilter in filter.DynamicFilters) { if (!FieldKeyRegex().IsMatch(dynamicFilter.FieldKey)) { throw new ScorelineQueryException("Scoreline field filter key is invalid.", "scoreline_field_filter_key_invalid"); } if (!allowed.ContainsKey(dynamicFilter.FieldKey)) { throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed"); } } return records .Where(record => filter.DynamicFilters.All(dynamicFilter => MatchDynamicFilter(record, allowed[dynamicFilter.FieldKey], dynamicFilter))) .ToArray(); } private static bool MatchDynamicFilter( ScorelineRecord record, ScorelineField field, ScorelineDynamicFilter filter) { if (record.FieldValues.ValueKind != JsonValueKind.Object || !record.FieldValues.TryGetProperty(filter.FieldKey, out var value)) { return false; } var fieldType = field.FieldType.Trim().ToLowerInvariant(); if (filter.Operator is "min" or "max") { if (!IsNumericType(fieldType)) { throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", "scoreline_field_range_type_invalid"); } if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected)) { throw new ScorelineQueryException("Scoreline range filter value must be numeric.", "scoreline_field_range_value_invalid"); } return filter.Operator == "min" ? actual >= expected : actual <= expected; } if (IsNumericType(fieldType)) { if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected)) { throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", "scoreline_field_value_invalid"); } return actual == expected; } var actualText = value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); return fieldType switch { "text" or "textarea" or "string" => actualText?.Contains(filter.Value, StringComparison.OrdinalIgnoreCase) == true, "boolean" or "bool" => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase), _ => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase) }; } private static bool IsNumericType(string fieldType) { return fieldType is "number" or "integer" or "decimal" or "float"; } private static bool TryGetDecimal(JsonElement value, out decimal result) { if (value.ValueKind == JsonValueKind.Number) { return value.TryGetDecimal(out result); } if (value.ValueKind == JsonValueKind.String) { return decimal.TryParse(value.GetString(), out result); } result = 0; return false; } private static ScorelineFieldItem ToFieldItem(ScorelineField field) { return new ScorelineFieldItem( field.Id, field.LegacyId, field.RegionId, field.FieldKey, field.FieldName, field.FieldType, field.Unit, field.IsFilter, field.IsRequired, field.IsVisible, field.IsTrend, field.Options, field.Placeholder, field.Description, field.SortOrder); } private static ScorelineRecordItem ToRecordItem(ScorelineRecord record) { return new ScorelineRecordItem( record.Id, record.LegacyId, record.RegionId, record.SchoolId, record.MajorId, record.Year, record.SchoolName, record.MajorName, record.FieldValues); } private static int ResolveLimit(int? limit, int fallback = 500) { return Math.Clamp(limit ?? fallback, 1, 2000); } [GeneratedRegex("^[A-Za-z][A-Za-z0-9_]{0,63}$")] private static partial Regex FieldKeyRegex(); } public sealed class ScorelineQueryException(string message, string code) : Exception(message) { public string Code { get; } = code; }