Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 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,4 @@ export const loadListingActivityPreview = ({
export const loadListingActivity = async ({
listing,
}: LoadedListing): Promise<ActivityLogEntry[]> =>
(await getListingWithActivityLog(listing.id))!.entries;
(await getListingWithActivityLogOrNull(listing.id))!.entries;
Comment thread
stefan-burke marked this conversation as resolved.
13 changes: 10 additions & 3 deletions src/features/admin/settings-listing-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,16 @@ 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");
if (
submittedDays.some(
(submittedDay) =>
!VALID_DAY_NAMES.some((validDay) => validDay === submittedDay),
)
) {
return { error: t("listing_defaults.days_required") };
}
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
11 changes: 5 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,10 @@ 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)) {
throw new Error("Stored bookable_days must be a JSON array");
}
return parsed;
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
},
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: boolean }>(
result: T,
): asserts result is T & { ok: true };
export function requireSuccess(
result: { ok: true } | { error: string; ok: false },
): void {
if (!result.ok) throw new Error(result.error);
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
}
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