Rewrite on the Bennett platform: Spring Boot + Vite/React SPA

Replaces the Next.js app. Same site, same look; the parts that were decisions rather
than markup now live in Java.

- catalogue, curated order, category filter and image URLs move from a TypeScript array
  into Postgres behind /api/products and /api/categories
- contact form uses the shared platform-starter-contact: validate, RECORD, send, then
  fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry
- PageMetaController rewrites title/description/OG per route, replacing what Next's SSR
  gave crawlers and link-preview scrapers
- 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF
  (including phone GPS) stripped
- fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so
  it was unreachable unless browsing All
This commit is contained in:
2026-07-22 22:09:51 -05:00
parent 207415dbbe
commit f471462d05
164 changed files with 4223 additions and 14003 deletions
@@ -0,0 +1,63 @@
package com.itsthevine.web;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.itsthevine.web.domain.ContactEnquiry;
import com.itsthevine.web.domain.ContactEnquiryRepository;
import net.thebennett.platform.contact.ContactException;
import net.thebennett.platform.contact.ContactService;
import net.thebennett.platform.contact.Enquiry;
/**
* The contact form. Keeps the response shape the old Next route used ({@code {ok:true}} /
* {@code {error:"..."}}), because the error text is shown to the visitor as-is.
*/
@RestController
@RequestMapping("/api/contact")
public class ContactController {
private final ContactService contact;
private final ContactEnquiryRepository enquiries;
public ContactController(ContactService contact, ContactEnquiryRepository enquiries) {
this.contact = contact;
this.enquiries = enquiries;
}
public record Submission(String name, String email, String message) {}
@PostMapping
public ResponseEntity<Map<String, Object>> submit(@RequestBody Submission body) {
Enquiry enquiry = Enquiry.of(body.name(), body.email(), body.message());
// Validate first so junk never reaches the table, then record it BEFORE attempting delivery:
// if the relay is down we still have the enquiry, flagged undelivered.
contact.validate(enquiry);
ContactEnquiry recorded = enquiries.save(
new ContactEnquiry(enquiry.name(), enquiry.email(), enquiry.message()));
contact.submit(enquiry);
recorded.markDelivered();
enquiries.save(recorded);
return ResponseEntity.ok(Map.of("ok", true));
}
/**
* The visitor sees this text, so it must stay the wording the service chose — never a stack trace
* or a generic 500.
*/
@org.springframework.web.bind.annotation.ExceptionHandler(ContactException.class)
public ResponseEntity<Map<String, Object>> handle(ContactException ex) {
HttpStatus status = ex.isClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.BAD_GATEWAY;
return ResponseEntity.status(status).body(Map.of("error", ex.getMessage()));
}
}
@@ -0,0 +1,12 @@
package com.itsthevine.web;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ItsTheVineApplication {
public static void main(String[] args) {
SpringApplication.run(ItsTheVineApplication.class, args);
}
}
@@ -0,0 +1,148 @@
package com.itsthevine.web;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import jakarta.servlet.http.HttpServletRequest;
/**
* Serves {@code index.html} with per-page title/description/OG tags filled in.
*
* <p>The site used to be server-rendered by Next, so every page came with its own metadata. A plain
* SPA would hand crawlers and link-preview scrapers one generic shell for all four pages — a real
* loss for a shop that people find by searching. Rendering just the {@code <head>} on the server keeps
* that, without dragging SSR (and a Node runtime) into the one-jar model.
*
* <p>Only the four real routes are listed. Anything else falls through to the platform's SPA
* fallback, which is what we want for 404s — no invented metadata for URLs that don't exist.
*/
@Controller
public class PageMetaController {
private static final Logger log = LoggerFactory.getLogger(PageMetaController.class);
private static final String NAME = "The Vine Coffeehouse + Bakery";
private static final String HOME_DESCRIPTION =
"A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, "
+ "custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.";
private record PageMeta(String title, String description) {}
private static final Map<String, PageMeta> PAGES = new LinkedHashMap<>(Map.of(
"/", new PageMeta(NAME, HOME_DESCRIPTION),
"/products", new PageMeta("Our products · " + NAME,
"Cinnamon rolls, caramel rolls, scones, cookie bars, macarons, brownies, pies, and "
+ "made-to-order cakes and decorated cookies from The Vine in Princeville, Illinois."),
"/history", new PageMeta("Our story · " + NAME,
"Morissa Bennett opened The Vine in 2024 at 215 E Main Street in downtown Princeville, "
+ "Illinois. We bake in our own kitchen on Main Street."),
"/contact", new PageMeta("Contact us · " + NAME,
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
+ "Illinois. Call (309) 701-0660 or send us a message.")));
private static final Pattern TITLE = Pattern.compile("<title>.*?</title>", Pattern.DOTALL);
private final ResourceLoader resourceLoader;
private final String baseUrl;
/** Cached because the file never changes at runtime — it's baked into the jar. */
private volatile String template;
public PageMetaController(ResourceLoader resourceLoader,
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
this.resourceLoader = resourceLoader;
this.baseUrl = baseUrl;
}
@GetMapping(value = {"/", "/products", "/history", "/contact"}, produces = MediaType.TEXT_HTML_VALUE)
@ResponseBody
public String page(HttpServletRequest request) {
String path = request.getRequestURI();
PageMeta meta = PAGES.getOrDefault(path, PAGES.get("/"));
String html = template();
if (html == null) {
// No built SPA (backend-only build). Nothing to decorate.
return "<!doctype html><title>" + escape(meta.title()) + "</title>";
}
return render(html, meta, path);
}
private String render(String html, PageMeta meta, String path) {
String out = TITLE.matcher(html).replaceFirst(
Matcher.quoteReplacement("<title>" + escape(meta.title()) + "</title>"));
out = setMeta(out, "name", "description", meta.description());
out = setMeta(out, "property", "og:title", meta.title());
out = setMeta(out, "property", "og:description", meta.description());
out = setMeta(out, "property", "og:url", baseUrl + ("/".equals(path) ? "" : path));
return out;
}
/**
* Rewrites the {@code content} of an existing meta tag. Deliberately does not add missing tags —
* index.html carries the full set, so a miss here means the template changed and should be fixed
* there rather than papered over with a duplicate tag.
*/
private static String setMeta(String html, String keyAttr, String key, String value) {
Pattern p = Pattern.compile(
"(<meta\\s+" + keyAttr + "=\"" + Pattern.quote(key) + "\"\\s+content=\")[^\"]*(\")");
Matcher m = p.matcher(html);
if (!m.find()) {
log.warn("index.html has no <meta {}=\"{}\"> to fill in", keyAttr, key);
return html;
}
// Splice by index rather than replaceFirst: replacement strings give $ and \ special meaning,
// and these values are prose.
return new StringBuilder(html)
.replace(m.start(), m.end(), m.group(1) + escape(value) + m.group(2))
.toString();
}
private String template() {
String cached = template;
if (cached == null) {
synchronized (this) {
if (template == null) {
template = load();
}
cached = template;
}
}
return cached.isEmpty() ? null : cached;
}
private String load() {
Resource resource = resourceLoader.getResource("classpath:/static/index.html");
if (!resource.exists()) {
log.warn("no classpath:/static/index.html — serving pages without metadata");
return "";
}
try (var in = resource.getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("could not read index.html", e);
return "";
}
}
/** Escapes for both element text and double-quoted attribute values. */
private static String escape(String s) {
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,96 @@
package com.itsthevine.web;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* The products page's brains: which categories to offer, in what order, what's in each, and where the
* photos are. All of this used to live in a TypeScript array shipped to the browser.
*/
@Service
public class ProductCatalog {
/** The filter shown first — every category at once. Not a stored category. */
public static final String ALL = "All";
/**
* Curated display order. The catalogue is sorted for browsing, not alphabetically, and the order
* predates the database, so it's stated here. Categories that exist in the data but aren't listed
* still show up (appended, alphabetically) rather than silently disappearing from the filter.
*/
private static final List<String> ORDER =
List.of("Cookies", "Cakes", "Rolls", "Pie", "Brownies", "Pastries");
private final ProductRepository products;
private final String assetBaseUrl;
public ProductCatalog(ProductRepository products,
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) {
this.products = products;
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
// up in every image URL on the page.
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
}
public record ProductView(Long id, String name, String category, List<String> images) {}
/**
* @param category a stored category, or {@link #ALL}/blank for everything
*/
@Transactional(readOnly = true)
public List<ProductView> list(String category) {
List<Product> found = (!StringUtils.hasText(category) || ALL.equalsIgnoreCase(category))
? products.findAllByOrderByPositionAsc()
: products.findAllByCategoryOrderByPositionAsc(category);
return found.stream().map(this::toView).toList();
}
/** The filter buttons, in display order, starting with "All". */
@Transactional(readOnly = true)
public List<String> categories() {
Set<String> present = products.findAllByOrderByPositionAsc().stream()
.map(Product::getCategory)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<String> ordered = new ArrayList<>();
ordered.add(ALL);
ORDER.stream().filter(present::contains).forEach(ordered::add);
present.stream()
.filter(c -> !ORDER.contains(c))
.sorted(Comparator.naturalOrder())
.forEach(ordered::add);
return ordered;
}
private ProductView toView(Product p) {
return new ProductView(p.getId(), p.getName(), p.getCategory(),
p.getImageKeys().stream().map(this::imageUrl).toList());
}
/**
* Some photo filenames contain spaces ("Cinnamon Rolls.webp"), and a raw space in a URL doesn't
* fetch — so each path segment is encoded. {@code URLEncoder} is form-encoding, which differs
* from path-encoding in exactly one way that matters here: it turns a space into '+'.
*/
private String imageUrl(String key) {
String encoded = Arrays.stream(key.replaceAll("^/+", "").split("/"))
.map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
.collect(Collectors.joining("/"));
return assetBaseUrl + "/images/" + encoded;
}
}
@@ -0,0 +1,29 @@
package com.itsthevine.web;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductController {
private final ProductCatalog catalog;
public ProductController(ProductCatalog catalog) {
this.catalog = catalog;
}
/** @param category filter; omit (or pass "All") for the whole catalogue */
@GetMapping("/api/products")
public List<ProductCatalog.ProductView> products(
@RequestParam(required = false) String category) {
return catalog.list(category);
}
@GetMapping("/api/categories")
public List<String> categories() {
return catalog.categories();
}
}
@@ -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 contact-form submission, recorded before we try to deliver it.
*
* <p>Writing this row first means a relay outage costs a notification, not the enquiry itself —
* {@code delivered} shows which ones still need chasing up by hand.
*/
@Entity
@Table(name = "enquiry")
public class ContactEnquiry extends BaseEntity {
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, length = 320)
private String email;
@Column(nullable = false, columnDefinition = "text")
private String message;
@Column(nullable = false)
private boolean delivered;
protected ContactEnquiry() {
// for JPA
}
public ContactEnquiry(String name, String email, String message) {
this.name = name;
this.email = email;
this.message = message;
this.delivered = false;
}
public void markDelivered() {
this.delivered = true;
}
public String getName() { return name; }
public String getEmail() { return email; }
public String getMessage() { return message; }
public boolean isDelivered() { return delivered; }
}
@@ -0,0 +1,6 @@
package com.itsthevine.web.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ContactEnquiryRepository extends JpaRepository<ContactEnquiry, Long> {
}
@@ -0,0 +1,50 @@
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.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/** Something the bakery makes, with the photos that show it off. */
@Entity
@Table(name = "product")
public class Product extends BaseEntity {
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, length = 60)
private String category;
/** Display order on the products page; the catalogue is curated, not alphabetical. */
@Column(name = "position", nullable = false)
private int position;
/**
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "position")
@Column(name = "image_key", nullable = false, length = 300)
private List<String> imageKeys = new ArrayList<>();
protected Product() {
// for JPA
}
public String getName() { return name; }
public String getCategory() { return category; }
public int getPosition() { return position; }
public List<String> getImageKeys() { return imageKeys; }
}
@@ -0,0 +1,12 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findAllByOrderByPositionAsc();
List<Product> findAllByCategoryOrderByPositionAsc(String category);
}
+63
View File
@@ -0,0 +1,63 @@
spring:
application:
name: itsthevine
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/itsthevine}
username: ${DB_USER:itsthevine}
password: ${DB_PASSWORD:changeme}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
enabled: true
mail:
host: ${SMTP_SERVER:localhost}
port: ${SMTP_PORT:25}
username: ${SMTP_USERNAME:}
password: ${SMTP_TOKEN:}
properties:
mail:
smtp:
# Only authenticate when we were actually given credentials — the LAN relay takes mail
# from the docker network without them.
auth: ${SMTP_AUTH:false}
starttls:
enable: ${SMTP_STARTTLS:false}
# The local relay / Proton Bridge presents a self-signed cert (CN=127.0.0.1). This trusts
# only the configured host, not every server we might ever talk to.
ssl:
trust: ${SMTP_SERVER:localhost}
platform:
web:
spa:
enabled: true
data:
auditing:
enabled: true
contact:
to: ${CONTACT_TO:}
from: ${CONTACT_FROM:}
hub-url: ${CONTACT_HUB_URL:}
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
site:
base-url: ${SITE_BASE_URL:https://itsthevine.com}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
health:
mail:
# OFF deliberately. Boot's mail contributor opens an SMTP connection on every health check, so a
# relay outage would report the container unhealthy and get it restarted — taking a perfectly
# good website down over a side feature. Enquiries are persisted either way, and a failed send
# is already surfaced to the visitor and the log.
enabled: false
@@ -0,0 +1,17 @@
-- Contact-form submissions. Written before delivery is attempted, so an SMTP outage costs a
-- notification rather than the enquiry; `delivered` marks the ones that still need chasing.
create table enquiry (
id bigserial primary key,
name varchar(200) not null,
email varchar(320) not null,
message text not null,
delivered boolean not null default false,
created_at timestamptz not null,
updated_at timestamptz
);
-- The only query anyone actually runs: what came in, newest first.
create index enquiry_created_at_idx on enquiry (created_at desc);
-- Finding the ones the relay never took.
create index enquiry_undelivered_idx on enquiry (created_at desc) where not delivered;
@@ -0,0 +1,150 @@
-- The product catalogue. Lives in the database rather than a TypeScript array so the
-- catalogue, its ordering and its category filter are server-side concerns like any other
-- Spring app — the SPA just renders what /api/products returns.
create table product (
id bigserial primary key,
name varchar(200) not null,
category varchar(60) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index product_category_idx on product (category, position);
-- Ordered photos for a product; the first is the one the card shows.
create table product_image (
product_id bigint not null references product (id) on delete cascade,
position integer not null,
image_key varchar(300) not null,
primary key (product_id, position)
);
-- Seeded from the catalogue the site shipped with; `position` preserves the original order.
insert into product (id, name, category, position, created_at) values (1, '76th Birthday Cake', 'Cakes', 1, now());
insert into product (id, name, category, position, created_at) values (2, '1964 Graduates Cake', 'Cakes', 2, now());
insert into product (id, name, category, position, created_at) values (3, 'Baby Shower Cake', 'Cakes', 3, now());
insert into product (id, name, category, position, created_at) values (4, 'Blueberry Cream Pie', 'Pie', 4, now());
insert into product (id, name, category, position, created_at) values (5, 'Bridesmaids Sugar Cookies', 'Cookies', 5, now());
insert into product (id, name, category, position, created_at) values (6, 'Bundt Cake', 'Cakes', 6, now());
insert into product (id, name, category, position, created_at) values (7, 'Caramel Rolls', 'Rolls', 7, now());
insert into product (id, name, category, position, created_at) values (8, 'Cat Birthday Cake', 'Cakes', 8, now());
insert into product (id, name, category, position, created_at) values (9, 'Chocolate Chip Scones', 'Pastries', 9, now());
insert into product (id, name, category, position, created_at) values (10, 'Christmas Sugar Cookies', 'Cookies', 10, now());
insert into product (id, name, category, position, created_at) values (11, 'Circus Birthday Cake', 'Cakes', 11, now());
insert into product (id, name, category, position, created_at) values (12, 'Cookie Bars', 'Cookies', 12, now());
insert into product (id, name, category, position, created_at) values (13, 'Cinnamon Rolls', 'Rolls', 13, now());
insert into product (id, name, category, position, created_at) values (14, 'Cow Birthday Cake', 'Cakes', 14, now());
insert into product (id, name, category, position, created_at) values (15, 'Cow Cupcakes', 'Cakes', 15, now());
insert into product (id, name, category, position, created_at) values (16, 'Doggy Sugar Cookies', 'Cookies', 16, now());
insert into product (id, name, category, position, created_at) values (17, 'Fall Sugar Cookies', 'Cookies', 17, now());
insert into product (id, name, category, position, created_at) values (18, 'Flower Cupcakes', 'Cakes', 18, now());
insert into product (id, name, category, position, created_at) values (19, 'Heart Cakes', 'Cakes', 19, now());
insert into product (id, name, category, position, created_at) values (20, 'Lemon Berry Cake', 'Cakes', 20, now());
insert into product (id, name, category, position, created_at) values (21, 'Macarons', 'Pastries', 21, now());
insert into product (id, name, category, position, created_at) values (22, 'Moana Birthday Cake', 'Cakes', 22, now());
insert into product (id, name, category, position, created_at) values (23, 'Natalie Purple Birthday Cake', 'Cakes', 23, now());
insert into product (id, name, category, position, created_at) values (24, 'Oreo Brownies', 'Brownies', 24, now());
insert into product (id, name, category, position, created_at) values (25, 'Peanut Butter Cookie Cake', 'Cakes', 25, now());
insert into product (id, name, category, position, created_at) values (26, 'Pink Rose Birthday Cake', 'Cakes', 26, now());
insert into product (id, name, category, position, created_at) values (27, 'Princeville Sugar Cookies', 'Cookies', 27, now());
insert into product (id, name, category, position, created_at) values (28, 'Princeville XC Sugar Cookies', 'Cookies', 28, now());
insert into product (id, name, category, position, created_at) values (29, 'Pumpkin Birthday Cake', 'Cakes', 29, now());
insert into product (id, name, category, position, created_at) values (30, 'Purple Birthday Cake', 'Cakes', 30, now());
insert into product (id, name, category, position, created_at) values (31, 'Rainbow Sugar Cookies', 'Cookies', 31, now());
insert into product (id, name, category, position, created_at) values (32, 'Retirement Cake', 'Cakes', 32, now());
insert into product (id, name, category, position, created_at) values (33, 'Scones', 'Pastries', 33, now());
insert into product (id, name, category, position, created_at) values (34, 'Soccer Sugar Cookies', 'Cookies', 34, now());
insert into product (id, name, category, position, created_at) values (35, 'Speciality Cookies', 'Cookies', 35, now());
-- 'Pies' in the original data, which no filter button matched — so this one was unreachable unless you
-- were browsing "All". Filed under 'Pie' with the other one.
insert into product (id, name, category, position, created_at) values (36, 'Strawberry Pie', 'Pie', 36, now());
insert into product (id, name, category, position, created_at) values (37, 'Timecapsul Sugar Cookies', 'Cookies', 37, now());
insert into product (id, name, category, position, created_at) values (38, 'Tractor Birthday Cake', 'Cakes', 38, now());
insert into product (id, name, category, position, created_at) values (39, 'Valentines Cookie Cakes', 'Cakes', 39, now());
insert into product (id, name, category, position, created_at) values (40, 'Yellow Wedding Cake', 'Cakes', 40, now());
insert into product_image (product_id, position, image_key) values (1, 0, 'products/76th_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (1, 1, 'products/76th_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (1, 2, 'products/76th_birthday_cake3.webp');
insert into product_image (product_id, position, image_key) values (2, 0, 'products/1964_graduates_cake.webp');
insert into product_image (product_id, position, image_key) values (2, 1, 'products/1964_graduates_cake2.webp');
insert into product_image (product_id, position, image_key) values (2, 2, 'products/1964_graduates_cake3.webp');
insert into product_image (product_id, position, image_key) values (3, 0, 'products/babyshower_cake.webp');
insert into product_image (product_id, position, image_key) values (4, 0, 'products/blueberry_cream_pie.webp');
insert into product_image (product_id, position, image_key) values (5, 0, 'products/bridesmaids_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (5, 1, 'products/bridesmaids_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (6, 0, 'products/bundt_cake.webp');
insert into product_image (product_id, position, image_key) values (7, 0, 'products/carmel_rolls.webp');
insert into product_image (product_id, position, image_key) values (8, 0, 'products/cat_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (9, 0, 'products/ChocalateChip_Scones.webp');
insert into product_image (product_id, position, image_key) values (10, 0, 'products/christmas_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (10, 1, 'products/christmas_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (11, 0, 'products/circus_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (11, 1, 'products/circus_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (12, 0, 'products/cookie_bars.webp');
insert into product_image (product_id, position, image_key) values (12, 1, 'products/cookie_bars2.webp');
insert into product_image (product_id, position, image_key) values (13, 0, 'products/cinnamonrolls.webp');
insert into product_image (product_id, position, image_key) values (14, 0, 'products/cow_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (14, 1, 'products/cow_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (15, 0, 'products/cow_cupcakes.webp');
insert into product_image (product_id, position, image_key) values (16, 0, 'products/doggy_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (16, 1, 'products/doggy_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (16, 2, 'products/doggy_sugar_cookies3.webp');
insert into product_image (product_id, position, image_key) values (17, 0, 'products/fall_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (17, 1, 'products/fall_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (18, 0, 'products/flower_cupcakes.webp');
insert into product_image (product_id, position, image_key) values (19, 0, 'products/heart_cakes.webp');
insert into product_image (product_id, position, image_key) values (19, 1, 'products/heart_cakes2.webp');
insert into product_image (product_id, position, image_key) values (19, 2, 'products/heart_cakes3.webp');
insert into product_image (product_id, position, image_key) values (19, 3, 'products/heart_cakes4.webp');
insert into product_image (product_id, position, image_key) values (19, 4, 'products/heart_cakes5.webp');
insert into product_image (product_id, position, image_key) values (19, 5, 'products/heart_cakes6.webp');
insert into product_image (product_id, position, image_key) values (20, 0, 'products/lemon_berry_cake.webp');
insert into product_image (product_id, position, image_key) values (20, 1, 'products/lemon_berry_cake2.webp');
insert into product_image (product_id, position, image_key) values (20, 2, 'products/lemon_berry_cake3.webp');
insert into product_image (product_id, position, image_key) values (20, 3, 'products/lemon_berry_cake4.webp');
insert into product_image (product_id, position, image_key) values (20, 4, 'products/lemon_berry_cake5.webp');
insert into product_image (product_id, position, image_key) values (21, 0, 'products/macarons.webp');
insert into product_image (product_id, position, image_key) values (22, 0, 'products/moana_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (22, 1, 'products/moana_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (23, 0, 'products/natalie_purple_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (23, 1, 'products/natalie_purple_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (23, 2, 'products/natalie_purple_birthday_cake3.webp');
insert into product_image (product_id, position, image_key) values (24, 0, 'products/oreo_brownies.webp');
insert into product_image (product_id, position, image_key) values (25, 0, 'products/peanutbutter_cookie_cake.webp');
insert into product_image (product_id, position, image_key) values (25, 1, 'products/peanutbutter_cookie_cake2.webp');
insert into product_image (product_id, position, image_key) values (26, 0, 'products/pink_rose_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (27, 0, 'products/princeville_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (27, 1, 'products/princeville_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (27, 2, 'products/princeville_sugar_cookies3.webp');
insert into product_image (product_id, position, image_key) values (27, 3, 'products/princeville_sugar_cookies4.webp');
insert into product_image (product_id, position, image_key) values (27, 4, 'products/princeville_sugar_cookies5.webp');
insert into product_image (product_id, position, image_key) values (28, 0, 'products/princeville_xc_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (28, 1, 'products/princeville_xc_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (29, 0, 'products/pumpkin_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (29, 1, 'products/pumpkin_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (29, 2, 'products/pumpkin_birthday_cake3.webp');
insert into product_image (product_id, position, image_key) values (30, 0, 'products/purple_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (31, 0, 'products/rainbow_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (31, 1, 'products/rainbow_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (32, 0, 'products/retirement_cake.webp');
insert into product_image (product_id, position, image_key) values (33, 0, 'products/scones.webp');
insert into product_image (product_id, position, image_key) values (34, 0, 'products/soccer_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (34, 1, 'products/soccer_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (35, 0, 'products/speciality_cookies.webp');
insert into product_image (product_id, position, image_key) values (36, 0, 'products/strawberry_pie.webp');
insert into product_image (product_id, position, image_key) values (36, 1, 'products/strawberry_pie2.webp');
insert into product_image (product_id, position, image_key) values (36, 2, 'products/strawberry_pie3.webp');
insert into product_image (product_id, position, image_key) values (37, 0, 'products/timecapsul_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (38, 0, 'products/tractor_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (39, 0, 'products/valentines_cookie_cakes.webp');
insert into product_image (product_id, position, image_key) values (39, 1, 'products/valentines_cookie_cakes2.webp');
insert into product_image (product_id, position, image_key) values (39, 2, 'products/valentines_cookie_cakes3.webp');
insert into product_image (product_id, position, image_key) values (40, 0, 'products/yellow_wedding_cake.webp');
insert into product_image (product_id, position, image_key) values (40, 1, 'products/yellow_wedding_cake2.webp');
insert into product_image (product_id, position, image_key) values (40, 2, 'products/yellow_wedding_cake3.webp');
-- bigserial keeps its own counter; move it past the seeded ids so future inserts don't collide.
select setval('product_id_seq', 40);