What's changing — in one sentence:
Wine stays a timeless label. A new WineVintage collection stores per-year grape blend + notes.
AuctionItem now references (wineId, vintage), which resolves to a WineVintage row when one exists.
The product page at /wines/:wineId aggregates all vintages under one label header (B2 UX).
No duplication of label fields
Vintage-specific grapes supported
One extra collection
One extra populate on detail pages
1. UX — Wine Product Page /wines/:wineId
Opus One Winery
Opus One · ALL VINTAGES
Opus One's flagship Bordeaux-style blend. Each vintage shows the year's specific grape breakdown and active auctions.
2018
Blend: Cab Sauv 81% · Merlot 7% · Cab Franc 6% · Petit Verdot 4% · Malbec 2%
3 active · $400 — $450
cellarmaster_jp · 750ml · Cellared$450.00 USD
whitfield · 750ml · Professional storage$420.00 USD
dubois_fr · 750ml · Original case$400.00 USD
2019
Blend: Cab Sauv 79% · Merlot 9% · Cab Franc 6% · Petit Verdot 4% · Malbec 2%
1 active · $350
tanaka_tokyo · 750ml · Cellared since release$350.00 USD
2017
Blend: Cab Sauv 80% · Merlot 8% · Cab Franc 7% · Petit Verdot 3% · Malbec 2%
2 active · from $380
dubois_fr · 750ml · Temperature-controlled$410.00 USD
whitfield · 750ml · Original case$380.00 USD
2015
Blend: Cab Sauv 82% · Merlot 6% · Cab Franc 7% · Petit Verdot 3% · Malbec 2%
5 active · from $520
cellarmaster_jp · 750ml · Cellared$680.00 USD
…and 4 more
2. DB — Option E
Legend:
PK ·
FK ·
new
producers
_id
name
country
region
description
website
unique(name, country)
←
wines (= LABEL)
_id
name
producerId
regionId
wineType
description
unique(name, producerId)
→ no 4-tuple needed
→ no 4-tuple needed
←
wineVintages NEW
_id
wineId
vintage (Number)
grapeVarietyIds[]
notes (optional)
unique(wineId, vintage)
auctionItems
_id
wineId → wine label
vintage (Number — pairs with wineId to resolve WineVintage)
sellerId
buyerId
startingPrice, currentBidAmount, …
bottleSize, storageConditions, quantity, images[]
status, timestamps
no change to indexes
How it resolves:
→ label =
→ blend =
If no WineVintage row exists for that pair, fall back to showing the label's default info (blend unknown for that year).
AuctionItem (wineId=W1, vintage=2018)→ label =
Wine W1→ blend =
WineVintage where wineId=W1 AND vintage=2018If no WineVintage row exists for that pair, fall back to showing the label's default info (blend unknown for that year).
Unchanged: producers, grapeVarieties, regions. These are already normalized reference tables and don't move.
3. How reads work (no row duplication)
Wine product page /wines/:wineId
// Page needs: label info + all vintages + all active auctions per vintage
1. Wine.findById(wineId)
.populate('producerId')
.populate('regionId') → 1 doc (the label)
2. WineVintage.find({ wineId })
.populate('grapeVarietyIds') → N vintage rows
3. AuctionItem.find({
wineId,
status: { $in: [Active, Sold] }
}) → auctions
4. Group auctions by vintage in memory, join with
step-2 WineVintage rows by vintage number. → render
Marketplace list, filter panel, card views
// Algolia stays the search/list index — no change.
// Its record per AuctionItem already denormalizes:
// labelName, producerName, regionName, vintage, grapeNames[]
//
// On label edit OR wineVintage edit → trigger a scoped reindex
// via the existing queue handlers (same pattern as catalog:approve).
Auction detail page
// One populate chain — no duplication, no snapshot.
AuctionItem.findById(id)
.populate({
path: 'wineId', → label
populate: [
{ path: 'producerId' },
{ path: 'regionId' },
]
})
// Then one extra lookup for the vintage-specific blend:
WineVintage.findOne({ wineId, vintage })
.populate('grapeVarietyIds') → blend for this year
4. Sell Wizard
List a bottle
Three layers: the wine label, the vintage details, and your specific bottle.
STEP 1
Pick the wine label
Autocomplete → Producer + Label name. If not found: "Add new wine to catalog" (goes to pending approval).
STEP 2
Vintage you're selling
If this
(wine, vintage) pair has a WineVintage row, show its blend and let seller confirm. Otherwise prompt for grape breakdown — creates a new pending WineVintage.STEP 3
Your specific bottle
Bottle size, storage conditions, quantity, images, starting price, end date — AuctionItem fields.
5. Change summary
✓ UNCHANGED
- producers, grapeVarieties, regions models + indexes
- Wine (label) unique index — stays
(name, producerId) - AuctionItem schema — still stores
vintageon the bottle (matches real-world: auction is for a specific year's bottle) - All existing list/search paths via Algolia
+ NEW
WineVintagemodel:{ wineId, vintage, grapeVarietyIds[], notes, status, createdBy }— unique(wineId, vintage)- Event handlers:
wineVintage:approved,wineVintage:updated→ reindex affected auctions in Algolia - Sell wizard step 2 — vintage-resolution + auto-create pending WineVintage if not found
- Admin catalog — new "Vintages" tab under each Wine for CRUD
~ MODIFIED
/wines/:wineIdendpoint — now joins WineVintage rows and groups auctions by vintage (B2 UX)mapAuctionItem— when detail view, include resolved WineVintage blend- Algolia record builder — read blend from WineVintage first, fall back to Wine
- Migration: for each distinct vintage in AuctionItems, create a WineVintage row (grapes copied from parent Wine as a starting point)