Catering tables: the spreadsheet becomes data the bakery can edit

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

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

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

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

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

Admin endpoints are @ConditionalOnProperty on SECURITY_MODE=OIDC like the rest, so a deployment with
no identity provider has no price writes. 18 new tests: the seeded spreadsheet, the alignment
invariant, money in both directions, and the HTTP surface the screen actually calls (including that
/packages/order isn't read as a table id, and that a refusal arrives as a ProblemDetail sentence).
This commit is contained in:
2026-07-26 15:13:39 -05:00
parent 3df813c5d8
commit 24d26c2bc9
20 changed files with 2170 additions and 51 deletions
@@ -0,0 +1,77 @@
package com.itsthevine.web;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The catering tables, editable from the site — prices move, and moving them shouldn't need a deploy.
*
* <p>Conditional on OIDC for the same reason as the product and category admins: with no identity
* provider configured the platform runs its permit-all chain, so an unconditional controller here
* would publish price writes to the open internet on any deployment that forgot to wire Authentik.
* Gated this way, "no auth configured" means these endpoints simply don't exist.
*
* <p>A table is saved whole rather than field by field. That isn't a shortcut — the columns and the
* values under them only mean anything together, so they have to arrive together (see
* {@code CateringPackage#arrange}).
*/
@RestController
@RequestMapping("/api/admin/catering")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminCateringController {
private final CateringMenu menu;
public AdminCateringController(CateringMenu menu) {
this.menu = menu;
}
public record Name(String name) {}
public record Order(List<Long> ids) {}
@GetMapping
public CateringMenu.MenuView all() {
return menu.everything();
}
@PostMapping("/packages")
public CateringMenu.PackageView add(@RequestBody Name body) {
return menu.add(body.name());
}
@PutMapping("/packages/{id}")
public CateringMenu.PackageView save(@PathVariable Long id,
@RequestBody CateringMenu.PackageEdit edit) {
return menu.save(id, edit);
}
/** Mapped above {@code /packages/{id}} by Spring's literal-beats-template rule, as with products. */
@PutMapping("/packages/order")
public List<CateringMenu.PackageView> reorder(@RequestBody Order order) {
return menu.reorder(order.ids());
}
@DeleteMapping("/packages/{id}")
public ResponseEntity<Map<String, Object>> remove(@PathVariable Long id) {
menu.remove(id);
return ResponseEntity.ok(Map.of("ok", true));
}
/** The page's own footnotes: the full list the editor is looking at. */
@PutMapping("/notes")
public List<String> notes(@RequestBody List<String> bodies) {
return menu.replaceNotes(bodies);
}
}
@@ -0,0 +1,20 @@
package com.itsthevine.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** The goodie box and catering tables, as a customer reads them. */
@RestController
public class CateringController {
private final CateringMenu menu;
public CateringController(CateringMenu menu) {
this.menu = menu;
}
@GetMapping("/api/catering")
public CateringMenu.MenuView catering() {
return menu.menu();
}
}
@@ -0,0 +1,194 @@
package com.itsthevine.web;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.itsthevine.web.domain.CateringNote;
import com.itsthevine.web.domain.CateringNoteRepository;
import com.itsthevine.web.domain.CateringPackage;
import com.itsthevine.web.domain.CateringPackageRepository;
/**
* The catering page's brains: which tables to show, in what order, what's in each, and what the
* prices read as.
*
* <p>Both the public page and the admin come through here, so there is one answer to "what does this
* table say" — an editor can never arrange something that reads differently once it's live. Prices
* arrive as whatever the editor typed and leave as text that's ready to print; {@code Money} is the
* only thing that decides either, because how much something costs is the shop's business and not
* the browser's.
*/
@Service
public class CateringMenu {
private static final int MOST_NOTES = 12;
private final CateringPackageRepository packages;
private final CateringNoteRepository notes;
public CateringMenu(CateringPackageRepository packages, CateringNoteRepository notes) {
this.packages = packages;
this.notes = notes;
}
/** A column. {@code price} is ready to print ("$24"), and null when the column doesn't state one. */
public record TierView(Long id, String label, String price) {}
/** A line, with one entry per column, in column order. */
public record RowView(Long id, String label, List<String> values) {}
public record PackageView(Long id, String name, String blurb,
List<TierView> tiers, List<RowView> rows, List<String> notes) {}
/** The page: every table, plus the terms that apply to all of them. */
public record MenuView(List<PackageView> packages, List<String> notes) {}
// What the admin screen sends back. A whole table at a time — see CateringPackage#arrange.
// `price` is the raw text from the box ("24", "$24.50", ""); Money decides what it means.
public record TierEdit(Long id, String label, String price) {}
public record RowEdit(Long id, String label, List<String> values) {}
public record PackageEdit(String name, String blurb, List<TierEdit> tiers, List<RowEdit> rows,
List<String> notes) {}
/**
* What a customer sees.
*
* <p>A table with no columns or no lines is left out. Adding a table and filling it in are two
* separate acts in the admin, and the gap between them shouldn't put a bare heading on the live
* page — an empty price table tells a customer nothing except that we're disorganised.
*/
@Transactional(readOnly = true)
public MenuView menu() {
List<PackageView> published = packages.findAllByOrderByPositionAsc().stream()
.filter(p -> !p.getTiers().isEmpty() && !p.getRows().isEmpty())
.map(CateringMenu::toView)
.toList();
return new MenuView(published, pageNotes());
}
/** What the editor sees: the same tables, including any they haven't finished. */
@Transactional(readOnly = true)
public MenuView everything() {
List<PackageView> all = packages.findAllByOrderByPositionAsc().stream()
.map(CateringMenu::toView)
.toList();
return new MenuView(all, pageNotes());
}
/** A new, empty table at the end of the page. Columns and lines come next, from the editor. */
@Transactional
public PackageView add(String name) {
int last = packages.findAllByOrderByPositionAsc().stream()
.mapToInt(CateringPackage::getPosition).max().orElse(0);
return toView(packages.save(new CateringPackage(name, last + 1)));
}
/**
* The whole table as the editor left it. Rejected in full or saved in full: the aggregate checks
* every column and line before it touches anything, and the transaction covers the rest.
*/
@Transactional
public PackageView save(Long id, PackageEdit edit) {
CateringPackage table = find(id);
table.describe(edit.name(), edit.blurb());
table.replaceNotes(clean(edit.notes()));
table.arrange(
orEmpty(edit.tiers()).stream()
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price()))
.toList(),
orEmpty(edit.rows()).stream()
.map(r -> new CateringPackage.Line(r.id(), r.label(), orEmpty(r.values())))
.toList());
return toView(packages.save(table));
}
@Transactional
public void remove(Long id) {
packages.delete(find(id));
}
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
@Transactional
public List<PackageView> reorder(List<Long> ids) {
List<CateringPackage> all = packages.findAllByOrderByPositionAsc();
List<CateringPackage> arranged = new ArrayList<>();
for (Long id : orEmpty(ids)) {
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
int position = 1;
for (CateringPackage table : arranged) {
table.moveTo(position++);
}
packages.saveAll(arranged);
return arranged.stream().map(CateringMenu::toView).toList();
}
/**
* The page's own footnotes, replaced by the list the editor is looking at — so removing one is an
* omission, exactly as it is everywhere else in this admin.
*
* <p>The notes that came back are reused in place rather than deleted and re-inserted, so editing
* a typo doesn't quietly restamp when the terms were written.
*/
@Transactional
public List<String> replaceNotes(List<String> bodies) {
List<String> wanted = clean(bodies);
if (wanted.size() > MOST_NOTES) {
throw new IllegalArgumentException(
"That's a lot of small print — " + MOST_NOTES + " notes at most.");
}
List<CateringNote> existing = notes.findAllByOrderByPositionAsc();
List<CateringNote> keeping = new ArrayList<>();
for (int i = 0; i < wanted.size(); i++) {
CateringNote note = i < existing.size() ? existing.get(i) : new CateringNote(wanted.get(i), i + 1);
note.say(wanted.get(i));
note.moveTo(i + 1);
keeping.add(note);
}
if (existing.size() > wanted.size()) {
notes.deleteAll(existing.subList(wanted.size(), existing.size()));
}
notes.saveAll(keeping);
return keeping.stream().map(CateringNote::getBody).toList();
}
private List<String> pageNotes() {
return notes.findAllByOrderByPositionAsc().stream().map(CateringNote::getBody).toList();
}
private CateringPackage find(Long id) {
return packages.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That table no longer exists."));
}
private static PackageView toView(CateringPackage table) {
List<TierView> tiers = table.getTiers().stream()
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice()))
.toList();
List<RowView> rows = table.getRows().stream()
.map(r -> new RowView(r.getId(), r.getLabel(), r.getValues()))
.toList();
return new PackageView(table.getId(), table.getName(), table.getBlurb(), tiers, rows, table.getNotes());
}
/** Blank lines are how a textarea says "nothing here"; they are not notes. */
private static List<String> clean(List<String> bodies) {
return orEmpty(bodies).stream()
.map(body -> body == null ? "" : body.trim())
.filter(body -> !body.isEmpty())
.toList();
}
/** A missing JSON array and an empty one mean the same thing to an editor. */
private static <T> List<T> orEmpty(List<T> items) {
return items == null ? List.of() : items;
}
}
@@ -0,0 +1,50 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* A footnote for the catering page as a whole — the terms that apply whichever table you were
* reading, like "we're happy to make changes, the price may change with them".
*
* <p>Its own table rather than a note on a package, because these outlive any one table: delete the
* wedding package and the page still has terms.
*/
@Entity
@Table(name = "catering_note")
public class CateringNote extends BaseEntity {
@Column(nullable = false, length = 600)
private String body;
@Column(name = "position", nullable = false)
private int position;
protected CateringNote() {
// for JPA
}
public CateringNote(String body, int position) {
say(body);
this.position = position;
}
public final void say(String body) {
this.body = Text.required(body, 600, "A note with nothing in it — delete it rather than blanking it.");
}
public void moveTo(int position) {
this.position = position;
}
public String getBody() {
return body;
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,10 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CateringNoteRepository extends JpaRepository<CateringNote, Long> {
List<CateringNote> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,214 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One price table on the catering page — "Office", "Parties", "Weddings".
*
* <p>The bakery keeps these as a spreadsheet, so a spreadsheet is what this models: {@link
* CateringTier}s are the columns (a size, and what it costs) and {@link CateringRow}s are the lines
* (a baked good, and how much of it each column includes). A line holds one value per column, in
* column order.
*
* <p>That alignment is the whole reason this is an aggregate rather than three tables edited
* separately. A column and the values under it only mean anything together — drop the middle column
* on its own and every remaining value shifts one place left, so the Large box silently starts
* advertising the Medium box's contents at the Large price. Only this class can rearrange a table,
* and it will not accept an arrangement whose lines and columns disagree.
*/
@Entity
@Table(name = "catering_package")
public class CateringPackage extends BaseEntity {
/** Wider than this doesn't fit a phone, and these tables are read on phones. */
private static final int MOST_COLUMNS = 8;
private static final int MOST_LINES = 40;
private static final int MOST_NOTES = 12;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 400)
private String blurb;
@Column(name = "position", nullable = false)
private int position;
/** The rules under this table: minimums, what can't be mixed, how delivery is charged. */
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "catering_package_note", joinColumns = @JoinColumn(name = "package_id"))
@OrderColumn(name = "position")
@Column(name = "body", nullable = false, length = 600)
private List<String> notes = new ArrayList<>();
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position")
private List<CateringTier> tiers = new ArrayList<>();
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position")
private List<CateringRow> rows = new ArrayList<>();
protected CateringPackage() {
// for JPA
}
public CateringPackage(String name, int position) {
describe(name, null);
this.position = position;
}
/**
* A column as the editor left it — its heading and its price. A null {@code id} is one they just
* added. (Named for the heading rather than the column so as not to shadow {@code @Column}.)
*/
public record Heading(Long id, String label, String price) {}
/** A line as the editor left it, with one value per column — blanks included. */
public record Line(Long id, String label, List<String> values) {}
public final void describe(String name, String blurb) {
this.name = Text.required(name, 120, "Please give the table a name, like \"Weddings\".");
String trimmed = Text.optional(blurb, 400);
this.blurb = trimmed.isEmpty() ? null : trimmed;
}
public void moveTo(int position) {
this.position = position;
}
public void replaceNotes(List<String> replacements) {
if (replacements.size() > MOST_NOTES) {
throw new IllegalArgumentException(
"That's a lot of small print — " + MOST_NOTES + " notes per table at most.");
}
List<String> cleaned = replacements.stream()
.map(note -> Text.required(note, 600, "One of the notes is empty — delete it rather than blanking it."))
.toList();
this.notes.clear();
this.notes.addAll(cleaned);
}
/**
* Make the table exactly this: these columns in this order, and these lines, each carrying one
* value per column.
*
* <p>The whole table arrives at once because that is the only way the editor can move a column and
* take its values with it. Anything they left out is deleted, anything carrying an id keeps its
* identity, and positions are renumbered from the order they arrived in rather than trusted from
* the request — so what's stored is what they were looking at when they hit save.
*/
public void arrange(List<Heading> columns, List<Line> lines) {
if (columns.size() > MOST_COLUMNS) {
throw new IllegalArgumentException(
"A table can have at most " + MOST_COLUMNS + " columns and still be readable on a phone.");
}
if (lines.size() > MOST_LINES) {
throw new IllegalArgumentException("A table can have at most " + MOST_LINES + " lines.");
}
for (Line line : lines) {
if (line.values().size() != columns.size()) {
// Not a message an editor should ever see: the screen sends whole tables. Worth saying
// out loud anyway, because the alternative is a table that quietly means something else.
throw new IllegalArgumentException("\"" + line.label() + "\" has " + line.values().size()
+ " entries but the table has " + columns.size()
+ " columns. Reload the page and try that again.");
}
}
List<CateringTier> arrangedTiers = new ArrayList<>();
int columnNumber = 1;
for (Heading column : columns) {
CateringTier tier = column.id() == null ? new CateringTier(this) : tier(column.id());
tier.describe(column.label(), column.price());
tier.moveTo(columnNumber++);
arrangedTiers.add(tier);
}
List<CateringRow> arrangedRows = new ArrayList<>();
int lineNumber = 1;
for (Line line : lines) {
CateringRow row = line.id() == null ? new CateringRow(this) : row(line.id());
row.describe(line.label());
row.replaceValues(line.values());
row.moveTo(lineNumber++);
arrangedRows.add(row);
}
// Both collections are rewritten only after every column and line has been accepted, so a
// rejected edit leaves the table exactly as it was.
settle(tiers, arrangedTiers, Comparator.comparingInt(CateringTier::getPosition));
settle(rows, arrangedRows, Comparator.comparingInt(CateringRow::getPosition));
}
/**
* Keep what was arranged, drop what wasn't. Removal from the collection is what deletes the row —
* these are {@code orphanRemoval} associations — so the omitted ones need no further handling.
*/
private static <T> void settle(List<T> stored, List<T> arranged, Comparator<T> byPosition) {
stored.removeIf(item -> !holds(arranged, item));
arranged.stream().filter(item -> !holds(stored, item)).forEach(stored::add);
stored.sort(byPosition);
}
/**
* Identity, deliberately: entities here inherit no {@code equals}, and two freshly built columns
* with the same heading are two different columns.
*/
private static boolean holds(List<?> items, Object item) {
return items.stream().anyMatch(candidate -> candidate == item);
}
private CateringTier tier(Long id) {
return tiers.stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow(this::changedUnderneath);
}
private CateringRow row(Long id) {
return rows.stream().filter(r -> id.equals(r.getId())).findFirst().orElseThrow(this::changedUnderneath);
}
private IllegalArgumentException changedUnderneath() {
return new IllegalArgumentException(
"Part of the " + name + " table isn't there any more. Reload the page to see it as it is now.");
}
public String getName() {
return name;
}
public String getBlurb() {
return blurb;
}
public int getPosition() {
return position;
}
public List<String> getNotes() {
return List.copyOf(notes);
}
public List<CateringTier> getTiers() {
return List.copyOf(tiers);
}
public List<CateringRow> getRows() {
return List.copyOf(rows);
}
}
@@ -0,0 +1,10 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CateringPackageRepository extends JpaRepository<CateringPackage, Long> {
List<CateringPackage> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,84 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One line of a catering table: a baked good, and how much of it each column includes.
*
* <p>The values are positional — index 0 belongs to the first column — and there is always exactly
* one per column, blanks included. {@link CateringPackage} is the only thing that can set them,
* because it is the only thing that knows how many columns there are.
*/
@Entity
@Table(name = "catering_row")
public class CateringRow extends BaseEntity {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "package_id", nullable = false)
private CateringPackage cateringPackage;
@Column(nullable = false, length = 200)
private String label;
/**
* One entry per column, in column order. An empty string is a cell the bakery hasn't filled in —
* the spreadsheet has several — so blanks are stored rather than dropped, which is what keeps the
* list aligned with the columns.
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "catering_row_value", joinColumns = @JoinColumn(name = "row_id"))
@OrderColumn(name = "position")
@Column(name = "value", nullable = false, length = 300)
private List<String> values = new ArrayList<>();
@Column(name = "position", nullable = false)
private int position;
protected CateringRow() {
// for JPA
}
CateringRow(CateringPackage cateringPackage) {
this.cateringPackage = cateringPackage;
}
void describe(String label) {
this.label = Text.required(label, 200, "Every line needs a name — what is it the customer gets?");
}
/** Replaced wholesale; the package has already checked there is one value per column. */
void replaceValues(List<String> replacements) {
List<String> cleaned = replacements.stream().map(value -> Text.optional(value, 300)).toList();
this.values.clear();
this.values.addAll(cleaned);
}
void moveTo(int position) {
this.position = position;
}
public String getLabel() {
return label;
}
public List<String> getValues() {
return List.copyOf(values);
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,72 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One column of a catering table: a size, and what it costs.
*
* <p>Only {@link CateringPackage} can change one. A tier means nothing apart from the table it sits
* in — its price is read against the row values beside it — so the package is the only thing allowed
* to rearrange them, which is what keeps a row's values and its columns the same length.
*/
@Entity
@Table(name = "catering_tier")
public class CateringTier extends BaseEntity {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "package_id", nullable = false)
private CateringPackage cateringPackage;
@Column(nullable = false, length = 120)
private String label;
/** Whole cents, and nullable — see {@link Money}. */
@Column(name = "price_cents")
private Integer priceCents;
@Column(name = "position", nullable = false)
private int position;
protected CateringTier() {
// for JPA
}
CateringTier(CateringPackage cateringPackage) {
this.cateringPackage = cateringPackage;
}
/** @param price as the editor typed it; empty for a column that doesn't state one */
void describe(String label, String price) {
this.label = Text.required(label, 120,
"Every column needs a heading — a size like \"Large\", or who it feeds like \"1520 people\".");
this.priceCents = Money.cents(price);
}
void moveTo(int position) {
this.position = position;
}
public String getLabel() {
return label;
}
public Integer getPriceCents() {
return priceCents;
}
/** What the column's price reads as — "$24", or null when it doesn't state one. */
public String getPrice() {
return Money.format(priceCents);
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,72 @@
package com.itsthevine.web.domain;
import java.math.BigDecimal;
import java.util.Locale;
/**
* What a price is, in one place: how the bakery types one in, how it's stored, and how it's printed.
*
* <p>Editors type "24", "$24", "24.50" or nothing at all, so the parsing lives here rather than in the
* browser — a price the server didn't agree to is not a price, and {@code Number(text) * 100} gives
* 2410.0000000000005 for "24.10" on the way past. Stored as whole cents, because money is not a
* floating-point number.
*/
public final class Money {
/** Ten thousand dollars. Above this it's a decimal point in the wrong place, not a wedding. */
private static final int MOST_ANYTHING_COSTS = 1_000_000;
private Money() {
}
/**
* @param typed what the editor put in the box
* @return whole cents, or null for a column that doesn't state a price
*/
public static Integer cents(String typed) {
String cleaned = typed == null ? "" : typed.replace("$", "").replace(",", "").replace(" ", "").trim();
if (cleaned.isEmpty()) {
// Not an error: "ask us" is a legitimate thing for a column to say, and it says it by
// leaving the price empty.
return null;
}
BigDecimal amount;
try {
amount = new BigDecimal(cleaned);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("\"" + typed
+ "\" isn't a price. Leave it empty if that column doesn't have one.");
}
if (amount.scale() > 2) {
throw new IllegalArgumentException("Prices go to the cent — \"" + typed + "\" is finer than that.");
}
if (amount.signum() < 0) {
throw new IllegalArgumentException("A price can't be less than nothing.");
}
long asCents = amount.movePointRight(2).longValueExact();
if (asCents > MOST_ANYTHING_COSTS) {
throw new IllegalArgumentException("That price is over $10,000 — check the decimal point.");
}
return (int) asCents;
}
/**
* "$24", "$1,250", "$24.50" — never "$24.0", and null stays null.
*
* <p>{@code Locale.US} rather than the default: the price of a cake in Princeville, Illinois does
* not depend on which locale the container started in, and a default of de-DE would print
* "$1.250" for one thousand two hundred and fifty dollars.
*/
public static String format(Integer cents) {
if (cents == null) {
return null;
}
int dollars = cents / 100;
int change = cents % 100;
return change == 0
? String.format(Locale.US, "$%,d", dollars)
: String.format(Locale.US, "$%,d.%02d", dollars, change);
}
}
@@ -0,0 +1,37 @@
package com.itsthevine.web.domain;
/**
* Trimming and length rules for editor-supplied prose.
*
* <p>Every one of these limits is a column width, so the choice is between checking them here and
* letting Postgres reject the insert — which reaches the editor as an unexplained 500 with their
* afternoon's work still unsaved. The message names the offending text, because a catering table is
* a grid of thirty small cells and "too long" alone doesn't say which one.
*/
final class Text {
private Text() {
}
static String required(String value, int max, String missing) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(missing);
}
return capped(trimmed, max);
}
/** Trimmed, possibly empty — a blank cell is a real thing to want. */
static String optional(String value, int max) {
return capped(value == null ? "" : value.trim(), max);
}
private static String capped(String text, int max) {
if (text.length() > max) {
String preview = text.substring(0, Math.min(40, text.length()));
throw new IllegalArgumentException(
"\"" + preview + "\" is longer than the " + max + " characters that fit there.");
}
return text;
}
}
@@ -0,0 +1,141 @@
-- Goodie boxes and catering: the Office / Parties / Weddings price tables the bakery hands out.
--
-- These arrived as a spreadsheet, and a spreadsheet is what they are, so that is what this models:
-- a PACKAGE is one table on the page, its TIERS are the columns (what you get, for a price) and its
-- ROWS are the lines (which baked good, and how much of it in each column). A row therefore holds
-- one value per column, in column order — the two are kept in step in Java, because a table whose
-- lines and columns disagree quietly misprices what a customer is actually buying.
--
-- All of it is data rather than markup so the prices can move without a deploy. They will: the last
-- line of the spreadsheet says the tables are "mostly just an idea for people".
create table catering_package (
id bigserial primary key,
name varchar(120) not null,
-- Optional sentence under the heading. Nothing in the spreadsheet fills this in; it exists so
-- the bakery can explain a table without one of us editing a page.
blurb varchar(400),
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Footnotes belonging to one table: the minimums, the flavor rules, the wedding delivery terms.
-- Kept as rows rather than one blob so each rule can be edited, reordered or dropped on its own.
create table catering_package_note (
package_id bigint not null references catering_package (id) on delete cascade,
position integer not null,
body varchar(600) not null,
primary key (package_id, position)
);
-- A column: the size, and what it costs. price_cents is nullable for a tier that is priced on
-- asking, and is cents rather than a formatted string so the app — not the browser — decides how
-- money is written.
create table catering_tier (
id bigserial primary key,
package_id bigint not null references catering_package (id) on delete cascade,
label varchar(120) not null,
price_cents integer,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index catering_tier_package_idx on catering_tier (package_id, position);
-- A line of the table: which baked good.
create table catering_row (
id bigserial primary key,
package_id bigint not null references catering_package (id) on delete cascade,
label varchar(200) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index catering_row_package_idx on catering_row (package_id, position);
-- One cell. `position` is the COLUMN — position 0 is the first tier, and so on — so a row always has
-- exactly as many values as its package has tiers. Empty strings are meaningful and expected: the
-- spreadsheet has lines that are named but not yet quantified.
create table catering_row_value (
row_id bigint not null references catering_row (id) on delete cascade,
position integer not null,
value varchar(300) not null,
primary key (row_id, position)
);
-- Footnotes for the page as a whole rather than any one table.
create table catering_note (
id bigserial primary key,
body varchar(600) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Seeded from the bakery's own spreadsheet. Wording is theirs; the only changes are expanded
-- shorthand ("4 dz cc or sc" -> "4 dz cupcakes or sugar cookies") and fixed typos, because these
-- lines are read by customers. Everything here is editable in the admin.
insert into catering_package (id, name, position, created_at) values
(1, 'Office', 1, now()),
(2, 'Parties', 2, now()),
(3, 'Weddings', 3, now());
insert into catering_tier (id, package_id, label, price_cents, position, created_at) values
(1, 1, 'Small', 2400, 1, now()),
(2, 1, 'Medium', 3200, 2, now()),
(3, 1, 'Large', 4000, 3, now()),
(4, 2, '1520 people', 5400, 1, now()),
(5, 2, '2030 people', 7600, 2, now()),
(6, 2, '3040 people', 9800, 3, now()),
(7, 3, '125 people', 23600, 1, now()),
(8, 3, '200 people', 31000, 2, now()),
(9, 3, '250 people', 38600, 3, now());
insert into catering_row (id, package_id, label, position, created_at) values
( 1, 1, 'Mini muffins', 1, now()),
( 2, 1, 'Mini scones', 2, now()),
( 3, 1, 'Mini cinnamon rolls', 3, now()),
( 4, 2, 'Cake', 1, now()),
( 5, 2, 'Cupcakes', 2, now()),
( 6, 2, 'Sugar cookies', 3, now()),
( 7, 3, 'Bride & groom cake (8 in)', 1, now()),
( 8, 3, 'Sheet cakes', 2, now()),
( 9, 3, '12x17 bars', 3, now()),
(10, 3, 'Cupcakes', 4, now()),
(11, 3, 'Sugar cookies', 5, now());
insert into catering_row_value (row_id, position, value) values
( 1, 0, '12 items'), ( 1, 1, '18 items'), ( 1, 2, '24 items'),
( 2, 0, '6+6'), ( 2, 1, '6+6+6 or 12+6'), ( 2, 2, '6+6+6+6 or 12+6+6 or 12+12'),
-- Named in the spreadsheet but never quantified; the minimum below is the only rule it gives.
( 3, 0, ''), ( 3, 1, ''), ( 3, 2, ''),
( 4, 0, '6 in cake'), ( 4, 1, '8 in cake'), ( 4, 2, '10 in cake'),
( 5, 0, '1 dz sugar cookies or cupcakes'), ( 5, 1, '1.5 dz your choice'), ( 5, 2, '2 dz your choice'),
( 6, 0, ''), ( 6, 1, ''), ( 6, 2, ''),
( 7, 0, 'B&G cake'), ( 7, 1, 'B&G cake'), ( 7, 2, 'B&G cake'),
( 8, 0, '2 pans or a sheet cake'), ( 8, 1, '3 pans or a sheet cake'), ( 8, 2, '4 pans or a sheet cake'),
( 9, 0, '4 dz cupcakes or sugar cookies'),
( 9, 1, '5 dz cupcakes or sugar cookies'),
( 9, 2, '6 dz cupcakes or sugar cookies'),
(10, 0, ''), (10, 1, ''), (10, 2, ''),
(11, 0, ''), (11, 1, ''), (11, 2, '');
insert into catering_package_note (package_id, position, body) values
(1, 0, 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.'),
(2, 0, 'Add an extra dozen for $20.'),
(2, 1, 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.'),
-- Wedding-specific, so it sits with the wedding table rather than under the whole page.
(3, 0, 'The delivery fee depends on where the wedding is, and setup is charged separately.'),
(3, 1, 'We don''t provide serving materials, and we don''t set up decorations.');
insert into catering_note (id, body, position, created_at) values
(1, 'We''re happy to make changes — the price may change with them.', 1, now()),
(2, 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.', 2, now());
-- bigserial keeps its own counter; move it past the seeded ids so future inserts don't collide.
select setval('catering_package_id_seq', 3);
select setval('catering_tier_id_seq', 9);
select setval('catering_row_id_seq', 11);
select setval('catering_note_id_seq', 2);