src/features/reviews/search-filters.ts
Linked repository file from this build.
import { z } from "zod";
import { isCalendarDate, isCalendarMonth } from "@/programs/snap-negative/input-limits";
import { getSearchField, isKnownSearchField, validSearchField } from "./search-fields";
const searchModes = ["contains", "starts", "ends", "exact", "between", "empty", "present"] as const;
const searchFilterSchema = z
.object({
field: z.string().refine(validSearchField, "Choose a stored review field."),
mode: z.enum(searchModes).optional(),
term: z.string().trim().max(1000).optional(),
from: z.string().trim().max(200).optional(),
through: z.string().trim().max(200).optional(),
exclude: z.boolean().optional(),
})
.superRefine((row, ctx) => {
const sample = row.from || row.through || row.term || "";
const kind = isKnownSearchField(row.field)
? getSearchField(row.field).kind
: /^-?\d+(?:\.\d+)?$/.test(sample)
? "number"
: /^\d{4}-\d{2}-\d{2}T/.test(sample)
? "datetime"
: isCalendarDate(sample)
? "date"
: isCalendarMonth(sample)
? "month"
: "text";
const mode = row.mode ?? (row.field === "sampleMonth" ? "between" : "contains");
if (mode === "empty" || mode === "present") return;
function valid(value: string) {
if (kind === "number")
return (
/^-?\d+(?:\.\d+)?$/.test(value) &&
Number.isFinite(Number(value)) &&
Math.abs(Number(value)) <= Number.MAX_SAFE_INTEGER
);
if (kind === "month") return isCalendarMonth(value);
if (kind === "date") return isCalendarDate(value);
if (kind === "datetime")
return (
/^(\d{4}-\d{2}-\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.test(
value,
) &&
isCalendarDate(value.slice(0, 10)) &&
Number.isFinite(Date.parse(value))
);
return true;
}
if (mode === "between") {
if (!["number", "date", "month", "datetime"].includes(kind))
ctx.addIssue({ code: "custom", message: "Ranges require a number, date, or month field." });
for (const key of ["from", "through"] as const)
if (row[key] && !valid(row[key]))
ctx.addIssue({ code: "custom", path: [key], message: `Enter a valid ${kind}.` });
if (
row.from &&
row.through &&
(kind === "number"
? Number(row.from) > Number(row.through)
: kind === "datetime"
? Date.parse(row.from) > Date.parse(row.through)
: row.from > row.through)
)
ctx.addIssue({
code: "custom",
path: ["through"],
message: "From must not follow through.",
});
} else if (mode === "exact" && row.term && !valid(row.term))
ctx.addIssue({ code: "custom", path: ["term"], message: `Enter a valid ${kind}.` });
});
export const searchFiltersSchema = z.array(searchFilterSchema).max(100);
export type SearchFilter = z.infer<typeof searchFilterSchema>;
export const searchSortsSchema = z
.array(
z.object({
field: z.string().refine(validSearchField, "Choose a stored review field."),
direction: z.enum(["asc", "desc"]),
}),
)
.max(10)
.refine(
(sorts) => new Set(sorts.map((sort) => sort.field)).size === sorts.length,
"Choose each sort field once.",
);
export type SearchSort = z.infer<typeof searchSortsSchema>[number];
export function parseSearchSorts(
raw: string | null,
field = "relevance",
direction = "desc",
): SearchSort[] {
if (!raw)
return field === "relevance"
? []
: [{ field, direction: direction === "asc" ? "asc" : "desc" }];
try {
const parsed = searchSortsSchema.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : [];
} catch {
return [];
}
}