Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
# <file>` — exit 0 means lint did not kill it.

src/ui/templates/components/data-table.tsx:85:43 0 → -1 # For the only changed case, an empty array, both branches render the same empty tbody; non-empty arrays make both comparisons false
src/shared/form-data.ts:16:33 ?? → || # trim returns a string, and its only falsy value is the empty string supplied by the fallback
src/shared/form-data.ts:35:23 !== → != # parsePositiveIntId returns number|null and cannot return undefined, so strict and loose inequality agree when comparing it with null
src/shared/forms/validation.ts:51:8 === → == # value is string|number|null and cannot be undefined, so strict and loose equality agree when comparing it with null
src/shared/forms/validation.ts:69:10 === → == # readSubmittedFieldValue returns string|null and cannot return undefined, so strict and loose equality agree when comparing it with null
src/shared/forms/validation.ts:101:46 = → += # this assignment runs only when trimmed is the empty string, so replacing it and appending to it produce the same defaultValue
src/shared/forms/validation.ts:105:16 !== → != # validateFieldText returns string|null and cannot return undefined, so strict and loose inequality agree when comparing it with null
src/features/admin/settings-listing-defaults.ts:53:15 === → == # parseNonNegativeInt returns number|null and cannot return undefined, so strict and loose equality agree when comparing it with null
src/features/admin/dashboard.ts:235:65 1 → 2 # The query needs any number above the 200-row display limit only to detect overflow; both limits produce the same truncated flag and the same first 200 displayed rows

src/features/admin/entity-pages.ts:275:24 ?? → || # opts.query is a URLSearchParams object when present, so it is always truthy
src/features/admin/site-content-page.ts:87:38 ?? → || # extraTabs is an array when present, and arrays are always truthy
Expand Down
14 changes: 9 additions & 5 deletions src/features/admin/aggregate-recalculation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* jscpd:ignore-start */

import { AUTH_FORM, requireSessionOr, withAuth } from "#routes/auth.ts";
import { htmlResponse, redirect } from "#routes/response.ts";
import { getFlash } from "#shared/flash-context.ts";
Expand Down Expand Up @@ -35,10 +36,13 @@ export const parseEditableAggregateForm = <TValues, TInput>(

export const selectedRecalculationFields = <T extends string>(
form: FormParams,
allowed: readonly T[],
allowed: readonly [T, ...T[]],
): T[] => {
const selected = new Set(form.getAll(RECALCULATE_FIELD_NAME));
return allowed.filter((field) => selected.has(field));
const selection = form.getRepeatedPicklist(RECALCULATE_FIELD_NAME, allowed);
if (!selection.ok) {
throw new Error(`Invalid recalculation field: ${selection.error}`);
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a validation response for bad recalc fields

When an authenticated listing/modifier/answer recalculate POST contains a stale or tampered recalculate_fields value, this exception bubbles through runRecalculatePost/withAuth without being converted to a response, so the operator gets a 500 instead of the same 400 re-render used for the empty-selection validation path. Treat the unknown choice as a form validation error at this boundary so bad request data fails closed without crashing the route.

Useful? React with 👍 / 👎.

}
return selection.value;
};

/**
Expand All @@ -51,7 +55,7 @@ export const selectedRecalculationFields = <T extends string>(
*/
export const runRecalculatePost = async <T extends string>(config: {
form: FormParams;
fields: readonly T[];
fields: readonly [T, ...T[]];
/** Re-render the recalculate page with the "choose a field" error. */
renderChoose: ResponseHandler;
reset: (selected: T[]) => Promise<unknown>;
Expand Down Expand Up @@ -108,7 +112,7 @@ export const createRecalculateHandlers = <T, F extends string, ID>(config: {
error?: string,
success?: string,
) => Promise<Response>;
fields: readonly F[];
fields: readonly [F, ...F[]];
entityId: (entity: T) => number;
reset: (entityId: number, selected: F[]) => Promise<unknown>;
log: (entity: T) => Promise<unknown>;
Expand Down
10 changes: 5 additions & 5 deletions src/features/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const handleAdminGet = (request: Request): Promise<Response> =>
newestAttendees,
successMessage,
stats,
settings.listingColumnOrder,
settings.listingColumnLayout,
activeType,
holidays,
unbookableIds,
Expand All @@ -170,7 +170,9 @@ const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> =
return adminListingsPage(
listings,
session,
settings.listingColumnOrder,
session.adminLevel === "editor"
? undefined
: settings.listingColumnLayout,
await loadListingAttributeFilterContext(request, listings),
);
});
Expand Down Expand Up @@ -232,9 +234,7 @@ const handleAdminLog: TypedRouteHandler<"GET /admin/log"> = sessionPage(
async (session) => {
const entries = await getAllActivityLog(LOG_DISPLAY_LIMIT + 1);
const truncated = entries.length > LOG_DISPLAY_LIMIT;
const displayEntries = truncated
? entries.slice(0, LOG_DISPLAY_LIMIT)
: entries;
const displayEntries = entries.slice(0, LOG_DISPLAY_LIMIT);
const refs = await loadActivityLogRefs(displayEntries);
return adminGlobalActivityLogPage(displayEntries, truncated, session, refs);
},
Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/entity-write-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const submittedValueProps = (
rejected
? {
error: rejected.error,
values: Object.fromEntries(rejected.form.entries()),
values: rejected.form.toRenderValues(),
}
: {};

Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/listings-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const renderCreateListingError = async (
// re-renders their selection rather than dropping every group.
selectedGroupIds: parseGroupIds(form),
templateId,
values: Object.fromEntries(form.entries()),
values: form.toRenderValues(),
},
400,
);
Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/logistics-agent-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const logisticsAgentPage: EditEntityPage<LogisticsAgent> =
...(rejected
? {
error: rejected.error,
values: Object.fromEntries(rejected.form.entries()),
values: rejected.form.toRenderValues(),
}
: {}),
});
Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ const modifierPage: EditEntityPage<Modifier> = defineEditEntityPage({
modifier,
ctx.session,
rejected?.error,
rejected ? Object.fromEntries(rejected.form.entries()) : undefined,
rejected?.form.toRenderValues(),
),
guard: requireSessionOr,
guideFooter: () => Promise.resolve(ModifiersGuideFooter()),
Expand Down
36 changes: 19 additions & 17 deletions src/features/admin/settings-general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ import {
settingsHandler,
settingsToggle,
} from "#routes/admin/settings-helpers.ts";
import { validateColumnTemplate } from "#shared/column-order.ts";
import { ATTENDEE_TABLE_COLUMNS } from "#shared/columns/attendee-columns.ts";
import { LISTING_TABLE_COLUMNS } from "#shared/columns/listing-columns.ts";
import { COLUMN_LAYOUTS } from "#shared/column-layout.ts";
import { clearSessionCookie } from "#shared/cookies.ts";
import { logActivity } from "#shared/db/activityLog.ts";
import { settings } from "#shared/db/settings.ts";
Expand Down Expand Up @@ -194,24 +192,28 @@ export const handleBookingFeePost = settingsHandler({
* Build a column-order settings handler for the listing or attendee table.
* Handles POST /admin/settings/{listing,attendee}-column-order - owner only
*/
const columnOrderHandler = (kind: "listing" | "attendee") => {
const columns =
kind === "listing" ? LISTING_TABLE_COLUMNS : ATTENDEE_TABLE_COLUMNS;
const update =
kind === "listing"
? settings.update.listingColumnOrder
: settings.update.attendeeColumnOrder;
const label =
kind === "listing" ? "Listing column order" : "Attendee column order";
const COLUMN_ORDER_SETTINGS = {
attendee: {
label: "Attendee column order",
update: settings.update.attendeeColumnOrder,
},
listing: {
label: "Listing column order",
update: settings.update.listingColumnOrder,
},
};

type ConfigurableColumnLayoutKind = keyof typeof COLUMN_ORDER_SETTINGS;

const columnOrderHandler = (kind: ConfigurableColumnLayoutKind) => {
const config = COLUMN_ORDER_SETTINGS[kind];
return settingsHandler({
advanced: true,
extract: (form) => form.getString("column_order").trim(),
formId: `settings-${kind}-column-order`,
label,
save: (value) => update(value),
// Empty value clears to the default column order
validate: (value) =>
value ? validateColumnTemplate(value, Object.keys(columns)) : null,
label: config.label,
save: config.update,
validate: COLUMN_LAYOUTS[kind].validate,
});
};

Expand Down
21 changes: 11 additions & 10 deletions src/features/admin/settings-listing-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,23 @@ const parseUrlField = (
/** Parse the bookable-days default: only set when its enable box is ticked, with
* at least one valid day (in canonical order). */
const parseDaysField = (form: FormParams): FieldParse => {
if (form.getString("default_bookable_days_enabled") !== "1") return {};
const submittedDays = form.getAll("default_bookable_days");
const invalidDay = submittedDays.find(
(submittedDay) =>
!VALID_DAY_NAMES.some((validDay) => validDay === submittedDay),
if (!form.getFlag("default_bookable_days_enabled")) return {};
const selection = form.getRepeatedPicklist(
"default_bookable_days",
VALID_DAY_NAMES,
);
if (invalidDay !== undefined)
if (!selection.ok) {
return {
error: t("fields.validation.invalid_day", {
day: invalidDay,
day: selection.error,
valid: VALID_DAY_NAMES.join(", "),
}),
};
const days = VALID_DAY_NAMES.filter((day) => submittedDays.includes(day));
if (days.length === 0) return { error: t("listing_defaults.days_required") };
return { value: days };
}
if (selection.value.length === 0) {
return { error: t("listing_defaults.days_required") };
}
return { value: selection.value };
};

/** Per-kind parser. The `Record` is keyed by {@link ListingDefaultKind}, so a
Expand Down
172 changes: 172 additions & 0 deletions src/shared/column-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/** Parse and validate configurable table column layouts without loading the
* Liquid rendering engine used later to format cell values. */

import * as v from "valibot";
import { reduce } from "#fp";
import type { Result } from "#shared/result.ts";

export type ColumnLayout<TColumn extends string> = {
readonly columnKeys: readonly TColumn[];
readonly filters: ReadonlyMap<TColumn, string>;
};

const LIQUID_TAG_RE = /\{\{\s*([^}]+?)\s*\}\}/g;

const parseTagBody = (
body: string,
): { key: string; filter: string | undefined } => {
const pipeIdx = body.indexOf("|");
if (pipeIdx === -1) return { filter: undefined, key: body.trim() };
return { filter: body.trim(), key: body.slice(0, pipeIdx).trim() };
};

interface LayoutParts<T extends string> {
columns: T[];
filters: Map<T, string>;
seen: Set<T>;
}

type CollectedLayout<T extends string> = Result<LayoutParts<T>>;

type ParsedLayout<T extends string> = Result<
Pick<LayoutParts<T>, "columns" | "filters">
>;

type ColumnSchema = v.GenericSchema<string> & {
readonly options: readonly string[];
};

const collectColumnTag =
<TSchema extends ColumnSchema>(schema: TSchema) =>
(
result: CollectedLayout<v.InferOutput<TSchema>>,
match: RegExpMatchArray,
): CollectedLayout<v.InferOutput<TSchema>> => {
if (!result.ok) return result;
const { key, filter } = parseTagBody(match[1]!);
const parsed = v.safeParse(schema, key);
if (!parsed.success) {
return {
error: `Unknown column "${key}". Available columns: ${schema.options.join(", ")}`,
ok: false,
};
}
const option = parsed.output;
const { columns, filters, seen } = result.value;
if (!seen.has(option)) {
seen.add(option);
columns.push(option);
if (filter) filters.set(option, filter);
}
return result;
};

const parseColumnTemplate = <TSchema extends ColumnSchema>(
template: string,
schema: TSchema,
): ParsedLayout<v.InferOutput<TSchema>> => {
const collected = reduce(collectColumnTag(schema), {
ok: true,
value: {
columns: [],
filters: new Map(),
seen: new Set(),
},
} satisfies CollectedLayout<v.InferOutput<TSchema>>)([
...template.matchAll(LIQUID_TAG_RE),
]);
if (!collected.ok) return collected;
const { columns, filters } = collected.value;

if (columns.length === 0) {
return { error: "Template must include at least one column", ok: false };
}

return { ok: true, value: { columns, filters } };
};

export const buildDefaultTemplate = (keys: readonly string[]): string =>
keys.map((key) => `{{${key}}}`).join(", ");

const defineColumnLayout = <
const TDefault extends readonly [string, ...string[]],
const TExtra extends readonly string[],
>(
defaultOrder: TDefault,
extra: TExtra,
) => {
const [first, ...rest] = defaultOrder;
const schema = v.picklist([first, ...rest, ...extra]);
const defaultLayout: ColumnLayout<TDefault[number] | TExtra[number]> = {
columnKeys: defaultOrder,
filters: new Map(),
};
return {
defaultLayout,
defaultOrder,
defaultTemplate: buildDefaultTemplate(defaultOrder),
options: schema.options as readonly (TDefault[number] | TExtra[number])[],
parse(template: string): ColumnLayout<TDefault[number] | TExtra[number]> {
if (!template) return defaultLayout;
const result = parseColumnTemplate(template, schema);
if (!result.ok) throw new Error(result.error);
return {
columnKeys: result.value.columns,
filters: result.value.filters,
};
},
schema,
validate(template: string): string | null {
if (!template) return null;
const result = parseColumnTemplate(template, schema);
return result.ok ? null : result.error;
},
};
};

export const COLUMN_LAYOUTS = {
attendee: defineColumnLayout(
[
"status",
"date",
"name",
"listings",
"email",
"phone",
"address",
"special_instructions",
"answers",
"qty",
"ticket",
"registered",
],
[],
),
listing: defineColumnLayout(
[
"name",
"description",
"status",
"attendees",
"tickets",
"revenue",
"cost",
"profit",
"created",
],
["date", "location", "price", "renewal"],
),
};

export type ColumnLayoutKind = keyof typeof COLUMN_LAYOUTS;
export type ListingColumn =
(typeof COLUMN_LAYOUTS)["listing"]["options"][number];
export type AttendeeColumn =
(typeof COLUMN_LAYOUTS)["attendee"]["options"][number];

export type ListingColumnLayout = ReturnType<
(typeof COLUMN_LAYOUTS)["listing"]["parse"]
>;
export type AttendeeColumnLayout = ReturnType<
(typeof COLUMN_LAYOUTS)["attendee"]["parse"]
>;
Loading
Loading