Skip to content

Commit c568876

Browse files
ascorbicmarcusbellamyshaw-cellclaude
authored
fix(core): preserve locale casing in API locale filter (#1551) (#2174)
* fix(core): preserve locale casing in API locale filter (#1551) The `localeCode` validator lowercased the `?locale=` filter and create-body locale, but config `locales`/`defaultLocale`, the stored `locale` column, and the public query path all keep the raw BCP-47 casing. As a result the content and search APIs returned zero rows for any locale with an uppercase region or script subtag (e.g. zh-TW, pt-BR, zh-Hant). Drop the `.toLowerCase()` transform so the value is preserved verbatim; validation stays case-insensitive. This also matches the sibling `localeFilterQuery` (taxonomies/menus), which never lowercased. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(core): apply localeCode validation to taxonomy/menu locale filter (#1551) The shared `localeFilterQuery` (used by taxonomy and menu endpoints) still used a plain `z.string()` while `contentListQuery` and the search schemas adopted the stricter, casing-preserving `localeCode`. Reusing `localeCode` here tightens BCP-47 validation and keeps locale handling consistent across the API: a malformed `?locale=` value is now rejected instead of silently matching zero rows. Addresses non-blocking review feedback on #1572. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(core): backfill locale casing on ec_* tables (#1572) Addresses review feedback on #1572: removing localeCode's lowercasing transform fixed new locale-filtered queries against the canonical casing, but pre-existing rows already stored under the lowercased form (e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter. Adds migration 044, which canonicalizes the locale column on every ec_* table against getI18nConfig().locales, case-insensitively. No-op when i18n isn't configured. Not reversible (down is a no-op) -- the original incorrect casing isn't recoverable and re-lowercasing would reintroduce the bug. Updates the "partially applied" migration-runner test to include the new trailing migration. * fix(core): keep locale taxonomy pivots in sync * chore(core): clarify locale casing comments * fix(core): repair locale casing from runtime config * fix(core): canonicalize localized API writes * fix(core): canonicalize taxonomy locale filters * fix(core): canonicalize search locale filters --------- Co-authored-by: Marcus (bug-testing) <marcusbellamyshaw@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8470b97 commit c568876

27 files changed

Lines changed: 775 additions & 73 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes locale-filtered content and search results for locales with uppercase region or script subtags. Existing content rows saved with lowercased locale values are repaired automatically.

packages/core/src/api/handlers/bylines.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "../../database/repositories/byline.js";
88
import { EmDashValidationError, type BylineSummary } from "../../database/repositories/types.js";
99
import type { Database } from "../../database/types.js";
10-
import { getI18nConfig } from "../../i18n/config.js";
10+
import { getI18nConfig, resolveConfiguredLocale } from "../../i18n/config.js";
1111
import type { ApiResult } from "../types.js";
1212

1313
// `undefined → null` so a missing field in the create payload matches the
@@ -138,7 +138,8 @@ export async function handleBylineCreate(
138138
};
139139
}
140140

141-
const localeErr = rejectUnknownLocale(input.locale);
141+
const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;
142+
const localeErr = rejectUnknownLocale(locale);
142143
if (localeErr) return localeErr;
143144

144145
const repo = new BylineRepository(db);
@@ -160,7 +161,7 @@ export async function handleBylineCreate(
160161
sourceGroup = source.translationGroup ?? source.id;
161162
}
162163

163-
const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
164+
const effectiveLocale = locale ?? getI18nConfig()?.defaultLocale ?? "en";
164165

165166
// Translation-group guard: the row-per-locale model (PR #916)
166167
// allows exactly one row per (translation_group, locale). Reject
@@ -216,7 +217,7 @@ export async function handleBylineCreate(
216217
};
217218
}
218219

219-
const byline = await repo.create(input);
220+
const byline = await repo.create({ ...input, locale: effectiveLocale });
220221
return { success: true, data: byline };
221222
} catch (error) {
222223
// Mirror handleBylineUpdate: surface customFields validation

packages/core/src/api/handlers/content.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { UserRepository } from "../../database/repositories/user.js";
3030
import { withTransaction } from "../../database/transaction.js";
3131
import type { Database } from "../../database/types.js";
3232
import { validateIdentifier } from "../../database/validate.js";
33-
import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js";
33+
import { getI18nConfig, isI18nEnabled, resolveConfiguredLocale } from "../../i18n/config.js";
3434
import { invalidateRedirectCache } from "../../redirects/cache.js";
3535
import { FTSManager } from "../../search/fts-manager.js";
3636
import { invalidateTermCache } from "../../taxonomies/index.js";
@@ -269,7 +269,11 @@ async function resolveId(
269269
identifier: string,
270270
locale?: string,
271271
): Promise<string | null> {
272-
const item = await repo.findByIdOrSlug(collection, identifier, locale);
272+
const item = await repo.findByIdOrSlug(
273+
collection,
274+
identifier,
275+
locale ? resolveConfiguredLocale(locale) : undefined,
276+
);
273277
return item?.id ?? null;
274278
}
275279

@@ -283,7 +287,11 @@ async function resolveIdIncludingTrashed(
283287
identifier: string,
284288
locale?: string,
285289
): Promise<string | null> {
286-
const item = await repo.findByIdOrSlugIncludingTrashed(collection, identifier, locale);
290+
const item = await repo.findByIdOrSlugIncludingTrashed(
291+
collection,
292+
identifier,
293+
locale ? resolveConfiguredLocale(locale) : undefined,
294+
);
287295
return item?.id ?? null;
288296
}
289297

@@ -427,7 +435,7 @@ export async function handleContentList(
427435
const repo = new ContentRepository(db);
428436
const where: FindManyOptions["where"] = {};
429437
if (params.status) where.status = params.status;
430-
if (params.locale) where.locale = params.locale;
438+
if (params.locale) where.locale = resolveConfiguredLocale(params.locale);
431439
if (params.authorId) where.authorId = params.authorId;
432440

433441
// A date range requires a target column; ignore stray from/to without
@@ -579,7 +587,11 @@ export async function handleContentGet(
579587
): Promise<ApiResult<ContentResponse>> {
580588
try {
581589
const repo = new ContentRepository(db);
582-
const item = await repo.findByIdOrSlug(collection, id, locale);
590+
const item = await repo.findByIdOrSlug(
591+
collection,
592+
id,
593+
locale ? resolveConfiguredLocale(locale) : undefined,
594+
);
583595

584596
if (!item) {
585597
return {
@@ -624,7 +636,11 @@ export async function handleContentGetIncludingTrashed(
624636
): Promise<ApiResult<ContentResponse>> {
625637
try {
626638
const repo = new ContentRepository(db);
627-
const item = await repo.findByIdOrSlugIncludingTrashed(collection, id, locale);
639+
const item = await repo.findByIdOrSlugIncludingTrashed(
640+
collection,
641+
id,
642+
locale ? resolveConfiguredLocale(locale) : undefined,
643+
);
628644

629645
if (!item) {
630646
return {
@@ -706,7 +722,9 @@ export async function handleContentCreate(
706722
// Default to the configured site locale rather than the repo's
707723
// hard-coded "en" — otherwise non-English default-locale sites
708724
// silently create entries in a locale the editor never chose.
709-
const effectiveLocale = body.locale ?? getI18nConfig()?.defaultLocale;
725+
const effectiveLocale = body.locale
726+
? resolveConfiguredLocale(body.locale)
727+
: getI18nConfig()?.defaultLocale;
710728

711729
let slug: string | null | undefined = body.slug;
712730
if (!slug) {

packages/core/src/api/handlers/menus.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
type UpdateMenuItemInput as UpdateMenuItemRepoInput,
2626
} from "../../database/repositories/menu.js";
2727
import type { Database } from "../../database/types.js";
28-
import { getI18nConfig } from "../../i18n/config.js";
28+
import { getI18nConfig, resolveConfiguredLocale } from "../../i18n/config.js";
2929
import type { ApiResult } from "../types.js";
3030

3131
// Re-export entity types so route files and tests can import them from the
@@ -89,7 +89,8 @@ async function resolveMenu(
8989
name: string,
9090
options: { locale?: string },
9191
): Promise<ResolveMenuResult> {
92-
const matches = await repo.findByName(name, options);
92+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
93+
const matches = await repo.findByName(name, { locale });
9394
if (matches.length === 0) {
9495
return {
9596
success: false,
@@ -124,7 +125,8 @@ export async function handleMenuList(
124125
): Promise<ApiResult<MenuListItem[]>> {
125126
try {
126127
const repo = new MenuRepository(db);
127-
const items = await repo.findMany(options);
128+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
129+
const items = await repo.findMany({ locale });
128130
return { success: true, data: items };
129131
} catch {
130132
return {
@@ -175,7 +177,9 @@ export async function handleMenuCreate(
175177

176178
// Duplicate guard: same (name, locale). Falls back to the configured
177179
// defaultLocale to match the column DEFAULT set by migration 036.
178-
const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
180+
const effectiveLocale = input.locale
181+
? resolveConfiguredLocale(input.locale)
182+
: (getI18nConfig()?.defaultLocale ?? "en");
179183
if (await repo.existsByNameAndLocale(input.name, effectiveLocale)) {
180184
return {
181185
success: false,
@@ -188,7 +192,7 @@ export async function handleMenuCreate(
188192
};
189193
}
190194

191-
const menu = await repo.create(input);
195+
const menu = await repo.create({ ...input, locale: effectiveLocale });
192196
return { success: true, data: menu };
193197
} catch {
194198
return {
@@ -212,7 +216,8 @@ export async function handleMenuGet(
212216
): Promise<ApiResult<MenuWithItems>> {
213217
try {
214218
const repo = new MenuRepository(db);
215-
const matches = await repo.findByName(name, options);
219+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
220+
const matches = await repo.findByName(name, { locale });
216221
if (matches.length === 0) {
217222
return {
218223
success: false,

packages/core/src/api/handlers/relations.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { InvalidCursorError } from "../../database/repositories/types.js";
1111
import type { ContentItem } from "../../database/repositories/types.js";
1212
import type { Database } from "../../database/types.js";
13+
import { resolveConfiguredLocale } from "../../i18n/config.js";
1314
import { SchemaRegistry } from "../../schema/registry.js";
1415
import type { ApiResult } from "../types.js";
1516

@@ -70,7 +71,10 @@ export async function handleRelationCreate(
7071
}
7172
}
7273

73-
const relation = await repo.create(input);
74+
const relation = await repo.create({
75+
...input,
76+
locale: input.locale ? resolveConfiguredLocale(input.locale) : undefined,
77+
});
7478
return { success: true, data: { relation } };
7579
} catch (error) {
7680
// A bad `translationOf` makes the repo throw loudly rather than mint an
@@ -127,7 +131,8 @@ export async function handleRelationList(
127131
): Promise<ApiResult<{ relations: Relation[] }>> {
128132
try {
129133
const repo = new RelationRepository(db);
130-
const relations = await repo.list(opts.locale);
134+
const locale = opts.locale ? resolveConfiguredLocale(opts.locale) : undefined;
135+
const relations = await repo.list(locale);
131136
return { success: true, data: { relations } };
132137
} catch {
133138
return {

packages/core/src/api/handlers/taxonomies.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ulid } from "ulidx";
1212

1313
import { TaxonomyRepository } from "../../database/repositories/taxonomy.js";
1414
import type { Database, TaxonomyDefTable } from "../../database/types.js";
15+
import { resolveConfiguredLocale } from "../../i18n/config.js";
1516
import { invalidateTaxonomyDefsCache, invalidateTermCache } from "../../taxonomies/index.js";
1617
import { fetchVisibleTermCounts } from "../../taxonomies/term-counts.js";
1718
import type { ApiResult } from "../types.js";
@@ -174,8 +175,9 @@ export async function handleTaxonomyList(
174175
options: { locale?: string } = {},
175176
): Promise<ApiResult<TaxonomyListResponse>> {
176177
try {
178+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
177179
let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
178-
if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
180+
if (locale !== undefined) query = query.where("locale", "=", locale);
179181
const [rows, collectionRows] = await Promise.all([
180182
query.execute(),
181183
db.selectFrom("_emdash_collections").select("slug").execute(),
@@ -216,6 +218,7 @@ export async function handleTaxonomyCreate(
216218
},
217219
): Promise<ApiResult<{ taxonomy: TaxonomyDef }>> {
218220
try {
221+
const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;
219222
if (!NAME_PATTERN.test(input.name)) {
220223
return {
221224
success: false,
@@ -265,19 +268,19 @@ export async function handleTaxonomyCreate(
265268

266269
// Duplicate guard scoped to locale (so the same name can exist in ES
267270
// and EN).
268-
if (input.locale !== undefined) {
271+
if (locale !== undefined) {
269272
const existing = await db
270273
.selectFrom("_emdash_taxonomy_defs")
271274
.select("id")
272275
.where("name", "=", input.name)
273-
.where("locale", "=", input.locale)
276+
.where("locale", "=", locale)
274277
.executeTakeFirst();
275278
if (existing) {
276279
return {
277280
success: false,
278281
error: {
279282
code: "CONFLICT",
280-
message: `Taxonomy '${input.name}' already exists in locale '${input.locale}'`,
283+
message: `Taxonomy '${input.name}' already exists in locale '${locale}'`,
281284
},
282285
};
283286
}
@@ -293,7 +296,7 @@ export async function handleTaxonomyCreate(
293296
label_singular: input.labelSingular ?? null,
294297
hierarchical: input.hierarchical ? 1 : 0,
295298
collections: JSON.stringify(collections),
296-
...(input.locale !== undefined ? { locale: input.locale } : {}),
299+
...(locale !== undefined ? { locale } : {}),
297300
translation_group: translationGroup ?? id,
298301
})
299302
.execute();
@@ -390,7 +393,8 @@ export async function handleTermList(
390393
if (!lookup.success) return lookup;
391394

392395
const repo = new TaxonomyRepository(db);
393-
const terms = await repo.findByName(taxonomyName, { locale: options.locale });
396+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
397+
const terms = await repo.findByName(taxonomyName, { locale });
394398

395399
// Counts match what visitors see on the public site: published (or
396400
// scheduled-and-due) entries that aren't soft-deleted, scoped to the
@@ -535,6 +539,7 @@ export async function handleTermCreate(
535539
},
536540
): Promise<ApiResult<TermResponse>> {
537541
try {
542+
const locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;
538543
// Taxonomy definitions are per-locale, but terms can exist in any locale
539544
// regardless of whether the def has been translated there. Look up the
540545
// def across all locales — we only care that it *exists*.
@@ -548,14 +553,14 @@ export async function handleTermCreate(
548553
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
549554

550555
// Conflict check is scoped to locale (per-locale slugs are unique).
551-
const existing = await repo.findBySlug(taxonomyName, input.slug, input.locale);
556+
const existing = await repo.findBySlug(taxonomyName, input.slug, locale);
552557
if (existing) {
553558
return {
554559
success: false,
555560
error: {
556561
code: "CONFLICT",
557-
message: input.locale
558-
? `Term '${input.slug}' already exists in '${taxonomyName}' (${input.locale})`
562+
message: locale
563+
? `Term '${input.slug}' already exists in '${taxonomyName}' (${locale})`
559564
: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
560565
},
561566
};
@@ -594,7 +599,7 @@ export async function handleTermCreate(
594599
label: input.label,
595600
parentId: parentId ?? undefined,
596601
data: input.description ? { description: input.description } : undefined,
597-
locale: input.locale,
602+
locale,
598603
translationOf: input.translationOf,
599604
});
600605

@@ -635,7 +640,8 @@ export async function handleTermGet(
635640
): Promise<ApiResult<TermGetResponse>> {
636641
try {
637642
const repo = new TaxonomyRepository(db);
638-
const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
643+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
644+
const term = await repo.findBySlug(taxonomyName, termSlug, locale);
639645

640646
if (!term) {
641647
return {
@@ -743,7 +749,8 @@ export async function handleTermUpdate(
743749
): Promise<ApiResult<TermResponse>> {
744750
try {
745751
const repo = new TaxonomyRepository(db);
746-
const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
752+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
753+
const term = await repo.findBySlug(taxonomyName, termSlug, locale);
747754

748755
if (!term) {
749756
return {
@@ -763,7 +770,7 @@ export async function handleTermUpdate(
763770

764771
// Check if new slug conflicts (per-locale uniqueness).
765772
if (newSlug !== undefined && newSlug !== termSlug) {
766-
const existing = await repo.findBySlug(taxonomyName, newSlug, options.locale);
773+
const existing = await repo.findBySlug(taxonomyName, newSlug, locale);
767774
if (existing && existing.id !== term.id) {
768775
return {
769776
success: false,
@@ -832,7 +839,8 @@ export async function handleTermDelete(
832839
): Promise<ApiResult<{ deleted: true }>> {
833840
try {
834841
const repo = new TaxonomyRepository(db);
835-
const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
842+
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
843+
const term = await repo.findBySlug(taxonomyName, termSlug, locale);
836844

837845
if (!term) {
838846
return {

packages/core/src/api/schemas/bylines.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22

3-
import { cursorPaginationQuery, httpUrl } from "./common.js";
3+
import { cursorPaginationQuery, httpUrl, localeCode } from "./common.js";
44

55
/** Slug pattern: lowercase letters, digits, and hyphens; must start with a letter */
66
const bylineSlugPattern = /^[a-z][a-z0-9-]*$/;
@@ -81,7 +81,7 @@ export const bylinesListQuery = cursorPaginationQuery
8181
* Rejects empty strings so the picker can't silently fetch the
8282
* unfiltered list when the admin URL has `?locale=` with no value.
8383
*/
84-
locale: z.string().min(1).optional(),
84+
locale: localeCode.optional(),
8585
})
8686
.meta({ id: "BylinesListQuery" });
8787

@@ -102,7 +102,7 @@ export const bylineCreateBody = z
102102
* configured `defaultLocale`) is used. Rejects empty strings — an
103103
* empty locale would create rows no resolver requests.
104104
*/
105-
locale: z.string().min(1).optional(),
105+
locale: localeCode.optional(),
106106
/**
107107
* When set, the new row joins the source byline's translation_group
108108
* rather than minting a fresh one. Requires `locale`.
@@ -126,7 +126,7 @@ export const bylineCreateBody = z
126126

127127
export const bylineTranslationCreateBody = z
128128
.object({
129-
locale: z.string().min(1),
129+
locale: localeCode,
130130
slug: z
131131
.string()
132132
.min(1)

0 commit comments

Comments
 (0)