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
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"#static/": "./src/ui/static/",
"#src/": "./src/",
"#test/": "./test/",
"#cli/": "./cli/",
"@libsql/client": "npm:@libsql/client@^0.17.2",
"fast-check": "npm:fast-check@^3.23.0",
"intl-messageformat": "npm:intl-messageformat@^10",
Expand Down
28 changes: 16 additions & 12 deletions src/features/admin/attendee-form-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,22 @@ const handleSubmitInner = async (

// Apply atomic create or edit. On a recoverable failure (capacity, no lines)
// re-render the submitted form in place so entered data is never lost.
const outcome =
mode === "create"
? await applyCreate(parsed, logisticsPlan)
: await applyEdit(
attendeeId!,
parsed,
attendee!,
questions,
parseQuestionAnswers({ optional: true })(form, questions),
logisticsPlan,
existingByKey,
);
let outcome: SaveOutcome;
if (mode === "create") {
outcome = await applyCreate(parsed, logisticsPlan);
} else {
const answers = parseQuestionAnswers({ optional: true })(form, questions);
if (!answers.ok) return showErrors({ formError: answers.error });
outcome = await applyEdit(
attendeeId!,
parsed,
attendee!,
questions,
answers,
logisticsPlan,
existingByKey,
);
}
if (outcome.ok) return outcome.response;
return showErrors({ saveError: outcome.saveError });
};
Expand Down
4 changes: 2 additions & 2 deletions src/features/admin/attendee-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { createEntityHandler } from "#routes/entity.ts";
import { htmlResponse, notFoundResponse, redirect } from "#routes/response.ts";
import type { TypedRouteHandler } from "#routes/router.ts";
import { getSearchParam } from "#routes/url.ts";
import { getAttendee } from "#shared/db/attendees/queries.ts";
import { getAttendeeOrNull } from "#shared/db/attendees/queries.ts";
import {
createOwnerNote,
deleteAttendeeNote,
Expand Down Expand Up @@ -51,7 +51,7 @@ const returnTarget = (attendeeId: number, returnUrl: string): string =>
const loadAttendeeOr404 = async (
attendeeId: number,
): Promise<Attendee | Response> => {
const attendee = await getAttendee(
const attendee = await getAttendeeOrNull(
attendeeId,
await requireRequestPrivateKey(),
);
Expand Down
4 changes: 2 additions & 2 deletions src/features/admin/attendees-route-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { htmlResponse } from "#routes/response.ts";
import { getSearchParam } from "#routes/url.ts";
import { createAuthedHandler } from "#shared/app-forms.ts";
import { decryptAttendeeOrNull } from "#shared/db/attendees/pii.ts";
import { getAttendee } from "#shared/db/attendees/queries.ts";
import { getAttendeeOrNull } from "#shared/db/attendees/queries.ts";
import { getListingWithAttendeeRaw } from "#shared/db/listings/attendees.ts";
import { getListingWithCount } from "#shared/db/listings/records.ts";
import { findByIdThen } from "#shared/find-by-id.ts";
Expand Down Expand Up @@ -66,7 +66,7 @@ export const withAttendee = withEntityLoader(loadAttendeeForListing);
const getDecryptedAttendee = async (
attendeeId: number,
): Promise<Attendee | null> =>
getAttendee(attendeeId, await requireRequestPrivateKey());
getAttendeeOrNull(attendeeId, await requireRequestPrivateKey());

/** Curried loader: decrypt the attendee (null → 404), then complete the
* load with whatever else the caller needs alongside it. */
Expand Down
5 changes: 3 additions & 2 deletions src/features/admin/listing-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { formatDateLabel } from "#shared/dates.ts";
import {
type ActivityLogEntry,
getListingActivityLog,
getListingWithActivityLog,
getListingWithActivityLogOrNull,
} from "#shared/db/activityLog.ts";
import { decryptAttendees } from "#shared/db/attendees/pii.ts";
import { getAttendeeNamesByIds } from "#shared/db/attendees/queries.ts";
Expand Down Expand Up @@ -310,4 +310,5 @@ export const loadListingActivityPreview = ({
export const loadListingActivity = async ({
listing,
}: LoadedListing): Promise<ActivityLogEntry[]> =>
(await getListingWithActivityLog(listing.id))!.entries;
// LoadedListing is created only after the same listing row has been found.
(await getListingWithActivityLogOrNull(listing.id))!.entries;
Comment thread
stefan-burke marked this conversation as resolved.
14 changes: 12 additions & 2 deletions src/features/admin/settings-listing-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,19 @@ const parseUrlField = (
* at least one valid day (in canonical order). */
const parseDaysField = (form: FormParams): FieldParse => {
if (form.getString("default_bookable_days_enabled") !== "1") return {};
const days = VALID_DAY_NAMES.filter((day) =>
form.getAll("default_bookable_days").includes(day),
const submittedDays = form.getAll("default_bookable_days");
const invalidDay = submittedDays.find(
(submittedDay) =>
!VALID_DAY_NAMES.some((validDay) => validDay === submittedDay),
);
if (invalidDay !== undefined)
return {
error: t("fields.validation.invalid_day", {
day: invalidDay,
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") };
Comment thread
stefan-burke marked this conversation as resolved.
return { value: days };
};
Expand Down
8 changes: 4 additions & 4 deletions src/shared/column-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import { createBaseLiquidEngine } from "#shared/liquid-engine.ts";
import { requireSuccess } from "#shared/result.ts";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -139,7 +140,7 @@ export const buildDefaultTemplate = (keys: readonly string[]): string =>
keys.map((k) => `{{${k}}}`).join(", ");

/**
* Parse a template and return column keys + filters, with fallback to defaults.
* Parse a template and return column keys and filters.
* Shared by all listing/attendee table renderers.
*/
export const resolveColumnLayout = (
Expand All @@ -151,9 +152,8 @@ export const resolveColumnLayout = (
return { columnKeys: [...defaultOrder], filters: new Map() };
}
const result = parseColumnTemplate(template, validKeys);
return result.ok
? { columnKeys: result.columns, filters: result.filters }
: { columnKeys: [...defaultOrder], filters: new Map() };
requireSuccess(result);
return { columnKeys: result.columns, filters: result.filters };
};

/** Matches only when `date` is the first filter applied to the raw value */
Expand Down
2 changes: 1 addition & 1 deletion src/shared/db/activityLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export type ListingWithActivityLog = {
* Get listing and its activity log in a single database round-trip.
* Uses batch API to reduce latency for remote databases.
*/
export const getListingWithActivityLog = async (
export const getListingWithActivityLogOrNull = async (
listingId: number,
limit = 100,
): Promise<ListingWithActivityLog | null> => {
Expand Down
26 changes: 15 additions & 11 deletions src/shared/db/attendees/capacity/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "#shared/db/capacity.ts";
import { inPlaceholders, queryAll, queryOne } from "#shared/db/client.ts";
import { listingGroups } from "#shared/db/groups.ts";
import { useListingById } from "./listing.ts";
import { getListingWithCount } from "#shared/db/listings/records.ts";
import { dateToStartEnd, expandDailyRange } from "./range.ts";
import type { ListingCapacityRow } from "./types.ts";

Expand Down Expand Up @@ -91,15 +91,16 @@ export const checkListingAvailability = async (
quantity = 1,
date?: string | null,
durationDays = 1,
): Promise<boolean> =>
useListingById(listingId, false, async (listing) => {
const checkDate = capacityDateFor(listing.listing_type, date);
return (
await checkLinesCapacity([
{ date: checkDate, durationDays, listingId, quantity },
])
)[0]!;
});
): Promise<boolean> => {
const listing = await getListingWithCount(listingId);
if (!listing) throw new Error(`Listing not found: ${listingId}`);
const checkDate = capacityDateFor(listing.listing_type, date);
return (
await checkLinesCapacity([
{ date: checkDate, durationDays, listingId, quantity },
])
)[0]!;
};

type DemandBucket = CapacityBucket;

Expand Down Expand Up @@ -172,7 +173,10 @@ export const checkBatchAvailabilityImpl = async (
listingIds,
);
const listingsById = mapById(identity<ListingCapacityRow>)(listingRows);
if (items.some((item) => !listingsById.has(item.listingId))) return false;
const missingListingId = listingIds.find((id) => !listingsById.has(id));
if (missingListingId !== undefined) {
throw new Error(`Listing not found: ${missingListingId}`);
}

const membership = await listingGroups.getIdsByKeys(listingIds);
const context: BatchAvailabilityContext = { date, items, listingsById };
Expand Down
2 changes: 1 addition & 1 deletion src/shared/db/attendees/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ export const getAttendeeKindsByIds = (
* Get an attendee by ID (decrypted)
* Requires private key for decryption - only available to authenticated sessions
*/
export const getAttendee = async (
export const getAttendeeOrNull = async (
id: number,
privateKey: CryptoKey,
): Promise<Attendee | null> => {
Expand Down
4 changes: 2 additions & 2 deletions src/shared/db/attendees/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ export const attendeeFromWhere = (
where.dailyRange !== undefined
? " JOIN listings AS listing ON listingAttendee.listing_id = listing.id"
: "";
// A single-attendee read (getAttendee) needs no ordering; a list read always
// A single-attendee read (getAttendeeOrNull) needs no ordering; a list read always
// passes one so its rows are deterministic.
const orderBy = order === undefined ? "" : ` ORDER BY ${ORDER_SQL[order]}`;
return {
Expand All @@ -366,7 +366,7 @@ export const attendeeFromWhere = (
export type GetAttendeesQuery<F extends AttendeeField> = {
fields: readonly F[];
where: AttendeeWhere;
/** Row order. Omit only for a single-row {@link getAttendee} read. */
/** Row order. Omit only for a single-row {@link getAttendeeOrNull} read. */
order?: AttendeeOrder;
/** Defaults to an INNER join; use `"left"` to keep an attendee whose booking
* linkage is missing (a single COALESCEd `listing_id = 0` row). */
Expand Down
14 changes: 8 additions & 6 deletions src/shared/db/listings/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,7 @@ const normalizeUtcDatetime = (value: string, label: string): string => {
}
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
logError({
code: ErrorCode.DATA_INVALID,
detail: `${label} invalid datetime (${value})`,
});
return "";
throw new Error(`${label} has invalid datetime: ${value}`);
}
return date.toISOString();
};
Expand Down Expand Up @@ -148,7 +144,13 @@ export const rawListingsTable = defineIdTable<Listing, ListingInput>(
default: () => [...DEFAULT_BOOKABLE_DAYS],
read: (value) => {
const parsed: unknown = JSON.parse(value as string);
return Array.isArray(parsed) ? parsed : [];
if (
!Array.isArray(parsed) ||
!parsed.every((day): day is string => typeof day === "string")
) {
throw new Error("Stored bookable_days must be a JSON string array");
}
return parsed;
},
write: (value) => JSON.stringify(value),
}),
Expand Down
25 changes: 9 additions & 16 deletions src/shared/db/questions/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const readQuestionAnswer = (
* - `{ optional: false }` (public booking) — every question must be answered
* with a valid option; the first missing/invalid one returns `ok: false`.
* - `{ optional: true }` (admin edit) — unanswered or invalid questions are
* skipped, so the result is always `ok: true` with the valid answers found.
* skipped. Supplied free text is still validated and can return `ok: false`.
*/
type MutableParsedQuestionAnswers = {
answerIds: number[];
Expand All @@ -83,10 +83,10 @@ const parseFreeTextAnswer: AnswerParser = (
const text = (form.get(`question_${question.id}`) ?? "").trim();
// Cap free-text length so an unauthenticated booking cannot submit an
// arbitrarily large value (expensive to encrypt, large blob to retain). The
// public input mirrors this with a maxlength; optional (admin) parsing skips
// an over-long value rather than erroring, keeping its always-ok contract.
// public input mirrors this with a maxlength. Optional means a blank answer
// may be omitted; it never makes supplied invalid text acceptable.
if (text.length > MAX_TEXTAREA_LENGTH) {
return optional ? null : `Answer is too long: ${question.text}`;
return `Answer is too long: ${question.text}`;
}
if (text) {
parsed.textAnswers.push({ questionId: question.id, text });
Expand Down Expand Up @@ -117,20 +117,14 @@ const parseQuestionAnswer: AnswerParser = (form, question, parsed, optional) =>
? parseFreeTextAnswer(form, question, parsed, optional)
: parseChoiceAnswer(form, question, parsed, optional);

export function parseQuestionAnswers(opts: {
optional: true;
}): (
form: URLSearchParams,
questions: QuestionWithAnswers[],
) => { ok: true; answerIds: number[]; textAnswers: TextAnswer[] };
export function parseQuestionAnswers(opts: {
optional: false;
}): (
type QuestionAnswersParser = (
form: URLSearchParams,
questions: QuestionWithAnswers[],
) => ParsedQuestionAnswers;
export function parseQuestionAnswers(opts: { optional: boolean }) {
return (

export const parseQuestionAnswers =
(opts: { optional: boolean }): QuestionAnswersParser =>
(
form: URLSearchParams,
questions: QuestionWithAnswers[],
): ParsedQuestionAnswers => {
Expand All @@ -144,4 +138,3 @@ export function parseQuestionAnswers(opts: { optional: boolean }) {
}
return { ok: true, ...parsed };
};
}
6 changes: 4 additions & 2 deletions src/shared/forms/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,13 @@ export const defineForm = <
const section = (
id: FormSectionId<TFields>,
values: FormRenderValuesFor<TFields> = {},
): string =>
renderFields(
): string => {
if (!sectionIds.includes(id)) throw new Error(`Unknown section: ${id}`);
return renderFields(
fields.filter((field) => field.section === id),
values as FieldValues,
);
};

return {
field: (name) => ({
Expand Down
10 changes: 10 additions & 0 deletions src/shared/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ export const errorResult = <E = string>(error: E): { error: E; ok: false } => ({
error,
ok: false,
});

/** Throw a failed boundary result and narrow successful results for the caller. */
export function requireSuccess<
T extends { ok: true } | { error: string; ok: false },
>(result: T): asserts result is Extract<T, { ok: true }>;
export function requireSuccess(
result: { ok: true } | { error: string; ok: false },
): void {
if (!result.ok) throw new Error(result.error);
}
12 changes: 7 additions & 5 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,18 +182,20 @@ export const MAX_DURATION_DAYS = 90;

/**
* The single definition of "a valid booking duration": a whole number of
* days in [1, MAX_DURATION_DAYS], with non-finite input degrading to 1.
* days in [1, MAX_DURATION_DAYS]. Non-finite input is invalid and throws.
*
* Every read of `duration_days` and every `durationDays` parameter funnels
* through here so the clamping policy lives in exactly one place — the column
* write, the per-day capacity expansion (JS + SQL), and all display paths
* agree by construction. Idempotent, so applying it to an already-normalized
* value (e.g. a column-clamped `listing.duration_days`) is a safe no-op.
*/
export const normalizeDurationDays = (value: number): number =>
Number.isFinite(value)
? Math.max(1, Math.min(MAX_DURATION_DAYS, Math.floor(value)))
: 1;
export const normalizeDurationDays = (value: number): number => {
if (!Number.isFinite(value)) {
throw new Error(`Invalid booking duration: ${String(value)}`);
}
return Math.max(1, Math.min(MAX_DURATION_DAYS, Math.floor(value)));
};

/**
* Per-day-count ticket prices for "customisable days" listings, in minor
Expand Down
7 changes: 3 additions & 4 deletions src/shared/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "#shared/db/client.ts";
import { denoDeployApi } from "#shared/deno-deploy-api.ts";
import { logDebug } from "#shared/logger.ts";
import { requireSuccess } from "#shared/result.ts";

/** GitHub repo URL — update here if the repo moves */
export const GITHUB_REPO = "chobbledotcom/tickets";
Expand Down Expand Up @@ -244,9 +245,7 @@ export const deployRelease = async (
): Promise<void> => {
const code = await downloadReleaseAsset(assetUrl);
const result = await deployScriptCode(code, scriptId);
if (!result.ok) {
throw new Error(result.error);
}
requireSuccess(result);
};

/** Fetch, download, and deploy the latest release via `deploy`, throwing on any failure. */
Expand All @@ -257,7 +256,7 @@ const deployLatest = async (
): Promise<ReleaseInfo> => {
const { code, release } = await fetchAndDownloadRelease();
const result = await deploy(code);
if (!result.ok) throw new Error(result.error);
requireSuccess(result);
return release;
};

Expand Down
File renamed without changes.
Loading
Loading