diff --git a/server/assets/atlas/admin1.geojson.gz b/server/assets/atlas/admin1.geojson.gz index ab15bff1b..6c833aeae 100644 Binary files a/server/assets/atlas/admin1.geojson.gz and b/server/assets/atlas/admin1.geojson.gz differ diff --git a/server/scripts/build-atlas-geo.mjs b/server/scripts/build-atlas-geo.mjs index 29cd5d33c..19a9c91f6 100644 --- a/server/scripts/build-atlas-geo.mjs +++ b/server/scripts/build-atlas-geo.mjs @@ -180,7 +180,11 @@ function normalizeAdm1(geo, a3, countryName) { return code } return geo.features.map(f => { - const name = f.properties?.shapeName || '' + let name = f.properties?.shapeName || '' + // geoBoundaries erroneously names Guangdong Province as "Guangzhou Province" (Guangzhou is the capital city). + if (a3 === 'CHN' && name === 'Guangzhou Province') { + name = 'Guangdong Province' + } const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS) if (!geometry) return null // shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 47bdb3555..c26f6cc09 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4374,6 +4374,47 @@ function runMigrations(db: Database.Database): void { `); db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)'); }, + /** + * Rename the misnamed Guangdong Province (shipped as "Guangzhou Province"). + * + * geoBoundaries labelled the whole province with the name of its capital, so + * every row a user collected under it carries the wrong code. All three + * tables that key on a region are moved over: the two per-user ones with an + * UPDATE OR IGNORE plus a DELETE, because a user who already holds the + * correct region would otherwise hit the unique index and keep a duplicate, + * and place_regions with a plain UPDATE, because place_id is its primary key + * and nothing there can collide. + * + * Appended LAST: the array is index-addressed against schema_version. + */ + () => { + db.prepare( + `UPDATE OR IGNORE visited_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, + ).run(); + db.prepare( + `DELETE FROM visited_regions + WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, + ).run(); + db.prepare( + `UPDATE OR IGNORE place_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, + ).run(); + // hidden_regions is the other direction: it remembers which derived region + // a user switched off. Left behind, the tombstone stops matching and the + // region a user deliberately hid comes back. + db.prepare( + `UPDATE OR IGNORE hidden_regions + SET region_code = 'CN-GUANGDONGPROVINCE' + WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`, + ).run(); + db.prepare( + `DELETE FROM hidden_regions + WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`, + ).run(); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/tests/unit/db/migration-hygiene.test.ts b/server/tests/unit/db/migration-hygiene.test.ts index 44b4ecd88..5b9741cc1 100644 --- a/server/tests/unit/db/migration-hygiene.test.ts +++ b/server/tests/unit/db/migration-hygiene.test.ts @@ -135,6 +135,8 @@ const ALLOWED_DESTRUCTIVE: Record = { 'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.', 'DELETE FROM visited_regions': 'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).', + 'DELETE FROM hidden_regions': + 'Guangdong rename (#2284): DELETE ... WHERE region_code = the retired CN-GUANGZHOUPROVINCE, run right after an UPDATE OR IGNORE re-codes the tombstone. Only a row the update had to skip survives to here, and it can only be skipped because UNIQUE(user_id, region_code) already holds the same tombstone under the correct code, so the user keeps the region hidden either way.', 'DELETE FROM reservation_day_positions': 'DELETE ... WHERE the row joins a reservation and a day sitting on different trips. The table has no trip_id and its two foreign keys only require the ids to exist, so a pair that never belonged together was storable; the writer scopes to the trip now and this clears what earlier builds allowed. Bounded by the join — a row whose reservation and day agree on their trip is untouched.', }; diff --git a/server/tests/unit/services/atlasBundle.test.ts b/server/tests/unit/services/atlasBundle.test.ts index 1eff452d7..c2694b901 100644 --- a/server/tests/unit/services/atlasBundle.test.ts +++ b/server/tests/unit/services/atlasBundle.test.ts @@ -78,4 +78,14 @@ describe('Atlas admin1 region bundle (#1217)', () => { expect(names.has(borough), `${borough} should be a region of its own`).toBe(true); } }); + + it('ATLAS-BUNDLE-006 — China ships Guangdong Province, not Guangzhou Province', () => { + const cn = (features as unknown as { properties: { iso_a2: string | null; name?: string; iso_3166_2: string } }[]) + .filter(f => f.properties.iso_a2 === 'CN'); + const names = cn.map(f => f.properties.name); + expect(names).toContain('Guangdong Province'); + expect(names).not.toContain('Guangzhou Province'); + expect(cn.some(f => f.properties.iso_3166_2 === 'CN-GUANGDONGPROVINCE')).toBe(true); + expect(cn.some(f => f.properties.iso_3166_2 === 'CN-GUANGZHOUPROVINCE')).toBe(false); + }); });