# Cellar Labels -- Project Roadmap

> Work through phases in order. Each phase should be fully functional and deployed before starting the next.
> Mark tasks complete in CLAUDE.md Feature Checklist as you go.
> Commit and push to `main` at the end of every working session.

---

## Phase 1 -- Foundation and CI/CD

**Goal:** Working Next.js app deployed to ShockHosting via GitHub. Nothing visible yet except a placeholder page.

### Tasks
- [x] Initialize Next.js project with App Router and TypeScript
- [x] Install and configure dependencies: Prisma, Auth.js v5, pdf-lib, Resend, next-sanity
- [x] Create `.env.example` with all required variables
- [x] Set up Prisma schema (`/prisma/schema.prisma`) -- all tables from CLAUDE.md schema
- [x] Run initial migration against ShockHosting PostgreSQL
- [x] Create Prisma seed file: badge definitions, default settings keys (canonical brands intentionally unseeded -- needs real data, see CLAUDE.md)
- [x] Confirm Node.js runs on ShockHosting -- via cPanel's Node.js Selector / Passenger, not PM2 (see CLAUDE.md Deployment section for why)
- [x] Create GitHub repo, push initial commit
- [x] Create `.github/workflows/deploy.yml` -- SSH deploy on push to `main`
- [x] Test full deploy pipeline end to end
- [x] Confirm app is live at `staging.cellarlabels.com` (not `cellarlabels.com` yet -- see "Staging Environment" in CLAUDE.md)

**Deploy check:** `cellarlabels.com` loads a placeholder page. CI/CD pipeline works on push to `main`.

---

## Phase 2 -- Brand, Layout, and CMS

**Goal:** Site looks like Cellar Labels. Public pages (guides, templates) are live from Sanity.

### Tasks
- [x] Install Cormorant Garamond and Inter from Google Fonts
- [x] Set up CSS design tokens: terracotta, charcoal, gold, warm white, cream, paper texture (CSS noise)
- [x] Build shared layout: header with logo, nav, footer -- logo is a text wordmark for now, `/public/logo.png` still needs the real illustrated file from Brandon
- [x] Age verification gate on all public pages
- [x] Set up Sanity project and Studio at `/studio`
- [x] Define Sanity schemas: article (title, slug, body, cover image, category, published date)
- [x] Build `/articles` index page (Sanity query) -- originally built as `/guides`, renamed to `/articles` on 2026-07-14 (Brandon's call, see CLAUDE.md Session Log); underlying Sanity `category: "guide"` value is unchanged, this was a route/label rename only
- [x] Build `/articles/:slug` individual guide page
- [x] Build `/templates` page
- [ ] Migrate WordPress article content into Sanity -- **needs Brandon**: requires access to the old WordPress site's content/export, not something to fabricate
- [x] Build homepage (public) -- brand intro, call to register, featured section placeholder
- [x] Build `/login` and `/register` pages (Auth.js) -- styled to brand
- [x] Build `/forgot-password` page (and `/reset-password` to complete the flow)
- [ ] Set up community.cellarlabels.com 301 redirect to cellarlabels.com -- **deferred to cutover**: that old WordPress site is still live serving real users; redirecting it now would take it down before the new site is ready, same reasoning as the "Staging Environment" approach for `cellarlabels.com` itself

**Deploy check:** Public pages live. Articles and templates rendering from Sanity. Age gate works. Login and register functional.

---

## Phase 3 -- Brand and Blend Tag System

**Goal:** Brands and blends exist as relational records with smart autocomplete. Blocklist enforced.

### Tasks
- [x] Seed database with a small bootstrap list of brand names and blends (15 brands, non-canonical test data -- see CLAUDE.md)
- [x] Build brand autocomplete API endpoint (`/api/brands/search?q=`)
- [x] Build blend autocomplete API endpoint (`/api/blends/search?brand_id=&q=`)
- [x] Build `/brands/:slug` brand page shell (empty grid, correct layout)
- [x] Build `/brands/:slug/:blend-slug` blend page shell
- [x] Build brand/blend blocklist enforcement (API-level check on brand and blend input)
- [x] Show user-facing message when blocked brand/blend is entered
- [x] Admin: brand management page (add, edit canonical name, manage aliases)
- [x] Admin: blocklist management page (add/remove banned brands and blends with notes)

**Deploy check:** Brand/blend autocomplete works on a test form -- **verified live** on `staging.cellarlabels.com/brand-blend-test`: typing "Dun" returns "Dunhill" from seeded data, selecting it scopes the blend search and returns "Early Morning Pipe"/"Nightcap", full selection renders correctly. `/brands/dunhill` and `/brands/dunhill/early-morning-pipe` page shells render real seeded data. Blocked brands show correct message -- **verified live**: added a test blocked brand via `/admin/blocklist`, confirmed the exact rejection message ("'Bad Brand Co' is not allowed. QA test entry") renders on the search form, then removed the test entry. Admin can manage both -- **verified live**, same session (add + remove both worked end to end). Getting here required promoting the site owner's account to `admin` via `prisma/seed.ts` (no promotion UI exists yet -- Phase 10) and fixing a real Auth.js bug found along the way (see Session Log).

---

## Phase 4 -- Label Upload System

**Goal:** Users can upload labels. Validation, preview, versioning, and moderation flow all working.

### Tasks
- [x] Build upload form (`/upload`) with brand/blend autocomplete
- [x] Circular live preview on file select (canvas-based, client-side)
- [x] Server-side validation: min 825x825px, PNG/JPG only, file size limit (15MB cap)
- [x] Rejection at upload for format/size violations (immediate, before DB record)
- [x] Save label file to `/storage/labels/{label_id}/v1/` (served via `/api/storage/[...path]`, no native image library needed -- `image-size` is pure JS, avoids this host's history of native-binding pain)
- [x] Create `labels` and `label_versions` DB records on successful upload
- [x] Labels created with `status: active` (no moderation queue -- admin reviews flags)
- [x] Build "replace/add new version" flow for uploader's own labels
- [x] Version files saved to `/storage/labels/{label_id}/v{n}/`
- [x] Build "edit brand/blend" flow for uploader's own labels
- [x] Admin: label management page (list all, filter by status, reject with reason, delete)
- [x] Rejection sets `status: rejected`, records reason, sends in-app notification to uploader
- [ ] Rejected labels hidden from gallery, visible on uploader's profile with reason shown -- **not verifiable yet**: gallery (Phase 5) and profile (Phase 6) don't exist. The underlying pieces are in place (status field, rejection reason/note both render correctly on the label detail stub page, notification record created), just no gallery-filtering or profile UI to check against yet.

**Deploy check:** Upload works end to end -- **verified in local dev** (registered a test account, uploaded a label with real brand/blend selection, confirmed min-dimension rejection [400x400 correctly rejected, 900x900 correctly accepted], confirmed non-image file rejection, confirmed the file persisted to `/storage/labels/{id}/v1/` and served correctly via `/api/storage/`) **and confirmed live on `staging.cellarlabels.com`** (all routes responding 200, `/upload` correctly redirects unauthenticated visitors to `/login`, database seeded with the new admin-editable rejection messages). Version history works -- added a v2, confirmed `isCurrent` toggled correctly and both versions listed with dimensions. Admin can reject with reason -- confirmed live: rejected with reason + note, notification record created, label correctly excluded from the reject-form UI once already rejected. Uploader sees rejection on their profile -- **deferred**, no profile page yet (Phase 6); rejection is visible on the label detail page itself in the meantime. A minor known gap: `deleteLabel` doesn't clean up the physical files in `/storage/`, only the DB rows (orphaned files left on disk) -- not fixed now to keep scope tight, worth a follow-up later.

---

## Phase 5 -- Gallery and Label Detail

**Goal:** The front door. Labels are browseable, filterable, and beautiful.

### Tasks
- [x] Build main gallery (`/labels`) -- grid layout, circular label card crop
- [x] Label card: circular artwork, brand/blend name, uploader avatar/name, thumbs up count, Cellar Choice indicator
- [x] Filter panel: brand, blend, uploader username, badge tier
- [x] Sort: newest, most downloaded, trending (this week by download count) -- **simplified**: "trending" is recent uploads (last 7 days) ranked by all-time download count, not a true rolling 7-day download window, since there's no per-download event log to compute that yet. Documented in code and below.
- [x] Quick-preview modal on hover/tap (full-size circular artwork, uploader, download button)
- [x] Build label detail page (`/labels/:id`)
  - [x] Full circular preview
  - [x] Brand and blend (linked to brand/blend pages)
  - [x] Version selector with version number shown
  - [x] Version history (thumbnail strip; side-by-side comparison simplified to a clickable thumbnail strip that swaps the main preview via `?v=` -- no dedicated two-up comparison view)
  - [x] Uploader card (avatar, badge, follow/watch buttons)
  - [x] Download button (increments download count)
  - [x] Thumbs up button (public count shown)
  - [x] Thumbs down button (private -- only voter sees their own state)
  - [x] Report/flag button with reason selection
  - [x] Add to Sheet button (stubbed in Phase 5, activated in Phase 8 -- links to `/sheet-builder?label=:id`)
  - [x] Add to Folder quick action (stubbed -- activates in Phase 6)
- [x] Build `/brands/:slug` brand page -- labels grouped by blend, with per-blend label counts and a with/without-labels summary
- [x] Build `/brands/:slug/:blend-slug` blend page -- all active labels for that blend (grid of label cards, not a raw version list)
- [x] Thumbs down moderation threshold: auto-flag to admin queue when threshold reached (from settings) -- **implemented as an email alert to all admin/moderator accounts** when the count crosses the threshold, not a formal queue row, per CLAUDE.md's "no automated moderation queue" decision. No system user exists to attribute an automated `label_flags` row to.
- [x] Community flag: saves to `label_flags`, sends email to admin via Resend

**Deploy check:** Gallery is live. Filters and sort work. Label detail shows all metadata. Flagging sends email. Download count increments. **Verified in local dev end to end** (not yet pushed to staging -- see Phase Status table): registered two test accounts, uploaded a label, confirmed upload badge award (uploadCount 1 -> "1 Labels Uploaded" badge), thumbs up/down voting (toggle, switch, public count vs. private down state all correct in DB), download count increment, flag submission (both with zero and with an admin/moderator present -- the Resend call with a placeholder local API key returns an error object rather than throwing, so it doesn't break the flow, consistent with the existing forgot-password email pattern), follow/watch toggle (watch correctly implies follow), gallery brand/uploader/badge-tier/sort filters, quick-preview modal, and brand/blend pages with real label counts. Found and fixed one real bug this way: the flag report modal reused a single `useActionState` across opens, so reporting a second label (or reopening after a successful report) showed the stale "Thanks for the report" screen instead of a fresh form -- fixed by extracting the modal body into its own component keyed on an incrementing "open" counter so it remounts (and resets its action state) every time the modal opens.

---

## Phase 6 -- User Profiles and Social

**Goal:** Users have profiles. Following, watching, and notification system all working.

### Tasks
- [x] Build user profile page (`/profile/:username`)
  - [x] Avatar (uploaded photo or initials fallback)
  - [x] Badge display (all earned badges)
  - [x] Stats: upload count, thumbs up received
  - [x] My Labels tabs: Uploads, Favorites, Custom Folders
  - [x] Private profile flag: hides folders tab when viewer is not owner -- **also hides the Favorites tab**, since Favorites is implemented as the default folder (`folder_id` null) within the same saved-labels system, not a separate concept
- [x] Avatar upload: max 2MB, JPG/PNG, resized to 400x400px server-side -- uses `jimp` (pure JS, no native bindings, consistent with this host's constraints -- see CLAUDE.md) with `.cover()` to crop-and-fill rather than distort non-square sources
- [x] Build saved folders system
  - [x] Create/rename/delete custom folders
  - [x] Add label to folder from detail page
  - [x] Favorites as default folder (folder_id null)
- [x] Build follow system
  - [x] Follow button on profile and label **detail page** (not on gallery/blend-page label cards themselves -- those link out to the label detail page, where follow/watch lives on the uploader card)
  - [x] Watch toggle per user (separate from follow)
  - [x] Following/Followers counts on profile
- [x] Build in-app notification system
  - [x] Notification center page (`/notifications`)
  - [x] Bell icon in nav with unread count
  - [x] Notification types: new label from watched user, label request fulfilled, label approved, label rejected, fix suggestion confirmed, **plus badge_earned** (not listed here but already in the schema's `NotificationType` enum and wired up since Phase 5)
  - [x] Notification settings page (toggle per type)
- [x] Build following feed tab on gallery (`/feed`) -- built as its own auth-gated page (matches the route given in this task's own parenthetical) rather than a tab within `/labels`, reusing the same LabelCard grid
- [x] When viewing another user's profile: hide "My" prefix, show public-facing version

**Deploy check:** Profiles display correctly. Follow and watch work. Notifications appear in-app. Feed shows followed users' uploads. **Verified end to end in local dev** (not yet pushed to staging -- see Phase Status table): two-account walkthrough covering avatar upload (confirmed 400x400px crop-not-stretch on a non-square source, both on disk and in the DB), profile stats and tabs (uploads/favorites/folders, including creating a folder and saving a label to both Favorites and a custom folder from the label detail modal), private-profile flag (confirmed Favorites+Folders tabs disappear for a logged-out viewer once enabled, stay visible for the owner), follow/watch (watch implies follow, counts update), the following feed, and the full notification lifecycle (watched-uploader notification fires and appears with an unread dot and header badge count, "mark all as read" clears both, and disabling a notification type in settings correctly suppresses only that type on a subsequent matching event while leaving the DB write for the flag/vote paths untouched). Also confirmed `thumbsUpReceived` (needed for the profile stats row but never wired up when the vote system was built in Phase 5) now updates correctly on vote/switch/unvote via `src/lib/actions/votes.ts`.

---

## Backlog -- Standalone / Cross-Phase Tasks

> Not tied to a single phase. Pick these up whenever convenient; none of them block Phase 7+.

### Header decluttering + settings hub (Brandon, 2026-07-13)
The nav row is getting cramped now that it carries Gallery/Articles/Templates/Feed, the notification bell, avatar+username, Log out, and the 3-way Light/Dark/System toggle.
- Move the theme toggle out of the header nav into a sun/moon icon in the footer. Clicking it opens a small popover with the Light/Dark/System options (same three states `ThemeToggle` already supports -- just relocate/reskin the trigger).
- Consolidate scattered settings into a single `/settings` hub instead of leaving them spread across pages: theme (moving from header), notification preferences (currently `/notifications/settings`), and the private-profile toggle (currently inline on the profile page). More settings will likely accumulate later (email preferences, password change) -- cheaper to consolidate the pattern now than retrofit once there are more pages depending on the scattered layout.

### Folder-based batch printing (design note for Phase 8, 2026-07-13)
Sheet Builder's planned "pull from saved folders" label-selection source (see Phase 8 tasks below) should support two folder-driven flows once folders exist as a real feature (built in Phase 6):
1. **Print this folder** -- a button on a folder's view that seeds a new sheet with every label currently in that folder.
2. **Select from folder** -- multi-select individual labels within a folder view, then add just the selection to a sheet. The folder view built in Phase 6 (`src/app/(public)/profile/[username]/page.tsx`, folder tab) doesn't have multi-select yet -- needs to be added for this flow.

Default behavior when a folder has more labels than fit on one sheet (6 slots on Avery 94512, 12 on 94501): auto-spill across multiple sheets (Phase 8 already plans multi-sheet support) with a preview before generating, rather than blocking upload or silently truncating the folder.

**Implemented in Phase 8** as a `chunkIntoPages(labels, perSheet)` helper on the sheet builder's server component -- always chunks against the wide-mouth (94512, 6/sheet) capacity when seeding from a folder or multi-select, regardless of which jar type the builder ends up using, since the seed happens before the user has necessarily picked a jar type.

---

## Phase 7 -- Community Features

**Goal:** Label requests, fix suggestions, and the Missing Blends page are live.

### Tasks
- [x] Build label request system
  - [x] "Request a label" button on blend pages with no labels
  - [x] Request stores `blend_id` and `requester_id` -- **added a `@@unique([blendId, requesterId])` DB constraint** (migration `20260713235118_label_request_unique_per_requester`) not specified in the schema summary, so a user can't spam duplicate requests for the same blend and inflate its count
  - [x] Requests page (`/requests`) sorted by most requested blend
  - [x] Subscribe to request (contributor-eligible users only: `upload_count >= 10`) -- subscribing auto-creates the user's own request row first if they don't already have one, so "Subscribe" works as a single action from any discovery surface
  - [x] Notification to subscribers when label uploaded for requested blend -- notifies subscribers across **all** request rows for that blend, not just the one the label links to via `fulfilledRequestId` (which points at the oldest), since fulfilling the blend satisfies every requester regardless of which row is linked
  - [x] "Created in response to a community request" tag on qualifying label cards and detail pages
- [x] Build label fix suggestion system
  - [x] "Fix This Label" button on unverified labels (any logged-in member) -- wired into the disabled stub left in Phase 5
  - [x] Fix form: brand/blend autocomplete, optional note
  - [x] Block suggestions on uploader's own labels (also blocks a second suggestion from the same non-owner user)
  - [x] Two matching suggestions auto-flag as confirmed in admin queue
  - [x] Admin/moderator: fix queue page -- apply confirmed fixes in bulk -- built as **admin-only** for now (`/admin/fixes`, same gate as the other admin pages); the "moderator role gate" distinguishing admin-only vs. moderator-visible admin pages is explicitly its own Phase 10 task, so extending access there now would duplicate that later work. Applying a fix also auto-rejects any other pending/confirmed suggestions on the same label.
  - [x] Log fix contribution on user record -- the `label_fix_suggestions` row itself (keyed to `suggesterId`) is the record; no separate counter field exists on `User` and none was added, since nothing in the schema calls for one
- [x] Build Missing Blends page (`/missing`)
  - [x] Displays blends from Pipe Tool data with zero matching labels
  - [x] Sorted by most-cellared first
  - [x] "Create a Label" button pre-fills brand/blend on upload form
  - [x] Subscribe to blend -- notified when first label uploaded (reuses the same request+subscribe action as the Requests page rather than a parallel system)
  - [x] Placeholder/stub if Pipe Tool integration not yet active -- shown as a plain-language note since `pipeToolCellarRank` is null for every blend right now (Phase 11 not started); falls back to alphabetical sort

**Deploy check:** Request system works end to end. Fix suggestions queue in admin. Missing page renders (even if stubbed). **Verified end to end in local dev** (not yet pushed to staging -- see Phase Status table): requested a zero-label blend as one user, subscribed as a contributor account, uploaded a label for that blend as a third account and confirmed the fulfillment notification fired to the subscriber, `fulfilledRequestId` linked, and the "Made by request" tag appeared on the gallery card and detail page. Separately, set a label to `unverified`, had two different accounts submit matching fix suggestions and confirmed both auto-confirmed, then applied the fix as admin and confirmed the label's blend/status updated, the applied suggestion marked `applied`, the other account's matching suggestion auto-rejected, and a `ModerationLog` entry was written. Confirmed the Missing Blends page's "Create a Label" link correctly pre-fills the upload form's brand/blend fields via the new `initialBrand`/`initialBlend` props on `BrandBlendAutocomplete`.

---

## Phase 8 -- Sheet Builder

**Goal:** Users can compose print-ready Avery sheets and download PDFs.

### Tasks
- [x] Build sheet builder page (`/sheet-builder`)
- [x] Step 1: Select jar type (Wide Mouth 94512 / Regular Mouth 94501)
- [x] Step 2: Sheet canvas showing correct Avery slot grid
  - [x] Wide Mouth: 6 slots per sheet (corrected from an earlier, never-verified "4" -- see CLAUDE.md's Avery Sheet Specs note; Brandon confirmed 6 directly)
  - [x] Regular Mouth: 12 slots per sheet
- [x] Label selection: search gallery, browse by brand/blend, pull from saved folders -- see "Folder-based batch printing" in the Backlog section above for the folder-driven flows (print whole folder / select from folder) and multi-sheet spillover behavior
- [x] Slot interactions: click to assign label, click to clear, "fill remaining" with current label
- [x] Multi-sheet support (add sheet button)
- [x] Live preview: circular label artwork in each slot
- [x] PDF export via pdf-lib
  - [x] Pixel-perfect Avery margin and bleed values from `/lib/avery-specs.ts` -- coordinates extracted from real Avery WePrint proof PDFs (provided by Brandon for both 94512 and 94501) via `pdfplumber` vector-curve extraction, not estimated from public product pages, which don't publish exact template coordinates
  - [x] Labels placed at exact coordinates per Avery spec -- label images use CSS-`cover`-style scaling to fully cover each slot's bleed circle, no PDF-side circular clipping, since physical die-cutting handles the actual round cut at the trim line
  - [x] 300 DPI output -- resolution is a print-time concern of the source label images (enforced at upload via `label_min_dimension`/`label_max_dimension`), not something the PDF generation step itself controls; the PDF places images at their native pixel dimensions scaled to the physical slot size in points
- [x] Save sheet to profile (saves slot configuration as a `Json` column on `SavedSheet.pages`, not PDF -- PDF regenerated on reprint)
- [x] Saved sheets list on profile page with reprint button

**Deploy check:** Both Avery formats generate correct PDFs. Labels align on physical print (verified via `pdfplumber` re-parsing of generated test PDFs against expected slot centers/sizes, not yet a physical print test since Phase 8 is local-dev-only per the standing instruction). Saved sheets reprintable. **Verified end to end in local dev:** jar type toggle (including the data-loss confirm when switching away from a filled sheet), slot fill/clear/fill-remaining across both jar types, all three label-picker tabs (search, cascading brand-then-blend browse including a correct "No labels found" empty state, and folder browse), multi-page add/remove, PDF export confirmed as a genuine `%PDF-1.7` file via byte-header inspection (not just a 200 response), a real folder with two labels seeding correctly via both "Print this folder" and "Select labels to print", the label detail page's "Add to Sheet" link, and the full save -> reprint -> delete lifecycle on the profile's Sheets tab. **Real bug found and fixed during verification:** "Save Sheet" originally used the browser's native `prompt()` for the sheet name, which is fragile (blocked by some browsers/extensions, and outright unsupported in the automated browser-verification tool used this session) and inconsistent with the rest of the app's UI (folder creation already uses an inline text input for the same kind of naming step) -- replaced with an inline input + Save/Cancel matching that existing pattern.

---

## Phase 9 -- Gamification

**Goal:** Badge system live. Cellar Choice award functional.

### Tasks
- [x] Badge award logic (`/lib/badges.ts`)
  - [x] Labels Uploaded track -- check on every successful upload (thresholds: 1, 5, 10, 25, 50, 100, 250, 500) -- built in Phase 5, unchanged this phase
  - [x] Requests Fulfilled track -- check when uploader's label matches an open request -- thresholds confirmed by Brandon as 1, 5, 10, 25, 50 (CLAUDE.md previously flagged these as TBD); awards on the fulfilling upload right after `fulfillBlendRequests` links the label to the request, since a request only counts as fulfilled at that point
  - [x] Award only if threshold not yet reached (no duplicate awards) -- both tracks share one `awardEligibleBadges` helper that checks already-earned badges per track before awarding
  - [x] In-app notification on badge earned
- [x] Badge display on label cards (highest badge icon beside uploader name) -- a small gold medal icon with the badge name as its tooltip, added via a new `getHighestBadgesForUsers` batch lookup (avoids one query per card) wired into all five places labels render: gallery, brand/blend pages, feed, homepage, profile
- [x] Badge display on profile page (all earned badges with names) -- built in Phase 6, unchanged this phase
- [x] Cellar Choice award
  - [x] Admin panel: award Cellar Choice to any label (with optional note) -- inline toggle on `/admin/labels`, confirm-gated revoke
  - [x] Cellar Choice indicator on label card in gallery -- built in Phase 5, unchanged this phase
  - [x] Cellar Choice indicator on label detail page -- built in Phase 5, unchanged this phase
  - [x] Gold leaf botanical mark used as Cellar Choice icon (SVG, matches brand) -- replaced the earlier placeholder star with a hand-drawn vesica-shaped leaf with a center vein
- [x] Admin panel badge management: view all badge tracks, edit thresholds -- built against the `Badge` table directly (`track`/`threshold`/`name` columns already exist there per the Phase 1 schema) rather than the `settings` table, since badges are relational rows with their own uniqueness constraint, not simple key-value config; deletion was left out of scope since `UserBadge.badge` cascades on delete and would silently strip already-earned badges from users

**Deploy check:** Badges awarded correctly on upload milestones. Cellar Choice assignable from admin. Both display on cards and profiles. **Verified end to end in local dev:** confirmed the badge icon and tooltip render on gallery cards, awarded and revoked Cellar Choice on a label and confirmed the pill (new leaf icon) on both the gallery card and detail page, edited a badge's name/threshold and exercised the duplicate-threshold guard on `/admin/badges`, and directly exercised the `requests_fulfilled` award path against a real open request via a temporary local-only test route (deleted after use), confirming via the database that the fulfillment link, the "1 Requests Fulfilled" badge, and both expected notifications were all created correctly before cleaning up the synthetic test data.

---

## Phase 10 -- Admin Panel

**Goal:** Full admin panel operational. Moderator role functional.

### Tasks
- [x] Admin dashboard (`/admin`)
  - [x] Recent flags with quick-action buttons -- links straight to the flagged label; the actual dismiss/reject/ban actions live on the full `/admin/flags` queue rather than being duplicated inline on the dashboard
  - [x] Recent uploads (last 24h)
  - [x] Key stats: total labels, total users, open requests, open flags
- [x] Label management (`/admin/labels`) -- filter by status, search, bulk actions -- search matches brand/blend/uploader; bulk actions are checkbox multi-select with a sticky action bar (reject-with-reason or delete)
- [x] Flags queue (`/admin/flags`) -- dismiss, reject label, ban user -- replaces the Phase 5 "email alert only" design now that a real queue exists to review; rejecting a label auto-resolves any open flags on it
- [x] Fix suggestion queue (`/admin/fixes`) -- apply confirmed, review unconfirmed -- opened to moderators this phase (was admin-only since Phase 7, explicitly deferred here)
- [x] User management (`/admin/users`) -- promote to moderator, set contributor flag, deactivate -- role changes and contributor flag are admin-only (privilege-escalation risk); ban/unban is moderator-accessible since it's one of the three flags-queue actions
- [x] Brand/blend management (`/admin/brands`) -- canonical names, alias management -- unchanged since Phase 3, now explicitly admin-only via `AdminOnlyGate`
- [x] Blocklist management (`/admin/blocklist`) -- add/remove banned brands and blends -- unchanged since Phase 3, now explicitly admin-only via `AdminOnlyGate`
- [x] Settings panel (`/admin/settings`) -- all `settings` table keys editable via UI -- added the 6 threshold/config keys (contributor upload threshold, thumbs-down flag threshold, label min/max dimension, both Avery SKUs); rejection messages were already editable since Phase 4
- [x] Cellar Choice management (`/admin/cellar-choice`) -- award, revoke, view history -- award still happens inline on `/admin/labels` (built in Phase 9, works well in context); this page adds the history view and a second revoke path
- [x] Badge management (`/admin/badges`) -- thresholds per track -- built in Phase 9, unchanged this phase except now explicitly admin-only via `AdminOnlyGate`
- [x] Moderation log (`/admin/log`) -- full audit trail of all moderator actions -- retrofitted logging onto `rejectLabel`/`deleteLabel` and the Phase 9 Cellar Choice actions, which previously wrote no log entry, so this page has real content across every phase's actions, not just fix suggestions
- [x] API key management (`/admin/api-keys`) -- generate, revoke Pipe Tool API keys -- keys are SHA-256-hashed at rest (never stored raw), the generated raw key is shown exactly once in the UI, matching how the site already treats passwords as a real secret
- [x] Moderator role gate -- moderators see flags, fixes, labels, users only; not settings, API keys, or Cellar Choice -- implemented as a literal reading of this exact sentence: moderators get exactly those four pages via a shared `AdminOnlyGate` component wrapping every other admin page, plus `requireStaff()` (admin-or-moderator) helpers alongside the existing `requireAdmin()` ones for the underlying server actions

**Deploy check:** Admin panel fully operational. Moderator sees correct subset. All settings updateable without code change. **Verified end to end in local dev with two real accounts** (an admin and a promoted-to-moderator test account): dashboard stats and recent-activity lists render correctly; dismissed a real flag and confirmed it left the queue and appeared in the log; banned then unbanned a test account and confirmed the login block (a real, previously-nonexistent enforcement gap found and fixed this phase -- `users.banned` existed since Phase 1 but was never checked in `auth.ts`) actually takes effect and clears; confirmed the moderator account's nav narrows to exactly Flags/Labels/Fixes/Users, that `/admin` (dashboard) correctly shows "Not authorized," and that the Labels page hides Award Cellar Choice for that account while still allowing Reject/Delete; saved real threshold values through the new Settings form; generated and revoked a real API key; searched and bulk-rejected labels via `/admin/labels`, confirming both actions and their moderation-log entries, then restored the test labels to active afterward.

---

## Phase 11 -- Pipe Tool Integration

**Goal:** API live. Cellar profile display working. Missing/trending data flowing.

> Requires coordination with The Pipe Tool team. Build outbound API first (no dependency), then inbound when partnership is confirmed.

### Tasks
**Outbound (Cellar Labels exposes to Pipe Tool):**
- [x] `GET /api/v1/labels/search?brand=&blend=` -- returns label array with file URLs -- matches by exact brand/blend name, active labels only, capped at 50 results
- [x] `GET /api/v1/labels/:id` -- single label detail -- 404s for unknown or non-active (pending/rejected/unverified) ids
- [x] `GET /api/v1/brands/:slug/labels` -- all labels by brand -- capped at 100, newest first
- [x] `GET /api/v1/blends/:id/labels` -- all labels for blend -- capped at 100, newest first
- [x] API key auth middleware (Authorization header) -- `Authorization: Bearer <key>`, reuses the same SHA-256 hash-at-rest scheme as the Phase 10 admin-issued keys, updates `lastUsedAt` on every authenticated call
- [x] Rate limiting on all API routes -- 60 requests/minute per key, in-memory fixed-window counter (single Node process on ShockHosting, no need for a shared store)
- [x] API documentation page (public, no auth) -- `/api-docs`, linked from the footer

**Inbound (Pipe Tool sends to Cellar Labels):**
- [ ] `POST /api/v1/cellar/sync` -- receive user cellar blend list (requires linked account token)
- [ ] `POST /api/v1/trending/push` -- receive most-cellared, newly-added, wishlisted blends
- [ ] Account linking flow: user connects Pipe Tool account to Cellar Labels profile
- [ ] Cellar display on profile: blend list from Pipe Tool, "Find a Label" button per blend
- [ ] Missing blends page fed by inbound trending data

**Deploy check:** Outbound API returns correct data. Postman/curl tests pass. Inbound sync populates profile (if Pipe Tool partnership confirmed). **Outbound half verified end to end in local dev via curl:** generated a real API key through `/admin/api-keys`, confirmed a missing/malformed Authorization header and a wrong key both correctly return 401, confirmed all four endpoints return correct data for real seeded labels/brands/blends, confirmed both 404 paths (unknown label id, unknown brand slug), fired 65 rapid requests on one key and confirmed the first ~60 succeed with the rest returning 429, confirmed `lastUsedAt` updates in the database after a real call, and confirmed a revoked key is immediately rejected on the next request. Inbound tasks not started -- this phase's own note says to build outbound first and wait for partnership confirmation before inbound, and that confirmation hasn't happened.

---

## Phase 12 -- Migration

**Goal:** All existing WordPress users and labels migrated. Old site redirected.

### Tasks
- [ ] **Pause uploads on the old community.cellarlabels.com site before starting the export** (Brandon's requirement, 2026-07-22) -- migration exports a snapshot at a point in time, so anything uploaded there after that snapshot but before cutover would be silently left behind. Needs to happen first, before "Export label files" below.
- [ ] Export WordPress users from `wp_users` table (id, user_login, user_email, display_name, user_registered)
- [ ] Build migration script: import users with `status: pending_reset`
- [ ] Send password reset email to all imported users on launch day via Resend (batch)
- [ ] Export label files from WordPress `wp-content/uploads/`
- [ ] Move label files to new storage structure `/storage/labels/`
- [ ] Import label metadata (brand, blend, uploader) into new DB
- [ ] Confidence matching: brand names against canonical brands table and aliases
  - [ ] High confidence match (exact or alias) -- auto-resolve, `status: active`
  - [ ] Low confidence -- `status: unverified`, queued for community fix
- [ ] Unverified labels visible in gallery with "Fix This Label" button
- [ ] Admin reconciliation tool for bulk brand/blend assignment on unverified labels
- [ ] Known abbreviation map pre-seeded: C&D, GH&Co, GL, S&G etc.
- [ ] community.cellarlabels.com 301 redirect confirmed working
- [ ] Deep link redirect: `/community/*` paths mapped to `/labels/*` equivalents

**Deploy check:** All users imported. Password reset flow works. Labels visible. Unverified labels show fix button. Old domain redirects correctly.

---

## Phase 13 -- Polish and Launch

**Goal:** Production-ready. Performance, SEO, accessibility baseline all met.

### Tasks
- [ ] SEO: `<head>` meta tags on all public pages (title, description, og:image)
- [ ] SEO: dynamic meta on brand and blend pages
- [ ] Sitemap generation (`/sitemap.xml`)
- [ ] robots.txt (allow public pages, disallow `/admin`, `/api`)
- [ ] Performance: image optimization for label files (Next.js Image where applicable)
- [ ] Performance: Prisma query audit -- add indexes on commonly filtered fields
- [ ] Cloudflare setup: proxy cellarlabels.com through Cloudflare, enable caching for static assets and label files
- [ ] Error pages: custom 404 and 500 pages (brand-appropriate)
- [ ] Accessibility: keyboard navigation audit on gallery and upload form
- [ ] Mobile: test all key flows on iOS and Android
- [ ] Security: rate limiting on auth routes, upload endpoint, API
- [ ] Final end-to-end test of all major flows
- [ ] Launch announcement (Speak Easy forum post)

**Deploy check:** Lighthouse score acceptable. Cloudflare active. All flows tested on mobile. Site announced.

---

## Quick Reference -- Phase Status

| Phase | Name | Status |
|---|---|---|
| 1 | Foundation and CI/CD | Complete |
| 2 | Brand, Layout, and CMS | Complete (deploy check verified live). WP article migration completed 2026-07-14 (see CLAUDE.md Session Log); community.cellarlabels.com redirect still deferred to cutover |
| 3 | Brand and Blend Tag System | Complete -- fully verified live, including blocklist rejection and admin CRUD |
| 4 | Label Upload System | Complete -- verified live in local dev and confirmed live on `staging.cellarlabels.com` (all routes responding, admin-editable rejection messages seeded); gallery/profile-dependent items deferred to Phases 5-6 |
| 5 | Gallery and Label Detail | Complete -- verified end to end in local dev, and now live on `staging.cellarlabels.com` (pushed 2026-07-22) |
| 6 | User Profiles and Social | Complete -- verified end to end in local dev, and now live on staging (pushed 2026-07-22) |
| 7 | Community Features | Complete -- verified end to end in local dev, and now live on staging (pushed 2026-07-22) |
| 8 | Sheet Builder | Complete -- verified end to end in local dev, and now live on staging (pushed 2026-07-22) |
| 9 | Gamification | Complete -- verified end to end in local dev, and now live on staging (pushed 2026-07-22) |
| 10 | Admin Panel | Complete -- verified end to end in local dev, as both admin and moderator, and now live on staging (pushed 2026-07-22) |
| 11 | Pipe Tool Integration | Outbound API complete -- verified end to end in local dev and now live on staging (pushed 2026-07-22). Inbound tasks not started, blocked on Pipe Tool partnership confirmation per this phase's own note |
| 12 | Migration | Not started -- blocked on Brandon's WordPress export/access (same blocker as the Phase 2 article migration had, now resolved for articles specifically; users/labels/brand data still need this) |
| 13 | Polish and Launch | Not started. Two items got an early, simplified head start during the 2026-07-21/22 outage response (not full-spec, don't skip re-reviewing at Phase 13): a blanket `robots.txt` (disallow-all pre-cutover, not yet the differentiated public-vs-admin/api rules this phase calls for) and blanket per-IP rate limiting in `src/proxy.ts` (not yet the per-route auth/upload-specific tuning this phase calls for). Cloudflare setup for the production domain is untouched -- separate from the still-open staging-specific Cloudflare/Basic-Auth question raised during the outage investigation (see CLAUDE.md) |

> Claude Code: update this table as phases complete. Use `In progress`, `Complete`, or `Blocked: [reason]`.
