Archived
Catering, and a pure Java/Spring site: Thymeleaf front to back #10
@@ -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.
|
||||
|
||||
@@ -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 & 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 \"15–20 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, '15–20 people', 5400, 1, now()),
|
||||
(5, 2, '20–30 people', 7600, 2, now()),
|
||||
(6, 2, '30–40 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("15–20 people", "20–30 people", "30–40 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user