website refactor
This commit is contained in:
197
apps/website/lib/routing/search-params/SearchParamBuilder.ts
Normal file
197
apps/website/lib/routing/search-params/SearchParamBuilder.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @file SearchParamBuilder.ts
|
||||
* Type-safe builder for constructing search parameter strings
|
||||
* Pure function, no side effects
|
||||
*/
|
||||
|
||||
import { SearchParamValidators } from './SearchParamValidators';
|
||||
|
||||
export class SearchParamBuilder {
|
||||
private params: URLSearchParams;
|
||||
|
||||
constructor() {
|
||||
this.params = new URLSearchParams();
|
||||
}
|
||||
|
||||
// Auth params
|
||||
returnTo(value: string | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validateReturnTo(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid returnTo: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('returnTo', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
token(value: string | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validateToken(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid token: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('token', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
email(value: string | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validateEmail(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid email: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('email', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
error(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('error', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
message(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('message', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Sponsor params
|
||||
campaignType(value: string | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validateCampaignType(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid campaign type: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('type', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
campaignId(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('campaignId', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Pagination params
|
||||
page(value: number | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validatePage(value.toString());
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid page: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('page', value.toString());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(value: number | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validateLimit(value.toString());
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid limit: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('limit', value.toString());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
offset(value: number | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('offset', value.toString());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Sorting params
|
||||
sortBy(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('sortBy', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
order(value: string | null): this {
|
||||
if (value !== null) {
|
||||
const validation = SearchParamValidators.validateOrder(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid order: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
this.params.set('order', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Filtering params
|
||||
status(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('status', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
role(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('role', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
tier(value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set('tier', value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Generic setter
|
||||
set(key: string, value: string | null): this {
|
||||
if (value !== null) {
|
||||
this.params.set(key, value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Build the query string
|
||||
build(): string {
|
||||
const queryString = this.params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
// Static factory methods for common scenarios
|
||||
static auth(returnTo?: string | null, token?: string | null, email?: string | null): string {
|
||||
const builder = new SearchParamBuilder();
|
||||
if (returnTo !== undefined) builder.returnTo(returnTo);
|
||||
if (token !== undefined) builder.token(token);
|
||||
if (email !== undefined) builder.email(email);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static pagination(page?: number | null, limit?: number | null): string {
|
||||
const builder = new SearchParamBuilder();
|
||||
if (page !== undefined) builder.page(page);
|
||||
if (limit !== undefined) builder.limit(limit);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static sorting(sortBy?: string | null, order?: string | null): string {
|
||||
const builder = new SearchParamBuilder();
|
||||
if (sortBy !== undefined) builder.sortBy(sortBy);
|
||||
if (order !== undefined) builder.order(order);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static sponsorCampaign(type?: string | null, campaignId?: string | null): string {
|
||||
const builder = new SearchParamBuilder();
|
||||
if (type !== undefined) builder.campaignType(type);
|
||||
if (campaignId !== undefined) builder.campaignId(campaignId);
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
208
apps/website/lib/routing/search-params/SearchParamParser.ts
Normal file
208
apps/website/lib/routing/search-params/SearchParamParser.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @file SearchParamParser.ts
|
||||
* Type-safe parser for search parameters from URL
|
||||
* Returns Result type for clean error handling
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { SearchParamValidators } from './SearchParamValidators';
|
||||
|
||||
export interface ParsedAuthParams {
|
||||
returnTo?: string | null;
|
||||
token?: string | null;
|
||||
email?: string | null;
|
||||
error?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedSponsorParams {
|
||||
type?: string | null;
|
||||
campaignId?: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedPaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ParsedSortingParams {
|
||||
sortBy?: string | null;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ParsedFilterParams {
|
||||
status?: string | null;
|
||||
role?: string | null;
|
||||
tier?: string | null;
|
||||
}
|
||||
|
||||
export class SearchParamParser {
|
||||
// Parse auth parameters
|
||||
static parseAuth(params: URLSearchParams): Result<ParsedAuthParams, string> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const returnTo = params.get('returnTo');
|
||||
if (returnTo !== null) {
|
||||
const validation = SearchParamValidators.validateReturnTo(returnTo);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
}
|
||||
}
|
||||
|
||||
const token = params.get('token');
|
||||
if (token !== null) {
|
||||
const validation = SearchParamValidators.validateToken(token);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
}
|
||||
}
|
||||
|
||||
const email = params.get('email');
|
||||
if (email !== null) {
|
||||
const validation = SearchParamValidators.validateEmail(email);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.err(errors.join(', '));
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
returnTo: params.get('returnTo'),
|
||||
token: params.get('token'),
|
||||
email: params.get('email'),
|
||||
error: params.get('error'),
|
||||
message: params.get('message'),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse sponsor parameters
|
||||
static parseSponsor(params: URLSearchParams): Result<ParsedSponsorParams, string> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const type = params.get('type');
|
||||
if (type !== null) {
|
||||
const validation = SearchParamValidators.validateCampaignType(type);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.err(errors.join(', '));
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
type: params.get('type'),
|
||||
campaignId: params.get('campaignId'),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
static parsePagination(params: URLSearchParams): Result<ParsedPaginationParams, string> {
|
||||
const result: ParsedPaginationParams = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
const page = params.get('page');
|
||||
if (page !== null) {
|
||||
const validation = SearchParamValidators.validatePage(page);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
} else {
|
||||
result.page = parseInt(page);
|
||||
}
|
||||
}
|
||||
|
||||
const limit = params.get('limit');
|
||||
if (limit !== null) {
|
||||
const validation = SearchParamValidators.validateLimit(limit);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
} else {
|
||||
result.limit = parseInt(limit);
|
||||
}
|
||||
}
|
||||
|
||||
const offset = params.get('offset');
|
||||
if (offset !== null) {
|
||||
const num = parseInt(offset);
|
||||
if (!isNaN(num)) {
|
||||
result.offset = num;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.err(errors.join(', '));
|
||||
}
|
||||
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
// Parse sorting parameters
|
||||
static parseSorting(params: URLSearchParams): Result<ParsedSortingParams, string> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const order = params.get('order');
|
||||
if (order !== null) {
|
||||
const validation = SearchParamValidators.validateOrder(order);
|
||||
if (!validation.isValid) {
|
||||
errors.push(...validation.errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.err(errors.join(', '));
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
sortBy: params.get('sortBy'),
|
||||
order: (params.get('order') as 'asc' | 'desc') || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse filter parameters
|
||||
static parseFilters(params: URLSearchParams): Result<ParsedFilterParams, string> {
|
||||
return Result.ok({
|
||||
status: params.get('status'),
|
||||
role: params.get('role'),
|
||||
tier: params.get('tier'),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse all parameters at once
|
||||
static parseAll(params: URLSearchParams): Result<
|
||||
{
|
||||
auth: ParsedAuthParams;
|
||||
sponsor: ParsedSponsorParams;
|
||||
pagination: ParsedPaginationParams;
|
||||
sorting: ParsedSortingParams;
|
||||
filters: ParsedFilterParams;
|
||||
},
|
||||
string
|
||||
> {
|
||||
const authResult = this.parseAuth(params);
|
||||
if (authResult.isErr()) return Result.err(authResult.getError());
|
||||
|
||||
const sponsorResult = this.parseSponsor(params);
|
||||
if (sponsorResult.isErr()) return Result.err(sponsorResult.getError());
|
||||
|
||||
const paginationResult = this.parsePagination(params);
|
||||
if (paginationResult.isErr()) return Result.err(paginationResult.getError());
|
||||
|
||||
const sortingResult = this.parseSorting(params);
|
||||
if (sortingResult.isErr()) return Result.err(sortingResult.getError());
|
||||
|
||||
const filtersResult = this.parseFilters(params);
|
||||
|
||||
return Result.ok({
|
||||
auth: authResult.unwrap(),
|
||||
sponsor: sponsorResult.unwrap(),
|
||||
pagination: paginationResult.unwrap(),
|
||||
sorting: sortingResult.unwrap(),
|
||||
filters: filtersResult.unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
48
apps/website/lib/routing/search-params/SearchParamTypes.ts
Normal file
48
apps/website/lib/routing/search-params/SearchParamTypes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file SearchParamTypes.ts
|
||||
* Pure type definitions for search parameters
|
||||
* No logic, just types
|
||||
*/
|
||||
|
||||
export interface SearchParamSpec {
|
||||
name: string;
|
||||
description?: string;
|
||||
defaultValue?: string | null;
|
||||
}
|
||||
|
||||
export type SearchParamMap = Record<string, SearchParamSpec>;
|
||||
|
||||
// Auth page search params
|
||||
export interface AuthSearchParams {
|
||||
returnTo: SearchParamSpec;
|
||||
token: SearchParamSpec;
|
||||
email: SearchParamSpec;
|
||||
error: SearchParamSpec;
|
||||
message: SearchParamSpec;
|
||||
}
|
||||
|
||||
// Sponsor campaign search params
|
||||
export interface SponsorCampaignSearchParams {
|
||||
type: SearchParamSpec;
|
||||
campaignId: SearchParamSpec;
|
||||
}
|
||||
|
||||
// Pagination search params
|
||||
export interface PaginationSearchParams {
|
||||
page: SearchParamSpec;
|
||||
limit: SearchParamSpec;
|
||||
offset: SearchParamSpec;
|
||||
}
|
||||
|
||||
// Sorting search params
|
||||
export interface SortingSearchParams {
|
||||
sortBy: SearchParamSpec;
|
||||
order: SearchParamSpec;
|
||||
}
|
||||
|
||||
// Generic filter params
|
||||
export interface FilterSearchParams {
|
||||
status: SearchParamSpec;
|
||||
role: SearchParamSpec;
|
||||
tier: SearchParamSpec;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file SearchParamValidators.ts
|
||||
* Pure validation logic for search parameters
|
||||
* No side effects, no dependencies
|
||||
*/
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class SearchParamValidators {
|
||||
// Auth validators
|
||||
static validateReturnTo(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
if (!value.startsWith('/')) {
|
||||
return { isValid: false, errors: ['returnTo must start with /'] };
|
||||
}
|
||||
if (value.includes('://') || value.includes('//')) {
|
||||
return { isValid: false, errors: ['returnTo must be a relative path'] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
static validateToken(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
if (value.length === 0) {
|
||||
return { isValid: false, errors: ['token cannot be empty'] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
static validateEmail(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
if (!value.includes('@')) {
|
||||
return { isValid: false, errors: ['email must be valid'] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Sponsor validators
|
||||
static validateCampaignType(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
const validTypes = ['leagues', 'teams', 'drivers', 'races', 'platform'];
|
||||
if (!validTypes.includes(value)) {
|
||||
return { isValid: false, errors: [`type must be one of: ${validTypes.join(', ')}`] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Pagination validators
|
||||
static validatePage(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
const num = parseInt(value);
|
||||
if (isNaN(num) || num < 1) {
|
||||
return { isValid: false, errors: ['page must be a positive integer'] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
static validateLimit(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
const num = parseInt(value);
|
||||
if (isNaN(num) || num < 1) {
|
||||
return { isValid: false, errors: ['limit must be a positive integer'] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Sorting validators
|
||||
static validateOrder(value: string | null): ValidationResult {
|
||||
if (value === null) return { isValid: true, errors: [] };
|
||||
if (!['asc', 'desc'].includes(value)) {
|
||||
return { isValid: false, errors: ['order must be asc or desc'] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Generic validators
|
||||
static validateRequired(value: string | null, fieldName: string): ValidationResult {
|
||||
if (value === null || value.length === 0) {
|
||||
return { isValid: false, errors: [`${fieldName} is required`] };
|
||||
}
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
static validateOptional(value: string | null): ValidationResult {
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user