teqo/src/utilities/campaignListUrl.ts
Francisco Ignácio Fontoura Solla 9fdbae05d7 Add C6 scale/DRY for supporters: bulk import, KPI aggregate, shared shells.
Phase 1 UX: StrictCombobox municipality in SupporterForm, AlertDialog in
RemoveSupporterDataButton. Phase 2 DRY: campaignListUrl + CampaignListPagination
+ campaignFormFields + mapCampaignFormActionError shared across nuclei,
supporters and action plans. Phase 3: single SQL aggregate for supporter list
KPIs via drizzle in the Payload txn. Phase 4: bulk drizzle insert for CSV import
inside the existing Payload txn with ON CONFLICT DO NOTHING, plus a fail-closed
skipContactPhoneInvariant context flag. Phase 5: HMAC-signed single-use import
token backed by a new supporterImportBatch staging collection so the full ok-row
set never crosses the Server Action boundary twice.
2026-07-18 22:17:31 -03:00

86 lines
2.8 KiB
TypeScript

export type RawSearchParams = Record<string, string | string[] | undefined>
export const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
export const normalizedText = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
export const strictDecimalInteger = (value: string | undefined): number | undefined => {
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
const number = Number(value)
return Number.isSafeInteger(number) ? number : undefined
}
export const inspectRawListParams = (
params: RawSearchParams,
paramNameSet: ReadonlySet<string>,
): { hasUnsupportedParams: boolean; query: string } => {
const serialized = new URLSearchParams()
let hasUnsupportedParams = false
for (const [name, value] of Object.entries(params)) {
if (!paramNameSet.has(name)) {
hasUnsupportedParams = true
continue
}
if (value === undefined) continue
for (const item of Array.isArray(value) ? value : [value]) {
serialized.append(name, item)
}
}
return { hasUnsupportedParams, query: serialized.toString() }
}
export type ListStateWithPage = { page: number }
export type ResolveListUrlOptions<State extends ListStateWithPage> = {
params: RawSearchParams
paramNameSet: ReadonlySet<string>
parse: (params: RawSearchParams) => State
buildSearchParams: (state: State) => URLSearchParams
basePath: string
totalPages?: number
}
export const resolveListUrl = <State extends ListStateWithPage>(
options: ResolveListUrlOptions<State>,
): {
state: State
href: string
redirectHref?: string
} => {
const { params, paramNameSet, parse, buildSearchParams, basePath, totalPages } = options
const parsedState = parse(params)
const page =
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
? totalPages
: parsedState.page
const state = page === parsedState.page ? parsedState : ({ ...parsedState, page } as State)
const canonicalParams = buildSearchParams(state)
const canonicalQuery = canonicalParams.toString()
const href = canonicalQuery ? `${basePath}?${canonicalQuery}` : basePath
const raw = inspectRawListParams(params, paramNameSet)
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
return {
state,
href,
...(needsRedirect ? { redirectHref: href } : {}),
}
}
export const buildListHref = <State>(
state: State,
buildSearchParams: (state: State, page?: number) => URLSearchParams,
basePath: string,
page: number,
): string => {
const params = buildSearchParams(state, page)
const query = params.toString()
return query ? `${basePath}?${query}` : basePath
}