all, String category) {
+ return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
+ }
+
+ private static String required(String value) {
+ String trimmed = value == null ? "" : value.trim();
+ if (trimmed.isEmpty()) {
+ throw new IllegalArgumentException("Please give the category a name.");
+ }
+ return trimmed;
+ }
+}
diff --git a/src/main/java/com/itsthevine/web/AdminController.java b/src/main/java/com/itsthevine/web/AdminController.java
deleted file mode 100644
index 66a0535..0000000
--- a/src/main/java/com/itsthevine/web/AdminController.java
+++ /dev/null
@@ -1,175 +0,0 @@
-package com.itsthevine.web;
-
-import java.time.Instant;
-import java.util.List;
-import java.util.UUID;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.HttpStatus;
-import org.springframework.transaction.annotation.Transactional;
-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.RequestParam;
-import org.springframework.web.bind.annotation.ResponseStatus;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.server.ResponseStatusException;
-
-import com.itsthevine.web.domain.ContactEnquiry;
-import com.itsthevine.web.domain.ContactEnquiryRepository;
-import com.itsthevine.web.domain.Product;
-import com.itsthevine.web.domain.ProductRepository;
-
-import net.thebennett.platform.storage.StorageService;
-
-/**
- * Everything behind the login: the catalogue, and the enquiries people have sent.
- *
- * The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
- * any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
- * the alternative is a role model nobody would maintain.
- */
-@RestController
-@RequestMapping("/api/admin")
-public class AdminController {
-
- private final ProductRepository products;
- private final ContactEnquiryRepository enquiries;
- private final StorageService storage;
- private final String bucket;
- private final String publicBaseUrl;
-
- public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
- StorageService storage,
- @Value("${vine.storage.bucket:itsthevine}") String bucket,
- @Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
- this.products = products;
- this.enquiries = enquiries;
- this.storage = storage;
- this.bucket = bucket;
- this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
- }
-
- // ---- products ----
-
- /** @param imageKeys bucket keys, in display order; the first is the one the card shows */
- public record ProductForm(String name, String category, Integer position, List imageKeys) {}
-
- public record AdminProduct(Long id, String name, String category, int position,
- List imageKeys, List imageUrls) {}
-
- @GetMapping("/products")
- @Transactional(readOnly = true)
- public List list() {
- return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
- }
-
- @PostMapping("/products")
- @ResponseStatus(HttpStatus.CREATED)
- @Transactional
- public AdminProduct create(@RequestBody ProductForm form) {
- validate(form);
- // Default to the end of the list so a new product does not silently displace an existing one.
- int position = form.position() != null ? form.position() : nextPosition();
- return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
- position, cleanKeys(form.imageKeys()))));
- }
-
- @PutMapping("/products/{id}")
- @Transactional
- public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
- validate(form);
- Product p = products.findById(id)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
- p.update(form.name().trim(), form.category().trim(),
- form.position() != null ? form.position() : p.getPosition(),
- cleanKeys(form.imageKeys()));
- return toAdmin(p);
- }
-
- @DeleteMapping("/products/{id}")
- @ResponseStatus(HttpStatus.NO_CONTENT)
- @Transactional
- public void delete(@PathVariable Long id) {
- if (!products.existsById(id)) {
- throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
- }
- // The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
- // the images survive it.
- products.deleteById(id);
- }
-
- // ---- enquiries ----
-
- /** @param delivered false means the relay refused it and nobody was notified */
- public record AdminEnquiry(Long id, String name, String email, String message,
- boolean delivered, Instant receivedAt) {}
-
- @GetMapping("/enquiries")
- @Transactional(readOnly = true)
- public List enquiries() {
- return enquiries.findAllByOrderByCreatedAtDesc().stream()
- .map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
- e.isDelivered(), e.getCreatedAt()))
- .toList();
- }
-
- // ---- photo upload ----
-
- /**
- * @param key what to store on the product
- * @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
- * never passes through this app
- * @param publicUrl where it will be readable from afterwards
- */
- public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
-
- @PostMapping("/images/presign-upload")
- public UploadTarget presignUpload(@RequestParam String filename,
- @RequestParam(defaultValue = "application/octet-stream") String contentType) {
- // A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
- // overwrite each other, and the bucket is public so keys should not be guessable.
- String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
- String key = "images/products/" + UUID.randomUUID() + "-" + safe;
- return new UploadTarget(key.substring("images/".length()),
- storage.presignPut(bucket, key, contentType).toString(),
- publicBaseUrl + "/" + key);
- }
-
- // ---- helpers ----
-
- private void validate(ProductForm form) {
- if (form.name() == null || form.name().isBlank()) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
- }
- if (form.category() == null || form.category().isBlank()) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
- }
- if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
- }
- }
-
- private static List cleanKeys(List keys) {
- return keys == null ? List.of()
- : keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
- }
-
- private int nextPosition() {
- return products.findAllByOrderByPositionAsc().stream()
- .mapToInt(Product::getPosition).max().orElse(0) + 1;
- }
-
- private AdminProduct toAdmin(Product p) {
- return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
- p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
- }
-
- private String publicUrl(String key) {
- return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
- }
-}
diff --git a/src/main/java/com/itsthevine/web/AdminProductController.java b/src/main/java/com/itsthevine/web/AdminProductController.java
new file mode 100644
index 0000000..29024db
--- /dev/null
+++ b/src/main/java/com/itsthevine/web/AdminProductController.java
@@ -0,0 +1,206 @@
+package com.itsthevine.web;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.ResponseEntity;
+import org.springframework.transaction.annotation.Transactional;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.itsthevine.web.domain.Product;
+import com.itsthevine.web.domain.ProductRepository;
+
+/**
+ * Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
+ *
+ * The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
+ * the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
+ * so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
+ * would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
+ * means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
+ * web contract expects of {@code /api/**}.
+ */
+@RestController
+@RequestMapping("/api/admin/products")
+@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
+public class AdminProductController {
+
+ private final ProductRepository products;
+ private final ProductPhotoService photos;
+ private final ProductCatalog catalog;
+
+ public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
+ this.products = products;
+ this.photos = photos;
+ this.catalog = catalog;
+ }
+
+ /**
+ * What the editor sees: the catalogue in display order.
+ *
+ * {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
+ * identifiers to arrange by. The public view only needs the former, but an editor rearranging
+ * photos has to name them back to us, and the URL is a rendering of the key rather than the key
+ * itself.
+ */
+ public record AdminView(Long id, String name, String category, int position,
+ List images, List keys) {}
+
+ public record Details(String name, String category) {}
+
+ public record Order(List ids) {}
+
+ @GetMapping
+ @Transactional(readOnly = true)
+ public List list() {
+ return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
+ }
+
+ /**
+ * New items go to the front — the newest work is what's worth showing first, and it saves the
+ * editor a reorder after every upload.
+ */
+ @PostMapping
+ @Transactional
+ public AdminView create(@RequestParam String name,
+ @RequestParam String category,
+ @RequestParam("photos") List files) {
+ String cleanName = required(name, "Please give it a name.");
+ String cleanCategory = required(category, "Please choose a category.");
+ if (files == null || files.isEmpty()) {
+ throw new IllegalArgumentException("Please add at least one photo.");
+ }
+
+ List keys = new ArrayList<>();
+ for (MultipartFile file : files) {
+ keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
+ }
+
+ Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
+ renumberWithFirst(saved);
+ return toView(saved);
+ }
+
+ @PutMapping("/{id}")
+ @Transactional
+ public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
+ Product product = find(id);
+ product.describe(required(details.name(), "Please give it a name."),
+ required(details.category(), "Please choose a category."));
+ return toView(products.save(product));
+ }
+
+ @PostMapping("/{id}/photos")
+ @Transactional
+ public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List files) {
+ Product product = find(id);
+ if (files == null || files.isEmpty()) {
+ throw new IllegalArgumentException("Please choose a photo to add.");
+ }
+ List keys = new ArrayList<>(product.getImageKeys());
+ for (MultipartFile file : files) {
+ keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
+ }
+ product.replacePhotos(keys);
+ return toView(products.save(product));
+ }
+
+ /**
+ * Reordering and removal both arrive as the full list the editor arranged, so the stored order is
+ * whatever they last saw rather than the result of replaying moves.
+ */
+ @PutMapping("/{id}/photos")
+ @Transactional
+ public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List keys) {
+ Product product = find(id);
+ List existing = product.getImageKeys();
+ List arranged = keys.stream().filter(existing::contains).distinct().toList();
+ if (arranged.isEmpty()) {
+ throw new IllegalArgumentException("An item needs at least one photo.");
+ }
+ product.replacePhotos(arranged);
+ return toView(products.save(product));
+ }
+
+ @DeleteMapping("/{id}")
+ @Transactional
+ public ResponseEntity