Catering tables: the spreadsheet becomes data the bakery can edit
build-and-publish / build (pull_request) Successful in 1m58s

The goodie box and catering prices arrived as a spreadsheet — Office, Parties and Weddings, each a
few columns of sizes and prices with lines of baked goods underneath. This puts it behind
/api/catering and makes every part of it editable at /admin, because the prices move and the
spreadsheet's own last line says the tables are "mostly just an idea for people".

A package is one table, its tiers are the columns, its rows are the lines, and a line holds one
value per column. That alignment is why this is an aggregate rather than three tables edited
separately: drop the middle column on its own and every remaining entry shifts one place left, so
the Large box advertises the Medium box's contents at the Large price and nothing looks broken.
CateringPackage#arrange takes a whole table, renumbers positions from the order it arrived in, and
refuses an arrangement whose lines and columns disagree.

Money owns prices — what "24", "$24" or "24.50" means and how it prints — so the browser never
formats money and never multiplies it by 100 in floating point. Cents in the column, "$24" in the
response. An empty price is "ask us", not zero.

Seeded from the bakery's own wording. Shorthand is expanded ("4 dz cc or sc") and typos fixed, since
customers read these lines; in the wedding table the labels and the values are offset in the source
spreadsheet, so they are carried over literally and can be renamed in the admin. The lines that are
named but never quantified keep their blank cells: dropping the blanks would shorten the line and
shift everything after it.

The public response leaves out a table with no columns or no lines — adding a table and filling it
in are two separate acts, and the gap between them shouldn't put a bare heading on the live page.
No public page renders any of this yet; this is the backend and the editor for it.

Admin endpoints are @ConditionalOnProperty on SECURITY_MODE=OIDC like the rest, so a deployment with
no identity provider has no price writes. 18 new tests: the seeded spreadsheet, the alignment
invariant, money in both directions, and the HTTP surface the screen actually calls (including that
/packages/order isn't read as a table id, and that a refusal arrives as a ProblemDetail sentence).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 15:13:39 -05:00
co-authored by Claude Opus 5
parent d5103e44b9
commit 748b1cd59b
20 changed files with 2170 additions and 51 deletions
+17 -2
View File
@@ -22,6 +22,14 @@ The SPA renders; it doesn't decide anything.
- **`/api/products`**, **`/api/categories`** — the catalogue, its curated order, the category filter
and the absolute image URLs. This was a TypeScript array shipped to every visitor; it's now a table
(`V2__products.sql`) read through `ProductCatalog`.
- **`/api/catering`** — the goodie box and catering price tables (Office, Parties, Weddings): the
columns, the prices already written the way they should be read, the entries under each column, and
the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`,
read through `CateringMenu`) rather than as markup, because the prices move and the last line of that
spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that
decides what a typed price means or how it prints. A table with no columns or no lines is left off the
public response — adding a table and filling it in are two separate acts in the admin, and the gap
between them shouldn't put a bare heading on the live page. *(No public page renders this yet.)*
- **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub.
Recorded before sending on purpose: a relay outage costs a notification, not the enquiry. Undelivered
ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`,
@@ -39,8 +47,15 @@ Photos are resized, stripped of EXIF, converted to webp and put in the bucket on
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
webp or ship glibc natives that don't run on Alpine).
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and
`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
The catering tables are editable there too, but a table at a time rather than a field at a time. That
isn't a different taste in interfaces: a column heading, its price and the entries beneath it only mean
anything together, so `CateringPackage#arrange` takes the whole table and refuses one whose lines and
columns disagree. Drop the middle column on its own and every remaining entry shifts one place left —
the Large box then advertises the Medium box's contents at the Large price, and nothing about the page
looks broken.
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController`,
`AdminCategoryController` and `AdminCateringController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
a bare 401 to handle.
+570
View File
@@ -0,0 +1,570 @@
import { useCallback, useEffect, useState } from 'react';
import {
addCateringTable,
adminCatering,
deleteCateringTable,
reorderCateringTables,
saveCateringNotes,
saveCateringTable,
type CateringTable,
} from '@/lib/api';
import {
ARROW_DOWN,
ARROW_LEFT,
ARROW_RIGHT,
ARROW_UP,
CHECK,
Icon,
PLUS,
TRASH,
X,
danger,
field,
iconButton,
primary,
secondary,
shift,
} from '@/components/admin/ui';
/**
* The goodie box and catering price tables, editable by the person who quotes them.
*
* A table is edited as a table and saved in one go, unlike the catalogue next door where every change
* saves as you make it. That's not a different taste in interfaces: a column heading, its price and
* the entries beneath it only mean anything together, so they have to be moved, added and removed
* together. Adding a column here adds an empty entry to every line, and removing one takes its
* entries with it — the server refuses any table whose lines and columns disagree, because the
* alternative is the Large box quietly advertising the Medium box's contents at the Large price.
*/
// --- what's on screen -------------------------------------------------------
type TierDraft = { id: number | null; label: string; price: string };
type RowDraft = { id: number | null; label: string; values: string[] };
type Draft = { name: string; blurb: string; tiers: TierDraft[]; rows: RowDraft[]; notes: string[] };
const draftOf = (table: CateringTable): Draft => ({
name: table.name,
blurb: table.blurb ?? '',
// The price arrives written out ("$24"); it goes back as whatever the editor leaves in the box, and
// the server decides what that's worth.
tiers: table.tiers.map((tier) => ({ id: tier.id, label: tier.label, price: tier.price ?? '' })),
rows: table.rows.map((row) => ({ id: row.id, label: row.label, values: [...row.values] })),
notes: [...table.notes],
});
/** Notes are edited as a list; deleting one is an omission, exactly as the server expects. */
const Notes = ({
notes,
hint,
disabled,
onChange,
}: {
notes: string[];
hint: string;
disabled?: boolean;
onChange: (notes: string[]) => void;
}) => (
<div>
<p className="text-sm text-bakery-600">{hint}</p>
<ul className="mt-2 space-y-2">
{notes.map((note, i) => (
<li key={i} className="flex items-start gap-2">
<textarea
className={`${field} min-h-[3.25rem]`}
rows={2}
value={note}
disabled={disabled}
onChange={(e) => onChange(notes.map((n, at) => (at === i ? e.target.value : n)))}
/>
<div className="flex gap-1 pt-1">
<button
type="button"
className={iconButton}
disabled={disabled || i === 0}
onClick={() => onChange(shift(notes, i, -1))}
aria-label="Move note up"
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={disabled}
onClick={() => onChange(notes.filter((_, at) => at !== i))}
aria-label="Remove note"
>
<Icon d={TRASH} />
</button>
</div>
</li>
))}
</ul>
<button
type="button"
className={`${secondary} mt-2`}
disabled={disabled}
onClick={() => onChange([...notes, ''])}
>
<Icon d={PLUS} />
Add a note
</button>
</div>
);
// --- one table --------------------------------------------------------------
const TableCard = ({
table,
first,
last,
onSaved,
onMove,
onDelete,
onError,
}: {
table: CateringTable;
first: boolean;
last: boolean;
onSaved: (saved: CateringTable) => void;
onMove: (delta: number) => void;
onDelete: () => void;
onError: (message: string) => void;
}) => {
const [draft, setDraft] = useState<Draft>(() => draftOf(table));
const [busy, setBusy] = useState(false);
const stored = draftOf(table);
const dirty = JSON.stringify(draft) !== JSON.stringify(stored);
// A reorder re-renders this card with a fresh copy from the server; the boxes should follow along
// unless they're being edited.
const [synced, setSynced] = useState(table);
if (synced !== table) {
setSynced(table);
if (!dirty) setDraft(draftOf(table));
}
const edit = (change: Partial<Draft>) => setDraft({ ...draft, ...change });
// Columns. Every one of these keeps the lines in step — that is the whole job of this screen.
const addColumn = () =>
edit({
tiers: [...draft.tiers, { id: null, label: '', price: '' }],
rows: draft.rows.map((row) => ({ ...row, values: [...row.values, ''] })),
});
const removeColumn = (column: number) =>
edit({
tiers: draft.tiers.filter((_, at) => at !== column),
rows: draft.rows.map((row) => ({ ...row, values: row.values.filter((_, at) => at !== column) })),
});
const moveColumn = (column: number, delta: number) =>
edit({
tiers: shift(draft.tiers, column, delta),
rows: draft.rows.map((row) => ({ ...row, values: shift(row.values, column, delta) })),
});
const setColumn = (column: number, change: Partial<TierDraft>) =>
edit({ tiers: draft.tiers.map((tier, at) => (at === column ? { ...tier, ...change } : tier)) });
// Lines.
const addLine = () =>
edit({ rows: [...draft.rows, { id: null, label: '', values: draft.tiers.map(() => '') }] });
const setLine = (line: number, change: Partial<RowDraft>) =>
edit({ rows: draft.rows.map((row, at) => (at === line ? { ...row, ...change } : row)) });
const setCell = (line: number, column: number, value: string) =>
setLine(line, {
values: draft.rows[line].values.map((entry, at) => (at === column ? value : entry)),
});
const save = async () => {
setBusy(true);
try {
onSaved(
await saveCateringTable(table.id, {
name: draft.name.trim(),
blurb: draft.blurb.trim() || null,
tiers: draft.tiers.map((tier) => ({ id: tier.id, label: tier.label.trim(), price: tier.price.trim() })),
rows: draft.rows.map((row) => ({
id: row.id,
label: row.label.trim(),
values: row.values.map((entry) => entry.trim()),
})),
notes: draft.notes,
}),
);
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
const columns = draft.tiers.length;
return (
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-start gap-2">
<div className="flex gap-1 pt-1">
<button
type="button"
className={iconButton}
disabled={first}
onClick={() => onMove(-1)}
aria-label={`Move the ${table.name} table up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={last}
onClick={() => onMove(1)}
aria-label={`Move the ${table.name} table down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
<div className="grid flex-1 gap-2 sm:grid-cols-[14rem_1fr]">
<label className="block">
<span className="sr-only">Table name</span>
<input
className={field}
value={draft.name}
onChange={(e) => edit({ name: e.target.value })}
placeholder="Weddings"
/>
</label>
<label className="block">
<span className="sr-only">A line under the heading</span>
<input
className={field}
value={draft.blurb}
onChange={(e) => edit({ blurb: e.target.value })}
placeholder="Optional — a line under the heading"
/>
</label>
</div>
</div>
{/* Wide tables scroll here rather than making the page scroll sideways. */}
<div className="mt-4 -mx-4 overflow-x-auto px-4">
<table className="w-full border-separate border-spacing-1">
<thead>
<tr>
<th scope="col" className="w-48 text-left text-sm font-medium text-bakery-600">
What they get
</th>
{draft.tiers.map((tier, column) => (
<th key={tier.id ?? `new-${column}`} scope="col" className="min-w-44 align-top">
<input
className={field}
value={tier.label}
onChange={(e) => setColumn(column, { label: e.target.value })}
placeholder="Small"
aria-label={`Heading for column ${column + 1}`}
/>
<input
className={`${field} mt-1`}
value={tier.price}
onChange={(e) => setColumn(column, { price: e.target.value })}
placeholder="$24 — leave empty to ask"
aria-label={`Price for column ${column + 1}`}
/>
<div className="mt-1 flex justify-center gap-1">
<button
type="button"
className={iconButton}
disabled={column === 0}
onClick={() => moveColumn(column, -1)}
aria-label="Move this column left"
>
<Icon d={ARROW_LEFT} />
</button>
<button
type="button"
className={iconButton}
disabled={column === columns - 1}
onClick={() => moveColumn(column, 1)}
aria-label="Move this column right"
>
<Icon d={ARROW_RIGHT} />
</button>
<button
type="button"
className={iconButton}
onClick={() => removeColumn(column)}
aria-label="Remove this column"
title="Removes this column and its entries on every line"
>
<Icon d={TRASH} />
</button>
</div>
</th>
))}
<th scope="col" className="w-32 align-top">
<button type="button" className={secondary} onClick={addColumn}>
<Icon d={PLUS} />
Column
</button>
</th>
</tr>
</thead>
<tbody>
{draft.rows.map((row, line) => (
<tr key={row.id ?? `new-${line}`}>
<th scope="row" className="text-left align-top">
<input
className={field}
value={row.label}
onChange={(e) => setLine(line, { label: e.target.value })}
placeholder="Mini muffins"
aria-label={`Name of line ${line + 1}`}
/>
</th>
{row.values.map((entry, column) => (
<td key={column} className="align-top">
<input
className={field}
value={entry}
onChange={(e) => setCell(line, column, e.target.value)}
placeholder="—"
aria-label={`${row.label || `Line ${line + 1}`}, ${
draft.tiers[column]?.label || `column ${column + 1}`
}`}
/>
</td>
))}
<td className="align-top">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={line === 0}
onClick={() => edit({ rows: shift(draft.rows, line, -1) })}
aria-label="Move this line up"
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={line === draft.rows.length - 1}
onClick={() => edit({ rows: shift(draft.rows, line, 1) })}
aria-label="Move this line down"
>
<Icon d={ARROW_DOWN} />
</button>
<button
type="button"
className={iconButton}
onClick={() => edit({ rows: draft.rows.filter((_, at) => at !== line) })}
aria-label="Remove this line"
>
<Icon d={TRASH} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button type="button" className={`${secondary} mt-1`} onClick={addLine}>
<Icon d={PLUS} />
Line
</button>
<div className="mt-4">
<Notes
notes={draft.notes}
hint="Small print under this table — minimums, what can't be mixed, how delivery is charged."
disabled={busy}
onChange={(notes) => edit({ notes })}
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-bakery-100 pt-3">
<button type="button" className={primary} disabled={busy || !dirty} onClick={() => void save()}>
<Icon d={CHECK} />
{busy ? 'Saving…' : 'Save this table'}
</button>
<button
type="button"
className={secondary}
disabled={busy || !dirty}
onClick={() => setDraft(draftOf(table))}
>
<Icon d={X} />
Undo my changes
</button>
{dirty && <span className="text-sm text-bakery-600">Not saved yet.</span>}
{(columns === 0 || draft.rows.length === 0) && !dirty && (
<span className="text-sm text-bakery-600">
Needs a column and a line before it shows on the page.
</span>
)}
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
<Icon d={TRASH} />
Delete table
</button>
</div>
</li>
);
};
// --- the section ------------------------------------------------------------
const Catering = ({ onError }: { onError: (message: string) => void }) => {
const [tables, setTables] = useState<CateringTable[] | null>(null);
const [pageNotes, setPageNotes] = useState<string[]>([]);
const [storedNotes, setStoredNotes] = useState<string[]>([]);
const [fresh, setFresh] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const menu = await adminCatering();
setTables(menu.packages);
setPageNotes(menu.notes);
setStoredNotes(menu.notes);
} catch (e) {
onError(e instanceof Error ? e.message : 'Could not load the catering tables.');
setTables([]);
}
}, [onError]);
useEffect(() => {
void load();
}, [load]);
/** Moving a table applies on screen first; this page shouldn't freeze between clicks. */
const settle = async (optimistic: CateringTable[], work: () => Promise<unknown>) => {
const before = tables ?? [];
setTables(optimistic);
try {
await work();
} catch (e) {
setTables(before);
onError(e instanceof Error ? e.message : 'That did not save.');
}
};
const guard = async (work: () => Promise<unknown>) => {
setBusy(true);
try {
await work();
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
const notesDirty = JSON.stringify(pageNotes) !== JSON.stringify(storedNotes);
return (
<section>
<h2 className="font-adbhashitha text-xl text-bakery-800">Goodie boxes &amp; catering</h2>
<p className="mt-1 text-sm text-bakery-600">
The price tables, in the order they appear on the page. Each one saves on its own.
</p>
{tables === null ? (
<p className="mt-3 text-bakery-600">Loading</p>
) : (
<>
<ul className="mt-3 space-y-4">
{tables.map((table, i) => (
<TableCard
key={table.id}
table={table}
first={i === 0}
last={i === tables.length - 1}
onError={onError}
onSaved={(saved) => setTables(tables.map((t) => (t.id === saved.id ? saved : t)))}
onMove={(delta) => {
const moved = shift(tables, i, delta);
void settle(moved, () => reorderCateringTables(moved.map((t) => t.id)));
}}
onDelete={() => {
if (!confirm(`Delete the ${table.name} table and everything in it?`)) return;
void settle(
tables.filter((t) => t.id !== table.id),
() => deleteCateringTable(table.id),
);
}}
/>
))}
</ul>
<div className="mt-4 flex gap-2">
<input
className={field}
value={fresh}
onChange={(e) => setFresh(e.target.value)}
placeholder="New table, e.g. Graduation parties"
/>
<button
type="button"
className={primary}
disabled={busy || !fresh.trim()}
onClick={() =>
void guard(async () => {
const added = await addCateringTable(fresh.trim());
setTables([...(tables ?? []), added]);
setFresh('');
})
}
>
<Icon d={PLUS} />
Add
</button>
</div>
<div className="mt-6 rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h3 className="font-adbhashitha text-lg text-bakery-800">Under the whole page</h3>
<div className="mt-2">
<Notes
notes={pageNotes}
hint="Terms that apply whichever table someone is reading."
disabled={busy}
onChange={setPageNotes}
/>
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
className={primary}
disabled={busy || !notesDirty}
onClick={() =>
void guard(async () => {
const saved = await saveCateringNotes(pageNotes);
setPageNotes(saved);
setStoredNotes(saved);
})
}
>
<Icon d={CHECK} />
Save these notes
</button>
{notesDirty && (
<button type="button" className={secondary} onClick={() => setPageNotes(storedNotes)}>
<Icon d={X} />
Undo
</button>
)}
</div>
</div>
</>
)}
</section>
);
};
export default Catering;
+52
View File
@@ -0,0 +1,52 @@
/**
* The small shared pieces of the admin screens: one icon set, one set of button and field looks.
*
* Extracted from the catalogue editor when the catering tables arrived, so the two screens can't
* drift into looking like two different products.
*/
export const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={`w-4 h-4 ${className}`}
aria-hidden="true"
>
<path d={d} />
</svg>
);
export const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
export const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
export const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
export const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
export const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
export const PLUS = 'M12 5v14M5 12h14';
export const CHECK = 'M20 6L9 17l-5-5';
export const X = 'M18 6L6 18M6 6l12 12';
const button =
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
export const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
export const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
export const danger = `${button} text-red-700 hover:bg-red-50`;
export const iconButton =
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
export const field =
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
export function shift<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
+72
View File
@@ -124,3 +124,75 @@ export const reorderCategories = (ids: number[]) =>
export const deleteCategory = (id: number) =>
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
// --- goodie boxes & catering ------------------------------------------------
/**
* A column of a catering table. `price` is already written the way it should be read ("$24") — the
* server owns money, both what a typed price means and how it prints — and is null for a column that
* doesn't state one. `id` is null only for a column the editor has just added and not yet saved.
*/
export interface CateringTier {
id: number | null;
label: string;
price: string | null;
}
/** A line of a catering table, with one entry per column, in column order — blanks included. */
export interface CateringRow {
id: number | null;
label: string;
values: string[];
}
/** One table: "Office", "Parties", "Weddings". */
export interface CateringTable {
id: number;
name: string;
blurb: string | null;
tiers: CateringTier[];
rows: CateringRow[];
/** The rules under this table: minimums, what can't be mixed. */
notes: string[];
}
export interface CateringMenu {
packages: CateringTable[];
/** Terms that apply to the page rather than to any one table. */
notes: string[];
}
/**
* A table as the editor left it, sent whole. It has to be whole: a column and the values beneath it
* only mean anything together, so moving or removing one has to carry its entries with it. The
* server rejects any table whose lines and columns disagree.
*/
export interface CateringTableEdit {
name: string;
blurb: string | null;
tiers: { id: number | null; label: string; price: string }[];
rows: { id: number | null; label: string; values: string[] }[];
notes: string[];
}
/** What a customer sees: finished tables only. */
export const fetchCatering = () => get<CateringMenu>('/api/catering');
/** What the editor sees: the same tables, including any they haven't finished filling in. */
export const adminCatering = () => get<CateringMenu>('/api/admin/catering');
export const addCateringTable = (name: string) =>
send<CateringTable>('/api/admin/catering/packages', 'POST', { name });
export const saveCateringTable = (id: number, table: CateringTableEdit) =>
send<CateringTable>(`/api/admin/catering/packages/${id}`, 'PUT', table);
export const deleteCateringTable = (id: number) =>
send<{ ok: boolean }>(`/api/admin/catering/packages/${id}`, 'DELETE');
export const reorderCateringTables = (ids: number[]) =>
send<CateringTable[]>('/api/admin/catering/packages/order', 'PUT', { ids });
/** The page's own footnotes: the full list, so removing one is an omission. */
export const saveCateringNotes = (notes: string[]) =>
send<string[]>('/api/admin/catering/notes', 'PUT', notes);
+23 -49
View File
@@ -15,6 +15,24 @@ import {
type AdminCategory,
type AdminProduct,
} from '@/lib/api';
import Catering from '@/components/admin/Catering';
import {
ARROW_DOWN,
ARROW_LEFT,
ARROW_RIGHT,
ARROW_UP,
CHECK,
Icon,
PLUS,
TRASH,
X,
danger,
field,
iconButton,
primary,
secondary,
shift,
} from '@/components/admin/ui';
/**
* The catalogue, editable by the person who bakes it.
@@ -28,54 +46,6 @@ import {
* sent to the identity provider before this ever loads.
*/
// --- little pieces ----------------------------------------------------------
const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={`w-4 h-4 ${className}`}
aria-hidden="true"
>
<path d={d} />
</svg>
);
const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
const PLUS = 'M12 5v14M5 12h14';
const CHECK = 'M20 6L9 17l-5-5';
const X = 'M18 6L6 18M6 6l12 12';
const button =
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
const danger = `${button} text-red-700 hover:bg-red-50`;
const iconButton =
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
const field =
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
function shift<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
// --- photos -----------------------------------------------------------------
/**
@@ -628,7 +598,7 @@ const AdminPage = () => {
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
<p className="text-bakery-600">Everything on the products page lives here.</p>
<p className="text-bakery-600">Everything on the products and catering pages lives here.</p>
</div>
<div className="flex items-center gap-2">
<a href="/products" className={secondary}>
@@ -707,6 +677,10 @@ const AdminPage = () => {
<p className="mt-3 text-bakery-600">Nothing here yet add something above.</p>
)}
</section>
{/* A different page, and a different shape of editing — see the note at the top of Catering. */}
<hr className="border-bakery-200" />
<Catering onError={setError} />
</div>
)}
</div>
@@ -0,0 +1,77 @@
package com.itsthevine.web;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The catering tables, editable from the site — prices move, and moving them shouldn't need a deploy.
*
* <p>Conditional on OIDC for the same reason as the product and category admins: with no identity
* provider configured the platform runs its permit-all chain, so an unconditional controller here
* would publish price writes to the open internet on any deployment that forgot to wire Authentik.
* Gated this way, "no auth configured" means these endpoints simply don't exist.
*
* <p>A table is saved whole rather than field by field. That isn't a shortcut — the columns and the
* values under them only mean anything together, so they have to arrive together (see
* {@code CateringPackage#arrange}).
*/
@RestController
@RequestMapping("/api/admin/catering")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminCateringController {
private final CateringMenu menu;
public AdminCateringController(CateringMenu menu) {
this.menu = menu;
}
public record Name(String name) {}
public record Order(List<Long> ids) {}
@GetMapping
public CateringMenu.MenuView all() {
return menu.everything();
}
@PostMapping("/packages")
public CateringMenu.PackageView add(@RequestBody Name body) {
return menu.add(body.name());
}
@PutMapping("/packages/{id}")
public CateringMenu.PackageView save(@PathVariable Long id,
@RequestBody CateringMenu.PackageEdit edit) {
return menu.save(id, edit);
}
/** Mapped above {@code /packages/{id}} by Spring's literal-beats-template rule, as with products. */
@PutMapping("/packages/order")
public List<CateringMenu.PackageView> reorder(@RequestBody Order order) {
return menu.reorder(order.ids());
}
@DeleteMapping("/packages/{id}")
public ResponseEntity<Map<String, Object>> remove(@PathVariable Long id) {
menu.remove(id);
return ResponseEntity.ok(Map.of("ok", true));
}
/** The page's own footnotes: the full list the editor is looking at. */
@PutMapping("/notes")
public List<String> notes(@RequestBody List<String> bodies) {
return menu.replaceNotes(bodies);
}
}
@@ -0,0 +1,20 @@
package com.itsthevine.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** The goodie box and catering tables, as a customer reads them. */
@RestController
public class CateringController {
private final CateringMenu menu;
public CateringController(CateringMenu menu) {
this.menu = menu;
}
@GetMapping("/api/catering")
public CateringMenu.MenuView catering() {
return menu.menu();
}
}
@@ -0,0 +1,194 @@
package com.itsthevine.web;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.itsthevine.web.domain.CateringNote;
import com.itsthevine.web.domain.CateringNoteRepository;
import com.itsthevine.web.domain.CateringPackage;
import com.itsthevine.web.domain.CateringPackageRepository;
/**
* The catering page's brains: which tables to show, in what order, what's in each, and what the
* prices read as.
*
* <p>Both the public page and the admin come through here, so there is one answer to "what does this
* table say" — an editor can never arrange something that reads differently once it's live. Prices
* arrive as whatever the editor typed and leave as text that's ready to print; {@code Money} is the
* only thing that decides either, because how much something costs is the shop's business and not
* the browser's.
*/
@Service
public class CateringMenu {
private static final int MOST_NOTES = 12;
private final CateringPackageRepository packages;
private final CateringNoteRepository notes;
public CateringMenu(CateringPackageRepository packages, CateringNoteRepository notes) {
this.packages = packages;
this.notes = notes;
}
/** A column. {@code price} is ready to print ("$24"), and null when the column doesn't state one. */
public record TierView(Long id, String label, String price) {}
/** A line, with one entry per column, in column order. */
public record RowView(Long id, String label, List<String> values) {}
public record PackageView(Long id, String name, String blurb,
List<TierView> tiers, List<RowView> rows, List<String> notes) {}
/** The page: every table, plus the terms that apply to all of them. */
public record MenuView(List<PackageView> packages, List<String> notes) {}
// What the admin screen sends back. A whole table at a time — see CateringPackage#arrange.
// `price` is the raw text from the box ("24", "$24.50", ""); Money decides what it means.
public record TierEdit(Long id, String label, String price) {}
public record RowEdit(Long id, String label, List<String> values) {}
public record PackageEdit(String name, String blurb, List<TierEdit> tiers, List<RowEdit> rows,
List<String> notes) {}
/**
* What a customer sees.
*
* <p>A table with no columns or no lines is left out. Adding a table and filling it in are two
* separate acts in the admin, and the gap between them shouldn't put a bare heading on the live
* page — an empty price table tells a customer nothing except that we're disorganised.
*/
@Transactional(readOnly = true)
public MenuView menu() {
List<PackageView> published = packages.findAllByOrderByPositionAsc().stream()
.filter(p -> !p.getTiers().isEmpty() && !p.getRows().isEmpty())
.map(CateringMenu::toView)
.toList();
return new MenuView(published, pageNotes());
}
/** What the editor sees: the same tables, including any they haven't finished. */
@Transactional(readOnly = true)
public MenuView everything() {
List<PackageView> all = packages.findAllByOrderByPositionAsc().stream()
.map(CateringMenu::toView)
.toList();
return new MenuView(all, pageNotes());
}
/** A new, empty table at the end of the page. Columns and lines come next, from the editor. */
@Transactional
public PackageView add(String name) {
int last = packages.findAllByOrderByPositionAsc().stream()
.mapToInt(CateringPackage::getPosition).max().orElse(0);
return toView(packages.save(new CateringPackage(name, last + 1)));
}
/**
* The whole table as the editor left it. Rejected in full or saved in full: the aggregate checks
* every column and line before it touches anything, and the transaction covers the rest.
*/
@Transactional
public PackageView save(Long id, PackageEdit edit) {
CateringPackage table = find(id);
table.describe(edit.name(), edit.blurb());
table.replaceNotes(clean(edit.notes()));
table.arrange(
orEmpty(edit.tiers()).stream()
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price()))
.toList(),
orEmpty(edit.rows()).stream()
.map(r -> new CateringPackage.Line(r.id(), r.label(), orEmpty(r.values())))
.toList());
return toView(packages.save(table));
}
@Transactional
public void remove(Long id) {
packages.delete(find(id));
}
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
@Transactional
public List<PackageView> reorder(List<Long> ids) {
List<CateringPackage> all = packages.findAllByOrderByPositionAsc();
List<CateringPackage> arranged = new ArrayList<>();
for (Long id : orEmpty(ids)) {
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
int position = 1;
for (CateringPackage table : arranged) {
table.moveTo(position++);
}
packages.saveAll(arranged);
return arranged.stream().map(CateringMenu::toView).toList();
}
/**
* The page's own footnotes, replaced by the list the editor is looking at — so removing one is an
* omission, exactly as it is everywhere else in this admin.
*
* <p>The notes that came back are reused in place rather than deleted and re-inserted, so editing
* a typo doesn't quietly restamp when the terms were written.
*/
@Transactional
public List<String> replaceNotes(List<String> bodies) {
List<String> wanted = clean(bodies);
if (wanted.size() > MOST_NOTES) {
throw new IllegalArgumentException(
"That's a lot of small print — " + MOST_NOTES + " notes at most.");
}
List<CateringNote> existing = notes.findAllByOrderByPositionAsc();
List<CateringNote> keeping = new ArrayList<>();
for (int i = 0; i < wanted.size(); i++) {
CateringNote note = i < existing.size() ? existing.get(i) : new CateringNote(wanted.get(i), i + 1);
note.say(wanted.get(i));
note.moveTo(i + 1);
keeping.add(note);
}
if (existing.size() > wanted.size()) {
notes.deleteAll(existing.subList(wanted.size(), existing.size()));
}
notes.saveAll(keeping);
return keeping.stream().map(CateringNote::getBody).toList();
}
private List<String> pageNotes() {
return notes.findAllByOrderByPositionAsc().stream().map(CateringNote::getBody).toList();
}
private CateringPackage find(Long id) {
return packages.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That table no longer exists."));
}
private static PackageView toView(CateringPackage table) {
List<TierView> tiers = table.getTiers().stream()
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice()))
.toList();
List<RowView> rows = table.getRows().stream()
.map(r -> new RowView(r.getId(), r.getLabel(), r.getValues()))
.toList();
return new PackageView(table.getId(), table.getName(), table.getBlurb(), tiers, rows, table.getNotes());
}
/** Blank lines are how a textarea says "nothing here"; they are not notes. */
private static List<String> clean(List<String> bodies) {
return orEmpty(bodies).stream()
.map(body -> body == null ? "" : body.trim())
.filter(body -> !body.isEmpty())
.toList();
}
/** A missing JSON array and an empty one mean the same thing to an editor. */
private static <T> List<T> orEmpty(List<T> items) {
return items == null ? List.of() : items;
}
}
@@ -0,0 +1,50 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* A footnote for the catering page as a whole — the terms that apply whichever table you were
* reading, like "we're happy to make changes, the price may change with them".
*
* <p>Its own table rather than a note on a package, because these outlive any one table: delete the
* wedding package and the page still has terms.
*/
@Entity
@Table(name = "catering_note")
public class CateringNote extends BaseEntity {
@Column(nullable = false, length = 600)
private String body;
@Column(name = "position", nullable = false)
private int position;
protected CateringNote() {
// for JPA
}
public CateringNote(String body, int position) {
say(body);
this.position = position;
}
public final void say(String body) {
this.body = Text.required(body, 600, "A note with nothing in it — delete it rather than blanking it.");
}
public void moveTo(int position) {
this.position = position;
}
public String getBody() {
return body;
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,10 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CateringNoteRepository extends JpaRepository<CateringNote, Long> {
List<CateringNote> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,214 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One price table on the catering page — "Office", "Parties", "Weddings".
*
* <p>The bakery keeps these as a spreadsheet, so a spreadsheet is what this models: {@link
* CateringTier}s are the columns (a size, and what it costs) and {@link CateringRow}s are the lines
* (a baked good, and how much of it each column includes). A line holds one value per column, in
* column order.
*
* <p>That alignment is the whole reason this is an aggregate rather than three tables edited
* separately. A column and the values under it only mean anything together — drop the middle column
* on its own and every remaining value shifts one place left, so the Large box silently starts
* advertising the Medium box's contents at the Large price. Only this class can rearrange a table,
* and it will not accept an arrangement whose lines and columns disagree.
*/
@Entity
@Table(name = "catering_package")
public class CateringPackage extends BaseEntity {
/** Wider than this doesn't fit a phone, and these tables are read on phones. */
private static final int MOST_COLUMNS = 8;
private static final int MOST_LINES = 40;
private static final int MOST_NOTES = 12;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 400)
private String blurb;
@Column(name = "position", nullable = false)
private int position;
/** The rules under this table: minimums, what can't be mixed, how delivery is charged. */
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "catering_package_note", joinColumns = @JoinColumn(name = "package_id"))
@OrderColumn(name = "position")
@Column(name = "body", nullable = false, length = 600)
private List<String> notes = new ArrayList<>();
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position")
private List<CateringTier> tiers = new ArrayList<>();
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position")
private List<CateringRow> rows = new ArrayList<>();
protected CateringPackage() {
// for JPA
}
public CateringPackage(String name, int position) {
describe(name, null);
this.position = position;
}
/**
* A column as the editor left it — its heading and its price. A null {@code id} is one they just
* added. (Named for the heading rather than the column so as not to shadow {@code @Column}.)
*/
public record Heading(Long id, String label, String price) {}
/** A line as the editor left it, with one value per column — blanks included. */
public record Line(Long id, String label, List<String> values) {}
public final void describe(String name, String blurb) {
this.name = Text.required(name, 120, "Please give the table a name, like \"Weddings\".");
String trimmed = Text.optional(blurb, 400);
this.blurb = trimmed.isEmpty() ? null : trimmed;
}
public void moveTo(int position) {
this.position = position;
}
public void replaceNotes(List<String> replacements) {
if (replacements.size() > MOST_NOTES) {
throw new IllegalArgumentException(
"That's a lot of small print — " + MOST_NOTES + " notes per table at most.");
}
List<String> cleaned = replacements.stream()
.map(note -> Text.required(note, 600, "One of the notes is empty — delete it rather than blanking it."))
.toList();
this.notes.clear();
this.notes.addAll(cleaned);
}
/**
* Make the table exactly this: these columns in this order, and these lines, each carrying one
* value per column.
*
* <p>The whole table arrives at once because that is the only way the editor can move a column and
* take its values with it. Anything they left out is deleted, anything carrying an id keeps its
* identity, and positions are renumbered from the order they arrived in rather than trusted from
* the request — so what's stored is what they were looking at when they hit save.
*/
public void arrange(List<Heading> columns, List<Line> lines) {
if (columns.size() > MOST_COLUMNS) {
throw new IllegalArgumentException(
"A table can have at most " + MOST_COLUMNS + " columns and still be readable on a phone.");
}
if (lines.size() > MOST_LINES) {
throw new IllegalArgumentException("A table can have at most " + MOST_LINES + " lines.");
}
for (Line line : lines) {
if (line.values().size() != columns.size()) {
// Not a message an editor should ever see: the screen sends whole tables. Worth saying
// out loud anyway, because the alternative is a table that quietly means something else.
throw new IllegalArgumentException("\"" + line.label() + "\" has " + line.values().size()
+ " entries but the table has " + columns.size()
+ " columns. Reload the page and try that again.");
}
}
List<CateringTier> arrangedTiers = new ArrayList<>();
int columnNumber = 1;
for (Heading column : columns) {
CateringTier tier = column.id() == null ? new CateringTier(this) : tier(column.id());
tier.describe(column.label(), column.price());
tier.moveTo(columnNumber++);
arrangedTiers.add(tier);
}
List<CateringRow> arrangedRows = new ArrayList<>();
int lineNumber = 1;
for (Line line : lines) {
CateringRow row = line.id() == null ? new CateringRow(this) : row(line.id());
row.describe(line.label());
row.replaceValues(line.values());
row.moveTo(lineNumber++);
arrangedRows.add(row);
}
// Both collections are rewritten only after every column and line has been accepted, so a
// rejected edit leaves the table exactly as it was.
settle(tiers, arrangedTiers, Comparator.comparingInt(CateringTier::getPosition));
settle(rows, arrangedRows, Comparator.comparingInt(CateringRow::getPosition));
}
/**
* Keep what was arranged, drop what wasn't. Removal from the collection is what deletes the row —
* these are {@code orphanRemoval} associations — so the omitted ones need no further handling.
*/
private static <T> void settle(List<T> stored, List<T> arranged, Comparator<T> byPosition) {
stored.removeIf(item -> !holds(arranged, item));
arranged.stream().filter(item -> !holds(stored, item)).forEach(stored::add);
stored.sort(byPosition);
}
/**
* Identity, deliberately: entities here inherit no {@code equals}, and two freshly built columns
* with the same heading are two different columns.
*/
private static boolean holds(List<?> items, Object item) {
return items.stream().anyMatch(candidate -> candidate == item);
}
private CateringTier tier(Long id) {
return tiers.stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow(this::changedUnderneath);
}
private CateringRow row(Long id) {
return rows.stream().filter(r -> id.equals(r.getId())).findFirst().orElseThrow(this::changedUnderneath);
}
private IllegalArgumentException changedUnderneath() {
return new IllegalArgumentException(
"Part of the " + name + " table isn't there any more. Reload the page to see it as it is now.");
}
public String getName() {
return name;
}
public String getBlurb() {
return blurb;
}
public int getPosition() {
return position;
}
public List<String> getNotes() {
return List.copyOf(notes);
}
public List<CateringTier> getTiers() {
return List.copyOf(tiers);
}
public List<CateringRow> getRows() {
return List.copyOf(rows);
}
}
@@ -0,0 +1,10 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CateringPackageRepository extends JpaRepository<CateringPackage, Long> {
List<CateringPackage> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,84 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One line of a catering table: a baked good, and how much of it each column includes.
*
* <p>The values are positional — index 0 belongs to the first column — and there is always exactly
* one per column, blanks included. {@link CateringPackage} is the only thing that can set them,
* because it is the only thing that knows how many columns there are.
*/
@Entity
@Table(name = "catering_row")
public class CateringRow extends BaseEntity {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "package_id", nullable = false)
private CateringPackage cateringPackage;
@Column(nullable = false, length = 200)
private String label;
/**
* One entry per column, in column order. An empty string is a cell the bakery hasn't filled in —
* the spreadsheet has several — so blanks are stored rather than dropped, which is what keeps the
* list aligned with the columns.
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "catering_row_value", joinColumns = @JoinColumn(name = "row_id"))
@OrderColumn(name = "position")
@Column(name = "value", nullable = false, length = 300)
private List<String> values = new ArrayList<>();
@Column(name = "position", nullable = false)
private int position;
protected CateringRow() {
// for JPA
}
CateringRow(CateringPackage cateringPackage) {
this.cateringPackage = cateringPackage;
}
void describe(String label) {
this.label = Text.required(label, 200, "Every line needs a name — what is it the customer gets?");
}
/** Replaced wholesale; the package has already checked there is one value per column. */
void replaceValues(List<String> replacements) {
List<String> cleaned = replacements.stream().map(value -> Text.optional(value, 300)).toList();
this.values.clear();
this.values.addAll(cleaned);
}
void moveTo(int position) {
this.position = position;
}
public String getLabel() {
return label;
}
public List<String> getValues() {
return List.copyOf(values);
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,72 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One column of a catering table: a size, and what it costs.
*
* <p>Only {@link CateringPackage} can change one. A tier means nothing apart from the table it sits
* in — its price is read against the row values beside it — so the package is the only thing allowed
* to rearrange them, which is what keeps a row's values and its columns the same length.
*/
@Entity
@Table(name = "catering_tier")
public class CateringTier extends BaseEntity {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "package_id", nullable = false)
private CateringPackage cateringPackage;
@Column(nullable = false, length = 120)
private String label;
/** Whole cents, and nullable — see {@link Money}. */
@Column(name = "price_cents")
private Integer priceCents;
@Column(name = "position", nullable = false)
private int position;
protected CateringTier() {
// for JPA
}
CateringTier(CateringPackage cateringPackage) {
this.cateringPackage = cateringPackage;
}
/** @param price as the editor typed it; empty for a column that doesn't state one */
void describe(String label, String price) {
this.label = Text.required(label, 120,
"Every column needs a heading — a size like \"Large\", or who it feeds like \"1520 people\".");
this.priceCents = Money.cents(price);
}
void moveTo(int position) {
this.position = position;
}
public String getLabel() {
return label;
}
public Integer getPriceCents() {
return priceCents;
}
/** What the column's price reads as — "$24", or null when it doesn't state one. */
public String getPrice() {
return Money.format(priceCents);
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,72 @@
package com.itsthevine.web.domain;
import java.math.BigDecimal;
import java.util.Locale;
/**
* What a price is, in one place: how the bakery types one in, how it's stored, and how it's printed.
*
* <p>Editors type "24", "$24", "24.50" or nothing at all, so the parsing lives here rather than in the
* browser — a price the server didn't agree to is not a price, and {@code Number(text) * 100} gives
* 2410.0000000000005 for "24.10" on the way past. Stored as whole cents, because money is not a
* floating-point number.
*/
public final class Money {
/** Ten thousand dollars. Above this it's a decimal point in the wrong place, not a wedding. */
private static final int MOST_ANYTHING_COSTS = 1_000_000;
private Money() {
}
/**
* @param typed what the editor put in the box
* @return whole cents, or null for a column that doesn't state a price
*/
public static Integer cents(String typed) {
String cleaned = typed == null ? "" : typed.replace("$", "").replace(",", "").replace(" ", "").trim();
if (cleaned.isEmpty()) {
// Not an error: "ask us" is a legitimate thing for a column to say, and it says it by
// leaving the price empty.
return null;
}
BigDecimal amount;
try {
amount = new BigDecimal(cleaned);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("\"" + typed
+ "\" isn't a price. Leave it empty if that column doesn't have one.");
}
if (amount.scale() > 2) {
throw new IllegalArgumentException("Prices go to the cent — \"" + typed + "\" is finer than that.");
}
if (amount.signum() < 0) {
throw new IllegalArgumentException("A price can't be less than nothing.");
}
long asCents = amount.movePointRight(2).longValueExact();
if (asCents > MOST_ANYTHING_COSTS) {
throw new IllegalArgumentException("That price is over $10,000 — check the decimal point.");
}
return (int) asCents;
}
/**
* "$24", "$1,250", "$24.50" — never "$24.0", and null stays null.
*
* <p>{@code Locale.US} rather than the default: the price of a cake in Princeville, Illinois does
* not depend on which locale the container started in, and a default of de-DE would print
* "$1.250" for one thousand two hundred and fifty dollars.
*/
public static String format(Integer cents) {
if (cents == null) {
return null;
}
int dollars = cents / 100;
int change = cents % 100;
return change == 0
? String.format(Locale.US, "$%,d", dollars)
: String.format(Locale.US, "$%,d.%02d", dollars, change);
}
}
@@ -0,0 +1,37 @@
package com.itsthevine.web.domain;
/**
* Trimming and length rules for editor-supplied prose.
*
* <p>Every one of these limits is a column width, so the choice is between checking them here and
* letting Postgres reject the insert — which reaches the editor as an unexplained 500 with their
* afternoon's work still unsaved. The message names the offending text, because a catering table is
* a grid of thirty small cells and "too long" alone doesn't say which one.
*/
final class Text {
private Text() {
}
static String required(String value, int max, String missing) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(missing);
}
return capped(trimmed, max);
}
/** Trimmed, possibly empty — a blank cell is a real thing to want. */
static String optional(String value, int max) {
return capped(value == null ? "" : value.trim(), max);
}
private static String capped(String text, int max) {
if (text.length() > max) {
String preview = text.substring(0, Math.min(40, text.length()));
throw new IllegalArgumentException(
"\"" + preview + "\" is longer than the " + max + " characters that fit there.");
}
return text;
}
}
@@ -0,0 +1,141 @@
-- Goodie boxes and catering: the Office / Parties / Weddings price tables the bakery hands out.
--
-- These arrived as a spreadsheet, and a spreadsheet is what they are, so that is what this models:
-- a PACKAGE is one table on the page, its TIERS are the columns (what you get, for a price) and its
-- ROWS are the lines (which baked good, and how much of it in each column). A row therefore holds
-- one value per column, in column order — the two are kept in step in Java, because a table whose
-- lines and columns disagree quietly misprices what a customer is actually buying.
--
-- All of it is data rather than markup so the prices can move without a deploy. They will: the last
-- line of the spreadsheet says the tables are "mostly just an idea for people".
create table catering_package (
id bigserial primary key,
name varchar(120) not null,
-- Optional sentence under the heading. Nothing in the spreadsheet fills this in; it exists so
-- the bakery can explain a table without one of us editing a page.
blurb varchar(400),
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Footnotes belonging to one table: the minimums, the flavor rules, the wedding delivery terms.
-- Kept as rows rather than one blob so each rule can be edited, reordered or dropped on its own.
create table catering_package_note (
package_id bigint not null references catering_package (id) on delete cascade,
position integer not null,
body varchar(600) not null,
primary key (package_id, position)
);
-- A column: the size, and what it costs. price_cents is nullable for a tier that is priced on
-- asking, and is cents rather than a formatted string so the app — not the browser — decides how
-- money is written.
create table catering_tier (
id bigserial primary key,
package_id bigint not null references catering_package (id) on delete cascade,
label varchar(120) not null,
price_cents integer,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index catering_tier_package_idx on catering_tier (package_id, position);
-- A line of the table: which baked good.
create table catering_row (
id bigserial primary key,
package_id bigint not null references catering_package (id) on delete cascade,
label varchar(200) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index catering_row_package_idx on catering_row (package_id, position);
-- One cell. `position` is the COLUMN — position 0 is the first tier, and so on — so a row always has
-- exactly as many values as its package has tiers. Empty strings are meaningful and expected: the
-- spreadsheet has lines that are named but not yet quantified.
create table catering_row_value (
row_id bigint not null references catering_row (id) on delete cascade,
position integer not null,
value varchar(300) not null,
primary key (row_id, position)
);
-- Footnotes for the page as a whole rather than any one table.
create table catering_note (
id bigserial primary key,
body varchar(600) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Seeded from the bakery's own spreadsheet. Wording is theirs; the only changes are expanded
-- shorthand ("4 dz cc or sc" -> "4 dz cupcakes or sugar cookies") and fixed typos, because these
-- lines are read by customers. Everything here is editable in the admin.
insert into catering_package (id, name, position, created_at) values
(1, 'Office', 1, now()),
(2, 'Parties', 2, now()),
(3, 'Weddings', 3, now());
insert into catering_tier (id, package_id, label, price_cents, position, created_at) values
(1, 1, 'Small', 2400, 1, now()),
(2, 1, 'Medium', 3200, 2, now()),
(3, 1, 'Large', 4000, 3, now()),
(4, 2, '1520 people', 5400, 1, now()),
(5, 2, '2030 people', 7600, 2, now()),
(6, 2, '3040 people', 9800, 3, now()),
(7, 3, '125 people', 23600, 1, now()),
(8, 3, '200 people', 31000, 2, now()),
(9, 3, '250 people', 38600, 3, now());
insert into catering_row (id, package_id, label, position, created_at) values
( 1, 1, 'Mini muffins', 1, now()),
( 2, 1, 'Mini scones', 2, now()),
( 3, 1, 'Mini cinnamon rolls', 3, now()),
( 4, 2, 'Cake', 1, now()),
( 5, 2, 'Cupcakes', 2, now()),
( 6, 2, 'Sugar cookies', 3, now()),
( 7, 3, 'Bride & groom cake (8 in)', 1, now()),
( 8, 3, 'Sheet cakes', 2, now()),
( 9, 3, '12x17 bars', 3, now()),
(10, 3, 'Cupcakes', 4, now()),
(11, 3, 'Sugar cookies', 5, now());
insert into catering_row_value (row_id, position, value) values
( 1, 0, '12 items'), ( 1, 1, '18 items'), ( 1, 2, '24 items'),
( 2, 0, '6+6'), ( 2, 1, '6+6+6 or 12+6'), ( 2, 2, '6+6+6+6 or 12+6+6 or 12+12'),
-- Named in the spreadsheet but never quantified; the minimum below is the only rule it gives.
( 3, 0, ''), ( 3, 1, ''), ( 3, 2, ''),
( 4, 0, '6 in cake'), ( 4, 1, '8 in cake'), ( 4, 2, '10 in cake'),
( 5, 0, '1 dz sugar cookies or cupcakes'), ( 5, 1, '1.5 dz your choice'), ( 5, 2, '2 dz your choice'),
( 6, 0, ''), ( 6, 1, ''), ( 6, 2, ''),
( 7, 0, 'B&G cake'), ( 7, 1, 'B&G cake'), ( 7, 2, 'B&G cake'),
( 8, 0, '2 pans or a sheet cake'), ( 8, 1, '3 pans or a sheet cake'), ( 8, 2, '4 pans or a sheet cake'),
( 9, 0, '4 dz cupcakes or sugar cookies'),
( 9, 1, '5 dz cupcakes or sugar cookies'),
( 9, 2, '6 dz cupcakes or sugar cookies'),
(10, 0, ''), (10, 1, ''), (10, 2, ''),
(11, 0, ''), (11, 1, ''), (11, 2, '');
insert into catering_package_note (package_id, position, body) values
(1, 0, 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.'),
(2, 0, 'Add an extra dozen for $20.'),
(2, 1, 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.'),
-- Wedding-specific, so it sits with the wedding table rather than under the whole page.
(3, 0, 'The delivery fee depends on where the wedding is, and setup is charged separately.'),
(3, 1, 'We don''t provide serving materials, and we don''t set up decorations.');
insert into catering_note (id, body, position, created_at) values
(1, 'We''re happy to make changes — the price may change with them.', 1, now()),
(2, 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.', 2, now());
-- bigserial keeps its own counter; move it past the seeded ids so future inserts don't collide.
select setval('catering_package_id_seq', 3);
select setval('catering_tier_id_seq', 9);
select setval('catering_row_id_seq', 11);
select setval('catering_note_id_seq', 2);
@@ -0,0 +1,150 @@
package com.itsthevine.web;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.MediaType;
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
/**
* The catering admin over HTTP, signed in: the paths, the JSON field names and the shape of a refusal.
*
* <p>{@code CateringMenuTest} covers what the tables mean; this covers the surface the admin screen
* actually calls. Both matter — a table can be modelled perfectly and still be unreachable because a
* URL has a typo in it, and the screen reads the sentence out of a ProblemDetail rather than showing
* a status code.
*/
@SpringBootTest(properties = {
"SECURITY_MODE=OIDC",
// Stated outright rather than via issuer-uri, which would fetch a discovery document at
// startup — that needs the network and a real identity provider. (As in AdminSecurityTest.)
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
"spring.security.oauth2.client.registration.authentik.client-id=test",
"spring.security.oauth2.client.registration.authentik.client-secret=test",
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
"[email protected]",
"[email protected]",
"platform.storage.access-key=test",
"platform.storage.secret-key=test"
})
@Testcontainers
// Rolled back per test, so each one starts from the seeded page. MockMvc runs the controller on this
// thread, which is what lets the test's transaction wrap the whole request.
@Transactional
class AdminCateringApiTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@Autowired
WebApplicationContext context;
MockMvc mvc;
@BeforeEach
void setUp() {
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(SecurityMockMvcConfigurers.springSecurity())
.build();
}
@Test
void handsTheEditorEveryTableWithItsColumnsPricedAndItsLinesFilledIn() throws Exception {
mvc.perform(get("/api/admin/catering").with(user("morissa")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.packages.length()").value(3))
.andExpect(jsonPath("$.packages[0].name").value("Office"))
// Written out, not a number the browser would have to format.
.andExpect(jsonPath("$.packages[0].tiers[0].price").value("$24"))
.andExpect(jsonPath("$.packages[0].rows[0].label").value("Mini muffins"))
.andExpect(jsonPath("$.packages[0].rows[0].values[1]").value("18 items"))
.andExpect(jsonPath("$.notes.length()").value(2));
}
@Test
void savesAWholeTableAtOnce() throws Exception {
String office = """
{"name":"Office boxes","blurb":"For meetings.",
"tiers":[{"id":null,"label":"Dozen","price":"$18.50"}],
"rows":[{"id":null,"label":"Mini muffins","values":["12 items"]}],
"notes":["Two days' notice, please."]}
""";
mvc.perform(put("/api/admin/catering/packages/1").with(user("morissa")).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(office))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Office boxes"))
// The column and line were new; they come back with ids so the next save edits them
// rather than adding more.
.andExpect(jsonPath("$.tiers[0].id").isNumber())
.andExpect(jsonPath("$.tiers[0].price").value("$18.50"))
.andExpect(jsonPath("$.rows[0].id").isNumber())
.andExpect(jsonPath("$.notes[0]").value("Two days' notice, please."));
}
@Test
void refusesAnArrangementThatWouldMisprintThePricesAndSaysWhy() throws Exception {
// Two columns, three entries on the line: exactly the mistake that shifts a box's contents.
String crooked = """
{"name":"Parties",
"tiers":[{"id":null,"label":"Small","price":"54"},{"id":null,"label":"Large","price":"98"}],
"rows":[{"id":null,"label":"Cake","values":["6 in","8 in","10 in"]}],
"notes":[]}
""";
mvc.perform(put("/api/admin/catering/packages/2").with(user("morissa")).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(crooked))
.andExpect(status().isBadRequest())
// `detail` is the field the SPA shows the editor.
.andExpect(jsonPath("$.detail")
.value(containsString("3 entries but the table has 2 columns")));
}
@Test
void reordersTheTablesFromItsOwnPathRatherThanReadingOrderAsAnId() throws Exception {
// /packages/order and /packages/{id} are both PUT; Spring's literal-beats-template rule is
// what keeps "order" from arriving as a table id.
mvc.perform(put("/api/admin/catering/packages/order").with(user("morissa")).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("{\"ids\":[3,1,2]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Weddings"))
.andExpect(jsonPath("$[2].name").value("Parties"));
}
@Test
void replacesThePageNotesWithTheListItWasGiven() throws Exception {
mvc.perform(put("/api/admin/catering/notes").with(user("morissa")).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("[\"Prices may change.\",\" \"]"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0]").value("Prices may change."));
}
}
@@ -3,6 +3,7 @@ package com.itsthevine.web;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
@@ -73,10 +74,18 @@ class AdminSecurityTest {
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/admin/catering")).andExpect(status().isUnauthorized());
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"x\"}"))
.andExpect(status().isUnauthorized());
// The prices are the one thing on this site a stranger would most enjoy editing.
mvc.perform(put("/api/admin/catering/packages/1").with(csrf()).contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Free\",\"tiers\":[],\"rows\":[],\"notes\":[]}"))
.andExpect(status().isUnauthorized());
mvc.perform(put("/api/admin/catering/notes").with(csrf()).contentType(MediaType.APPLICATION_JSON)
.content("[\"anything\"]"))
.andExpect(status().isUnauthorized());
}
@Test
@@ -94,6 +103,7 @@ class AdminSecurityTest {
// Locking the admin must not lock the menu.
mvc.perform(get("/api/products")).andExpect(status().isOk());
mvc.perform(get("/api/categories")).andExpect(status().isOk());
mvc.perform(get("/api/catering")).andExpect(status().isOk());
}
@Test
@@ -0,0 +1,295 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import com.itsthevine.web.domain.Money;
import jakarta.persistence.EntityManager;
/**
* The catering tables, against the real seeded spreadsheet — so the migration is covered too.
*
* <p>Every test that writes runs inside the test's own transaction and is rolled back, so the seeded
* page is the same for each one.
*/
@SpringBootTest
@Testcontainers
@Transactional
class CateringMenuTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
// The contact starter refuses to start on a blank recipient, and this app has a
// ContactController, so the context needs one even to test the catering page.
registry.add("platform.contact.to", () -> "[email protected]");
registry.add("platform.contact.from", () -> "[email protected]");
registry.add("platform.storage.access-key", () -> "test");
registry.add("platform.storage.secret-key", () -> "test");
}
@Autowired
CateringMenu catering;
@Autowired
EntityManager entityManager;
@Test
void carriesTheBakerysSpreadsheetIntoTheDatabase() {
List<CateringMenu.PackageView> tables = catering.menu().packages();
assertThat(tables).extracting(CateringMenu.PackageView::name)
.containsExactly("Office", "Parties", "Weddings");
CateringMenu.PackageView office = tables.get(0);
assertThat(office.tiers()).extracting(CateringMenu.TierView::label)
.containsExactly("Small", "Medium", "Large");
assertThat(office.tiers()).extracting(CateringMenu.TierView::price)
.containsExactly("$24", "$32", "$40");
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
.containsExactly("Mini muffins", "Mini scones", "Mini cinnamon rolls");
assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items");
assertThat(office.rows().get(1).values().get(2)).isEqualTo("6+6+6+6 or 12+6+6 or 12+12");
assertThat(office.notes()).singleElement().asString().contains("Minimum of 6 items per baked good");
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
.containsExactly("1520 people", "2030 people", "3040 people");
assertThat(tables.get(2).tiers()).extracting(CateringMenu.TierView::price)
.containsExactly("$236", "$310", "$386");
// Wedding delivery terms belong to the wedding table, not to the page.
assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee"));
}
@Test
void everyLineCarriesOneEntryPerColumn() {
// The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is
// advertised at another box's price.
assertThat(catering.everything().packages()).allSatisfy(table ->
assertThat(table.rows()).allSatisfy(row ->
assertThat(row.values()).hasSameSizeAs(table.tiers())));
}
@Test
void keepsBlankCellsRatherThanCollapsingThem() {
// "Mini cinnamon rolls" is named but not quantified in the spreadsheet. Dropping its blanks
// would shorten the line and shift everything after it.
CateringMenu.RowView rolls = catering.menu().packages().get(0).rows().get(2);
assertThat(rolls.label()).isEqualTo("Mini cinnamon rolls");
assertThat(rolls.values()).containsExactly("", "", "");
}
@Test
void statesThePageWideTermsSeparatelyFromAnyOneTable() {
assertThat(catering.menu().notes()).hasSize(2);
assertThat(catering.menu().notes().get(0)).contains("price may change");
}
@Test
void writesMoneyTheWayAPriceListDoes() {
assertThat(Money.format(2400)).isEqualTo("$24");
assertThat(Money.format(23600)).isEqualTo("$236");
assertThat(Money.format(2450)).isEqualTo("$24.50");
assertThat(Money.format(2405)).isEqualTo("$24.05");
assertThat(Money.format(150000)).isEqualTo("$1,500");
// "Ask us" is a legitimate price. It must not render as "$0".
assertThat(Money.format(null)).isNull();
}
@Test
void takesAPriceHoweverTheBakeryTypesIt() {
assertThat(Money.cents("24")).isEqualTo(2400);
assertThat(Money.cents("$24")).isEqualTo(2400);
assertThat(Money.cents(" $1,250.00 ")).isEqualTo(125000);
// The float route gives 2410.0000000000005 for this one.
assertThat(Money.cents("24.10")).isEqualTo(2410);
assertThat(Money.cents("")).isNull();
assertThat(Money.cents(null)).isNull();
assertThatThrownBy(() -> Money.cents("ask us"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("isn't a price");
assertThatThrownBy(() -> Money.cents("24.005"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("go to the cent");
}
@Test
void droppingAColumnTakesItsValuesWithIt() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
Long medium = office.tiers().get(1).id();
catering.save(office.id(), withoutColumn(office, 1));
// Straight back to the database: the point of this test is the rows that were deleted, and a
// session cache would happily show the right answer without them having been.
entityManager.flush();
entityManager.clear();
CateringMenu.PackageView saved = catering.everything().packages().get(0);
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items");
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
}
@Test
void refusesATableWhoseLinesAndColumnsDisagree() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
// A column removed but the values left alone — the mistake that would shift every price.
CateringMenu.PackageEdit half = new CateringMenu.PackageEdit(
office.name(), office.blurb(),
asEdits(office).subList(0, 2),
asLines(office),
office.notes());
assertThatThrownBy(() -> catering.save(office.id(), half))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Mini muffins")
.hasMessageContaining("3 entries but the table has 2 columns");
}
@Test
void editingATableKeepsTheLinesItAlreadyHad() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
Long muffins = office.rows().get(0).id();
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items")));
catering.save(office.id(), new CateringMenu.PackageEdit(
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
CateringMenu.PackageView saved = catering.everything().packages().get(0);
assertThat(saved.name()).isEqualTo("Office boxes");
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
// Same line, edited — not a new line that happens to read the same.
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items");
}
@Test
void aNewTableStaysOffThePublicPageUntilItSaysSomething() {
CateringMenu.PackageView fresh = catering.add("Holiday boxes");
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
.containsExactly("Office", "Parties", "Weddings", "Holiday boxes");
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
.doesNotContain("Holiday boxes");
// Once it has a column and a line, it's a price table and it belongs on the page.
catering.save(fresh.id(), new CateringMenu.PackageEdit("Holiday boxes", null,
List.of(new CateringMenu.TierEdit(null, "Dozen", "18")),
List.of(new CateringMenu.RowEdit(null, "Frosted cut-outs", List.of("12 items"))),
List.of()));
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
.contains("Holiday boxes");
}
@Test
void putsTheTablesWhereTheEditorLeftThem() {
List<CateringMenu.PackageView> tables = catering.everything().packages();
List<Long> weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id());
assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name)
.containsExactly("Weddings", "Office", "Parties");
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
.containsExactly("Weddings", "Office", "Parties");
}
@Test
void refusesPricesAndHeadingsThatCantBeRight() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("less than nothing");
assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "$50,000")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("$10,000");
assertThatThrownBy(() -> catering.save(office.id(), withColumnLabel(office, " ")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Every column needs a heading");
assertThatThrownBy(() -> catering.save(office.id(), withCell(office, "x".repeat(301))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("300 characters");
}
@Test
void replacingThePageNotesIsTheWholeList() {
assertThat(catering.replaceNotes(List.of("One term.", " ", "Another term.")))
// A blank line in the editor is not a note.
.containsExactly("One term.", "Another term.");
assertThat(catering.menu().notes()).containsExactly("One term.", "Another term.");
assertThat(catering.replaceNotes(List.of("Only this one now."))).hasSize(1);
entityManager.flush();
entityManager.clear();
assertThat(catering.menu().notes()).containsExactly("Only this one now.");
}
// --- turning what was read back into what the editor would send ---------------------------
/** Note the round trip: what came back as "$24" goes out again as "$24" and must still mean 2400. */
private static List<CateringMenu.TierEdit> asEdits(CateringMenu.PackageView table) {
return table.tiers().stream()
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price()))
.toList();
}
private static List<CateringMenu.RowEdit> asLines(CateringMenu.PackageView table) {
return table.rows().stream()
.map(r -> new CateringMenu.RowEdit(r.id(), r.label(), r.values()))
.toList();
}
/** The table with one column gone, and every line's values narrowed to match — as the screen sends it. */
private static CateringMenu.PackageEdit withoutColumn(CateringMenu.PackageView table, int column) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.remove(column);
List<CateringMenu.RowEdit> rows = table.rows().stream().map(row -> {
List<String> values = new ArrayList<>(row.values());
values.remove(column);
return new CateringMenu.RowEdit(row.id(), row.label(), values);
}).toList();
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, rows, table.notes());
}
private static CateringMenu.PackageEdit withColumnPrice(CateringMenu.PackageView table, String price) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
}
private static CateringMenu.PackageEdit withColumnLabel(CateringMenu.PackageView table, String label) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price()));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
}
private static CateringMenu.PackageEdit withCell(CateringMenu.PackageView table, String value) {
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(table));
List<String> values = new ArrayList<>(rows.get(0).values());
values.set(0, value);
rows.set(0, new CateringMenu.RowEdit(rows.get(0).id(), rows.get(0).label(), values));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), asEdits(table), rows, table.notes());
}
}