fix(vehicles): canonicalise brand casing (case-insensitive brand match)
Decode persist looked brands up with a case-sensitive eq(), so an
uppercase decode string ("FORD") missed canonical "Ford" → brand_id
NULL + raw uppercase stored as brand_name, splitting one brand across
casing variants in analytics/catalog. Now matches brands
case-insensitively and stores the canonical name. Migration
0013_fix_brand_casing backfills existing rows (60 on prod, 1 on dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
apps/api/drizzle/0013_fix_brand_casing.sql
Normal file
28
apps/api/drizzle/0013_fix_brand_casing.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Canonicalise vehicle brand casing + backfill missing brand_id.
|
||||||
|
--
|
||||||
|
-- Root cause: the decode persist (vehicles.service.ts) looked brands up with a
|
||||||
|
-- case-SENSITIVE `eq(brands.name, brandName)`. A decode source that yields an
|
||||||
|
-- uppercase brand string (e.g. "FORD", "MERCEDES-BENZ") missed the canonical
|
||||||
|
-- "Ford" / "Mercedes-Benz" row → brand_id stayed NULL and the raw uppercase
|
||||||
|
-- string was stored as brand_name. That split the same brand across casing
|
||||||
|
-- variants in analytics + the catalog ("Ford" vs "FORD"). The companion code
|
||||||
|
-- fix makes the lookup case-insensitive and stores the canonical name; this
|
||||||
|
-- migration repairs the rows already written.
|
||||||
|
--
|
||||||
|
-- Backfill every vehicle whose brand_name matches a canonical brand
|
||||||
|
-- case-insensitively: set the FK and the canonical-cased name. (catalog_vehicles
|
||||||
|
-- is intentionally untouched — its brand_name namespace does not match the
|
||||||
|
-- brands table, so 0 rows would qualify.)
|
||||||
|
UPDATE "vehicles" v
|
||||||
|
SET brand_id = b.id, brand_name = b.name
|
||||||
|
FROM "brands" b
|
||||||
|
WHERE v.brand_id IS NULL AND lower(v.brand_name) = lower(b.name);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Defensive: realign any vehicle whose brand_id is set but whose denormalised
|
||||||
|
-- brand_name has drifted from the canonical brands.name (none today, but keeps
|
||||||
|
-- the FK and the denormalised name consistent going forward).
|
||||||
|
UPDATE "vehicles" v
|
||||||
|
SET brand_name = b.name
|
||||||
|
FROM "brands" b
|
||||||
|
WHERE v.brand_id = b.id AND v.brand_name <> b.name;
|
||||||
@@ -92,6 +92,13 @@
|
|||||||
"when": 1780581755559,
|
"when": 1780581755559,
|
||||||
"tag": "0012_lifecycle_email_sent",
|
"tag": "0012_lifecycle_email_sent",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780600000000,
|
||||||
|
"tag": "0013_fix_brand_casing",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -193,16 +193,22 @@ export class VehiclesService {
|
|||||||
|
|
||||||
// 3. Brand access check
|
// 3. Brand access check
|
||||||
let brandId: string | null = null;
|
let brandId: string | null = null;
|
||||||
const brandName = resolved.brandName;
|
// Match the brands table case-insensitively and adopt the CANONICAL name:
|
||||||
|
// decode sources emit the brand with inconsistent casing ("FORD" vs "Ford"),
|
||||||
|
// and a case-sensitive lookup left brand_id NULL + stored the raw uppercase
|
||||||
|
// string, splitting one brand across casing variants. See migration
|
||||||
|
// 0013_fix_brand_casing for the backfill of pre-existing rows.
|
||||||
|
let brandName = resolved.brandName;
|
||||||
if (brandName) {
|
if (brandName) {
|
||||||
const [brand] = await this.db
|
const [brand] = await this.db
|
||||||
.select()
|
.select()
|
||||||
.from(brands)
|
.from(brands)
|
||||||
.where(eq(brands.name, brandName))
|
.where(sql`lower(${brands.name}) = lower(${brandName})`)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (brand) {
|
if (brand) {
|
||||||
brandId = brand.id;
|
brandId = brand.id;
|
||||||
|
brandName = brand.name;
|
||||||
await this.checkBrandAccess(userId, brandId);
|
await this.checkBrandAccess(userId, brandId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user