# Cellar Labels -- Claude Code Context

> This file is the single source of truth for this project.
> Claude Code must read this file at the start of every session.
> Claude Code must update the Phase Tracker and Session Log sections after completing work.

---

## Project Overview

**Site:** cellarlabels.com (replacing community.cellarlabels.com)
**Owner:** Brandon
**What it is:** A locked community platform for pipe tobacco enthusiasts to upload, share, download, and print round jar labels. Users must register to access any labels. The site also serves as the public-facing blog for guides and printing templates.

**Core value proposition:** The only place that combines a community label library with a browser-based print sheet builder designed specifically for Avery round label formats.

---

## Brand Guide

| Token | Value |
|---|---|
| Primary | `#D03A00` Deep Terracotta |
| Charcoal | `#111111` Text, outlines |
| Gold | `#D8A03D` Antique Gold -- accents, Cellar Choice award, botanical elements |
| Warm White | `#F8F5F0` Card surfaces |
| Cream | `#F5F2EC` Page background |
| Heading font | Cormorant Garamond (Google Fonts) -- everyday content headings |
| Body font | Inter (Google Fonts) |
| Style | Vintage print, botanical tobacco leaves, paper texture (CSS noise, no image file), serif headlines + clean sans body |

**Dark mode:** Site-wide light/dark theming, default follows OS/browser `prefers-color-scheme`, user can override via a Light/Dark/System toggle in the header (persisted in `localStorage`, powered by `next-themes`, `attribute="class"` on `<html>`). `charcoal`/`cream`/`warm-white` are semantic tokens (ink / page background / card surface), not literal colors -- each is a CSS variable (`src/styles/globals.css`, `:root` vs `.dark`) referenced via `rgb(var(--x) / <alpha-value>)` in `tailwind.config.ts` so existing utility classes like `bg-cream` or `text-charcoal/60` automatically flip. Terracotta stays `#D03A00` in both modes (Brandon's call -- a brightened dark-mode variant tested better for small-text contrast but read as too orange/off-brand; the standard color dips under WCAG AA for small text on near-black, ~3.8:1, but is fine for the logo/heading use it's mostly used for). Gold is unchanged (reads well on both). Plain `text-white` (not a token) is used for text sitting on the always-orange terracotta button fill, and modal scrims use `bg-black/70` (not `bg-charcoal`) -- both are literal, theme-invariant colors, not part of the semantic system.

**Logo:** Round, deep terracotta, serif CELLAR LABELS text, tobacco leaf botanical art in antique gold. `/public/logo.png` -- real asset in place as of Phase 2, used in the Header.

**Wordmark / display lettering:** `/public/wordmark.png` -- "Cellar Labels" set in Sacred Bridge (Bombastype), a licensed font. **Not loaded as a live webfont** -- the license only covers rendered-image use, not embedding. Used for large brand-forward moments (hero H1, age gate, footer) as a pre-rendered transparent PNG via plain `<img>`. The source font file lives at `/src/fonts/SacredBridge.ttf` for regenerating the image if the wording ever needs to change -- it must never be loaded via `next/font/local` or any `@font-face`/CSS embedding again.

**Other brand images:** `/public/images/` -- jar mockups, blank label templates, misc article imagery (legacy WordPress assets, not yet wired into any page).

---

## Tech Stack

| Layer | Choice | Notes |
|---|---|---|
| Framework | Next.js (App Router) | SSR for public pages and SEO; client components for auth app |
| Database | PostgreSQL | Hosted on ShockHosting |
| ORM | Prisma | Schema in `/prisma/schema.prisma` |
| Auth | Auth.js v5 | Role-based sessions; no WP hash migration -- forced password reset on import |
| File Storage | ShockHosting filesystem | `/storage/labels/`, `/storage/avatars/`, `/storage/sheets/` |
| CDN | Cloudflare (free plan) | In front of cellarlabels.com |
| CMS | Sanity | Articles, printing templates. Studio hosted standalone at Sanity's free `*.sanity.studio` domain, not part of this app (see `studio/`) |
| PDF Generation | pdf-lib | Sheet Builder output -- Avery 94512 (2.75") and 94501 (2.00") |
| Email | Resend | Flag alerts, rejection notices, password resets |
| Hosting | ShockHosting | Node.js + PostgreSQL confirmed |
| Process Manager | LiteSpeed (cPanel Node.js Selector) | Keeps Next.js running on ShockHosting -- not PM2, see Deployment section. Confirmed directly by ShockHosting support on 2026-07-21; this app's `.htaccess` uses `Passenger*`-named directives for compatibility, but the actual web server/process manager is LiteSpeed, not Phusion Passenger |
| CI/CD | GitHub Actions | Push to `main` triggers deploy to ShockHosting via SSH |

---

## Roles

| Role | Access |
|---|---|
| `admin` | Full access including admin panel and all settings |
| `moderator` | Flags queue, fix queue, label approve/reject, user management (ban, not role changes) -- no dashboard, brands, blocklist, badges, Cellar Choice, moderation log, admin settings, or API keys |
| `member` | Standard registered user |

Contributor status is a `contributor_flag: bool` on the user record. Automatically set to `true` when `upload_count >= 10`. Not a role.

---

## Database Schema Summary

Full schema is in `/prisma/schema.prisma`. Tables by domain:

**Users & Auth:** `users`, `sessions` (Auth.js managed)

**Content:** `brands`, `brand_aliases`, `blends`, `labels`, `label_versions`

**Community:** `label_votes`, `label_flags`, `label_fix_suggestions`, `label_requests`

**Social:** `follows`, `notifications`, `saved_folders`, `saved_labels`

**Gamification:** `badges`, `user_badges`, `cellar_choice_awards`

**Admin:** `settings`, `moderation_log`

### Key field notes

- `labels.status` enum: `pending | active | rejected | unverified`
- `labels.rejection_reason` enum: `black_background | non_round | min_size | duplicate | brand_blend_mismatch | custom`
- `users.role` enum: `admin | moderator | member`
- `follows.watch_uploads` bool: simple follow vs. watched follow (triggers notification)
- `saved_labels.folder_id` is nullable -- null means general Favorites
- `user_badges.label_id` is nullable -- only populated for request-fulfilled badges
- `settings` is a key-value table -- all admin-configurable thresholds live here
- `label_versions.is_current` -- gallery always shows current version; detail page shows all

### Admin-configurable settings keys

| Key | Default | Description |
|---|---|---|
| `contributor_upload_threshold` | `10` | Uploads required for contributor status |
| `thumbs_down_flag_threshold` | `5` | Thumbs down before auto-flag to moderation |
| `label_min_dimension` | `825` | Minimum px for label upload (width and height) -- calculated for 300dpi print quality at the largest Avery label size (2.75") |
| `label_max_dimension` | `2000` | Maximum px for label upload (width and height) -- anything larger has no printing benefit for this use case, kept tight given constrained storage/bandwidth on this host |
| `sheet_builder_avery_wide` | `94512` | Wide mouth Avery SKU (2.75") |
| `sheet_builder_avery_regular` | `94501` | Regular mouth Avery SKU (2.00") |

---

## Feature Checklist

### Label System
- [ ] Brand/blend smart autocomplete (dropdown filtered by brand then blend)
- [ ] Upload with circular live preview before submission
- [ ] Min 825x825px enforcement + format validation (PNG/JPG only)
- [ ] Label versioning (version number on detail page, view older versions)
- [ ] Rejected labels hidden from gallery, visible to uploader with reason
- [ ] Uploader can edit brand/blend tags on own labels
- [ ] Uploader can replace/add new version of own label
- [ ] Banned brand/blend blocklist enforced on upload form
- [ ] Community flagging (report a label) -- triggers email to admin
- [ ] Thumbs up (public count) / thumbs down (private, moderation signal only)
- [ ] Moderation log for all admin/moderator actions

### Gallery
- [ ] Grid gallery with circular label preview
- [ ] Filter: brand, blend, uploader, badge tier
- [ ] Sort: newest, most downloaded, most favorited, trending (this week)
- [ ] Quick-preview on hover without leaving gallery
- [ ] Following feed tab (uploads from followed/watched users)
- [ ] Cellar Choice award indicator on label cards

### Label Detail Page
- [ ] Full-size circular preview
- [ ] Version history (side-by-side comparison)
- [ ] Uploader card with badge, follow/watch buttons
- [ ] Related labels (same brand, same uploader)
- [ ] Download button
- [ ] Add to Sheet button
- [ ] Add to Folder quick action
- [ ] Thumbs up button (public) / thumbs down (private to voter)
- [ ] Report/flag button
- [ ] "Created in response to a community request" tag where applicable

### Sheet Builder
- [ ] Select jar type: Wide Mouth (Avery 94512, 2.75") or Regular Mouth (Avery 94501, 2.00")
- [ ] Interactive Avery grid with label slots
- [ ] Fill slots from gallery search, saved folders, brand/blend browse
- [ ] Duplicate slot fill ("fill remaining with this")
- [ ] Multiple sheets
- [ ] Pixel-perfect PDF export via pdf-lib
- [ ] Saved sheets on user profile for reprinting

### User Profiles & Social
- [ ] Profile page with avatar (upload or initials fallback)
- [ ] My Labels: Uploads, Favorites, Ledger Labels, Custom Folders
- [ ] Private profile option (hides folders and Pipe Tool cellar, not uploaded labels)
- [ ] One-way follow (Twitter model)
- [ ] Watch toggle per user (separate from follow -- triggers in-app notification)
- [ ] In-app notifications only (no email)
- [ ] Notification settings independent of follow state
- [ ] Following feed on main gallery

### Community Features
- [ ] Label request system (tied to blend)
- [ ] Request subscriptions for contributor-eligible users
- [ ] In-app notification when subscribed request is fulfilled
- [ ] Label fix suggestion system (community fixes unverified labels)
- [ ] Two-confirmation auto-flag for fix suggestions
- [ ] Community flagging with reason selection
- [ ] Unverified labels show "Fix This Label" button
- [ ] Missing blends page (Pipe Tool data -- blends with no labels)

### Brand & Blend Pages
- [ ] Brand page: all labels grouped by blend, count with/without labels
- [ ] Blend page: all label versions, open request status
- [ ] Trending indicator (from Pipe Tool data)
- [ ] SEO meta tags on brand/blend pages

### Badge System
Three tracks, all independent:
- [ ] Labels Uploaded track: 1, 5, 10, 25, 50, 100, 250, 500
- [ ] Requests Fulfilled track: 1, 5, 10, 25, 50 (confirmed by Brandon in Phase 9)
- [ ] Badge display on label cards (highest badge, beside username)
- [ ] Badge display on profile page (all earned badges)
- [ ] Cellar Choice award (admin-issued, per label not per user)
- [ ] Admin panel badge threshold management

### Pipe Tool Integration
- [ ] Outbound API: search labels, label detail, brand labels, blend labels
- [ ] API key management in admin panel
- [ ] Inbound sync: user cellar blend list for profile display
- [ ] Inbound push: trending/most-cellared/newly-added blends
- [ ] Missing blends page fed by Pipe Tool data
- [ ] "Find a Label" button flow from Pipe Tool cellar context

### Blog / CMS (Sanity)
- [ ] Sanity Studio setup, standalone deploy at `*.sanity.studio`
- [ ] Articles index and individual article pages (renamed from "Guides" on 2026-07-14)
- [ ] Printing templates page
- [ ] Homepage content block
- [ ] WordPress article migration

### Admin Panel
- [ ] Dashboard: recent flags, recent uploads, key stats
- [ ] Label management: filter, reject, delete, Cellar Choice
- [ ] Flags queue with email notification on new flag
- [ ] Fix suggestion queue (brand/blend reconciliation tool)
- [ ] User management: role promotion, contributor flag, bans
- [ ] Brand/blend management with canonical names and aliases
- [ ] Blocklist: banned brands and blends with admin notes
- [ ] Settings panel: all threshold keys
- [ ] Moderation log with full audit trail
- [ ] API key management (Pipe Tool)

### Migration
- [ ] WordPress user export and import (forced password reset flow)
- [ ] Legacy label file migration to new storage structure
- [ ] Legacy brand/blend data import with confidence matching
- [ ] Unverified label queue for labels that could not be auto-matched
- [ ] community.cellarlabels.com 301 redirect to cellarlabels.com
- [ ] Age verification gate on public pages

---

## Avery Sheet Specs

| Product | Diameter | Jar Type | Labels per sheet |
|---|---|---|---|
| Avery 94512 | 2.75 in (198px @ 72dpi / 825px @ 300dpi) | Wide mouth | 6 |
| Avery 94501 | 2.00 in (144px @ 72dpi / 600px @ 300dpi) | Regular mouth | 12 |

Corrected from an earlier "4 per sheet" for 94512 that was never verified against the real product (Sheet Builder wasn't built until Phase 8) -- Avery's own product pages consistently list 6, confirmed by Brandon.

PDF generation must use exact Avery bleed and margin values. Reference: `/lib/avery-specs.ts`

---

## File Structure (target)

```
cellar-labels/
  .github/
    workflows/
      deploy.yml          -- GitHub Actions CI/CD
  studio/                 -- Standalone Sanity Studio (own package.json, deployed to *.sanity.studio, not part of the Next.js app or its build)
  prisma/
    schema.prisma         -- Full database schema
    seed.ts               -- Seed canonical brands, badges, settings
  public/
    logo.png
  src/
    app/                  -- Next.js App Router
      (public)/           -- Public routes (no auth required)
      (auth)/             -- Authenticated routes
      admin/              -- Admin/moderator routes
      api/                -- API routes (internal + Pipe Tool)
    components/
      ui/                 -- Base UI components
      labels/             -- Label-specific components
      gallery/            -- Gallery components
      sheet-builder/      -- Sheet Builder components
      admin/              -- Admin panel components
    lib/
      auth.ts             -- Auth.js config
      db.ts               -- Prisma client
      avery-specs.ts      -- Avery sheet dimensions for pdf-lib
      upload.ts           -- File upload and validation utilities
      badges.ts           -- Badge award logic
      notifications.ts    -- In-app notification helpers
      email.ts            -- Resend email helpers
    styles/
      globals.css         -- Brand tokens, fonts, paper texture
  CLAUDE.md               -- This file
  ROADMAP.md              -- Phase breakdown
  .env.local              -- Never committed (see .env.example)
  .env.example            -- Committed template
```

---

## Environment Variables

See `.env.example` for full list. Key vars:

```
DATABASE_URL=
NEXTAUTH_SECRET=
NEXTAUTH_URL=
RESEND_API_KEY=
SANITY_PROJECT_ID=    -- also needed in studio/.env.local for local `sanity dev` (not secret, safe to duplicate)
SANITY_DATASET=       -- also needed in studio/.env.local for local `sanity dev` (not secret, safe to duplicate)
SANITY_API_TOKEN=     -- only used by one-off migration scripts, never by the running app or Studio (Studio auths interactively via the editor's own Sanity account login)
PIPE_TOOL_API_KEY=
STORAGE_PATH=         -- Absolute path to file storage on ShockHosting
```

> **Temporary override:** the `NEXTAUTH_URL` GitHub secret is currently set to `https://staging.cellarlabels.com`, not the production value shown above. See "Staging Environment" below -- it must be changed back to `https://cellarlabels.com` at cutover.

---

## Deployment

**Trigger:** Push to `main` branch on GitHub
**Process:**
1. GitHub Actions SSHes into ShockHosting
2. Pulls latest from `main`
3. Runs `npm ci` (using the Node binary from the cPanel Node.js Selector's venv)
4. Runs `npx prisma migrate deploy`
5. Runs `npm run build`
6. Restarts app by touching `tmp/restart.txt` in the app root -- LiteSpeed (cPanel's Node.js Selector process manager) detects the touched file and reloads the app on the next request. No PM2 involved.

See `.github/workflows/deploy.yml` for full workflow.

**Why LiteSpeed, not PM2:** ShockHosting's cPanel "Setup Node.js App" wires the app up via auto-generated `.htaccess` directives (`PassengerAppRoot`, `PassengerNodejs`, `PassengerAppType`, `PassengerStartupFile`) that this host's LiteSpeed web server reads for Phusion Passenger compatibility -- LiteSpeed itself is the actual process manager here, not genuine Passenger (confirmed directly by ShockHosting support on 2026-07-21, after support traced a process-limit outage to "multiple LiteSpeed Node.js instances"; this file previously said Passenger based only on the `.htaccess` directive names). LiteSpeed spawns and supervises the Node process itself. Running PM2 alongside it would mean two process managers competing for the same port -- not a supported pattern on this host. Discovered while setting up staging in Phase 1; see "Staging Environment" above.

**ShockHosting setup required before first deploy:**
- Node.js enabled on hosting account via cPanel's "Setup Node.js App" (this also wires up LiteSpeed's app process management -- no separate process manager install needed)
- PostgreSQL database created and `DATABASE_URL` configured
- SSH key from GitHub Actions added to ShockHosting authorized_keys
- Repo cloned to hosting directory
- `.env.local` created on server (never deployed via GitHub)

---

## Staging Environment (Phases 1-12)

The old WordPress site is still live at `cellarlabels.com`. To avoid taking it down before the new site is ready, Phases 1-12 are built and deployed to a staging subdomain instead of the production domain. `cellarlabels.com` itself is left untouched until cutover.

- **Staging URL:** `https://staging.cellarlabels.com`
- **ShockHosting:** subdomain `staging.cellarlabels.com` created in cPanel Domains, with its own Node.js app (cPanel Setup Node.js App) bound to it -- not to `cellarlabels.com`
- **`NEXTAUTH_URL`** GitHub secret is temporarily set to `https://staging.cellarlabels.com`

### Cutover checklist (before pointing `cellarlabels.com` at the new app)
- [x] Phase 2 Sanity article migration complete
- [ ] Phase 12 full WordPress migration complete (users, labels, brand/blend data)
- [ ] Update `NEXTAUTH_URL` GitHub secret back to `https://cellarlabels.com`
- [ ] Re-point deploy target (Node.js app / `DEPLOY_PATH`) from `staging.cellarlabels.com` to `cellarlabels.com`
- [ ] Update DNS so the root domain serves the new app instead of the current live WordPress hosting
- [ ] Confirm `community.cellarlabels.com` 301 redirect still works post-cutover
- [ ] Take the old live WordPress site offline / archive it

---

## Important Decisions Log

- No forum -- Speak Easy (speak-easy.club) already owns that community
- No built-in Cellar Ledger -- Pipe Tool API integration instead
- No *automated* moderation queue -- flagged labels still require a human report, no algorithmic auto-flagging beyond the existing thumbs-down email alert. Community flagging originally shipped as email-alert-only (Phase 5); Phase 10 added the actual `/admin/flags` review queue the emails point to, since "email alert is sufficient" was about not needing automation, not about not needing a queue at all.
- Thumbs down count is never shown publicly -- only used for internal moderation threshold
- "Claim a request" was explicitly rejected -- multiple labels per blend is the goal
- Label requests never close -- new labels for a blend can always be contributed
- Contributor status is activity-gated (10 uploads), not role-based
- Password reset required for all migrated WordPress users -- no hash migration
- Profile photos stored at max 400x400px, resized server-side on upload
- Paper texture is CSS-only (no image file)
- Cellar Choice is admin-issued per label, not algorithmic
- Building on a `staging.cellarlabels.com` subdomain through Phase 12 instead of the production domain, since the old WordPress site is still live at `cellarlabels.com` and content migration (Phases 2 and 12) hasn't happened yet -- see "Staging Environment" above for the cutover checklist
- No `--` (double hyphen) or em dash (`—`/`&mdash;`) in any text rendered on the site -- use periods, parentheses, commas, or `/` instead. Applies to all user-facing copy: headings, labels, messages, notifications. Does not apply to code comments, commit messages, or this file.
- Rejection reason messages (shown to uploaders) are admin-editable at `/admin/settings`, stored in the `settings` table keyed `rejection_message_{reason}` -- not hardcoded from the `RejectionReason` enum. Editing a message applies retroactively to already-rejected labels, since the message is looked up at render time rather than snapshotted onto the `Label` row.
- Brand/blend display format is `Brand - Blend` (single hyphen, spaced) everywhere on the site, not `Brand / Blend`. Applies to label cards, headings, notification text, admin queues, everywhere the pair is shown together.
- Label requests are one row per (blend, requester) with no repeat-counting: re-requesting a blend you've already requested (or clicking "+1" on the Requests page) is a no-op, not an error and not an incrementing count. "Most requested" is the number of unique requesters. (A brief earlier version let re-requests increment a `count` field -- reverted after Brandon flagged it as a spam vector, since a single user could otherwise inflate a blend's ranking by repeat-clicking.)
- Watch is presented to users as a bell icon, not a second button -- it's hidden until Follow is active, defaults on the moment you follow someone, and toggling it swaps between a bell and a muted/slashed bell rather than "Watch"/"Watching" text. Same underlying `Follow.watchUploads` data as before, UI-only change (Brandon's call: "Follow and Watch" as two buttons read as confusing; a notification bell is a more familiar pattern).
- Contributors (`contributor_flag`, 10+ uploads) can opt in to a "notify me when a blend gets its first-ever request" alert (`User.notifyNewRequests`, toggle on `/requests`) so prolific uploaders can go fulfill fresh demand. This replaced an earlier "subscribe to be notified when a request you're watching is fulfilled" design that didn't make sense for contributors specifically (why would the person capable of filling a request want to know when someone *else* filled it?) -- fulfillment notifications now go automatically to every requester of a blend instead, no subscribe step needed, since requesting a blend already is the expression of wanting to know when it exists.
- Moderators can access exactly `/admin/flags`, `/admin/labels`, `/admin/fixes`, `/admin/users` -- a literal reading of ROADMAP's Phase 10 wording ("moderators see flags, fixes, labels, users only"). Everything else in the admin panel (dashboard, brands, blocklist, badges, Cellar Choice, moderation log, settings, API keys) is admin-only. Within the shared pages, moderators can ban/unban users but not change roles or the contributor flag (privilege-escalation risk), and cannot award/revoke Cellar Choice even though that control lives inline on the Labels page they can otherwise fully use.
- API keys (Pipe Tool integration, `/admin/api-keys`) are stored as a SHA-256 hash plus a short prefix for display, never the raw key -- the raw value is returned once from the generate action and shown in the UI exactly once, matching how the site already handles passwords (bcrypt, no plaintext storage) rather than treating API keys as a lesser secret.
- The old WordPress "Printing Templates" page (static Word/Pages download templates for home printing) was deliberately not migrated during the Phase 2 article migration. Its 6-label content is fully superseded by Sheet Builder (Phase 8); its 4-label variant was for two-up shipping label paper specifically to avoid a label straddling the sheet's middle gap, a paper type/layout Sheet Builder doesn't support (it only knows Avery 94512/94501). Brandon's call: skip both, not worth migrating a manual workaround Sheet Builder already replaces, or building out support for a paper type the tool doesn't handle. `/templates` stays empty until there's real template content worth putting there.
- Sanity Studio is not part of the deployed Next.js app -- it lives in `studio/` as its own standalone project, deployed to Sanity's free `*.sanity.studio` hosting instead of `/studio` on the production domain. Done in response to a real weekend-long ShockHosting outage (200/200 process ceiling hit with low CPU/memory, a shared-host resource pattern that had already occurred once before in Phase 1/3) -- running a full separate CMS admin app inside the same 24/7 production process for an editor who uses it occasionally was unnecessary weight on an account with no headroom to spare. The main app keeps `next-sanity`/`@sanity/image-url` for reading published content; it has zero dependency on the `sanity`/`@sanity/vision` packages that Studio itself needs.

---

## Session Log

> Claude Code: append a new entry after each working session. Include date, phase worked, and what was completed or left in progress.

| Date | Phase | Completed | In Progress / Blockers |
|---|---|---|---|
| -- | -- | Project initialized | -- |
| 2026-07-12 | 1 | **Phase 1 complete.** All 9 infra setup steps (GitHub repo, secrets, ShockHosting Node.js/PostgreSQL/SSH, `.env.local`, Sanity project). Next.js app scaffolded, dependencies installed, Prisma schema + initial migration + seed script written and applied, `.github/workflows/deploy.yml` written and passing end to end. Discovered and fixed: Prisma 7's config/driver-adapter changes, npm lockfile cross-platform issues, cPanel's symlinked `node_modules` breaking Turbopack and npm postinstall scripts, this host's `npm` defaulting to `omit=dev`, Tailwind v4's native Rust engine incompatible with this host's resource limits (downgraded to v3). The `SIGABRT`/thread-limit failures that blocked the first several deploy attempts turned out to be caused by ShockHosting's IL-S1 server migration (in progress this same day) rather than a permanent account limit -- the `experimental.cpus: 1` build cap was kept anyway since it's harmless and lighter-weight. Deploy check passed: `staging.cellarlabels.com` loads the placeholder page via the automated pipeline. | -- |
| 2026-07-12 | 2 | **Phase 2 complete** (except two deferred items, see below). Restructured into `src/` to match CLAUDE.md's documented layout (Phase 1 was scaffolded with `--no-src-dir`). Brand fonts/colors/paper-texture, shared Header/Footer, server-checked age gate, embedded Sanity Studio at `/studio` with an `article` schema (guide/template categories), `/guides` + `/guides/:slug` + `/templates` pages querying Sanity, homepage, and full Auth.js v5 credentials auth (login/register/forgot-password/reset-password, bcryptjs hashing, Resend for reset emails). Verified end to end against the real deployed site and database, not just locally -- registration, login, and Studio all confirmed working on `staging.cellarlabels.com`. Found and fixed a real bug this way: Auth.js needs `trustHost: true` for any non-Vercel deployment, or every login/register fails with `UntrustedHost` (local dev doesn't enforce this check, so it only surfaced against the real server). | **Deferred, need input:** WordPress article migration into Sanity needs Brandon's export/access. `community.cellarlabels.com` → `cellarlabels.com` redirect deferred to cutover -- that site is still live. `/public/logo.png` still needs the real illustrated file; header uses a text wordmark for now. |
| 2026-07-12 | 3 | **Phase 3 complete, fully verified live** including the admin/blocklist paths that were initially blocked. Added `brands`/`blends`/`brand_aliases` relational tables, `BlockedEntry` model + migration, `/api/brands/search` and `/api/blends/search` endpoints (brand-scoped, blocklist-aware), reusable debounced `BrandBlendAutocomplete` component, `/brands/[slug]` and `/brands/[slug]/[blendSlug]` page shells, `requireAdmin()`-gated `/admin/brands` and `/admin/blocklist` management pages. Seeded a small **bootstrap list** of 15 real brands/blends (explicitly non-canonical test data, not scraped -- Brandon chose this over scraping third-party sources) instead of the full canonical brand/blend list, which needs Brandon's input later. Live-verified on `staging.cellarlabels.com/brand-blend-test`: brand search ("Dun" -> Dunhill), brand-scoped blend search (Dunhill -> Early Morning Pipe / Nightcap), full selection state, both brand/blend page shells, and end-to-end blocklist add/reject-message/remove via the real admin panel. **Registration simplified:** dropped the separate Display Name field (defaults to username at signup, editable later in profile settings once that exists) and added a honeypot field to `registerUser` for basic bot-signup prevention. **Admin-promotion blocker resolved:** rather than fighting infra access (phpPgAdmin is SSO-locked to the wrong DB role; the DB port isn't reachable remotely from outside the server), `prisma/seed.ts` now promotes the site owner's account (`thedeadprophet@cellarlabels.com`) to `admin` on every seed run -- no-op if that account hasn't registered yet. Runs automatically as part of the existing deploy pipeline, so it needed no new infra access. **Real bug found and fixed this way:** the Auth.js `session` callback only ever copied `role` onto `session.user`, never `id`. `requireAdmin()` requires both, so every admin-gated server action (blocklist add/remove, brand management) was throwing "Not authorized" and surfacing as an opaque 500 for actual admins too -- the page-level role gate in `admin/layout.tsx` never exercised this because it only checks `role`. Fixed by setting `session.user.id = token.sub` in `src/lib/auth.ts`. This was a latent Phase 2 bug, invisible until an admin session actually tried to run a server action. | **Deploy infra saga -- fully resolved, site confirmed live.** Three stacked issues, each masking the next, found and fixed over the course of this session by finally getting shell access via cPanel's Terminal instead of only retrying the GitHub Actions pipeline blind. (1) `P1001: Can't reach database server at localhost:5432` -- Prisma's Rust query engine resolves `localhost` differently than `psql`/the app's own `pg.Pool` do on this host (`psql "$DATABASE_URL"` connected instantly, Prisma never did). Fixed by changing `DATABASE_URL` in the server's `.env.local` to `127.0.0.1` (server-only change, not in git). (2) Once past that, `migrate deploy` hung indefinitely after "2 migrations found" -- Prisma's default telemetry ping to `checkpoint.prisma.io` hanging rather than timing out. Fixed by adding `export CHECKPOINT_DISABLE=1` to `.github/workflows/deploy.yml` (commit `0a5c71f`). (3) The account also has a 200-process (NPROC) CloudLinux LVE cap shared across **everything** on the account, not just this app -- confirmed live via cPanel's Resource Usage page. This account runs 4 WordPress sites (down from 9 -- Brandon deleted 5 unused ones during this session) plus a second Node.js app (`websale.listertools`), all sharing the same 200-process pool. Repeated deploy retries during troubleshooting (~15-20 attempts before the real fixes were found) left orphaned `npm run build` process trees running on the server each time a GitHub Actions job was cancelled (cancelling the Actions job does **not** kill the remote SSH-spawned process), which is what drove NPROC to its ceiling and caused `lve_suwrapper: Unable to fork` errors, including at one point breaking `ps` itself. After WordPress cleanup + killing the orphaned trees, NPROC settled at a healthy ~60-65/200 baseline -- ruling out "this plan's limit is too tight" as the real problem; it was leftover mess from the troubleshooting itself. **Real lesson, not just a fix:** when a deploy fails, stop after 1-2 retries and diagnose (or get shell access) rather than continuing to `gh run rerun` -- each retry is a full `npm install` + build cycle, expensive on a shared account, and repeating a failing operation can turn a config bug into a genuine resource-exhaustion problem, which is exactly what happened here. **Final recovery:** two of the killed builds left the server's `.next` directory incomplete (Next.js clears it at the start of every build), which is what was actually causing the live site's 503s at the end -- not NPROC, not Prisma. Fixed by running `npm run build` to completion once directly in the terminal (with `CHECKPOINT_DISABLE=1` and the corrected `DATABASE_URL` now permanent in `.env.local`), then `touch tmp/restart.txt`. Site confirmed back to 200 on `/`, `/brands/dunhill`, `/login`; `/upload` correctly redirects unauthenticated visitors to `/login`. `npx prisma db seed` also run manually to populate the new `rejection_message_*` settings live. Both Node apps and all remaining WordPress sites were left running normally. **For future sessions:** if a deploy ever gets stuck again, check `ps -ef` in cPanel's Terminal for orphaned `npm run`/build trees (old `STIME`, `PPID 1`) before assuming it's a new bug -- this exact scenario is likely to recur since GitHub Actions cancellation still doesn't clean up the remote process. |
| 2026-07-12 | 4 | **Phase 4 complete, verified live in local dev only** -- staging deploy is still blocked on the unresolved infra issue above, so this could not be verified on `staging.cellarlabels.com` this session. Built `/upload` (brand/blend autocomplete reused from Phase 3, canvas-based circular live preview), `src/lib/upload.ts` (server-side validation: PNG/JPG only, min-dimension check read from the `label_min_dimension` setting, 15MB size cap -- uses the pure-JS `image-size` package instead of `sharp`, deliberately avoiding this host's history of native-binding pain with Tailwind v4/Turbopack), `/api/storage/[...path]` route to serve uploaded files (path-traversal guarded), `src/lib/actions/labels.ts` (`uploadLabel`, `addLabelVersion`, `updateLabelBrandBlend`, `rejectLabel`, `deleteLabel`), a minimal `/labels/[id]` detail page stub (full gallery/detail UI is Phase 5) with owner-only replace-version and edit-brand/blend controls, and `/admin/labels` (status filter, reject with reason + note, delete). **Set up local Postgres for the first time this project** (`brew install postgresql@16`) since `staging` verification wasn't available -- registered a real test account, walked the entire flow end to end: upload with a too-small image (correctly rejected, "Image must be at least 825x825px (got 400x400)"), upload with a non-image file (correctly rejected, "File is not a valid image"), successful upload (900x900, file persisted to `/storage/labels/{id}/v1/`, served correctly), replace-version (v2 added, `isCurrent` toggled correctly, both versions listed with dimensions), edit-brand/blend (title updated live), promoted the test user to admin directly in the local DB and confirmed reject-with-reason (notification record created, correct message) and delete (cascade-deleted `label_versions` too). **Incident during testing:** clicking the native "Choose File" button at one point triggered a real OS file picker rather than staying sandboxed, and a real file from the local filesystem ("McClelland - Oldchurch.png", 1.8MB) ended up selected in the input -- caught before submission via a `file.size`/`file.name` check, cleared without uploading anywhere. Worth being cautious of in future sessions when testing file inputs via the browser tool. **Known gap, not fixed (kept scope tight):** `deleteLabel` removes the DB rows but not the physical files under `/storage/labels/{id}/` -- orphaned files accumulate on delete. **Deferred to later phases:** "rejected labels hidden from gallery" (Phase 5) and "visible on uploader's profile" (Phase 6) can't be fully verified since neither page exists yet -- the underlying data (status, rejection reason/note, notification) is all correctly in place for those pages to consume once built. **Follow-up same session, Brandon-requested:** rejection messages are now admin-editable at `/admin/settings` (stored in `settings`, applies retroactively to already-rejected labels) instead of being derived from the raw enum key, and all `--`/em-dash usage in user-facing text was replaced (periods, parentheses, `/`) per a new standing rule -- **no `--` or em dashes in any text rendered on the site, going forward.** Found and fixed a real bug this way: `src/lib/rejection-reasons.ts` mixed client-safe constants with a server-only DB-backed function, so importing it into a `"use client"` form broke the build ("Module not found: Can't resolve 'dns'") by pulling Prisma's `pg` dependency into the browser bundle. Split into a client-safe module (`rejection-reasons.ts`) and a server-only one (`rejection-message.ts`), and added the `server-only` package as a guard on `db.ts`/`settings.ts`/`upload.ts` so this class of mistake fails loudly instead of cryptically if it recurs. | **Needs a successful staging deploy** to verify Phase 4 against the real production-like environment (Passenger, ShockHosting file storage path, real `STORAGE_PATH`) -- local dev only proves the application logic, not host-specific concerns like file write permissions under Passenger's process user. |
| 2026-07-12 | 4 | **Dark mode added (Brandon-requested, mid-Phase 4/5 gap).** Site-wide light/dark theming via `next-themes` (`attribute="class"`, `defaultTheme="system"`), a Light/Dark/System toggle in the header (desktop nav + mobile menu), default follows OS/browser `prefers-color-scheme`, override persisted in `localStorage`. Implemented by converting the existing `charcoal`/`cream`/`warm-white` Tailwind color tokens (already used semantically as ink/page-bg/card-bg across ~40 files) into CSS-variable-backed colors (`rgb(var(--x) / <alpha-value>)` in `tailwind.config.ts`, values flipped between `:root` and `.dark` in `src/styles/globals.css`) instead of hand-editing every component -- existing classes like `bg-cream` or `text-charcoal/60` now flip automatically. Found and fixed two places where a token was being used as a literal color rather than semantically, which would have broken under the flip: `text-warm-white` (button label text sitting on the always-orange terracotta fill, 14 files) changed to plain `text-white`; the AgeGate modal scrim (`bg-charcoal/90`) changed to `bg-black/70`, since scrims should stay dark in both themes. Terracotta is brightened for dark mode (`#F1793E` vs `#D03A00`, original fails WCAG AA as text on near-black at ~3.8:1, brightened variant holds ~6.7:1); Gold (`#D8A03D`) is unchanged, reads well on both. Verified in local dev at desktop and mobile widths: system-detected dark on load, manual Light/Dark/System toggle, persistence across navigation, no hydration-mismatch console errors (`suppressHydrationWarning` on `<html>` per next-themes' documented pattern, since it sets the class before React hydrates). AgeGate's own dark-mode rendering wasn't re-verified live this session (its cookie is httpOnly, couldn't easily re-trigger it from the browser tool) but the fix is a static, theme-invariant color so it doesn't depend on runtime state. | -- |
| 2026-07-13 | 5 | **Phase 5 complete, verified end to end in local dev.** Built the main gallery (`/labels`, brand/blend/uploader/badge-tier filters and newest/most-downloaded/trending sort via URL search params, no client JS required), a full label detail page rebuild (linked brand/blend headings, version selector via `?v=`, thumbs up/down voting, download with count increment, report/flag modal, uploader card with badge + follow/watch, disabled Add to Sheet/Folder stubs for later phases), and real `/brands/:slug` and `/brands/:slug/:blend-slug` pages (label counts, grid of active labels) replacing the Phase 3 shells. Also wired up several pieces that Phase 5's UI needed but didn't exist yet: `src/lib/badges.ts` (awards all eligible upload-count badges retroactively on each upload, both single and batch), `src/lib/actions/votes.ts` (toggle/switch/untoggle thumbs up/down, thumbs-down count staying private per CLAUDE.md's decision), `src/lib/actions/flags.ts` and `src/lib/actions/social.ts` (follow/one-way, watch-implies-follow, watchers get notified on new uploads). Reused the existing `charcoal`/`cream`/`warm-white`/`bg-black/70` dark-mode-aware tokens throughout with no new theming work needed. **Simplified two ROADMAP items during build, documented inline:** "trending this week" sorts recent (7-day) uploads by all-time download count rather than a true rolling 7-day window, since there's no per-download event log yet; the thumbs-down auto-flag threshold sends an email to all admin/moderator accounts rather than creating a formal queue entry, since there's no "system" user to attribute an automated `label_flags` row to and CLAUDE.md's Important Decisions Log already establishes "no automated moderation queue -- email alert is sufficient." **Verified live in local dev**, not staging (everything since dark mode has been local-only per Brandon's standing instruction): registered two test accounts, uploaded a label (confirmed the "1 Labels Uploaded" badge auto-awarded), full vote toggle/switch/private-downvote behavior confirmed correct in the DB from both accounts' perspectives, download count increment, flag submission both with and without an admin/moderator present (Resend's 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 code path), follow/watch toggle (watch correctly implies follow), all gallery filters and sorts, the quick-preview modal, and both brand/blend pages against real uploaded data. **Real bug found and fixed this way:** the flag report modal's `useActionState` lived on the outer `FlagButton` component, which never unmounts, so reopening the modal after a successful report (or to report a second time) 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 counter that increments on every open, forcing a remount. **Known gap, not fixed (out of Phase 5 scope):** the Header still hardcodes "Log In"/"Register" regardless of session state -- it was never made session-aware in Phase 2 and doing so belongs with Phase 6's profile work (avatar, user menu). | Staging deploy still pending per the standing "keep working locally for now" instruction. |
| 2026-07-13 | 6 | **Phase 6 complete, verified end to end in local dev.** Built the user profile page (`/profile/:username` -- avatar, all earned badges, upload/thumbs-up/follower/following stats, Uploads/Favorites/Folders tabs via URL search params, private-profile flag hiding Favorites+Folders from non-owners since Favorites is just the default folder in the same saved-labels system), avatar upload (`src/lib/avatar.ts`, new `jimp` dependency -- pure JS, no native bindings, consistent with this host's constraints -- `.cover()` crops-and-fills to 400x400px rather than distorting non-square sources), the saved folders system (create/rename/delete, a reusable `AddToFolderButton` modal wired into the label detail page's previously-stubbed button), the full follow/watch UI on profiles (reusing Phase 5's `FollowWatchButtons`), the in-app notification center and per-type settings page, and the following feed (`/feed`). Finally made the **Header session-aware** (Phase 5's known gap): it now receives session/unread-count data from `RootLayout` and shows an avatar+username+notification-bell menu instead of hardcoded Log In/Register when logged in. Also built `src/lib/notifications.ts` (a `createNotification`/`createNotifications` wrapper that checks the new `NotificationSetting` table before writing, defaulting to enabled with no row) and retrofitted the three existing Phase 5 notification call sites (badge-earned, label-rejected, watched-upload) to go through it instead of writing `db.notification.create` directly, so the new settings page actually has teeth. **Real gap found and fixed along the way:** `users.thumbs_up_received` (needed for the profile stats row) was declared in the Phase 1 schema but never incremented anywhere -- Phase 5's `voteLabel` action only updated the label's own `thumbsUpCount`. Fixed by extending `src/lib/actions/votes.ts`'s existing transaction to also adjust the uploader's `thumbsUpReceived` by the same delta on create/switch/untoggle. **Verified in local dev** with two test accounts end to end: avatar upload confirmed 400x400px on disk and in the DB from a non-square source, folder create/save-to-Favorites/save-to-custom-folder from the label detail modal, private-profile flag confirmed to hide Favorites+Folders for a logged-out viewer while staying visible to the owner, follow/watch and its counts, the following feed, and the complete notification lifecycle (watched-upload notification fires with unread dot + header badge count, mark-all-as-read clears both, and disabling "new label from watched user" in settings correctly suppressed that notification on a subsequent upload while the badge-earned notification from the same session still fired normally). | Staging deploy still pending per the standing "keep working locally for now" instruction. |
| 2026-07-13 | 7 | **Phase 7 complete, verified end to end in local dev.** Built the label request system (`/requests`, "Request a label" on zero-label blend pages, contributor-gated subscriptions, `fulfilledRequestId` linking + subscriber notifications on upload, "Made by request" tag on cards/detail), the label fix suggestion system ("suggest a fix" wired into Phase 5's unverified-label banner stub, auto-confirm when two different members suggest the same brand/blend, admin fix queue at `/admin/fixes` with apply/reject), and the Missing Blends page (`/missing`, zero-label blends ordered by `pipeToolCellarRank` with an honest "not connected yet, alphabetical for now" note since Phase 11 hasn't happened). Added `initialBrand`/`initialBlend` pre-fill props to `BrandBlendAutocomplete` so Missing Blends' "Create a Label" link and the upload page's `?blend=` param actually skip retyping. **One real schema gap found and fixed:** `LabelRequest` had no unique constraint on `(blendId, requesterId)`, so a user could spam duplicate requests for the same blend and inflate its "most requested" count -- added one via a hand-written migration (`prisma migrate dev` refused to run in this non-interactive environment even via `--create-only`; worked around it by writing the migration SQL directly, matching Prisma's own naming convention, and applying with `prisma migrate deploy`, which is designed for non-interactive CI and is what this project's actual deploy pipeline already uses). **Scope call:** the admin fix queue is admin-only for now, not moderator-accessible, even though CLAUDE.md's Roles table lists "fix queue" under moderator access -- ROADMAP's own Phase 10 has an explicit "Moderator role gate" task covering exactly this distinction across all admin pages at once, so gating it piecemeal now would just get redone there. **Verified in local dev** with four test accounts end to end: request -> subscribe -> upload -> fulfillment notification and tag, two independent matching fix suggestions auto-confirming, admin applying one (confirmed the label's blend/status updated, the applied suggestion marked `applied`, the other account's matching suggestion auto-rejected, and a `ModerationLog` row written), and the Missing Blends page's pre-fill flowing correctly into the upload form. | Staging deploy still pending per the standing "keep working locally for now" instruction. |
| 2026-07-14 | 7 | **Brandon-requested polish, mid-Phase 7/8 gap.** Changed the "Brand / Blend" display format to "Brand - Blend" across all 10 places it appeared (label cards, both label detail headings, admin labels/fixes queues, the requests/missing pages, notification message text, and the brand/blend autocomplete's "Selected:" line). Fixed 4 live violations of the standing no-`--`/em-dash rule found during this same sweep (the admin fix queue's intro copy and note interpolation, the Missing Blends page's Pipe Tool disclaimer, and a vote button tooltip) -- these had slipped through Phase 7 despite the rule being in place since Phase 4, so this doubled as an audit rather than just fixing the one instance Brandon spotted. **Bigger change:** redesigned how label request counts work. Previously a second request from the same user for the same blend was blocked outright (`"You already requested this blend."`, added in Phase 7 specifically to stop the count from being inflated by duplicates). Brandon wants the opposite: submitting a request that already exists, or clicking a new "+1" button while browsing, should always succeed and bump the count. Added a `count` field to `LabelRequest` (still one row per requester, but now incrementable) via a hand-written migration (same non-interactive `migrate deploy` workaround as Phase 7's first migration), replaced the single `createLabelRequest` form action with a simpler `requestLabel(blendId)` that upserts-and-increments, and changed the Requests page's "most requested" sort from counting rows to summing `count`. The blend page's request button no longer locks into a static "already requested" message -- it stays clickable as "+1 request" and shows a running "You've requested this N times" count. Also added a clickable A-Z alphabet filter (by brand's first letter, greyed out for letters with no requested blends) and a Most Requested / Brand A-Z sort toggle to the Requests page, both via URL search params. **Verified in local dev:** clicking +1 on both the Requests page and a blend page repeatedly, confirmed via direct DB query that the same user's row increments rather than erroring or duplicating, confirmed the displayed sum updates correctly across repeat clicks, and confirmed the alphabet filter correctly disables letters with zero matches while keeping others clickable. | -- |
| 2026-07-14 | 7 | **Second round of Brandon-requested polish, same session.** Redesigned Follow/Watch on the label detail page: Watch is now a bell icon (not a second text button), hidden until Follow is active, auto-enabled the moment you follow someone, and click-to-mute shows a slashed bell instead of unfollowing anything -- same `Follow.watchUploads` data underneath, just a more familiar notification-bell pattern per Brandon's read that "Follow and Watch" as two buttons was confusing. Extracted the icon into a reusable `src/components/ui/BellIcon.tsx` (muted variant included) rather than inlining it, since the header's notification bell could reasonably switch to it too later. **Reworked contributor request notifications** after Brandon pointed out the Phase 7 design didn't make sense: contributors were only able to subscribe to "this specific request was fulfilled," which is backwards for the people actually capable of fulfilling requests -- they'd want to know about fresh demand, not their own follow-through. Replaced it with a global, contributor-gated opt-in (`User.notifyNewRequests`, toggle on `/requests`) that fires once when a blend gets its first-ever request across the whole catalog, and made fulfillment notifications automatic for every requester of a blend (no subscribe step -- requesting a blend already expresses wanting to know when it's fulfilled). This let the entire `LabelRequestSub` model and its subscribe UI be deleted rather than just rewired. **Also reverted the `count`-based request system from earlier this same session** after Brandon flagged it as a spam vector on sight ("This implies a single user can request a label multiple times... opens some spam where a user can manipulate the system to get their request to the top of the queue") -- re-requesting a blend is a no-op again, not an incrementing count; "most requested" is back to counting unique requesters. Two migrations this session: one adding `count` (now itself reverted by the second), and one removing it again alongside dropping `LabelRequestSub` and adding `notify_new_requests` + the `new_label_request` notification type -- both hand-written and applied via `migrate deploy` for the same non-interactive-environment reason as Phase 7's migrations. **Verified in local dev** with four accounts: follow auto-enables the bell, click-to-mute shows the slashed icon and persists, unfollow clears both; repeat-clicking a request (both the blend page button and the Requests page's +1) confirmed idempotent via direct DB query (no count inflation); a contributor who opted in received the new-request notification when a different user requested a never-before-requested blend, while non-opted-in users did not; and an older fulfillment notification from earlier in the session confirmed the automatic-for-all-requesters path still works. | -- |
| 2026-07-14 | 8 | **Phase 8 complete, verified end to end in local dev.** Built the Sheet Builder: jar type toggle (Wide Mouth Avery 94512 / Regular Mouth Avery 94501) with a data-loss confirm when switching away from a filled sheet, a slot grid driven entirely by `src/lib/avery-specs.ts` (column count per jar type, so the wide layout renders 2 columns and the regular layout renders 3), per-slot fill/clear/"fill remaining", multi-page add/remove, a 3-tab label picker (search, brand-then-blend cascading browse, folder browse), PDF export via `pdf-lib`, and save/reprint/delete of named sheets on the profile's new Sheets tab. Also wired two folder-driven entry points from the profile's Folders tab: "Print this folder" (seeds the builder with every label in a folder, auto-spilling across multiple sheets via a `chunkIntoPages` helper if the folder holds more than one sheet's worth) and "Select labels to print" (a checkbox-select mode on the folder grid for a specific subset), plus a real "Add to Sheet" link from the label detail page replacing Phase 5's disabled stub. **Avery spec correction, caught before it shipped:** CLAUDE.md had documented 94512 as 4 labels per sheet, never verified since Sheet Builder didn't exist until now -- Avery's own product pages consistently say 6, which Brandon confirmed directly ("You are correct, it's 6. And 94501 is 12"). CLAUDE.md's Avery Sheet Specs table was corrected before building against it. **Exact print-alignment coordinates came from real proof PDFs, not estimation:** since Avery's public pages don't publish precise slot/margin coordinates and getting physical die-cut label placement wrong wastes real money, Brandon provided actual Avery WePrint proof PDFs for both 94512 and 94501 (blank sheets with trim/safe/bleed guide lines). Extracted exact vector coordinates from both using Python's `pdfplumber` (reading bezier-curve bounding boxes, cross-referenced against known label diameters to tell trim/safe/bleed circles apart from near-duplicate curve artifacts), converted from pdfplumber's top-down coordinate system to pdf-lib's bottom-left-origin system, and hand-verified the resulting `avery-specs.ts` constants by generating test PDFs and re-parsing them with `pdfplumber` to confirm pixel-exact slot centers and sizes for both jar types before wiring it into the real UI. **Design choices, not scope creep:** label images use CSS-`cover`-style scaling to fully cover each slot's bleed circle with no PDF-side circular clipping, since physical die-cutting handles the actual round cut at the trim line; `SavedSheet.pages` is a single `Json` column rather than relational slot tables, since sheets are always read/written as one unit and a relational model would add joins for no benefit; folder/multi-select seeding always chunks against the wide-mouth (94512) capacity regardless of what jar type the builder ends up using, since the seed happens before the user has necessarily picked a jar type. **Verified end to end in the browser**, not just via `tsc`/`eslint`: filled and cleared slots across both jar types, exercised all three label-picker tabs including a blend with zero labels correctly showing "No labels found", jar-type-switch data-loss confirm, multi-page add/remove, downloaded a PDF and independently confirmed via `pdfplumber`-style header/byte inspection that it's a genuine `%PDF-1.7` file (not an error page), created a folder with two labels and confirmed both "Print this folder" (correct auto-seed + message) and "Select labels to print" (correct single-label seed) route into the builder correctly, and confirmed the "Add to Sheet" link from a label detail page seeds a fresh sheet. **Real bug found and fixed during this verification pass:** "Save Sheet" used the browser's native `prompt()` to ask for a sheet name, which threw outright in the automated browser-verification tool ("prompt() is not supported") and is fragile in general (blocked by many real browsers' popup/extension settings, and inconsistent with the rest of the app, which already uses inline text inputs for this exact pattern, e.g. folder creation). Replaced with an inline name input + Save/Cancel that appears in place of the "Save Sheet" button, matching the folder-creation UI pattern already established in Phase 6. Re-verified save -> appears correctly on the profile's Sheets tab -> reprint produces a valid PDF via the `sheetId` lookup path -> delete removes it, all working after the fix. | -- |
| 2026-07-14 | 8 | **Brandon-requested profile page cleanup, mid-Phase 8/9 gap.** The profile header was cluttered: a large "Choose File" input and "Update avatar" button sat directly under the avatar circle, crowding the username and stats. Replaced with a hover-to-edit pattern: the avatar (now a larger `xl` size added to `Avatar.tsx`) is a button that shows a camera icon overlay on hover (new `CameraIcon.tsx`), opening a modal (`AvatarEditModal.tsx`) with the upload-and-save flow that used to live inline; the username now sits directly to the right of the avatar instead of below the old upload form. Extracted this into a client component (`AvatarWithEdit.tsx`, owner-only, falls back to a plain `Avatar` for visitors) and deleted the old inline `AvatarUploadForm.tsx` it replaced. Also removed the redundant "1 Labels Uploaded" badge pill from the profile header, since the numeric "uploads" stat directly below it already conveys the same milestone (uploads-track badges specifically, not badges generally, are excluded from the profile's earned-badges pill row via a `badge: { track: { not: "uploads" } }` filter, so a future Requests Fulfilled or other track badge still shows there), and renamed the "thumbs up received" stat to "upvotes" per Brandon's read that the up-arrow icon used throughout the site is a voting mechanism, not a thumbs gesture. **Verified in local dev:** profile header renders with username beside the avatar, hovering the avatar shows the camera overlay, the modal opens/uploads/saves correctly, the badge pill is gone, and the stat reads "N upvotes". | -- |
| 2026-07-14 | 9 | **Phase 9 complete, verified end to end in local dev.** Built the badge system's second track and its remaining UI: a `requests_fulfilled` badge track (thresholds 1, 5, 10, 25, 50, confirmed by Brandon after CLAUDE.md flagged them as still TBD) seeded alongside the existing `uploads` track, refactored `src/lib/badges.ts` to share one `awardEligibleBadges` helper between both tracks' award functions instead of duplicating the loop, and wired `maybeAwardRequestFulfilledBadges` into both the single-upload (`labels.ts`) and batch-upload (`batch-upload.ts`) actions right after `fulfillBlendRequests` runs, since a request is only fulfilled (and thus countable) at that point. Added `getHighestBadgesForUsers(userIds)` (a batch version of the existing single-user `getHighestBadge`) and used it to add the previously-missing "highest badge beside uploader name" display on label cards across all five places labels render (gallery, brand/blend pages, feed, homepage, profile) -- a small gold medal icon with the badge name as its tooltip, added to `LabelCard.tsx`. Built the Cellar Choice admin flow that was previously just a data model with no UI: `awardCellarChoice`/`revokeCellarChoice` server actions in `admin.ts`, and a `CellarChoiceControl.tsx` toggle on `/admin/labels` (inline note field on award, confirm-gated revoke). Replaced `CellarChoiceBadge.tsx`'s placeholder star icon with a hand-drawn gold leaf/botanical mark (a vesica shape with a center vein) to match the brand's botanical motif instead of a generic achievement star. Built `/admin/badges` (new page, plus `BadgeRow.tsx`/`NewBadgeForm.tsx`), letting admins view both tracks, edit a badge's threshold/name inline, and add new tiers, backed by new `createBadgeThreshold`/`updateBadge` actions in `admin.ts` -- both guard against creating or moving a badge onto a threshold that's already taken within its track (Badge has a `@@unique([track, threshold])` constraint), and deletion was deliberately left out of scope since `UserBadge.badge` cascades on delete and would silently strip already-earned badges from users. **Verified end to end in local dev:** confirmed the badge icon and its tooltip render on gallery cards; awarded Cellar Choice to a label as admin and confirmed the pill (with the new leaf icon) appears on both the gallery card and detail page, then confirmed the button flips to "Revoke Cellar Choice" and works; edited a badge's name inline and added then removed a test threshold on `/admin/badges`, confirming the duplicate-threshold guard rejects a clashing add; and directly exercised the new `requests_fulfilled` award path end to end via a temporary local-only test route (deleted after use) that created a label against a real open request, called the real `fulfillBlendRequests`/`maybeAwardRequestFulfilledBadges` functions, and confirmed via the DB that `fulfilled_request_id` linked correctly, the "1 Requests Fulfilled" badge was awarded, and both the `badge_earned` and `request_fulfilled` notifications fired -- then cleaned up the synthetic label/badge/notifications afterward so the dev DB stays representative for future sessions. | -- |
| 2026-07-14 | 10 | **Phase 10 complete, verified end to end in local dev, as both admin and moderator.** Built the full admin panel: dashboard (`/admin`, key stats and recent flags/uploads), a real flags queue (`/admin/flags`, dismiss/reject/ban actions, replacing the "email alert only" design from earlier phases with an actual working queue now that this phase calls for one), user management (`/admin/users`, search, role promote/demote, contributor flag, ban/unban), a full site-thresholds section on Settings (`/admin/settings`, the 6 `settings` table keys from CLAUDE.md's config table, previously only rejection messages were editable there), a Cellar Choice history page (`/admin/cellar-choice`, revoke plus the award history Phase 9's inline `/admin/labels` control didn't surface anywhere), a moderation log viewer (`/admin/log`), API key management (`/admin/api-keys`, SHA-256-hashed storage with the raw key shown exactly once at creation, ahead of Phase 11's actual Pipe Tool usage of these keys), and search plus multi-select bulk reject/delete on the existing `/admin/labels` queue. **Real, previously-invisible bug found and fixed:** `users.banned`/`banned_at` have existed in the schema since Phase 1 but were never checked anywhere -- banning a user had zero actual effect, they could still log in freely. Fixed with a one-line check in `src/lib/auth.ts`'s `authorize()`, verified live (banned test account correctly got "Invalid email or password" on login attempt). **Moderator role gate implemented as the literal reading of ROADMAP's own wording** ("moderators see flags, fixes, labels, users only") -- moderators get exactly those four pages; everything else (dashboard, brands, blocklist, badges, cellar choice, log, API keys, settings) is admin-only via a new shared `AdminOnlyGate` server component wrapping each restricted page, plus `requireStaff()` (admin-or-moderator) helpers added alongside the existing `requireAdmin()` ones in `labels.ts`, `admin.ts`, and `fix-queue.ts` for the underlying server actions. This closes a scope note from Phase 7 that deliberately left the fix queue admin-only "since gating it piecemeal now would just get redone" once this phase's gate landed. Cellar Choice's award/revoke controls stay admin-only even though they live inline on the (moderator-visible) Labels page -- `LabelsBulkList.tsx` only renders `CellarChoiceControl` when `viewerRole === "admin"`. Retrofitted `moderationLog` entries onto `rejectLabel`/`deleteLabel` (previously silent) and the Phase 9 Cellar Choice actions, so the new Log page actually has content across every phase's moderation actions, not just fix suggestions. Added a `ModerationLog` entry from `rejectLabel` that also auto-resolves any open flags on that label, since there's nothing left for a flag to be "open" about once the label is rejected out of the gallery. **New schema:** `ApiKey` model (hand-written migration, same non-interactive `migrate deploy` workaround as every prior phase's schema change). **Verified end to end in local dev with two real accounts** (`testuploader` as admin, promoted `collector2` to moderator for this session): dashboard stats and recent-activity lists render correctly; dismissed a real community flag and confirmed it left the queue and appeared in the log; banned then unbanned a test account and confirmed the login block actually takes effect and clears; promoted a member to moderator via the Users page and confirmed, logged in as that account, that the nav correctly narrows to Flags/Labels/Fixes/Users, `/admin` (dashboard) correctly renders "Not authorized. This page is admin-only," and the Labels page correctly hides the Award Cellar Choice button while still showing Reject/Delete; saved real values through the new Settings thresholds form; generated and revoked a real API key end to end; searched the Labels page by brand name and confirmed it filtered correctly; multi-selected two labels and bulk-rejected them, confirming both actually changed status and both appeared as separate entries in the moderation log, then restored them to active afterward since the bulk-reject was purely a verification exercise, not a real moderation decision. **One real bug found and fixed during this verification pass, not before:** after adding the `ApiKey` model and running `prisma generate`, the *already-running* dev server kept using its in-memory copy of the old Prisma Client (missing the new model) since Next.js dev doesn't reload node_modules on schema regeneration -- surfaced as `Cannot read properties of undefined (reading 'findMany')` on the API Keys page specifically (the one new page touching the new model). Fixed by restarting the dev server; worth remembering for future schema-adding phases, since `prisma generate` alone isn't sufficient once a dev server is already up. | -- |
| 2026-07-14 | 2 | **WordPress article migration completed, closing the last open item from Phase 2.** Brandon provided a real WXR export (`cellarlabels.WordPress.2026-07-14.xml`, ~1.6MB) from the still-live WordPress site. Of 352 total `<item>` entries in the export (mostly `attachment`/`dlm_download`/Divi-theme cruft), only 4 were actual blog posts, and only 3 of those were publishable: "Getting Started: A Brief Overview," "Choosing The Right Jar," and "Creating Custom Labels: Using a Free Online Photo Editor" (all `category: Cellaring Guides`, all `status: publish`). The 4th, "Cellaring Tobacco in Mylar Bags," was a `status: draft` post with genuinely empty content (an unfinished Divi shell with no text) -- correctly excluded, since migrating a never-published draft as a live guide would have published content the original author never intended to ship. **Hand-converted rather than auto-converted:** the post content was wrapped in Divi page-builder shortcode markup (`et_pb_section`/`et_pb_row`/`et_pb_text`, plus Grammarly `<g class="gr_...">` span artifacts throughout the raw HTML). At only 3 posts, hand-building each one's Portable Text `body` array in a migration script was more reliable than writing a generic Divi-to-Portable-Text converter, and let real cleanup happen along the way: dropped a Divi cross-promo/CTA block that wasn't real article content (replaced with a plain "Next up: [link]" paragraph using the new site's actual slug), repointed internal links from the dead `cellarlabels.com/printing-templates/` and `community.cellarlabels.com/login/` (the old community site this project explicitly replaces) to the new site's `/templates` and `/register` routes, converted a 2-cell HTML `<table>` to a 2-item bullet list since the `article` schema's `body` field only supports `block`/`image` types, dropped an `<iframe>` video embed since the same video was already linked as plain text immediately after it in the original, and fixed a few obvious original typos (`equaly`->`equally`, `stye`->`style`, `Photopia`->`Photopea`) plus softened two "leave us a comment" lines since the new site has no post-comment system. **Images and one file attachment migrated as real Sanity assets, not links to the old host:** fetched each post's featured image (via WXR `_thumbnail_id` cross-referenced against `<wp:attachment_url>`) plus 4 additional inline images from "Choosing The Right Jar," uploaded all of them to Sanity via `client.assets.upload("image", ...)`. The one post with a downloadable file (a Photopea label template `.zip`) had its own attachment uploaded via `client.assets.upload("file", ...)` and linked to its resulting `cdn.sanity.io` URL rather than the original `cellarlabels.com/wp-content/uploads/...` URL, since the old host is scheduled to go offline at cutover (Phase 12) and a dead download link would be a silent regression discovered only after the fact. **Blocker resolved mid-task:** `SANITY_API_TOKEN` in `.env.local` was actually empty (not just under-permissioned as first suspected), so every write attempt failed with "Insufficient permissions; permission 'create' required." Brandon provided a real Editor-role token directly in chat, which was written straight to `.env.local` (gitignored, never printed to any command output or transcript per the standing credential-handling rule) and the migration re-run successfully. **Verified live in local dev:** all 3 guides render on `/guides` with correct cover images and dates, each detail page's Portable Text body renders correctly (headings, paragraphs, numbered/bulleted lists, italics, inline images, external links), all rewritten internal links resolve to real new-site routes, and the migrated zip file downloads from its new permanent Sanity CDN URL with a byte-for-byte identical size (3,909,126 bytes) to the original. The one-off migration script lived only in the scratchpad directory (and a temporary in-repo copy for module resolution, deleted immediately after running) -- it was not committed, since it's a single-use data migration, not a piece of the application. | -- |
| 2026-07-14 | 2 | **A 4th page migrated ("Online Label Designer") and a real inline-image rendering bug found and fixed in the process.** Brandon pointed out `cellarlabels.com/online-label-designer/` was worth keeping -- a guide to using Avery.com's own online design tool to order professionally printed labels, a genuinely different use case from Sheet Builder (home printing via browser-generated PDF) that nothing on the new site currently covers. This page turned out to be a WordPress `page` post type, not a blog `post`, which is why it wasn't caught in the initial 4-post scan (that scan filtered on `post_type == "post"` specifically) -- worth remembering that WordPress "pages" can hold real evergreen content, not just blog posts, if any further gaps turn up later. Verified both of its live Avery.com deep links (`avery.com/blank/labels/94512` and `.../94501`) still resolve to the correct, correct-SKU product pages before migrating (curl reported 403 on both, which turned out to be Avery's bot-blocking rather than a dead link -- confirmed live via the actual browser tool instead of trusting curl's result). Migrated with the same hand-built-Portable-Text approach as the first 3 posts: typo fixes (`diamter`->`diameter`, `mulitple`->`multiple`, `artice`->`article`), and the dead "Free Template" link repointed at the already-migrated `/guides/online-label-editor` guide instead of the old WordPress URL. **Real bug found during this migration's verification, not the first one's:** none of the *inline* body images in either this guide or "Choosing The Right Jar" (migrated in the previous session) were actually rendering -- only each guide's separately-templated `coverImage` was ever visible. The `[slug]/page.tsx` detail page called `<PortableText value={guide.body} />` with no `components` prop, so `@portabletext/react` had no renderer registered for the schema's `image` block type and silently dropped every inline image block instead of erroring. This is a pre-existing gap in the original Phase 2 CMS build, not something introduced by either migration -- it was just never exercised, since Phase 2 shipped before any real article content (with inline images) existed to reveal it. The previous session's migration log entry claims inline images were "verified... rendering correctly," which was wrong -- what actually rendered was each guide's `coverImage` positioned right after the intro paragraph, easy to mistake for an inline body image at a glance; the actual inline-only images further down the page were never checked closely enough to notice they were missing. Fixed by adding a `portableTextComponents` object with an `image` type handler (`urlFor(value).width(1200).url()`) passed into `<PortableText components={...} />`. **Verified live in local dev after the fix:** all 5 previously-invisible inline images across both affected guides now render (confirmed via direct DOM `img[src]` inspection, not just visual screenshot, to be certain), all 4 guides now list correctly on `/guides`, and both Avery.com links and the internal Free Template link resolve correctly on the new guide's detail page. | -- |
| 2026-07-14 | 2 | **Route renamed from `/guides` to `/articles`** (Brandon's call, landed on "Articles" over an initial "Blog" suggestion as cleaner). Moved `src/app/(public)/guides/` -> `src/app/(public)/articles/` (via `git mv` to preserve history), renamed the Sanity query constants (`GUIDES_QUERY`/`GUIDE_BY_SLUG_QUERY` -> `ARTICLES_QUERY`/`ARTICLE_BY_SLUG_QUERY`), updated the Header/Footer nav link text and href, and updated page copy ("Guides"/"guide" -> "Articles"/"article" in headings, empty-state text, and the "Back to guides" link). The underlying Sanity schema is untouched -- `article` documents still use `category: "guide"` internally (that's a content-taxonomy value, not a URL, and templates still use `category: "template"` on the same schema) -- this was purely a route and label rename, not a data model change. **Patched the 3 already-migrated articles' internal cross-links directly in Sanity** (`Getting Started`'s "Next up" link, `Choosing The Right Jar`'s "Getting Started" link, `Online Label Designer`'s "Free Template" link) from `/guides/...` to `/articles/...` via a small script using `client.patch(docId).set({ body: ... }).commit()`, since those hrefs are stored as literal strings inside each document's Portable Text `markDefs` and would have otherwise 404'd after the route moved. **Verified live in local dev:** `/articles` lists all 4 articles with nav correctly reading "Articles," the old `/guides` URL now cleanly 404s, and all three patched cross-links resolve to their new `/articles/...` paths. | -- |
| 2026-07-15 | 4 | **Interactive circular crop on label upload (Brandon-requested, mid-Phase 4/5-era gap closed retroactively).** Uploading a label previously did no real image processing -- the raw file was saved verbatim, and the "circular preview" was cosmetic CSS/canvas only. Brandon wanted labels to actually be transparent-background circular PNGs (saves home-printer ink, guarantees square/rectangular source art always renders as a true circle both on screen and in print, not just relying on the eventual Avery die-cut to save an ugly on-screen preview) with an interactive pan/zoom tool so users can fix framing mistakes before saving. Built a new shared `LabelCropTool.tsx` (`react-easy-crop`, new dependency) used identically by `UploadForm.tsx` (single upload) and `ReplaceVersionForm.tsx` ("Replace image"), plus `src/lib/image-crop.ts` (`cropToCircularPng`, `centerSquareCropRect`, `loadImageFromFile`) with the actual canvas export logic, shared by both the interactive tool and Batch Upload's new automatic (non-interactive, center + cover-fit) crop in `BatchUploadForm.tsx`. Output is always a fixed `1600x1600` transparent PNG with the circle filling the canvas edge-to-edge (not the old cosmetic preview's `0.4`-ratio inset), which matters because `generate-sheet.ts`'s PDF export assumes the image's square bounding box is exactly what should cover the Avery bleed circle -- confirmed this required zero changes to PDF generation, verified via a real sheet build showing a full-bleed circle with no unprinted ring. `src/lib/upload.ts` tightened to PNG-only (every upload path now always submits a PNG from the crop pipeline; JPG arriving server-side is a bypass/stale-client signal, not a legitimate case) with the existing min/max dimension checks kept as defense-in-depth. Added a new `text_cut_off` `RejectionReason` (hand-written `ALTER TYPE` migration, `rejection-reasons.ts` entries, `prisma/seed.ts` default message) for moderators to reject labels where the crop cut off important text -- confirmed `RejectLabelForm.tsx`/`LabelsBulkList.tsx`/admin Settings already iterate `REJECTION_REASONS` dynamically, so no changes needed there. Submission for both single-upload forms switched from native `<form action={formAction}>` to `onSubmit` building a `FormData` (via `new FormData(e.currentTarget)`, which picks up `BrandBlendAutocomplete`'s hidden `blendId` input automatically) with the cropped blob substituted for `file`, then calling `formAction(fd)`. **Two real bugs found and fixed during browser verification, not before:** (1) `formAction(fd)` called directly from a plain `onSubmit` handler triggered React's "called outside of a transition" warning and made `isPending` unreliable -- fixed by wrapping the call in `startTransition()`. (2) `react-easy-crop`'s actual enforced minimum zoom (to fully cover the crop circle) can land above the naive `zoom=1` assumption depending on image aspect ratio, which could push the effective source resolution below `label_min_dimension` even at the library's own most-zoomed-out state, permanently blocking some legitimately-sized uploads with no way to satisfy the guard -- fixed by tracking the largest crop-area width ever observed (the true achievable floor) as the resolution-guard baseline instead of comparing directly against the raw setting, so the guard only fires when a user has zoomed in *past* the achievable floor, never at it. **A third bug surfaced fixing lint, not manual testing:** an initial `useMemo`-for-the-object-URL refactor (done to satisfy `react-hooks/set-state-in-effect`, a real lint error in this project's config, not a style nit) didn't survive React Strict Mode's double-invoked effect cleanup -- the memoized blob URL got revoked on the simulated remount but never recreated, leaving the `<Cropper>`'s image permanently broken (confirmed via `naturalWidth: 0` in the live DOM). Reverted to the original effect+state pattern with a scoped, explained `eslint-disable-next-line react-hooks/set-state-in-effect` (object URL lifecycle management is exactly the documented "external system" exception to that rule) rather than fighting it with a pattern that silently breaks under Strict Mode. **Verified end to end in local dev**, including the two bugs above only surfacing on real browser exercise (not `tsc`/`eslint`, both of which pass clean): single upload with a real rectangular test image (pan, zoom, resolution-guard trigger and release, confirmed the saved file is a genuine `1600x1600` RGBA PNG with alpha `0` at all four corners and full opacity at center via direct pixel inspection, not just visual/CSS-clipped appearance), Replace Image on an existing label producing a correctly transparent v2, Batch Upload with two mismatched-aspect-ratio files producing correctly auto-cropped transparent circles with no interactive step, a Sheet Builder PDF export including a freshly-cropped label rendering full-bleed with no code changes needed, and a moderator rejecting a label with the new "Text cut off" reason showing the correct default message with no `--`/em-dash. Test labels and their storage files were deleted afterward to keep the dev DB representative. | -- |
| 2026-07-15 | 4 | **Crop tool follow-up fixes, same-day Brandon feedback on the feature above.** (1) The ring/crop proportions were backwards: the previous version scaled the jar-lid ring artwork *up* to 800x800 to match a fixed 320px crop area, making the ring balloon far larger than the label preview it was meant to frame, with its white inner paper area left fully exposed instead of hidden behind the crop. Fixed by keeping the ring at its normal 320px display size and sizing the interactive crop circle *down* to exactly the ring's inner paper circle (256px, `RING_SIZE * INNER_RADIUS_RATIO * 2`) so it visually covers the white area, matching how the original Phase 4 cosmetic preview looked. (2) Added a "Reset" link next to the zoom slider in `LabelCropTool.tsx` that snaps `crop`/`zoom` back to their defaults, for when a user has panned/zoomed somewhere they can't recover from and wants to start over without re-selecting the file. (3) Explicitly verified Brandon's stated acceptance criterion -- a perfectly square, edge-to-edge source image must need zero cropping by default -- by generating a real 1000x1000 test image and pixel-comparing the exported crop against the original: the opaque circle's horizontal span matched exactly (0 to full width) on both, and the zoom slider settled at exactly `1` with no resolution warning, confirming the earlier session's "library's true floor can exceed 1" finding is specific to non-square sources (where some crop is unavoidable to fill a square frame), not a general default-zoom bug. | -- |
| 2026-07-15 | 4 | **Second round of crop tool fixes, same day, after Brandon caught a real layering bug with a screenshot.** The prior fix sized the crop circle to match the ring's inner boundary, but the ring `<img>` was still rendered *behind* the Cropper in the DOM, so the Cropper's square bounding box (react-easy-crop's `cropShape="round"` only dims the out-of-circle corners, it doesn't remove them) painted on top and poked out past the ring's rim exactly as shown in Brandon's screenshot. Tried the straightforward fix first (move the ring's `<img>` to render after/on top of the Cropper) and it made things worse: sampling `jar_lid_ring.png`'s alpha channel confirmed the ring artwork is **fully opaque everywhere**, including its own baked-in "paper" area (the same white texture with the etched "Cellar Labels" wordmark and leaf line art visible when no image is selected) -- so simply layering it on top hid the crop preview completely rather than framing it. Fixed properly with two changes: (a) `overflow-hidden rounded-full` on the Cropper's wrapper div to hard-clip away the square bounding box and darkened corners, switching `cropShape` from `"round"` to `"rect"` since react-easy-crop's own circular mask is now redundant with the CSS clip (the actual crop rectangle math is identical either way, `cropShape` only affects the drawn overlay); (b) a CSS `mask-image: radial-gradient(...)` on the ring `<img>` that punches a transparent hole through the ring's own opaque center, letting the now-circularly-clipped crop preview show through underneath while the ring's rim stays opaque on top as the frame -- got the percentage math wrong on the first pass (radial-gradient's default sizing is relative to the box's *farthest-corner* distance, not its radius, so `INNER_RADIUS_RATIO*100` landed the transparent hole at the wrong size and mostly hid the ring instead), fixed by adding the `closest-side` sizing keyword so percentages resolve against the box's actual half-width and recomputing the stop as `(CROPPER_SIZE / RING_SIZE) * 100`. Also restored the "Hide jar ring" / "Show jar ring" toggle (`showRing` state) that existed in the original Phase 4 cosmetic preview but was dropped when `LabelCropTool.tsx` replaced it -- hidden state falls back to a plain bordered circle with no ring overlay. **Verified live in local dev**, not just visually: a rectangular test image now shows the crop content filling the ring's inner circle with the gold rim framing it cleanly and zero square corners visible in any zoom/pan state, the hide/show toggle correctly swaps between the framed and plain-circle views, and a full upload with the ring showing still produces a correctly transparent `1600x1600` stored file (re-verified via direct pixel inspection) -- confirming the visual-only DOM/CSS changes didn't touch the actual crop export math. | -- |
| 2026-07-16 | -- | **Navigation audit (Brandon-requested).** Two real, fully-built community pages had zero path to them from anywhere in the app -- `/requests` and `/missing` (Missing Blends) were only reachable by typing the URL directly, confirmed via a repo-wide grep for `href` references to either route before this session (zero matches for both). Added both to `Header.tsx`'s `NAV_LINKS` (shared by desktop and mobile nav), alongside a new `/upload` link for logged-in users styled as a filled CTA pill (matching the existing Register button's treatment, since it's the site's primary action rather than a passive destination). Deliberately did not add a top-level `/sheet-builder` link -- unlike Requests/Missing it already has real entry points (label detail's "Add to Sheet", profile's Sheets tab), and adding it plus the others made the desktop nav wrap onto two lines and clip the theme toggle at normal widths; trimmed back to keep one clean row (also shortened "Missing Blends" to "Missing" in the nav label only, page title unchanged). Also deleted `src/app/(public)/brand-blend-test/page.tsx`, a Phase 3 dev scaffold whose own on-page copy said "This will be replaced by the real label upload form in Phase 4" -- confirmed zero references anywhere in the codebase, dead since Phase 4 shipped. **Verified live in local dev:** both pages load correctly via their new header links, nav renders on one line at desktop width with no clipping, and the mobile hamburger menu lists everything (including Sheet Builder was intentionally left off there too, for consistency with desktop). | -- |
| 2026-07-16 | 11 | **Phase 11 outbound API complete.** Built the four read-only `/api/v1/...` endpoints Pipe Tool needs to search and display the label catalog: `labels/search` (by exact brand/blend name), `labels/:id`, `brands/:slug/labels`, `blends/:id/labels`. All four share `src/lib/api-labels.ts` (`PUBLIC_LABEL_INCLUDE` Prisma include shape + `serializePublicLabel`, which builds absolute `fileUrl`/`url` values via `NEXTAUTH_URL` since relative paths are meaningless to an external API consumer -- reused the same absolute-URL pattern already used for email links in `votes.ts`/`flags.ts`/`auth.ts`) and only ever return `status: active` labels. Auth is `src/lib/api-auth.ts`'s `authenticateApiRequest()` -- `Authorization: Bearer <key>`, reuses the exact SHA-256 hash-at-rest scheme from Phase 10's admin-issued `ApiKey` model (no new schema needed), updates `lastUsedAt` on every authenticated call, and enforces a 60-requests/minute-per-key limit via a simple in-memory fixed-window `Map` -- deliberately not Redis or any shared store, since this runs as a single Node process on ShockHosting (see CLAUDE.md's Tech Stack/Deployment sections), consistent with this project's established preference for the simplest thing that works on this specific host. Added a public, unauthenticated `/api-docs` page (linked from the footer) documenting the auth header, rate limit, and all four endpoints with real example request/response shapes. Left the inbound half of this phase (`cellar/sync`, `trending/push`, account linking) untouched -- the phase's own header note says to build outbound first and wait for Pipe Tool partnership confirmation before inbound, and that hasn't happened. **Verified end to end via curl against a real generated API key** (not just `tsc`/`eslint`): confirmed a missing and a wrong Authorization header both 401 with a clear message, confirmed all four endpoints return correct real data (matched against direct `psql` queries for the same records) including both the brand-filter and unfiltered `labels/search` cases, confirmed 404s on an unknown label id and an unknown brand slug, fired 65 requests in a loop on one key and confirmed the ~60th onward correctly returned 429, confirmed `last_used_at` updated in the database after a real authenticated call, and confirmed revoking the key via `/admin/api-keys` immediately made it start returning 401 again. Test key generated and revoked as part of this verification, left in its revoked state rather than deleted (matches how the existing "Pipe Tool staging" key from Phase 10 was already left revoked-not-deleted in this dev DB). | -- |
| 2026-07-17 | -- | **Admin-editable site navigation (Brandon-requested), replacing the hardcoded Header/Footer links from the navigation audit above with a real admin-owned system.** Brandon wanted to see a list of existing pages and pick which go in which menu, add fully custom links, and build hover-opened submenus (e.g. Requests and Missing nested under Gallery). New self-referential `NavItem` model (`prisma/schema.prisma`, hand-written migration per this project's established non-interactive workaround) with `menu` (header/footer), `parentId` (one level of nesting only, enforced in the server action, not the schema -- Postgres self-relations can't declaratively cap depth), `order`, `visibility` (always / logged-in-only / logged-out-only, confirmed with Brandon that auth-gated links like Feed/Upload/Log In/Register should be admin-editable too, matching how they already conditionally showed before), `isButtonStyle` (for the CTA-pill treatment Upload/Register use), and a nullable unique `seedKey` so `prisma/seed.ts` can upsert the current hardcoded structure as defaults idempotently (matching this file's existing per-row-upsert-on-natural-key convention, e.g. badges/settings/brands -- deliberately not a bulk count-guard, which would silently stop seeding new defaults once the table is non-empty) without ever clobbering an admin's edits on redeploy. Account-UI chrome (avatar/username link, notification bell, logout, theme toggle) deliberately stays hardcoded in `Header.tsx` -- those are session controls, not navigable pages. New `/admin/navigation` page (admin-only, matching the `page.tsx`+`Row`+`NewForm` shape already used by `/admin/badges`) lets Brandon add/edit/delete items in both menus, with an "existing pages" `<select>` (`src/lib/site-pages.ts`, a plain static registry of the site's linkable static routes -- dynamic `[slug]`/`[id]` routes and admin pages excluded since a generic picker can't supply an entity id for those) that autofills the label/URL fields but doesn't create a persisted reference, so routes can change later without needing a migration. Submenu hover dropdowns (`src/components/layout/NavLink.tsx`, shared by both `Header.tsx` and `Footer.tsx`) use pure CSS `group`/`group-hover` with no new JS state, extending the exact same utility pair already proven in `LabelCard.tsx`'s thumbnail hover-fade -- confirmed no ancestor has `overflow-hidden` that would clip the dropdown panel. Visibility filtering happens server-side in `layout.tsx` (which already does the session/`currentUser` fetch) rather than in either component, since `Footer.tsx` was a plain server component with no session access of its own and this avoids turning it into a client component just for that. **Verified end to end in local dev**, not just `tsc`/`eslint`: seeded and re-ran the seed a second time to confirm idempotency (row count unchanged), confirmed the logged-out header/footer match the old hardcoded state exactly, confirmed the Gallery hover dropdown works both logged out and logged in, registered a fresh test account and confirmed Feed/Upload/Log In/Register correctly swap based on session state, confirmed the mobile hamburger menu shows submenu children as an always-expanded indented list (no hover on touch), promoted the test account to admin and used `/admin/navigation` to add an item via the existing-page picker (confirmed autofill) and a fully custom external link (both went live immediately via `revalidatePath`), added a third child under Gallery and confirmed it appeared as a third dropdown item, confirmed the one-level-nesting guard rejects giving Gallery (which already has children) a parent with the exact validation message, and confirmed deleting Gallery cascade-deleted both its seeded children and the ad-hoc test child from the database in one operation. Test nav items, the promoted test account, and its session were all cleaned up afterward (re-running the seed restored the deleted defaults under fresh ids) so the dev DB matches the shipped default menu structure. | -- |
| 2026-07-21 | -- | **Sanity Studio extracted out of the production app, in response to a real weekend-long outage.** The ShockHosting account hit its 200-process CloudLinux LVE ceiling and stayed there through the weekend, causing hourly site downtime -- cPanel Resource Usage showed low CPU (24%) and low memory (18.8%) alongside the maxed process count, pointing to accumulated idle/orphaned processes rather than genuinely heavy load, and self-service diagnosis hit a wall when even a freshly opened cPanel Terminal session couldn't fork `ps -ef` (confirmed zero process headroom account-wide). A ShockHosting support ticket was filed for the immediate lockup (root-level intervention needed, outside what SSH/cPanel access can fix). Independent of that specific trigger, the account has hit this exact ceiling before (Phase 1/3 history above) on a shared host running 4 WordPress sites plus 2 Node apps -- so alongside the ticket, committed to a permanent footprint reduction: the full Sanity Studio CMS admin (`sanity` + `@sanity/vision`, React app for editing, ~93MB) was bundled directly into this Next.js app at `/studio`, running in the same Passenger-managed process as the public site 24/7 even though only Brandon uses it, occasionally. Moved it to Sanity's own free-hosted Studio pattern instead. **Confirmed architecturally clean before touching anything:** public content-reading (`src/sanity/lib/client.ts`/`queries.ts`/`image.ts`, used by `/articles` and `/templates`) depends only on `next-sanity` and `@sanity/image-url`, with zero dependency on `sanity`/`@sanity/vision` -- the only files needing those packages were `sanity.config.ts`, `sanity.cli.ts` (both repo root), the `/studio` route itself, and the single `article` schema type definition. `git mv`'d all three into a new `studio/` directory (repo-root sibling to `src/`, same git repo for convenience but a fully independent deployable unit -- own `package.json`/`node_modules`, never touched by the main app's `npm install` or `next build` since npm doesn't recurse into subdirectories without an explicit workspace config, which was deliberately not set up). Removed `src/app/studio/` entirely from the main app, along with `sanity`, `@sanity/vision`, and `styled-components` from its `package.json` -- confirmed via grep that `styled-components` had zero usage anywhere in `src/`, meaning it was purely a Studio peer-dependency riding along as a top-level dependency since Phase 1/2. Also removed `next.config.ts`'s `transpilePackages: ["swr"]` (a Studio-only ESM/CJS bundling workaround, `swr` being a transitive dep of `sanity` with no direct usage) and its `env: { SANITY_PROJECT_ID, SANITY_DATASET }` block, which existed solely to expose those vars to Studio's `"use client"` bundle -- confirmed every remaining Sanity-reading page (`/articles`, `/articles/[slug]`, `/templates`) is a server component, so `process.env` already works there natively without that config. New `studio/package.json` pins the same versions the main app had (`sanity`/`@sanity/vision` ^6.4.0, `react`/`react-dom` 19.2.4, `styled-components` ^6.4.3), plus its own `tsconfig.json`, `.gitignore`, and a local-dev-only `.env.local` with the (non-secret, per the removed next.config.ts comment) project ID/dataset. **Deployment to Sanity's hosted `*.sanity.studio` domain requires Brandon's own browser-based `sanity login` and hasn't happened yet** -- this session only did the local extraction and verification; **no deploy or push to ShockHosting was attempted**, since the account can't fork any process at all right now and a deploy attempt would likely fail outright or make the lockup worse. | Studio deploy completed same day (see next row) -- only the ShockHosting support ticket resolution remained blocking, then that outage investigation surfaced a separate, more concrete finding (see next row). |
| 2026-07-21 | -- | **Sanity Studio deployed live, and the ShockHosting outage got a real root-cause investigation with two concrete fixes shipped.** Studio: `cd studio && npx sanity login && npx sanity deploy --url cellarlabels` completed with Brandon completing the interactive browser login himself (CLI login is separate from a browser session -- authenticates the `sanity` CLI's own local token, not just Studio's web app). Live at `https://cellarlabels.sanity.studio`, confirmed showing the real project's 4 migrated articles. Saved the deploy's `appId` into `studio/sanity.cli.ts`'s new `deployment` block so future deploys don't re-prompt. **Outage investigation:** ShockHosting support's reply confirmed the 200/200 process spike was caused by "multiple LiteSpeed Node.js instances" for `staging.cellarlabels.com` specifically (their web server is LiteSpeed, not literally Phusion Passenger as this file previously said -- LiteSpeed just reads the same `Passenger*` `.htaccess` directives for compatibility; correcting that here since support named the real process manager directly). Found `stderr.log` in the app root full of repeated `Unhandled Rejection: Error: ENOENT ... .next/prerender-manifest.json` crashes, but its last-modified timestamp (Jul 14, 6:45pm) lines up almost exactly with that day's last successful GitHub Actions deploy (23:57 UTC) -- confirmed via `gh run list` that **zero workflow runs happened between then and this outage**, and confirmed `.next/prerender-manifest.json` is present and healthy right now. Together this rules the crash-loop in as a real, previously-seen failure mode (a deploy-time race where Passenger/LiteSpeed tries starting the app before the new build finishes writing) but rules it out as the direct trigger for the weekend's incident specifically, since nothing redeployed and the build has been intact the whole time. With deploys and app-level crash-restart both ruled out, and a codebase audit turning up no `child_process`/`cluster`/timer-based process-spawning anywhere and a correctly-singleton Prisma client in `src/lib/db.ts`, the remaining and most plausible explanation is unthrottled traffic: **every public route had zero rate limiting** except the authenticated Pipe Tool API from Phase 11 (`src/lib/api-auth.ts`) -- including `/api/storage/[...path]`, which does a full synchronous `fs.readFile` of the raw image file (label images can be several MB) on every single request with no caching layer of its own. A crawler or bot hitting that route (or any public page) hard over a quiet weekend, with LiteSpeed scaling up worker instances under concurrent load, fits every piece of evidence found so far without requiring any code change or deploy to explain it. **Shipped two hardening changes as a result, both local-only, not yet deployed:** (1) `app.js` now wraps `app.prepare()` in a `.catch()` that logs and calls `process.exit(1)` instead of letting a broken build die via an unhandled promise rejection -- if the ENOENT race ever recurs, it now fails loud and clean instead of being silently eligible for a respawn loop. (2) A new blanket per-IP rate limiter: `src/lib/rate-limit.ts` (in-memory fixed-window counter, same pattern as the existing per-API-key limiter, generalized to take any key) plus `src/proxy.ts` applying it to every route except Next's own `_next/static`/`_next/image`/`favicon.ico` internals -- general routes capped at 300 req/min per IP, `/api/storage/*` capped tighter at 120 req/min per IP given its higher per-request cost. Deliberately named `proxy.ts`, not `middleware.ts` -- Next.js 16.2.10 (this project's version) deprecated the `middleware` file convention in favor of `proxy` (confirmed via the installed package's own source, `PROXY_FILENAME`/`MIDDLEWARE_FILENAME` constants in `node_modules/next/dist/lib/constants.js`), including a renamed export (`export function proxy(...)`, not `middleware`). **One real bug caught during verification, not before:** the matcher's first draft excluded any path ending in a common static-asset extension (`.png`/`.jpg`/etc.) to skip build assets -- but that also silently exempted `/api/storage/*` itself, since it serves paths ending in those exact extensions, exempting the one route the whole thing was built to protect. Fixed by only excluding Next's known internal prefixes, not by file extension. **Verified in local dev, not just `tsc`:** 130 rapid requests against a nonexistent storage path returned exactly 120 `404`s then 10 `429`s once past the storage-specific budget; normal navigation (`/`, `/labels` with real gallery data and images) confirmed completely unaffected, with no server errors in the dev log. **Deliberately not concluded as proven:** the bot-traffic theory is the strongest remaining explanation given everything ruled out, but wasn't confirmed against real evidence (the weekend's access logs) since that requires cPanel/File Manager access this session didn't have time to complete. | **Two real next steps, both need Brandon:** (1) Check the raw access logs for `staging.cellarlabels.com` (cPanel File Manager, `~/logs/` or `~/access-logs/`) for a request-volume spike over the exact outage window, to actually confirm or rule out the bot-traffic theory rather than leaving it as the leading-but-unproven explanation. (2) Decide whether `staging.cellarlabels.com` should get an additional layer given it's pre-launch and was never meant for public traffic in the first place -- either confirm it's proxied through Cloudflare (orange-cloud, not DNS-only grey-cloud) for free edge-level bot mitigation, or add cPanel's Directory Privacy (HTTP Basic Auth) to wall it off from the open internet entirely until cutover. Neither of these has been done yet. Also still blocked on the original ShockHosting support ticket for the process-count ceiling itself, and no deploy to production has been attempted this session (`app.js`/`proxy.ts` changes are local-only, verified in dev, ready to ship once deploys are safe again). |
| 2026-07-21 | -- | **First real evidence for the bot-traffic theory, plus a `robots.txt` gap closed.** Brandon pulled today's raw access log for `staging.cellarlabels.com` via cPanel's Metrics -> Raw Access tool (a separate tool from File Manager, and only ever covers the current day, not historical/rotated logs). Only 5 requests total in the SSL log, but two are OpenAI's `GPTBot` hitting `/` and getting back `429` with 0 bytes, and two are OpenAI's `OAI-SearchBot` requesting `/robots.txt` and getting `404` -- confirming the site is actively being crawled by AI bots right now. The `429`s are notable and still unexplained: this app's own rate limiting (`src/proxy.ts`, added earlier today) is local-only and has never been deployed, so something else on ShockHosting's side (most likely a host-level WAF/bot-mitigation layer) is already blocking `GPTBot` specifically, independent of anything built this session -- Brandon doesn't know of one either, so this remains an open question, not a confirmed explanation. Regardless of that mystery, the `404` on `/robots.txt` was a real, unconditional gap: the site had no crawl guidance for any bot at all. Added `src/app/robots.ts` (Next.js's `MetadataRoute.Robots` file convention, generates `/robots.txt` automatically) disallowing all crawling whenever `NEXTAUTH_URL` isn't the real production domain -- correctly covers both local dev and the current staging deploy (per the Environment Variables section's documented staging override), and will automatically flip to allowing crawling once `NEXTAUTH_URL` is switched back to `https://cellarlabels.com` at cutover, with no manual toggle needed. Verified locally: `/robots.txt` correctly returns `Disallow: /` in dev. **Important limitation flagged, not resolved:** today's 5-request sample cannot confirm or rule out what actually happened over the outage weekend, since cPanel's Raw Access tool only ever exposes the current day -- the real historical data (if it still exists) would be in the rotated, monthly-archived per-domain log files under the account's `logs`/`access-logs` folders found earlier in File Manager, which still needs checking for a `staging.cellarlabels.com` entry covering the actual Jul 18-20 window. | Same two outstanding items as the prior row: find and check the archived (not current-day) access log for the actual outage window, and decide on Cloudflare proxying vs. cPanel Directory Privacy for staging. Also newly open: what is issuing the unexplained `429`s to `GPTBot` today, if not this app's own code. |
| 2026-07-21 | -- | **Bot-traffic theory directly disproven by the real archived logs -- correcting the prior two rows' leading explanation.** Found and downloaded the actual monthly-archived logs from the `logs` folder (`staging.cellarlabels.com-Jul-2026.gz` and `-ssl_log-Jul-2026.gz`, distinct from cPanel's Raw Access tool which only ever exposes the current day), covering the real Jul 18-20 outage window this time. Result: **the SSL log shows exactly 3 requests total across the entire two-day incident window** (one each at Jul 19 06:51, Jul 19 10:07, Jul 20 10:10), all simple `GET / -> 200` from what looks like an uptime monitor (identical Firefox-120-on-Linux-i686 fingerprint, sequential 23.27.145.x IPs) -- nothing resembling a crawler storm or any meaningful volume at all. Checked the full month's status-code distribution for anything else that might explain it: the only 5xx cluster anywhere in the entire log is from Jul 12, all from one IP (`206.190.195.248`) whose user agent literally includes `Claude/1.20186.0` -- that's this assistant, from the already-documented Phase 1/3 infra-troubleshooting session, not a new finding. Zero errors, zero elevated request volume anywhere near Jul 19-20. **Conclusion: incoming traffic did not cause this outage.** Given ShockHosting support separately confirmed "multiple LiteSpeed Node.js instances" were running for this app specifically during that same window, the combination of near-zero traffic with duplicate instances anyway points to something internal to LiteSpeed's own process/instance lifecycle management on ShockHosting's side (a worker-recycling policy, a health-check subsystem, or similar) -- not application code, not a deploy, and not external load. This is outside what's diagnosable from cPanel or this codebase; only ShockHosting's own server-side visibility can explain it. **What stays valid regardless:** the `app.js` crash-handling fix, the rate-limiting `proxy.ts`, and the new `robots.txt` are all still worth keeping -- GPTBot/OAI-SearchBot genuinely are crawling the site right now (confirmed via today's Raw Access log in the prior row), a pre-launch staging site having zero rate limiting and no crawl guidance was a real gap independent of whether it caused this specific incident, and hardening against it costs nothing. They're just no longer believed to explain the weekend's process exhaustion. | **Next step changed:** rather than more local investigation (code, DB pooling, and now traffic have all been ruled out), the right next move is following up on the existing ShockHosting support ticket with this specific finding -- ask them directly what could spawn additional Node instances for this app with no corresponding incoming traffic, since that requires their server-side visibility into LiteSpeed's actual instance-management behavior, which neither cPanel nor this codebase exposes. Cloudflare-proxying vs. cPanel Directory Privacy for staging (from two rows up) is still a reasonable precaution to take regardless, just no longer believed to be addressing the actual cause. |
| 2026-07-22 | -- | **Closed out support's original checklist, deployed everything, and caught a real production bug the same session it was introduced.** Before replying to the support ticket, went back through their original 5 suggestions one by one rather than assuming they were all covered: confirmed cPanel's Node.js Selector shows only one app instance configured (no duplicates), confirmed the account's only cron job is an unrelated weekly Softaculous backup (rules out a rogue scheduled restart), and read the live `.htaccess` directly (only possible once the app was actually running again, since cPanel strips the `Passenger*` directives entirely while an app is stopped) -- confirms the only directives present are the standard `PassengerAppRoot`/`PassengerBaseURI`/`PassengerNodejs`/`PassengerAppType`/`PassengerStartupFile`, with **no instance-count or concurrency setting anywhere**, closing that thread from our side for good. Brandon then asked to push everything currently uncommitted before restarting the app rather than just today's fixes, so the full backlog went out as three separate commits (matching this project's existing commit-message conventions rather than one undifferentiated blob): Phase 11's Pipe Tool API (fully self-contained, no shared-file overlap), the label crop tool + admin-editable navigation + Sanity Studio extraction (grouped together since they share `schema.prisma`/`seed.ts`/`package.json`), and today's outage hardening (`app.js`, `proxy.ts`, `rate-limit.ts`, `robots.ts`, plus the LiteSpeed doc correction). Both deploys succeeded via `gh run watch`, applying the two pending Prisma migrations (`text_cut_off` rejection reason, `NavItem`) along the way. **Real bug found immediately after deploying, not before:** `staging.cellarlabels.com/studio` came back with `ERR_TOO_MANY_REDIRECTS` instead of a clean 404. Root cause, confirmed via direct `curl`: `deploy.yml`'s `git pull origin main` runs directly inside `DEPLOY_PATH`, which *is* this domain's public web root (`PassengerAppRoot`) -- so the new standalone `studio/` project (a sibling directory to `src/` in the same git repo, by design from yesterday's extraction plan) was landing in the public webroot on every deploy. LiteSpeed serves static files off disk before falling through to the Node app, so this **publicly exposed `studio/`'s raw source files** (`curl .../studio/package.json` returned the real file) and caused the redirect loop, since the bare directory has no index file (LiteSpeed 301s `/studio` -> `/studio/` per standard directory-slash behavior, then something 308s back, forever). This was a genuine oversight in the original extraction plan -- keeping `studio/` in the same repo for convenience never accounted for the deploy pipeline pulling straight into the public web root. Fixed two ways: manually deleted the live `studio/` directory via cPanel File Manager as an immediate stopgap, and added `rm -rf studio` to `deploy.yml` immediately after every `git pull`, before anything else runs -- self-correcting on every future deploy regardless of whether `studio/` changes that cycle, since a fast-forward `git pull` only re-touches files its diff actually references, so once removed it stays removed until a real commit touches that path again (at which point the very next deploy's `rm -rf` removes it again anyway). Pushed as its own fourth commit, deployed and verified via `curl`: `/studio` now returns a clean Next.js 404, `/studio/package.json` now 404s, and the homepage/`robots.txt`/`/articles` all confirmed 200 on the real live site post-deploy. | Only remaining open items: the ShockHosting support follow-up (drafted, not yet sent -- now even more specifically informed, since we can tell them concretely that neither cron jobs nor `.htaccess` show anything on our end), and the still-undecided Cloudflare-proxying vs. cPanel Directory Privacy question for staging. |
| 2026-07-22 | -- | **Decision: hold the support follow-up and observe for 48 hours instead of sending it immediately.** Brandon's call -- the live deploy now genuinely differs from the incident state (rate limiting, crash-safe `app.js` startup, `robots.txt`, and the `studio/` exposure fix are all live), so real observation is a better signal than sending the follow-up on speculation alone. If the process count stays healthy, that's reasonable evidence the hardening helped or the original trigger was transient; if it recurs anyway despite all of this, that's strong confirmation the cause is genuinely internal to LiteSpeed's own instance management (not application-side), and makes the already-drafted support follow-up far more pointed to send. Check back via cPanel's Resource Usage tool (same 200/200 process view used throughout this investigation) around 2026-07-24; if anything looks off, pull that day's Raw Access log the same way as this session before deciding next steps. | Send the drafted ShockHosting follow-up only if the process count problem recurs in the next 48 hours; otherwise this incident is considered resolved (or at minimum, no longer actively investigated) pending confirmation the account stays stable. The Cloudflare-proxying vs. cPanel Directory Privacy decision for staging remains open independent of this. |
