/** * F-136 — Brand names Title Case + SEO title backfill. * * Brands imported from OpenCart in F-114 are ALL-CAPS (e.g. "A.VOGEL", * "BIOCOP", "COMPLEMENTOS Y NUTRICIÓN") and have `seo_title IS NULL`. The * admin form auto-fills `seo_title` from `name` on create, but existing * rows need to be normalized. * * This migration: * - Title-cases ALL-CAPS brand names ("A.VOGEL" → "A.Vogel", * "COMPLEMENTOS Y NUTRICIÓN" → "Complementos y Nutrición"). * - Backfills `seo_title` from the (now Title-Cased) name where the * column is NULL or empty. * - Does NOT touch slugs (they are already lowercase, derived via * `slugify()` from the original ALL-CAPS name). * - Does NOT touch mixed-case names like "BioSana" (idempotency). * * Idempotency: * - Re-running on an already-Title-Cased row is a no-op because * `toTitleCase()` returns the input unchanged when it is not all-caps. * - We only issue an UPDATE if either the name actually changes or the * `seo_title` was empty, so `updated_at` is not bumped unnecessarily. * * Note: the `toTitleCase` logic is inlined here (rather than imported * from `src/shared/text.ts`) so the migration is self-contained and does * not require `npm run build` to run first. The shared helper is the * single source of truth for the future; this inline copy is kept in * sync via the vitest unit tests in `src/shared/tests/text.test.ts`. * * @param {import('node-pg-migrate').MigrationBuilder} pgm */ /** * Mirror of `toTitleCase` in `project/src/shared/text.ts`. * * Convert an ALL-CAPS brand/category name to Title Case. Splits on word * boundaries (space, hyphen, dot, ampersand, slash), capitalizes the * first letter of each word, lowercases the rest. Preserves Spanish * single-letter conjunctions (y/e/o/u) in lowercase when they are not * the first word. Returns mixed-case strings unchanged. * * Keep this in sync with `src/shared/text.ts` — the vitest tests cover * the behaviour and would catch any drift. */ function toTitleCase(input) { if (!input) return input; if (input !== input.toUpperCase() || input === input.toLowerCase()) { return input; } const LOWERCASE_WORDS = new Set(['y', 'e', 'o', 'u']); const parts = input.split(/([\s.\-/&]+)/g); let isFirstWord = true; return parts .map((part) => { if (/^[\s.\-/&]+$/.test(part)) return part; if (part.length === 0) return part; const lower = part.toLocaleLowerCase('es-ES'); if (!isFirstWord && LOWERCASE_WORDS.has(lower)) return lower; isFirstWord = false; return ( part.charAt(0).toLocaleUpperCase('es-ES') + part.slice(1).toLocaleLowerCase('es-ES') ); }) .join(''); } /** True iff `value` is null, undefined, or empty string. */ function isBlank(value) { return value === null || value === undefined || value === ''; } export const up = async (pgm) => { const rows = await pgm.db.select( 'SELECT id, name, seo_title FROM brands_brands', ); let updated = 0; for (const row of rows) { const titleCased = toTitleCase(row.name); const newSeoTitle = isBlank(row.seo_title) ? titleCased : row.seo_title; const nameChanged = titleCased !== row.name; const seoChanged = newSeoTitle !== row.seo_title; if (!nameChanged && !seoChanged) continue; await pgm.db.query( `UPDATE brands_brands SET name = $1, seo_title = $2, updated_at = NOW() WHERE id = $3`, [titleCased, newSeoTitle, row.id], ); updated += 1; } console.log(`F-136: updated ${updated} brand row(s) (of ${rows.length} total)`); }; export const down = async () => { // Destructive data migration — no automatic rollback. The previous // names (ALL CAPS) are not recoverable from this migration alone. };