From 80a3c042527980fbc2c02ba1e09ce3f68b56c929 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Mon, 4 May 2026 22:07:19 +0200 Subject: [PATCH 1/3] feat(routes): enhance routing structure and add local PostgreSQL script - Refactored the routing constants to improve organization and maintainability, separating routes into distinct categories (root, foundation, collection, admin). - Added a new script in package.json to start a local PostgreSQL service for development convenience. - Updated various components to utilize the new route structure, ensuring consistent link generation across the application. - Improved category handling in queries to include links for easier navigation. --- DB_DRIFT_FIX.md | 200 ++++++++++++++++++ app/(home)/page.tsx | 11 +- app/(pages)/admin/page.tsx | 4 +- app/(pages)/brands/[slug]/page.tsx | 19 +- .../categories/[...category]/page.tsx | 6 +- .../items/[slug]/components/breadcrumbs.tsx | 11 +- .../delete-barometer/delete-barometer.tsx | 3 +- app/(pages)/collection/items/[slug]/page.tsx | 8 +- app/(pages)/collection/new-arrivals/page.tsx | 2 +- .../{documents => ephemera}/DocumentEdit.tsx | 0 .../{documents => ephemera}/DocumentTable.tsx | 2 +- .../{documents => ephemera}/[cat-no]/page.tsx | 6 +- .../document-edit-schema.ts | 0 app/(pages)/{documents => ephemera}/page.tsx | 0 app/sitemap.ts | 14 +- components/containers/header/mobile-menu.tsx | 2 +- constants/routes.ts | 61 ++++-- package.json | 2 + .../migration.sql | 31 +++ .../migration.sql | 17 ++ scripts/seed-blur-data.ts | 74 +++++++ server/barometers/queries.ts | 28 ++- server/brands/queries.ts | 1 + server/categories/queries.ts | 19 +- server/menu/queries.ts | 6 +- 25 files changed, 463 insertions(+), 64 deletions(-) create mode 100644 DB_DRIFT_FIX.md rename app/(pages)/{documents => ephemera}/DocumentEdit.tsx (100%) rename app/(pages)/{documents => ephemera}/DocumentTable.tsx (98%) rename app/(pages)/{documents => ephemera}/[cat-no]/page.tsx (97%) rename app/(pages)/{documents => ephemera}/document-edit-schema.ts (100%) rename app/(pages)/{documents => ephemera}/page.tsx (100%) create mode 100644 prisma/migrations/20260416000000_add_forecasters_ephemera_update_friends/migration.sql create mode 100644 prisma/migrations/20260416000100_attach_category_images/migration.sql create mode 100644 scripts/seed-blur-data.ts diff --git a/DB_DRIFT_FIX.md b/DB_DRIFT_FIX.md new file mode 100644 index 0000000..ebc1afd --- /dev/null +++ b/DB_DRIFT_FIX.md @@ -0,0 +1,200 @@ +# Database Drift Fix — Technical Task + +## TL;DR + +Production database schema and `_prisma_migrations` table diverge from what any branch's migration files describe. `prisma migrate dev` is effectively broken on any branch that does not already carry the hidden changes. This document explains the current mess and lays out a plan to bring the migration history, `schema.prisma`, and the actual production schema back into sync. + +This is **infrastructure debt**, not a feature. Schedule it as a dedicated task with a dedicated PR. + +--- + +## Symptoms + +Running `bunx prisma migrate dev` on the `master` branch (or any branch based on it, e.g. `new-menu-items`) after `bun run import-data` produces a drift report like: + +``` +- Drift detected: Your database schema is not in sync with your migration history. + +[+] Added enums: Currency, OrderStatus, PaymentStatus +[+] Added tables: Customer, Order, OrderItem, Payment, Product, + ProductImage, ProductOption, ProductVariant, ShippingAddress + +[*] Changed the `Barometer` table + [+] Added index on columns (categoryId, conditionId, date, manufacturerId, subCategoryId) + +[*] Changed the `Document` table + [+] Added index on columns (conditionId, date) + +[*] Changed the `InaccuracyReport` table + [+] Added index on columns (barometerId, status) + +[*] Changed the `PdfFile` table + [+] Added index on columns (manufacturerId) + +[*] Stripe tables — extra non-unique indexes on Order, OrderItem, Payment, + ProductImage, ProductOption, ProductVariant, Customer + +- Migrations applied to the database but absent from the migrations directory: + 20251018032256_add_stripe_tables + 20251031174439_rename_product_image_alt_to_name +``` + +Workaround currently in use: `prisma migrate deploy` (which does not check drift) is used to apply new migrations. This works but is a leaky abstraction — any future `migrate dev` will keep complaining. + +--- + +## Root Cause Analysis + +Three independent sources of divergence: + +### 1. Stripe migrations applied to prod without merging the code + +The `stripe` branch contains four migrations: + +- `20251018032256_add_stripe_tables` +- `20251031174439_rename_product_image_alt_to_name` +- `20260224213119_add_product_variants` +- `20260224221204_make_customer_user_optional` + +Only the first two are recorded in production's `_prisma_migrations`. The Stripe branch has never been merged into `master`, so these files do not exist on `master`. Yet the tables they create (`Customer`, `Order`, `OrderItem`, `Payment`, `Product`, `ProductImage`, `ShippingAddress`) are present in production. + +**Additionally**, tables `ProductVariant` and `ProductOption` — created by the third Stripe migration (`20260224213119_add_product_variants`) — are **also present in production**, but the migration itself is **not** recorded in `_prisma_migrations`. Somebody created those tables bypassing Prisma (either raw SQL, `prisma db push`, or manually applying the migration without inserting into `_prisma_migrations`). + +### 2. Orphan indexes with no origin + +Indexes exist in production that are not created by any migration in any branch: + +- `Barometer_categoryId_idx`, `Barometer_conditionId_idx`, `Barometer_date_idx`, `Barometer_manufacturerId_idx`, `Barometer_subCategoryId_idx` +- `Document_conditionId_idx`, `Document_date_idx` +- `InaccuracyReport_barometerId_idx`, `InaccuracyReport_status_idx` +- `PdfFile_manufacturerId_idx` +- Several non-unique indexes on Stripe tables (e.g. `Order_customerId_idx`, `Order_status_idx`, `Order_createdAt_idx`, `OrderItem_orderId_idx`, `OrderItem_productId_idx`, `OrderItem_variantId_idx`, `Payment_orderId_idx`, `Payment_status_idx`, `ProductImage_productId_idx`, `ProductImage_variantId_idx`, `ProductOption_productId_idx`, `ProductVariant_productId_idx`, `ProductVariant_sku_idx`, `Customer_userId_idx`) + +Grep the entire repository (including `stripe` branch) for `CREATE INDEX` or `@@index` — none of these will appear. They were added manually, probably during perf tuning. + +### 3. `schema.prisma` on `master` does not describe Stripe models + +Even if the Stripe migration files were copied into `master`, `schema.prisma` on `master` contains no `Customer`, `Order`, `Product`, etc. models. `prisma generate` would produce a client without them — which is fine for non-shop code, but it means `migrate dev` (which compares schema → DB) would want to drop them. + +--- + +## Fix Plan + +The goal: make `master` a truthful representation of what is actually in production, without pulling in Stripe application code (which isn't live). + +### Step 1 — Pull Stripe migration files into `master` + +Copy from `stripe` branch into `master`: + +``` +prisma/migrations/20251018032256_add_stripe_tables/migration.sql +prisma/migrations/20251031174439_rename_product_image_alt_to_name/migration.sql +``` + +These are exactly the migrations already recorded in `_prisma_migrations` on prod, so no re-application happens on deploy. + +### Step 2 — Add Stripe models to `schema.prisma` + +Port the `Customer`, `Product`, `ProductImage`, `Order`, `OrderItem`, `ShippingAddress`, `Payment` models and the `OrderStatus`, `PaymentStatus`, `Currency` enums from `stripe` branch (state at commit `2944172 feat: product images`, after the second migration but before `add_product_variants`). + +Also add the reverse relation `Customer Customer?` on `User`. + +Reference diff: + +```bash +git diff master...2944172 -- prisma/schema.prisma +``` + +### Step 3 — Baseline migration for `ProductVariant`, `ProductOption`, and orphan indexes + +Create one new migration (e.g. `_baseline_production_state`) that brings the file-level history in line with reality: + +- `CREATE TABLE IF NOT EXISTS "ProductVariant" (...)` — copy structure from prod via `pg_dump --schema-only -t 'ProductVariant'` on the remote. +- `CREATE TABLE IF NOT EXISTS "ProductOption" (...)` — same. +- All foreign keys added via `DO $$ BEGIN ... ALTER TABLE ... ADD CONSTRAINT ... EXCEPTION WHEN duplicate_object THEN NULL; END $$;` idempotency blocks. +- `CREATE INDEX IF NOT EXISTS "Barometer_categoryId_idx" ON "Barometer"("categoryId");` for all orphan indexes. + +**Critical:** every DDL statement must be idempotent (`IF NOT EXISTS`, `IF EXISTS`, or guarded with `pg_catalog` lookups). The migration must succeed on: + +- Prod DB (where everything already exists) → applies cleanly, writes row into `_prisma_migrations`, creates nothing. +- Fresh dev DB (created via `migrate reset` or `createdb` + `migrate deploy`) → creates everything from scratch. + +Verify by dumping prod schema after the merge and diffing against `bunx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script`. They must be identical. + +### Step 4 — Add matching `@@index` / `@@unique` directives to `schema.prisma` + +For every orphan index added in step 3, add the corresponding Prisma-level directive, e.g.: + +```prisma +model Barometer { + // ... + @@index([categoryId]) + @@index([conditionId]) + @@index([date]) + @@index([manufacturerId]) + @@index([subCategoryId]) +} +``` + +This ensures `prisma migrate dev` in the future sees no drift. + +### Step 5 — Verify + +On a local DB that is a fresh copy of prod (via `bun run import-data`): + +```bash +bunx dotenv -e .env.local prisma migrate deploy +# Should say "No pending migrations" or only apply the new baseline migration. + +bunx dotenv -e .env.local prisma migrate dev --create-only +# Should say "Already in sync, no schema change or pending migration was found." +``` + +If both pass — the fix is correct. + +### Step 6 — Deploy + +```bash +bun run migrate:remote +``` + +The baseline migration runs on prod as a no-op (thanks to `IF NOT EXISTS`), but the `_prisma_migrations` table now contains a record of it. From this point forward, `migrate dev` works from any branch, and the history is internally consistent. + +### Step 7 — Merge / rebase `stripe` branch on top + +Once `master` reflects prod reality, the `stripe` branch needs to be rebased onto the new `master`. Its migrations `20260224213119_add_product_variants` and `20260224221204_make_customer_user_optional` should be **rewritten** — because `ProductVariant`/`ProductOption` are already in master now. Either: + +- Delete those two migration folders from `stripe` and rely on the baseline migration from step 3, **or** +- Keep them as no-ops (idempotent guards), purely for historical traceability. + +Decide based on whether anyone has a dev DB that already recorded those migration names. + +--- + +## Non-goals + +- **Do not** bring Stripe application code (routes, pages, webhooks, React components) into `master`. Only database schema and migrations. The shop feature itself continues to live on the `stripe` branch until it is ready to ship. +- **Do not** drop or alter any existing prod data. This is a metadata reconciliation, not a data migration. + +--- + +## Prevention + +Going forward: + +1. **Prisma only.** No `db push` on prod. No ad hoc `CREATE INDEX` via `psql`. Every schema change goes through a migration file that is committed to `master` before being applied to prod. +2. **Deployment order.** Never `prisma migrate deploy` from a feature branch against prod unless that branch is about to be merged. Applying migrations from a branch that may be abandoned creates exactly this mess. +3. **Shadow DB.** Ensure `prisma.config.ts` (or `.env.local`) has a `shadowDatabaseUrl` configured so `migrate dev` catches drift early during local development. +4. **CI check.** Add a GitHub Action that runs `prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --exit-code` on every PR. Breaks the build if migrations and schema disagree. + +--- + +## Historical Context + +This debt accumulated because: + +- The `stripe` feature was partially rolled out to prod (DB-level) ahead of its code release. +- Someone performed manual DB operations to add performance indexes. +- The `stripe` branch was never merged, so its migration files never reached `master`. + +The current state is not anyone's single mistake — it is the natural outcome of treating `prisma migrate deploy` as a quick fix. The cleanup here is the cost of skipping the paper trail. diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index f43a6d3..c37eeac 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -1,7 +1,6 @@ import 'server-only' import { CategoryCard, Hero, NewArrivals, SearchField } from '@/components/elements' -import { Route } from '@/constants' import { getCategories } from '@/server/categories/queries' import { cn } from '@/utils' @@ -17,14 +16,8 @@ export default async function HomePage() {
- {categories.map(({ id, name, image }, i) => ( - + {categories.map(({ id, name, image, link }, i) => ( + ))}
diff --git a/app/(pages)/admin/page.tsx b/app/(pages)/admin/page.tsx index b69d3bf..eed2fb2 100644 --- a/app/(pages)/admin/page.tsx +++ b/app/(pages)/admin/page.tsx @@ -26,9 +26,9 @@ export default function Admin() { Add new document - + - View Documents + View Ephemerae diff --git a/app/(pages)/brands/[slug]/page.tsx b/app/(pages)/brands/[slug]/page.tsx index dea6fab..f2d251d 100644 --- a/app/(pages)/brands/[slug]/page.tsx +++ b/app/(pages)/brands/[slug]/page.tsx @@ -7,7 +7,7 @@ import Link from 'next/link' import { Fragment } from 'react' import { BarometerCardWithIcon, ImageLightbox, ShowMore } from '@/components/elements' import { Card, Separator } from '@/components/ui' -import { fileStorage, Route, Tag } from '@/constants' +import { fileStorage, isRouteKey, Route, Tag } from '@/constants' import { title } from '@/constants/metadata' import { prisma } from '@/prisma/prismaClient' import { type BrandDTO, getBrand } from '@/server/brands/queries' @@ -22,7 +22,7 @@ async function getBarometersByManufacturer(slug: string) { 'use cache' cacheLife('max') cacheTag(Tag.barometers) - return prisma.barometer.findMany({ + const barometers = await prisma.barometer.findMany({ where: { manufacturer: { slug } }, select: { id: true, @@ -31,6 +31,7 @@ async function getBarometersByManufacturer(slug: string) { category: { select: { name: true, + label: true, }, }, images: { @@ -48,6 +49,18 @@ async function getBarometersByManufacturer(slug: string) { }, orderBy: { name: 'asc' }, }) + return barometers.map(barometer => { + const { label } = barometer.category + if (!isRouteKey(label)) throw new Error(`A category ${label} doesn't exist in the app`) + return { + ...barometer, + category: { + ...barometer.category, + label, + link: Route[label], + }, + } + }) } export async function generateMetadata(props: Props): Promise { @@ -128,7 +141,7 @@ export default async function Manufacturer(props: Props) { diff --git a/app/(pages)/collection/categories/[...category]/page.tsx b/app/(pages)/collection/categories/[...category]/page.tsx index 7c48f17..9d1df55 100644 --- a/app/(pages)/collection/categories/[...category]/page.tsx +++ b/app/(pages)/collection/categories/[...category]/page.tsx @@ -23,8 +23,8 @@ interface CollectionProps { export async function generateMetadata(props: CollectionProps): Promise { const { category } = await props.params - const [categoryName] = category - const { description } = await getCategory(categoryName) + const [categoryName, sortCriteria, pageNo] = category + const { description, link } = await getCategory(categoryName) const { barometers } = await getBarometersByParams(categoryName, 1, 5, 'date') const collectionTitle = `${title}: ${capitalize(categoryName)} Barometers Collection` // TODO: Load scaled image rather that full size @@ -34,7 +34,7 @@ export async function generateMetadata(props: CollectionProps): Promise - + {categorySlug} diff --git a/app/(pages)/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx b/app/(pages)/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx index 2a12db9..0485df5 100644 --- a/app/(pages)/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx +++ b/app/(pages)/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx @@ -14,7 +14,6 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui' -import { Route } from '@/constants' import { deleteBarometer } from '@/server/barometers/actions' import type { BarometerDTO } from '@/server/barometers/queries' import { cn } from '@/utils' @@ -38,7 +37,7 @@ export function DeleteBarometer({ barometer, className }: Props) { if (!result.success) throw new Error(result.error) toast.success('Barometer deleted successfully') setOpen(false) - router.replace(Route.Categories + barometer.category.name) + router.replace(barometer.category.link) } catch (error) { toast.error(error instanceof Error ? error.message : 'Error deleting barometer') } diff --git a/app/(pages)/collection/items/[slug]/page.tsx b/app/(pages)/collection/items/[slug]/page.tsx index d68a5d7..e5d242a 100644 --- a/app/(pages)/collection/items/[slug]/page.tsx +++ b/app/(pages)/collection/items/[slug]/page.tsx @@ -81,7 +81,11 @@ export default async function Page(props: Props) { const dimensions = (barometer?.dimensions ?? []) as Dimensions return ( <> - +
@@ -110,7 +114,7 @@ export default async function Page(props: Props) { title="Category" edit={} > - + {barometer.category.label} diff --git a/app/(pages)/collection/new-arrivals/page.tsx b/app/(pages)/collection/new-arrivals/page.tsx index 2d42376..b477543 100644 --- a/app/(pages)/collection/new-arrivals/page.tsx +++ b/app/(pages)/collection/new-arrivals/page.tsx @@ -39,7 +39,7 @@ export default async function NewArrivals(props: newArrivalsProps) { barometerName={name} barometerLink={Route.Barometer + slug} categoryName={category.name} - categoryLink={Route.Categories + category.name} + categoryLink={category.link} manufacturer={ (manufacturer.firstName ? `${manufacturer.firstName} ` : '') + manufacturer.name } diff --git a/app/(pages)/documents/DocumentEdit.tsx b/app/(pages)/ephemera/DocumentEdit.tsx similarity index 100% rename from app/(pages)/documents/DocumentEdit.tsx rename to app/(pages)/ephemera/DocumentEdit.tsx diff --git a/app/(pages)/documents/DocumentTable.tsx b/app/(pages)/ephemera/DocumentTable.tsx similarity index 98% rename from app/(pages)/documents/DocumentTable.tsx rename to app/(pages)/ephemera/DocumentTable.tsx index 7f12855..7b9fa8c 100644 --- a/app/(pages)/documents/DocumentTable.tsx +++ b/app/(pages)/ephemera/DocumentTable.tsx @@ -130,7 +130,7 @@ function DocumentTable({ archive = [], conditions, allBarometers }: Props) { }) const selectRow = (row: TableRow) => { - router.push(Route.Documents + encodeURIComponent(row.catalogueNumber)) + router.push(Route.Ephemera + encodeURIComponent(row.catalogueNumber)) } return ( diff --git a/app/(pages)/documents/[cat-no]/page.tsx b/app/(pages)/ephemera/[cat-no]/page.tsx similarity index 97% rename from app/(pages)/documents/[cat-no]/page.tsx rename to app/(pages)/ephemera/[cat-no]/page.tsx index 88bfdde..b9b2566 100644 --- a/app/(pages)/documents/[cat-no]/page.tsx +++ b/app/(pages)/ephemera/[cat-no]/page.tsx @@ -79,7 +79,7 @@ export default async function Document({ params }: Props) { - Documents + Ephemera {doc.catalogueNumber} @@ -255,9 +255,9 @@ export default async function Document({ params }: Props) { )} - {/* Back to Documents */} + {/* Back to Ephemera */}
diff --git a/app/(pages)/documents/document-edit-schema.ts b/app/(pages)/ephemera/document-edit-schema.ts similarity index 100% rename from app/(pages)/documents/document-edit-schema.ts rename to app/(pages)/ephemera/document-edit-schema.ts diff --git a/app/(pages)/documents/page.tsx b/app/(pages)/ephemera/page.tsx similarity index 100% rename from app/(pages)/documents/page.tsx rename to app/(pages)/ephemera/page.tsx diff --git a/app/sitemap.ts b/app/sitemap.ts index df231f4..7b074c9 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,8 @@ import type { MetadataRoute } from 'next' import { Route } from '@/constants/routes' -import { prisma } from '@/prisma/prismaClient' +import { getAllBarometers } from '@/server/barometers/queries' +import { getAllBrands } from '@/server/brands/queries' +import { getCategories } from '@/server/categories/queries' export default async function sitemap(): Promise { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL @@ -45,7 +47,7 @@ export default async function sitemap(): Promise { } async function getItemPages(baseUrl: string, now: Date): Promise { - const barometers = await prisma.barometer.findMany({ select: { slug: true } }) + const barometers = await getAllBarometers() return barometers.map(({ slug }) => ({ url: baseUrl + Route.Barometer + slug, priority: 0.8, @@ -54,16 +56,16 @@ async function getItemPages(baseUrl: string, now: Date): Promise { - const categories = await prisma.category.findMany({ select: { name: true } }) - return categories.map(({ name }) => ({ - url: baseUrl + Route.Categories + name, + const categories = await getCategories() + return categories.map(({ link }) => ({ + url: baseUrl + link, priority: 0.9, lastModified: now, })) } async function getBrandPages(baseUrl: string, now: Date): Promise { - const brands = await prisma.manufacturer.findMany({ select: { slug: true } }) + const brands = await getAllBrands() return brands.map(({ slug }) => ({ url: baseUrl + Route.Brands + slug, priority: 0.8, diff --git a/components/containers/header/mobile-menu.tsx b/components/containers/header/mobile-menu.tsx index 09e2889..eb3fb87 100644 --- a/components/containers/header/mobile-menu.tsx +++ b/components/containers/header/mobile-menu.tsx @@ -33,7 +33,7 @@ function MenuContent({ menu = [], closeMenu }: Props & { closeMenu: () => void } return ( e.preventDefault()} // Prevent auto-focus to avoid focus outline on accordion > Navigation Menu diff --git a/constants/routes.ts b/constants/routes.ts index b4586cf..ce0a180 100644 --- a/constants/routes.ts +++ b/constants/routes.ts @@ -1,24 +1,55 @@ -export const Route = { +const root = { Home: '/', History: '/history/', - Foundation: '/foundation/', - Donate: '/foundation/donate/', + Ephemera: '/ephemera/', About: '/about/', Brands: '/brands/', Terms: '/terms-and-conditions/', - Categories: '/collection/categories/', - Barometer: '/collection/items/', - NewArrivals: '/collection/new-arrivals/', - Admin: '/admin/', - AddBarometer: '/admin/add-barometer/', - AddBrand: '/admin/add-brand/', - AddDocument: '/admin/add-document/', - Reports: '/admin/reports/', - Materials: '/admin/materials/', - Movements: '/admin/movements/', CookiePolicy: '/cookies/', PrivacyPolicy: '/privacy/', - Documents: '/documents/', } as const -export type Route = (typeof Route)[keyof typeof Route] +const foundation = '/foundation' +const foundationRoutes = { + Foundation: `${foundation}/`, + Donate: `${foundation}/donate/`, +} + +const categories = '/categories' +const collection = '/collection' +const collCat = collection + categories +const collectionRoutes = { + Categories: `${collCat}/`, + Barometer: `${collection}/items/`, + NewArrivals: `${collection}/new-arrivals/`, + Forecasters: `${collCat}/forecasters/`, + "Friends'": `${collCat}/friends/`, + Miscellaneous: `${collCat}/miscellaneous/`, + Recorders: `${collCat}/recorders/`, + Bourdon: `${collCat}/bourdon/`, + Pocket: `${collCat}/pocket/`, + Aneroid: `${collCat}/aneroid/`, + Mercury: `${collCat}/mercury/`, +} as const + +const admin = '/admin' +const adminRoutes = { + Admin: `${admin}/`, + AddBarometer: `${admin}/add-barometer/`, + AddBrand: `${admin}/add-brand/`, + AddDocument: `${admin}/add-document/`, + Reports: `${admin}/reports/`, + Materials: `${admin}/materials/`, + Movements: `${admin}/movements/`, +} as const + +export const Route = { + ...root, + ...foundationRoutes, + ...collectionRoutes, + ...adminRoutes, +} as const + +export function isRouteKey(value: string): value is keyof typeof Route { + return value in Route +} diff --git a/package.json b/package.json index f2bd5bb..b452b51 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ "scripts": { "dev": "next dev --turbopack -p 3001", "dev:remote": "bun ./scripts/tunnel.ts next dev", + "postgres:local": "brew services start postgresql@16", "prisma": "dotenv -e .env.local prisma studio", "prisma:remote": "bun ./scripts/tunnel.ts prisma studio", "import-data": "bun ./scripts/import-data.ts", + "seed-blur": "bun ./scripts/seed-blur-data.ts", "build": "next build", "postinstall": "prisma generate", "analyze": "ANALYZE=true next build", diff --git a/prisma/migrations/20260416000000_add_forecasters_ephemera_update_friends/migration.sql b/prisma/migrations/20260416000000_add_forecasters_ephemera_update_friends/migration.sql new file mode 100644 index 0000000..be4230f --- /dev/null +++ b/prisma/migrations/20260416000000_add_forecasters_ephemera_update_friends/migration.sql @@ -0,0 +1,31 @@ +-- Insert new categories: Forecasters, Ephemera (Landing + Navigation) +INSERT INTO "Category" ("id", "name", "description", "label", "order", "location", "createdAt", "updatedAt") +VALUES + ( + 'c9a2b5d7-3e48-4f12-a9b1-8c7d6e5f4a3b', + 'forecasters', + 'Instruments designed to interpret current atmospheric conditions and translate them into short-term weather predictions. These devices range from scientifically grounded systems based on barometric pressure and cloud observation to more intuitive or empirical forecasting tools, reflecting both the practical needs and popular understanding of weather in different periods.', + 'Forecasters', + 8, + ARRAY['Landing', 'Navigation']::"CategoryLocation"[], + NOW(), + NOW() + ), + ( + 'd8b3c6e9-4f59-4a23-b0c2-9d8e7f6a5b4c', + 'ephemera', + 'A collection of historical printed materials associated with weather instruments, including advertisements, manuals, receipts, catalogues, and other documentary traces. These items provide valuable context on the production, distribution, and everyday use of meteorological devices, preserving the commercial and cultural environment in which they existed.', + 'Ephemera', + 9, + ARRAY['Landing', 'Navigation']::"CategoryLocation"[], + NOW(), + NOW() + ) +ON CONFLICT ("name") DO NOTHING; + +-- Promote Friends category to both Landing and Navigation +UPDATE "Category" +SET + "location" = ARRAY['Landing', 'Navigation']::"CategoryLocation"[], + "updatedAt" = NOW() +WHERE "name" = 'friends'; diff --git a/prisma/migrations/20260416000100_attach_category_images/migration.sql b/prisma/migrations/20260416000100_attach_category_images/migration.sql new file mode 100644 index 0000000..4ce6910 --- /dev/null +++ b/prisma/migrations/20260416000100_attach_category_images/migration.sql @@ -0,0 +1,17 @@ +-- Create Image records for Forecasters, Ephemera, Friends +-- Actual image files are pre-uploaded to MinIO at the corresponding paths. +-- blurData will be generated lazily by scripts/seed-category-blur.ts. +INSERT INTO "Image" ("id", "url", "name", "createdAt", "updatedAt") +VALUES + ('e0f1a2b3-c4d5-4e6f-a7b8-c9d0e1f2a3b4', 'categories/forecasters.png', 'Forecasters', NOW(), NOW()), + ('f1e2d3c4-b5a6-4978-8b9c-0d1e2f3a4b5c', 'categories/ephemera.png', 'Ephemera', NOW(), NOW()), + ('a2b3c4d5-e6f7-4890-9abc-def012345678', 'categories/friends.png', 'Friends', NOW(), NOW()) +ON CONFLICT ("id") DO NOTHING; + +-- Attach images to their categories via the M2M pivot table +INSERT INTO "_CategoryImages" ("A", "B") +VALUES + ('c9a2b5d7-3e48-4f12-a9b1-8c7d6e5f4a3b', 'e0f1a2b3-c4d5-4e6f-a7b8-c9d0e1f2a3b4'), + ('d8b3c6e9-4f59-4a23-b0c2-9d8e7f6a5b4c', 'f1e2d3c4-b5a6-4978-8b9c-0d1e2f3a4b5c'), + ('015303d7-4dd3-4f58-9456-c605f34ff903', 'a2b3c4d5-e6f7-4890-9abc-def012345678') +ON CONFLICT ("A", "B") DO NOTHING; diff --git a/scripts/seed-blur-data.ts b/scripts/seed-blur-data.ts new file mode 100644 index 0000000..f2942b0 --- /dev/null +++ b/scripts/seed-blur-data.ts @@ -0,0 +1,74 @@ +import dotenv from 'dotenv' +import pLimit from 'p-limit' +import sharp from 'sharp' +import { prisma } from '@/prisma/prismaClient' +import { minioBucket, minioClient } from '@/services/minio' + +dotenv.config({ path: '.env.local' }) + +const limit = pLimit(5) + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks) +} + +async function generateBlurData(url: string): Promise { + const stream = await minioClient.getObject(minioBucket, url) + const buffer = await streamToBuffer(stream) + const blurBuffer = await sharp(buffer) + .resize(64, 64, { fit: 'inside' }) + .blur(1.5) + .png({ quality: 60, compressionLevel: 6, adaptiveFiltering: true }) + .toBuffer() + return `data:image/png;base64,${blurBuffer.toString('base64')}` +} + +async function main() { + const urlPrefix = process.argv[2] + const images = await prisma.image.findMany({ + where: { + blurData: null, + ...(urlPrefix ? { url: { startsWith: urlPrefix } } : {}), + }, + select: { id: true, url: true }, + }) + + if (images.length === 0) { + console.log('Nothing to do — no images without blurData found.') + return + } + + console.log(`Generating blurData for ${images.length} image(s)...`) + + const results = await Promise.all( + images.map(({ id, url }) => + limit(async () => { + try { + const blurData = await generateBlurData(url) + await prisma.image.update({ where: { id }, data: { blurData } }) + console.log(` ✓ ${url}`) + return { url, ok: true as const } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(` ✗ ${url}: ${message}`) + return { url, ok: false as const, error: message } + } + }), + ), + ) + + const failed = results.filter(r => !r.ok) + console.log(`\n✅ Done: ${results.length - failed.length}/${results.length}`) + if (failed.length > 0) process.exit(1) +} + +main() + .catch(err => { + console.error(err) + process.exit(1) + }) + .finally(() => prisma.$disconnect()) diff --git a/server/barometers/queries.ts b/server/barometers/queries.ts index 1fd6bb4..4efdab9 100644 --- a/server/barometers/queries.ts +++ b/server/barometers/queries.ts @@ -2,7 +2,7 @@ import 'server-only' import type { Prisma } from '@prisma/client' import { cacheLife, cacheTag } from 'next/cache' -import { DEFAULT_PAGE_SIZE, Tag } from '@/constants' +import { DEFAULT_PAGE_SIZE, isRouteKey, Route, Tag } from '@/constants' import { prisma } from '@/prisma/prismaClient' import type { SortValue } from '@/types' @@ -70,6 +70,7 @@ export async function getBarometersByParams( category: { select: { name: true, + label: true, }, }, images: { @@ -91,7 +92,17 @@ export async function getBarometersByParams( ]) return { - barometers, + barometers: barometers.map(barometer => { + const { label } = barometer.category + if (!isRouteKey(label)) throw new Error(`Unknown category name ${label}`) + return { + ...barometer, + category: { + ...barometer.category, + link: Route[label], + }, + } + }), // if page size is 0 the DB returns all records in one page page: pageSize ? pageNo : 1, totalPages: pageSize ? Math.ceil(totalItems / pageSize) : 1, @@ -108,7 +119,7 @@ export async function getBarometer(slug: string) { cacheLife('max') cacheTag(Tag.barometers) - return prisma.barometer.findFirst({ + const barometer = await prisma.barometer.findFirst({ where: { slug: { equals: slug, @@ -199,6 +210,16 @@ export async function getBarometer(slug: string) { }, }, }) + if (!barometer) return null + const { label } = barometer.category + if (!isRouteKey(label)) throw new Error(`Unknown category ${label}`) + return { + ...barometer, + category: { + ...barometer.category, + link: Route[label], + }, + } } export async function getAllBarometers() { @@ -210,6 +231,7 @@ export async function getAllBarometers() { select: { id: true, name: true, + slug: true, }, orderBy: { name: 'asc', diff --git a/server/brands/queries.ts b/server/brands/queries.ts index fb943c1..46b3c99 100644 --- a/server/brands/queries.ts +++ b/server/brands/queries.ts @@ -15,6 +15,7 @@ export async function getAllBrands() { name: true, firstName: true, id: true, + slug: true, }, orderBy: [ { diff --git a/server/categories/queries.ts b/server/categories/queries.ts index cbe96bd..fac35d9 100644 --- a/server/categories/queries.ts +++ b/server/categories/queries.ts @@ -2,7 +2,7 @@ import 'server-only' import type { CategoryLocation } from '@prisma/client' import { cacheLife, cacheTag } from 'next/cache' -import { Tag } from '@/constants' +import { isRouteKey, Route, Tag } from '@/constants' import { prisma } from '@/prisma/prismaClient' export async function getCategories(location?: CategoryLocation) { @@ -28,10 +28,15 @@ export async function getCategories(location?: CategoryLocation) { }, }, }) - return categories.map(({ images: [image], ...category }) => ({ - ...category, - image, - })) + return categories.map(({ images: [image], label, ...category }) => { + if (!isRouteKey(label)) throw new Error(`A category ${label} doesn't exist in the app`) + return { + ...category, + label, + image, + link: Route[label], + } + }) } export async function getCategory(name: string) { @@ -41,6 +46,7 @@ export async function getCategory(name: string) { const { images: [image], + label, ...category } = await prisma.category.findFirstOrThrow({ where: { @@ -63,9 +69,12 @@ export async function getCategory(name: string) { }, }, }) + if (!isRouteKey(label)) throw new Error(`A category ${label} doesn't exist in the app`) return { ...category, image, + label, + link: Route[label], } } diff --git a/server/menu/queries.ts b/server/menu/queries.ts index b956842..9fb5f9a 100644 --- a/server/menu/queries.ts +++ b/server/menu/queries.ts @@ -38,11 +38,7 @@ export async function getMenuData(): Promise { id: 2, label: 'Collection', link: '/collection', - children: categories.map(cat => ({ - id: cat.id, - link: Route.Categories + cat.name.toLocaleLowerCase(), - label: cat.label, - })), + children: categories, }, { id: 3, From 44141d39b4aeaf986e20b7f01bab445f505be29f Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Sun, 10 May 2026 23:52:51 +0200 Subject: [PATCH 2/3] feat(vscode): update settings for Prisma and enhance layout attributes - Added a new setting to `.vscode/settings.json` to disable pinning to Prisma 6. - Updated the `RootLayout` component in `layout.tsx` to include a `data-scroll-behavior` attribute for smoother scrolling. - Removed an unnecessary blank line in `routes.ts` for cleaner code. --- .vscode/settings.json | 3 ++- app/layout.tsx | 2 +- constants/routes.ts | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1f2cfb2..3f5a77f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,6 @@ }, "[json]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "prisma.pinToPrisma6": false } diff --git a/app/layout.tsx b/app/layout.tsx index b446d02..8b7e3b9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -73,7 +73,7 @@ export async function generateMetadata() { export default function RootLayout({ children }: PropsWithChildren) { return ( - + diff --git a/constants/routes.ts b/constants/routes.ts index ce0a180..8447a74 100644 --- a/constants/routes.ts +++ b/constants/routes.ts @@ -49,7 +49,6 @@ export const Route = { ...collectionRoutes, ...adminRoutes, } as const - export function isRouteKey(value: string): value is keyof typeof Route { return value in Route } From 22955ea036f824316bed10a43f7fd44abf8ad9b3 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Mon, 11 May 2026 00:29:36 +0200 Subject: [PATCH 3/3] chore: update .gitignore and remove obsolete DB_DRIFT_FIX.md - Added a new entry for local documentation in .gitignore to exclude the .docs/ directory. - Deleted the outdated DB_DRIFT_FIX.md file, which is no longer relevant to the project. --- .gitignore | 3 + DB_DRIFT_FIX.md | 200 ------------------------------------------------ 2 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 DB_DRIFT_FIX.md diff --git a/.gitignore b/.gitignore index c1a0e5e..961ceca 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ public/uploads # Postgre SQL (Neon) Backups backups + +# Local documentation +.docs/ diff --git a/DB_DRIFT_FIX.md b/DB_DRIFT_FIX.md deleted file mode 100644 index ebc1afd..0000000 --- a/DB_DRIFT_FIX.md +++ /dev/null @@ -1,200 +0,0 @@ -# Database Drift Fix — Technical Task - -## TL;DR - -Production database schema and `_prisma_migrations` table diverge from what any branch's migration files describe. `prisma migrate dev` is effectively broken on any branch that does not already carry the hidden changes. This document explains the current mess and lays out a plan to bring the migration history, `schema.prisma`, and the actual production schema back into sync. - -This is **infrastructure debt**, not a feature. Schedule it as a dedicated task with a dedicated PR. - ---- - -## Symptoms - -Running `bunx prisma migrate dev` on the `master` branch (or any branch based on it, e.g. `new-menu-items`) after `bun run import-data` produces a drift report like: - -``` -- Drift detected: Your database schema is not in sync with your migration history. - -[+] Added enums: Currency, OrderStatus, PaymentStatus -[+] Added tables: Customer, Order, OrderItem, Payment, Product, - ProductImage, ProductOption, ProductVariant, ShippingAddress - -[*] Changed the `Barometer` table - [+] Added index on columns (categoryId, conditionId, date, manufacturerId, subCategoryId) - -[*] Changed the `Document` table - [+] Added index on columns (conditionId, date) - -[*] Changed the `InaccuracyReport` table - [+] Added index on columns (barometerId, status) - -[*] Changed the `PdfFile` table - [+] Added index on columns (manufacturerId) - -[*] Stripe tables — extra non-unique indexes on Order, OrderItem, Payment, - ProductImage, ProductOption, ProductVariant, Customer - -- Migrations applied to the database but absent from the migrations directory: - 20251018032256_add_stripe_tables - 20251031174439_rename_product_image_alt_to_name -``` - -Workaround currently in use: `prisma migrate deploy` (which does not check drift) is used to apply new migrations. This works but is a leaky abstraction — any future `migrate dev` will keep complaining. - ---- - -## Root Cause Analysis - -Three independent sources of divergence: - -### 1. Stripe migrations applied to prod without merging the code - -The `stripe` branch contains four migrations: - -- `20251018032256_add_stripe_tables` -- `20251031174439_rename_product_image_alt_to_name` -- `20260224213119_add_product_variants` -- `20260224221204_make_customer_user_optional` - -Only the first two are recorded in production's `_prisma_migrations`. The Stripe branch has never been merged into `master`, so these files do not exist on `master`. Yet the tables they create (`Customer`, `Order`, `OrderItem`, `Payment`, `Product`, `ProductImage`, `ShippingAddress`) are present in production. - -**Additionally**, tables `ProductVariant` and `ProductOption` — created by the third Stripe migration (`20260224213119_add_product_variants`) — are **also present in production**, but the migration itself is **not** recorded in `_prisma_migrations`. Somebody created those tables bypassing Prisma (either raw SQL, `prisma db push`, or manually applying the migration without inserting into `_prisma_migrations`). - -### 2. Orphan indexes with no origin - -Indexes exist in production that are not created by any migration in any branch: - -- `Barometer_categoryId_idx`, `Barometer_conditionId_idx`, `Barometer_date_idx`, `Barometer_manufacturerId_idx`, `Barometer_subCategoryId_idx` -- `Document_conditionId_idx`, `Document_date_idx` -- `InaccuracyReport_barometerId_idx`, `InaccuracyReport_status_idx` -- `PdfFile_manufacturerId_idx` -- Several non-unique indexes on Stripe tables (e.g. `Order_customerId_idx`, `Order_status_idx`, `Order_createdAt_idx`, `OrderItem_orderId_idx`, `OrderItem_productId_idx`, `OrderItem_variantId_idx`, `Payment_orderId_idx`, `Payment_status_idx`, `ProductImage_productId_idx`, `ProductImage_variantId_idx`, `ProductOption_productId_idx`, `ProductVariant_productId_idx`, `ProductVariant_sku_idx`, `Customer_userId_idx`) - -Grep the entire repository (including `stripe` branch) for `CREATE INDEX` or `@@index` — none of these will appear. They were added manually, probably during perf tuning. - -### 3. `schema.prisma` on `master` does not describe Stripe models - -Even if the Stripe migration files were copied into `master`, `schema.prisma` on `master` contains no `Customer`, `Order`, `Product`, etc. models. `prisma generate` would produce a client without them — which is fine for non-shop code, but it means `migrate dev` (which compares schema → DB) would want to drop them. - ---- - -## Fix Plan - -The goal: make `master` a truthful representation of what is actually in production, without pulling in Stripe application code (which isn't live). - -### Step 1 — Pull Stripe migration files into `master` - -Copy from `stripe` branch into `master`: - -``` -prisma/migrations/20251018032256_add_stripe_tables/migration.sql -prisma/migrations/20251031174439_rename_product_image_alt_to_name/migration.sql -``` - -These are exactly the migrations already recorded in `_prisma_migrations` on prod, so no re-application happens on deploy. - -### Step 2 — Add Stripe models to `schema.prisma` - -Port the `Customer`, `Product`, `ProductImage`, `Order`, `OrderItem`, `ShippingAddress`, `Payment` models and the `OrderStatus`, `PaymentStatus`, `Currency` enums from `stripe` branch (state at commit `2944172 feat: product images`, after the second migration but before `add_product_variants`). - -Also add the reverse relation `Customer Customer?` on `User`. - -Reference diff: - -```bash -git diff master...2944172 -- prisma/schema.prisma -``` - -### Step 3 — Baseline migration for `ProductVariant`, `ProductOption`, and orphan indexes - -Create one new migration (e.g. `_baseline_production_state`) that brings the file-level history in line with reality: - -- `CREATE TABLE IF NOT EXISTS "ProductVariant" (...)` — copy structure from prod via `pg_dump --schema-only -t 'ProductVariant'` on the remote. -- `CREATE TABLE IF NOT EXISTS "ProductOption" (...)` — same. -- All foreign keys added via `DO $$ BEGIN ... ALTER TABLE ... ADD CONSTRAINT ... EXCEPTION WHEN duplicate_object THEN NULL; END $$;` idempotency blocks. -- `CREATE INDEX IF NOT EXISTS "Barometer_categoryId_idx" ON "Barometer"("categoryId");` for all orphan indexes. - -**Critical:** every DDL statement must be idempotent (`IF NOT EXISTS`, `IF EXISTS`, or guarded with `pg_catalog` lookups). The migration must succeed on: - -- Prod DB (where everything already exists) → applies cleanly, writes row into `_prisma_migrations`, creates nothing. -- Fresh dev DB (created via `migrate reset` or `createdb` + `migrate deploy`) → creates everything from scratch. - -Verify by dumping prod schema after the merge and diffing against `bunx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script`. They must be identical. - -### Step 4 — Add matching `@@index` / `@@unique` directives to `schema.prisma` - -For every orphan index added in step 3, add the corresponding Prisma-level directive, e.g.: - -```prisma -model Barometer { - // ... - @@index([categoryId]) - @@index([conditionId]) - @@index([date]) - @@index([manufacturerId]) - @@index([subCategoryId]) -} -``` - -This ensures `prisma migrate dev` in the future sees no drift. - -### Step 5 — Verify - -On a local DB that is a fresh copy of prod (via `bun run import-data`): - -```bash -bunx dotenv -e .env.local prisma migrate deploy -# Should say "No pending migrations" or only apply the new baseline migration. - -bunx dotenv -e .env.local prisma migrate dev --create-only -# Should say "Already in sync, no schema change or pending migration was found." -``` - -If both pass — the fix is correct. - -### Step 6 — Deploy - -```bash -bun run migrate:remote -``` - -The baseline migration runs on prod as a no-op (thanks to `IF NOT EXISTS`), but the `_prisma_migrations` table now contains a record of it. From this point forward, `migrate dev` works from any branch, and the history is internally consistent. - -### Step 7 — Merge / rebase `stripe` branch on top - -Once `master` reflects prod reality, the `stripe` branch needs to be rebased onto the new `master`. Its migrations `20260224213119_add_product_variants` and `20260224221204_make_customer_user_optional` should be **rewritten** — because `ProductVariant`/`ProductOption` are already in master now. Either: - -- Delete those two migration folders from `stripe` and rely on the baseline migration from step 3, **or** -- Keep them as no-ops (idempotent guards), purely for historical traceability. - -Decide based on whether anyone has a dev DB that already recorded those migration names. - ---- - -## Non-goals - -- **Do not** bring Stripe application code (routes, pages, webhooks, React components) into `master`. Only database schema and migrations. The shop feature itself continues to live on the `stripe` branch until it is ready to ship. -- **Do not** drop or alter any existing prod data. This is a metadata reconciliation, not a data migration. - ---- - -## Prevention - -Going forward: - -1. **Prisma only.** No `db push` on prod. No ad hoc `CREATE INDEX` via `psql`. Every schema change goes through a migration file that is committed to `master` before being applied to prod. -2. **Deployment order.** Never `prisma migrate deploy` from a feature branch against prod unless that branch is about to be merged. Applying migrations from a branch that may be abandoned creates exactly this mess. -3. **Shadow DB.** Ensure `prisma.config.ts` (or `.env.local`) has a `shadowDatabaseUrl` configured so `migrate dev` catches drift early during local development. -4. **CI check.** Add a GitHub Action that runs `prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --exit-code` on every PR. Breaks the build if migrations and schema disagree. - ---- - -## Historical Context - -This debt accumulated because: - -- The `stripe` feature was partially rolled out to prod (DB-level) ahead of its code release. -- Someone performed manual DB operations to add performance indexes. -- The `stripe` branch was never merged, so its migration files never reached `master`. - -The current state is not anyone's single mistake — it is the natural outcome of treating `prisma migrate deploy` as a quick fix. The cleanup here is the cost of skipping the paper trail.