102 lines
3.7 KiB
JavaScript
102 lines
3.7 KiB
JavaScript
/**
|
|
* 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.
|
|
};
|