chore: always use language maps, allow filtering multiple languages

This commit is contained in:
Tiago Farto
2026-05-18 17:05:44 +00:00
parent 0df059adcd
commit 49473f17e3
6 changed files with 182 additions and 78 deletions
@@ -17,7 +17,7 @@ import {
} from "@/app/api/v3/surveys/serializers";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { normalizeV3SurveyLanguageTag } from "../language";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
@@ -26,20 +26,19 @@ const surveyParamsSchema = z.object({
const surveyQuerySchema = z
.object({
lang: z
.string()
.trim()
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const normalizedLanguage = normalizeV3SurveyLanguageTag(value);
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!normalizedLanguage) {
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: "Language must be a valid locale code",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return normalizedLanguage;
return parsedLanguageQuery.languages;
})
.optional(),
})
+42 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
@@ -22,6 +26,43 @@ describe("normalizeV3SurveyLanguageTag", () => {
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
+38
View File
@@ -3,10 +3,14 @@ type TV3SurveyLanguageInput = {
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
@@ -17,6 +21,40 @@ export function normalizeV3SurveyLanguageTag(value: string): string | null {
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
function getLanguageSubtag(languageTag: string): string {
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
}
+42 -18
View File
@@ -131,7 +131,7 @@ describe("serializeV3SurveyResource", () => {
});
});
test("localizes the implicit default language for surveys without configured languages", () => {
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
@@ -141,10 +141,10 @@ describe("serializeV3SurveyResource", () => {
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: "en" });
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
expect(resource.language).toBe("en-US");
expect(resource).toMatchObject({ welcomeCard: { headline: "Welcome" } });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
@@ -168,18 +168,18 @@ describe("serializeV3SurveyResource", () => {
});
});
test("localizes fields for case-insensitive underscore language selectors", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: "DE_de" });
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource.language).toBe("de-DE");
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: "Willkommen" },
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: "Was sollen wir verbessern?",
subheader: "Tell us more",
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
@@ -188,17 +188,41 @@ describe("serializeV3SurveyResource", () => {
});
test("resolves language-only selectors against configured survey languages", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: "de" });
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
expect(resource.language).toBe("de-DE");
expect(resource).toMatchObject({ welcomeCard: { headline: "Willkommen" } });
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
});
test("localizes disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: "fr" });
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
expect(resource.language).toBe("fr-FR");
expect(resource).toMatchObject({ welcomeCard: { headline: "Bienvenue" } });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects ambiguous language-only selectors", () => {
@@ -230,7 +254,7 @@ describe("serializeV3SurveyResource", () => {
],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey, { lang: "pt" })).toThrow(
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
);
});
+30 -31
View File
@@ -95,32 +95,41 @@ function getI18nValueForLanguage(value: Record<string, string>, languageCode: st
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
configuredLanguageCodes: Set<string>
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of configuredLanguageCodes) {
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage && translatedValue !== undefined) {
result[languageCode] = translatedValue;
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, configuredLanguageCodes));
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, configuredLanguageCodes),
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
@@ -128,24 +137,6 @@ function serializeCanonicalValue(
return value as TSerializedValue;
}
function serializeLocalizedValue(value: unknown, language: string): TSerializedValue {
if (isI18nString(value)) {
return getI18nValueForLanguage(value, language) ?? value.default;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeLocalizedValue(entry, language));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, serializeLocalizedValue(entry, language)])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
@@ -156,7 +147,15 @@ function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: stri
return result.code;
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string }) {
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
@@ -166,11 +165,12 @@ export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { l
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const language = options?.lang ? resolveRequestedLanguage(languages, options.lang) : undefined;
const serializeValue = language
? (value: unknown) => serializeLocalizedValue(value, language)
: (value: unknown) => serializeCanonicalValue(value, defaultLanguage, configuredLanguageCodes);
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
@@ -182,7 +182,6 @@ export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { l
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
...(language ? { language } : {}),
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
+24 -21
View File
@@ -203,7 +203,7 @@ paths:
description: |
Returns the public v3 survey management resource for one survey. By default, translatable
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
to request a localized projection where translatable fields are returned as strings.
to filter those maps to one or more requested locale codes.
tags:
- V3 Surveys
parameters:
@@ -217,15 +217,23 @@ paths:
- in: query
name: lang
required: false
style: form
explode: false
schema:
type: string
examples: [de-DE, de_DE, de]
type: array
items:
type: string
examples:
- [de-DE]
- [de-DE, pt-PT]
description: |
Locale code for a localized read projection. The parser is case-insensitive, accepts
`_` or `-` separators, and normalizes to canonical BCP 47 casing (`de_DE`, `DE-de` → `de-DE`).
A language-only selector (`de`) resolves to the matching configured survey language when exactly
one exists; otherwise it returns `400`. Disabled-but-configured languages are readable in the
management API so unfinished translations can be completed. Aliases are not accepted.
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
The response shape stays stable: translatable fields are always maps keyed by locale code, never
strings. The parser is case-insensitive, accepts `_` or `-` separators, and normalizes to canonical
BCP 47 casing (`de_DE`, `DE-de` → `de-DE`). A language-only selector (`de`) resolves to the matching
configured survey language when exactly one exists; otherwise it returns `400`. Disabled-but-configured
languages are readable in the management API so unfinished translations can be completed. Aliases are
not accepted.
responses:
"200":
description: Survey retrieved successfully
@@ -283,8 +291,8 @@ paths:
hiddenFields:
enabled: false
variables: []
localized:
summary: Localized projection with ?lang=en-US
filtered:
summary: Language-filtered projection with ?lang=en-US,de-DE
value:
data:
id: clseedsurveycsat000000
@@ -296,7 +304,6 @@ paths:
status: inProgress
metadata: {}
defaultLanguage: en-US
language: en-US
languages:
- code: en-US
default: true
@@ -314,7 +321,9 @@ paths:
type: rating
range: 5
scale: smiley
headline: How satisfied are you with our product?
headline:
en-US: How satisfied are you with our product?
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
@@ -460,16 +469,13 @@ components:
enabled: { type: boolean }
isEncrypted: { type: boolean }
TranslatableText:
oneOf:
- type: string
description: Localized projection string returned when `?lang=` is used.
allOf:
- $ref: "#/components/schemas/TranslatableTextMap"
description: |
Survey authoring text. Default `GET /api/v3/surveys/{surveyId}` returns locale maps keyed by
real locale codes such as `en-US` and `de-DE`. Localized reads with `?lang=` return strings.
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
The internal storage key `default` is never exposed by v3.
examples:
- What should we improve?
- en-US: What should we improve?
de-DE: Was sollten wir verbessern?
TranslatableTextMap:
@@ -1130,9 +1136,6 @@ components:
defaultLanguage:
type: string
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
language:
type: string
description: Present only on localized `?lang=` responses.
languages:
type: array
items: