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
|
- **`/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
|
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`.
|
(`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.
|
- **`/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
|
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`,
|
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
|
(`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).
|
webp or ship glibc natives that don't run on Alpine).
|
||||||
|
|
||||||
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and
|
The catering tables are editable there too, but a table at a time rather than a field at a time. That
|
||||||
`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
|
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
|
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
|
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
|
||||||
a bare 401 to handle.
|
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) =>
|
export const deleteCategory = (id: number) =>
|
||||||
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
|
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 AdminCategory,
|
||||||
type AdminProduct,
|
type AdminProduct,
|
||||||
} from '@/lib/api';
|
} 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.
|
* The catalogue, editable by the person who bakes it.
|
||||||
@@ -28,54 +46,6 @@ import {
|
|||||||
* sent to the identity provider before this ever loads.
|
* 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 -----------------------------------------------------------------
|
// --- photos -----------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -628,7 +598,7 @@ const AdminPage = () => {
|
|||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a href="/products" className={secondary}>
|
<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>
|
<p className="mt-3 text-bakery-600">Nothing here yet — add something above.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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.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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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 static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
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.
|
// 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/products")).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(get("/api/admin/categories")).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/products").with(csrf())).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\":\"x\"}"))
|
.content("{\"name\":\"x\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@@ -94,6 +103,7 @@ class AdminSecurityTest {
|
|||||||
// Locking the admin must not lock the menu.
|
// Locking the admin must not lock the menu.
|
||||||
mvc.perform(get("/api/products")).andExpect(status().isOk());
|
mvc.perform(get("/api/products")).andExpect(status().isOk());
|
||||||
mvc.perform(get("/api/categories")).andExpect(status().isOk());
|
mvc.perform(get("/api/categories")).andExpect(status().isOk());
|
||||||
|
mvc.perform(get("/api/catering")).andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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