chore: assorted improvements

This commit is contained in:
Tiago Farto
2026-05-18 15:31:51 +00:00
parent ff7ac26ba5
commit 311e49311b
9 changed files with 163 additions and 140 deletions
-1
View File
@@ -1 +0,0 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
@@ -10,7 +10,11 @@ import {
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import { V3SurveyLanguageError, serializeV3SurveyResource } from "@/app/api/v3/surveys/serializers";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { normalizeV3SurveyLanguageTag } from "../language";
@@ -131,6 +135,19 @@ export const GET = withV3ApiWrapper({
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
+9 -6
View File
@@ -31,12 +31,8 @@ describe("resolveV3SurveyLanguageCode", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("returns disabled when the resolved language is disabled", () => {
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({
ok: false,
reason: "disabled",
message: "Language 'fr-FR' is disabled for this survey",
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns ambiguous when language-only tags match multiple configured languages", () => {
@@ -59,4 +55,11 @@ describe("resolveV3SurveyLanguageCode", () => {
message: "Language 'es-ES' is not configured for this survey",
});
});
test("resolves the implicit default language for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
+1 -17
View File
@@ -5,7 +5,7 @@ type TV3SurveyLanguageInput = {
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown" | "disabled" | "ambiguous"; message: string };
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
@@ -44,14 +44,6 @@ export function resolveV3SurveyLanguageCode(
);
if (exactMatch) {
if (!exactMatch.enabled) {
return {
ok: false,
reason: "disabled",
message: `Language '${exactMatch.code}' is disabled for this survey`,
};
}
return { ok: true, code: exactMatch.code };
}
@@ -71,14 +63,6 @@ export function resolveV3SurveyLanguageCode(
const languageMatch = matchingLanguages[0];
if (languageMatch) {
if (!languageMatch.enabled) {
return {
ok: false,
reason: "disabled",
message: `Language '${languageMatch.code}' is disabled for this survey`,
};
}
return { ok: true, code: languageMatch.code };
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { V3SurveyLanguageError, serializeV3SurveyResource } from "./serializers";
import { V3SurveyUnsupportedShapeError, serializeV3SurveyResource } from "./serializers";
const baseSurvey = {
id: "survey_1",
@@ -28,6 +28,7 @@ const baseSurvey = {
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
@@ -74,6 +75,73 @@ describe("serializeV3SurveyResource", () => {
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect((resource.welcomeCard as any).headline).toEqual({ "en-US": "Welcome" });
expect((resource.blocks as any)[0].elements[0].headline).toEqual({
"en-US": "What should we improve?",
});
});
test("localizes the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: "en" });
expect(resource.language).toBe("en-US");
expect((resource.welcomeCard as any).headline).toBe("Welcome");
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect((resource.welcomeCard as any).headline).toEqual({
"en-US": "Welcome",
"de-DE": "Willkommen",
});
});
test("localizes fields for case-insensitive underscore language selectors", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: "DE_de" });
@@ -90,11 +158,11 @@ describe("serializeV3SurveyResource", () => {
expect((resource.welcomeCard as any).headline).toBe("Willkommen");
});
test("rejects disabled language selectors", () => {
expect(() => serializeV3SurveyResource(baseSurvey, { lang: "fr" })).toThrow(V3SurveyLanguageError);
expect(() => serializeV3SurveyResource(baseSurvey, { lang: "fr" })).toThrow(
"Language 'fr-FR' is disabled for this survey"
);
test("localizes disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: "fr" });
expect(resource.language).toBe("fr-FR");
expect((resource.welcomeCard as any).headline).toBe("Bienvenue");
});
test("rejects ambiguous language-only selectors", () => {
@@ -130,4 +198,17 @@ describe("serializeV3SurveyResource", () => {
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
);
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+37 -5
View File
@@ -3,6 +3,7 @@ import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/s
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
@@ -25,6 +26,13 @@ export class V3SurveyLanguageError extends Error {
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
@@ -40,11 +48,17 @@ function toIsoString(value: Date | string): string {
}
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
return (survey.languages ?? []).map((surveyLanguage) => ({
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
@@ -52,7 +66,7 @@ function getDefaultLanguage(survey: TInternalSurvey): string {
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: "default";
: DEFAULT_V3_SURVEY_LANGUAGE;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -67,6 +81,17 @@ function isI18nString(value: unknown): value is Record<string, string> {
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
@@ -78,8 +103,9 @@ function serializeCanonicalValue(
};
for (const languageCode of configuredLanguageCodes) {
if (languageCode !== defaultLanguage && typeof value[languageCode] === "string") {
result[languageCode] = value[languageCode];
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage && translatedValue !== undefined) {
result[languageCode] = translatedValue;
}
}
@@ -104,7 +130,7 @@ function serializeCanonicalValue(
function serializeLocalizedValue(value: unknown, language: string): TSerializedValue {
if (isI18nString(value)) {
return value[language] ?? value.default;
return getI18nValueForLanguage(value, language) ?? value.default;
}
if (Array.isArray(value)) {
@@ -131,6 +157,12 @@ function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: stri
}
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"
);
}
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -28,32 +28,6 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
@@ -65,16 +39,18 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
position === "popper" &&
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -122,8 +98,6 @@ export {
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
-68
View File
@@ -184,74 +184,6 @@ services:
- ./docker/cube/schema:/cube/conf/model:ro
restart: on-failure
# Run Hub DB migrations (goose + river) before the API starts. Idempotent; runs on every compose up.
# Hub image pinned via HUB_IMAGE_TAG so docker does not silently reuse a stale :latest cache.
# Keep hub, hub-migrate, and any future hub-worker on the same tag — they share one image and
# drift breaks migrations or job processing.
hub-migrate:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
restart: "no"
entrypoint: ["sh", "-c"]
command:
[
'if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"; else echo ''Migration tools (goose/river) not in image.''; exit 1; fi',
]
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
depends_on:
postgres:
condition: service_healthy
# Formbricks Hub API (ghcr.io/formbricks/hub). Shares the same Postgres database as Formbricks by default.
hub:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
depends_on:
hub-migrate:
condition: service_completed_successfully
ports:
- "8080:8080"
environment:
API_KEY: ${HUB_API_KEY:-dev-api-key}
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
# Explicit Postgres env so migrations and any libpq fallback use the service host, not localhost
PGHOST: postgres
PGPORT: "5432"
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: postgres
PGSSLMODE: disable
cube:
profiles: ["xm"]
image: cubejs/cube:v1.6.6
env_file:
- apps/web/.env
depends_on:
postgres:
condition: service_healthy
hub-migrate:
condition: service_completed_successfully
ports:
- 4000:4000
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube}
CUBEJS_DEFAULT_API_SCOPES: meta,data
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./docker/cube/cube.js:/cube/conf/cube.js:ro
- ./docker/cube/schema:/cube/conf/model:ro
restart: on-failure
volumes:
postgres:
driver: local
+4 -3
View File
@@ -225,7 +225,8 @@ paths:
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`. Aliases are not accepted.
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.
- in: query
name: version
required: false
@@ -251,7 +252,7 @@ paths:
data:
$ref: "#/components/schemas/SurveyResource"
"400":
description: Invalid survey id, unsupported query parameter, unsupported version selector, or unknown/disabled language
description: Invalid survey id, unsupported query parameter, unsupported version selector, unknown language, or unsupported legacy survey shape
content:
application/problem+json:
schema:
@@ -422,7 +423,7 @@ components:
additionalProperties: true
defaultLanguage:
type: string
description: Real locale code for the survey default language when configured; otherwise `default`.
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.