Compare commits

..

3 Commits

Author SHA1 Message Date
Dhruwang
250195c04d fix: coverage 2026-02-06 13:19:41 +05:30
Dhruwang
a83893f598 revert unrelated change 2026-02-06 13:17:16 +05:30
Dhruwang
186eb7d051 fix: webhook data not being sent 2026-02-06 12:47:07 +05:30
51 changed files with 252 additions and 1000 deletions

View File

@@ -384,24 +384,24 @@ export const generateResponseTableColumns = (
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
: [];
const metadataColumns = getMetadataColumnsData(t);

View File

@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";

View File

@@ -1,15 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -33,38 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
@@ -147,11 +113,6 @@ export const PUT = withV1ApiWrapper({
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {

View File

@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -35,27 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
@@ -146,11 +123,6 @@ export const POST = withV1ApiWrapper({
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -8,7 +8,10 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);

View File

@@ -7,7 +7,10 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -155,7 +158,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -107,23 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -8,7 +8,7 @@ import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessT
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { logFileDeletion } from "./lib/audit-logs";
@@ -39,26 +39,21 @@ export const GET = async (
}
}
// Stream the file directly instead of redirecting to S3
// This enables Next.js Image component to work with relative URLs
const streamResult = await getFileStreamForDownload(fileName, environmentId, accessType);
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
if (!streamResult.ok) {
const errorResponse = getErrorResponseFromStorageError(streamResult.error, { fileName });
if (!signedUrlResult.ok) {
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
return errorResponse;
}
const { body, contentType, contentLength } = streamResult.data;
return new Response(body, {
status: 200,
return new Response(null, {
status: 302,
headers: {
"Content-Type": contentType,
...(contentLength > 0 && { "Content-Length": String(contentLength) }),
Location: signedUrlResult.data,
"Cache-Control":
accessType === "private"
? "no-store, no-cache, must-revalidate"
: "public, max-age=31536000, immutable",
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
};

View File

@@ -141,5 +141,52 @@ describe("Time Utilities", () => {
expect(convertDatesInObject("string")).toBe("string");
expect(convertDatesInObject(123)).toBe(123);
});
test("should not convert dates in contactAttributes", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
email: "test@example.com",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
expect(result.contactAttributes.email).toBe("test@example.com");
});
test("should not convert dates in variables", () => {
const input = {
updatedAt: "2024-03-20T15:30:00",
variables: {
createdAt: "2024-03-20T16:30:00",
userId: "123",
},
};
const result = convertDatesInObject(input);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
expect(result.variables.userId).toBe("123");
});
test("should not convert dates in data or meta", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
data: {
createdAt: "2024-03-20T16:30:00",
},
meta: {
updatedAt: "2024-03-20T17:30:00",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
});
});
});

View File

@@ -160,7 +160,12 @@ export const convertDatesInObject = <T>(obj: T): T => {
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
}
const newObj: any = {};
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
for (const key in obj) {
if (keysToIgnore.has(key)) {
newObj[key] = obj[key];
continue;
}
if (
(key === "createdAt" || key === "updatedAt") &&
typeof obj[key] === "string" &&

View File

@@ -1,6 +1,5 @@
import { z } from "zod";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { responses } from "@/modules/api/v2/lib/response";
@@ -16,6 +15,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { formatValidationErrorsForApi, validateResponseData } from "../lib/validation";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -198,7 +198,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -207,7 +206,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
{
type: "bad_request",
details: formatValidationErrorsForV2Api(validationErrors),
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);

View File

@@ -5,10 +5,10 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import {
formatValidationErrorsForApi,
formatValidationErrorsForV1Api,
formatValidationErrorsForV2Api,
validateResponseData,
} from "@/modules/api/lib/validation";
} from "./validation";
const mockTransformQuestionsToBlocks = vi.fn();
const mockGetElementsFromBlocks = vi.fn();
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", true, mockQuestions);
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
@@ -124,36 +124,15 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only present fields when finished is false", () => {
const partialResponseData: TResponseData = { element1: "test" };
const partialElements = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", false);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
});
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", true);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
});
});
describe("formatValidationErrorsForV2Api", () => {
describe("formatValidationErrorsForApi", () => {
test("should convert error map to V2 API format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toEqual([
{
@@ -172,7 +151,7 @@ describe("formatValidationErrorsForV2Api", () => {
],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
@@ -185,7 +164,7 @@ describe("formatValidationErrorsForV2Api", () => {
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");

View File

@@ -10,20 +10,17 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -40,28 +37,22 @@ export const validateResponseData = (
}
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
const elements = getElementsFromBlocks(blocksToUse);
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished
? allElements
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
// Validate all elements
const errorMap = validateBlockResponses(elements, responseData, languageCode);
// Return null if no errors (validation passed), otherwise return error map
return Object.keys(errorMap).length === 0 ? null : errorMap;
};
/**
* Converts validation error map to V2 API error response format
* Converts validation error map to API error response format (V2)
*
* @param errorMap - Validation error map from validateResponseData
* @returns V2 API error response details
* @returns API error response details
*/
export const formatValidationErrorsForV2Api = (errorMap: TValidationErrorMap) => {
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
const details: ApiErrorDetails = [];
for (const [elementId, errors] of Object.entries(errorMap)) {

View File

@@ -1,7 +1,6 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { responses } from "@/modules/api/v2/lib/response";
@@ -14,6 +13,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
import { formatValidationErrorsForApi, validateResponseData } from "./lib/validation";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
@@ -134,7 +134,6 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);
@@ -143,7 +142,7 @@ export const POST = async (request: Request) =>
request,
{
type: "bad_request",
details: formatValidationErrorsForV2Api(validationErrors),
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);

View File

@@ -1,7 +1,7 @@
"use server";
import { z } from "zod";
import { ZId, ZStorageUrl } from "@formbricks/types/common";
import { ZId, ZUrl } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -11,7 +11,7 @@ import { updateOrganizationFaviconUrl } from "@/modules/ee/whitelabel/favicon-cu
const ZUpdateOrganizationFaviconUrlAction = z.object({
organizationId: ZId,
faviconUrl: ZStorageUrl,
faviconUrl: ZUrl,
});
export const updateOrganizationFaviconUrlAction = authenticatedActionClient

View File

@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZStorageUrl } from "@formbricks/types/common";
import { ZId, ZUrl } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationWhitelabel } from "@formbricks/types/organizations";
import { validateInputs } from "@/lib/utils/validate";
@@ -11,7 +11,7 @@ export const updateOrganizationFaviconUrl = async (
organizationId: string,
faviconUrl: string | null
): Promise<boolean> => {
validateInputs([organizationId, ZId], [faviconUrl, ZStorageUrl.nullable()]);
validateInputs([organizationId, ZId], [faviconUrl, ZUrl.nullable()]);
try {
const organization = await prisma.organization.findUnique({

View File

@@ -24,7 +24,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { resolveStorageUrl } from "@/modules/storage/utils";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
interface PreviewEmailTemplateProps {
@@ -309,7 +308,7 @@ export async function PreviewEmailTemplate({
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={resolveStorageUrl(choice.imageUrl)}
src={choice.imageUrl}
/>
) : (
<Link
@@ -317,7 +316,7 @@ export async function PreviewEmailTemplate({
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={resolveStorageUrl(choice.imageUrl)} />
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}

View File

@@ -41,7 +41,6 @@ import { createEmailChangeToken, createInviteToken, createToken, createTokenForL
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import { resolveStorageUrl } from "@/modules/storage/utils";
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
@@ -277,12 +276,10 @@ export const sendEmbedSurveyPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate(locale);
// Resolve relative storage URLs to absolute URLs for email rendering
const resolvedLogoUrl = logoUrl ? resolveStorageUrl(logoUrl) : undefined;
const html = await renderEmbedSurveyPreviewEmail({
html: innerHtml,
environmentId,
logoUrl: resolvedLogoUrl,
logoUrl,
t,
...legalProps,
});
@@ -300,11 +297,9 @@ export const sendEmailCustomizationPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate(locale);
// Resolve relative storage URLs to absolute URLs for email rendering
const resolvedLogoUrl = logoUrl ? resolveStorageUrl(logoUrl) : undefined;
const emailHtmlBody = await renderEmailCustomizationPreviewEmail({
userName,
logoUrl: resolvedLogoUrl,
logoUrl,
t,
...legalProps,
});
@@ -321,8 +316,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const email = data.email;
const surveyName = data.surveyName;
const singleUseId = data.suId;
// Resolve relative storage URLs to absolute URLs for email rendering
const logoUrl = data.logoUrl ? resolveStorageUrl(data.logoUrl) : "";
const logoUrl = data.logoUrl || "";
const token = createTokenForLinkSurvey(surveyId, email);
const t = await getTranslate(data.locale);
const getSurveyLink = (): string => {

View File

@@ -72,13 +72,13 @@ describe("fileUpload", () => {
test("should handle successful file upload with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response - now returns relative path
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "/storage/test-env/public/file.jpg",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
@@ -98,18 +98,18 @@ describe("fileUpload", () => {
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("/storage/test-env/public/file.jpg");
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle upload error with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response - now returns relative path
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "/storage/test-env/public/file.jpg",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
@@ -134,13 +134,13 @@ describe("fileUpload", () => {
test("should handle upload error", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response - now returns relative path
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "/storage/test-env/public/file.jpg",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},

View File

@@ -14,6 +14,14 @@ vi.mock("crypto", () => ({
randomUUID: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://webapp.example.com",
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
@@ -36,6 +44,7 @@ vi.mock("@formbricks/storage", () => ({
// Import mocked dependencies
const { logger } = await import("@formbricks/logger");
const { getPublicDomain } = await import("@/lib/getPublicUrl");
const {
deleteFile: deleteFileFromS3,
deleteFilesByPrefix,
@@ -54,6 +63,7 @@ describe("storage service", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(randomUUID).mockReturnValue(mockUUID);
vi.mocked(getPublicDomain).mockReturnValue("https://public.example.com");
});
describe("getSignedUrlForUpload", () => {
@@ -80,7 +90,7 @@ describe("storage service", () => {
expect(result.data).toEqual({
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
fileUrl: `/storage/env-123/public/test-image--fid--${mockUUID}.jpg`,
fileUrl: `https://public.example.com/storage/env-123/public/test-image--fid--${mockUUID}.jpg`,
});
}
@@ -92,7 +102,7 @@ describe("storage service", () => {
);
});
test("should return relative URL for private files", async () => {
test("should use WEBAPP_URL for private files", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
@@ -112,7 +122,9 @@ describe("storage service", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.fileUrl).toBe(`/storage/env-123/private/test-doc--fid--${mockUUID}.pdf`);
expect(result.data.fileUrl).toBe(
`https://webapp.example.com/storage/env-123/private/test-doc--fid--${mockUUID}.pdf`
);
}
});
@@ -137,7 +149,9 @@ describe("storage service", () => {
expect(result.ok).toBe(true);
if (result.ok) {
// The filename should be URL-encoded to prevent # from being treated as a URL fragment
expect(result.data.fileUrl).toBe(`/storage/env-123/public/testfile--fid--${mockUUID}.txt`);
expect(result.data.fileUrl).toBe(
`https://public.example.com/storage/env-123/public/testfile--fid--${mockUUID}.txt`
);
}
expect(getSignedUploadUrl).toHaveBeenCalledWith(

View File

@@ -1,17 +1,17 @@
import { randomUUID } from "crypto";
import { logger } from "@formbricks/logger";
import {
type FileStreamResult,
type StorageError,
StorageErrorCode,
deleteFile as deleteFileFromS3,
deleteFilesByPrefix,
getFileStream,
getSignedDownloadUrl,
getSignedUploadUrl,
} from "@formbricks/storage";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TAccessType } from "@formbricks/types/storage";
import { WEBAPP_URL } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { sanitizeFileName } from "./utils";
export const getSignedUrlForUpload = async (
@@ -51,11 +51,15 @@ export const getSignedUrlForUpload = async (
return signedUrlResult;
}
// Return relative path - can be resolved to absolute URL at runtime when needed
// Use PUBLIC_URL for public files, WEBAPP_URL for private files
const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL;
return ok({
signedUrl: signedUrlResult.data.signedUrl,
presignedFields: signedUrlResult.data.presignedFields,
fileUrl: `/storage/${environmentId}/${accessType}/${encodeURIComponent(updatedFileName)}`,
fileUrl: new URL(
`${baseUrl}/storage/${environmentId}/${accessType}/${encodeURIComponent(updatedFileName)}`
).href,
});
} catch (error) {
logger.error({ error }, "Error getting signed url for upload");
@@ -91,35 +95,6 @@ export const getSignedUrlForDownload = async (
}
};
/**
* Get a file stream for downloading/streaming files directly
* Use this instead of signed URL redirect for Next.js Image component compatibility
*/
export const getFileStreamForDownload = async (
fileName: string,
environmentId: string,
accessType: TAccessType
): Promise<Result<FileStreamResult, StorageError>> => {
try {
const fileNameDecoded = decodeURIComponent(fileName);
const fileKey = `${environmentId}/${accessType}/${fileNameDecoded}`;
const streamResult = await getFileStream(fileKey);
if (!streamResult.ok) {
return streamResult;
}
return streamResult;
} catch (error) {
logger.error({ error }, "Error getting file stream for download");
return err({
code: StorageErrorCode.Unknown,
});
}
};
// We don't need to return or throw any errors, even if the file doesn't exist, we should not fail the request, nor log any errors, those will be handled by the deleteFile function
export const deleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) =>
await deleteFileFromS3(`${environmentId}/${accessType}/${fileName}`);

View File

@@ -1,30 +0,0 @@
/**
* Client-safe URL helper utilities for storage files.
* These functions can be used in both server and client components.
*/
/**
* Extracts the original file name from a storage URL.
* Handles both relative paths (/storage/...) and absolute URLs.
* @param fileURL The storage URL to parse
* @returns The original file name, or empty string if parsing fails
*/
export const getOriginalFileNameFromUrl = (fileURL: string): string => {
try {
const lastSegment = fileURL.startsWith("/storage/")
? (fileURL.split("/").pop() ?? "")
: (new URL(fileURL).pathname.split("/").pop() ?? "");
const fileNameFromURL = lastSegment.split(/[?#]/)[0];
const [namePart, fidPart] = fileNameFromURL.split("--fid--");
if (!fidPart) return namePart ? decodeURIComponent(namePart) : "";
const dotIdx = fileNameFromURL.lastIndexOf(".");
const hasExt = dotIdx > fileNameFromURL.indexOf("--fid--");
const ext = hasExt ? fileNameFromURL.slice(dotIdx + 1) : "";
return decodeURIComponent(ext ? `${namePart}.${ext}` : namePart);
} catch {
return "";
}
};

View File

@@ -7,7 +7,6 @@ import {
isAllowedFileExtension,
isValidFileTypeForExtension,
isValidImageFile,
resolveStorageUrl,
sanitizeFileName,
validateFileUploads,
validateSingleFile,
@@ -397,38 +396,4 @@ describe("storage utils", () => {
expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
});
});
describe("resolveStorageUrl", () => {
test("should return empty string for null or undefined input", () => {
expect(resolveStorageUrl(null)).toBe("");
expect(resolveStorageUrl(undefined)).toBe("");
expect(resolveStorageUrl("")).toBe("");
});
test("should return absolute URL unchanged (backward compatibility)", () => {
const httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
const httpUrl = "http://example.com/storage/env-123/public/image.jpg";
expect(resolveStorageUrl(httpsUrl)).toBe(httpsUrl);
expect(resolveStorageUrl(httpUrl)).toBe(httpUrl);
});
test("should resolve relative /storage/ path to absolute URL", async () => {
// Use actual implementation with mocked dependencies
const { resolveStorageUrl: actualResolveStorageUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const relativePath = "/storage/env-123/public/image.jpg";
const result = actualResolveStorageUrl(relativePath);
// Should prepend the base URL (from mocked WEBAPP_URL or getPublicDomain)
expect(result).toContain("/storage/env-123/public/image.jpg");
expect(result.startsWith("http")).toBe(true);
});
test("should return non-storage paths unchanged", () => {
expect(resolveStorageUrl("/some/other/path")).toBe("/some/other/path");
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
});
});
});

View File

@@ -1,15 +1,30 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { StorageError, StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/storage";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { WEBAPP_URL } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getOriginalFileNameFromUrl } from "./url-helpers";
// Re-export for backward compatibility with server-side code
export { getOriginalFileNameFromUrl };
export const getOriginalFileNameFromUrl = (fileURL: string) => {
try {
const lastSegment = fileURL.startsWith("/storage/")
? fileURL
: (new URL(fileURL).pathname.split("/").pop() ?? "");
const fileNameFromURL = lastSegment.split(/[?#]/)[0];
const [namePart, fidPart] = fileNameFromURL.split("--fid--");
if (!fidPart) return namePart ? decodeURIComponent(namePart) : "";
const dotIdx = fileNameFromURL.lastIndexOf(".");
const hasExt = dotIdx > fileNameFromURL.indexOf("--fid--");
const ext = hasExt ? fileNameFromURL.slice(dotIdx + 1) : "";
return decodeURIComponent(ext ? `${namePart}.${ext}` : namePart);
} catch (error) {
logger.error({ error, fileURL }, "Error parsing file URL");
return "";
}
};
/**
* Sanitize a provided file name to a safe subset.
@@ -148,31 +163,3 @@ export const getErrorResponseFromStorageError = (
}
}
};
/**
* Resolves a storage URL to an absolute URL.
* - If already absolute, returns as-is (backward compatibility for old data)
* - If relative (/storage/...), prepends the appropriate base URL
* @param url The storage URL (relative or absolute)
* @param accessType The access type to determine which base URL to use (defaults to "public")
* @returns The resolved absolute URL, or empty string if url is falsy
*/
export const resolveStorageUrl = (
url: string | undefined | null,
accessType: "public" | "private" = "public"
): string => {
if (!url) return "";
// Already absolute URL - return as-is (backward compatibility for old data)
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
// Relative path - resolve with base URL
if (url.startsWith("/storage/")) {
const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL;
return `${baseUrl}${url}`;
}
return url;
};

View File

@@ -10,11 +10,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import {
TMultipleChoiceOptionDisplayType,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
} from "@formbricks/types/surveys/elements";
import { TMultipleChoiceOptionDisplayType, TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";

View File

@@ -89,7 +89,7 @@ export const SurveyPlacementCard = ({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<div className="flex items-center pr-5 pl-2">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -2,7 +2,7 @@
import { DownloadIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
interface FileUploadResponseProps {
selected: string[];

View File

@@ -187,7 +187,7 @@ export const ThemeStylingPreviewSurvey = ({
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
isEditorView>
{!project.styling?.isLogoHidden && (
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<button className="absolute top-5 left-5" onClick={scrollToEditLogoSection}>
<ClientLogo projectLogo={project.logo} previewSurvey />
</button>
)}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -9,6 +9,11 @@ jiti("./lib/env");
/** @type {import('next').NextConfig} */
const getHostname = (url) => {
const urlObj = new URL(url);
return urlObj.hostname;
};
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
basePath: process.env.BASE_PATH || undefined,

View File

@@ -447,9 +447,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -474,9 +474,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -494,9 +494,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -518,9 +518,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -542,9 +542,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -562,9 +562,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -582,9 +582,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -616,9 +616,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -658,9 +658,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
@@ -688,9 +688,9 @@ test.describe("Multi Language Survey Create", async () => {
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.getByRole("textbox", { name: '“Back” Button Label', exact: true })
.first()
.fill(surveys.germanCreate.back);
await page

View File

@@ -220,7 +220,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: "Add “Other”", exact: true }).click();
await page.getByRole("button", { name: 'Add “Other”', exact: true }).click();
// Multi Select Question
await page
@@ -440,7 +440,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: "Add “Other”", exact: true }).click();
await page.getByRole("button", { name: 'Add “Other”', exact: true }).click();
// Multi Select Question
await page

View File

@@ -1,351 +0,0 @@
/**
* Data Migration: Convert absolute storage URLs to relative paths
*
* This migration converts URLs like:
* http://localhost:3000/storage/env123/public/image.png
* https://app.formbricks.com/storage/env123/public/image.png
*
* To relative paths:
* /storage/env123/public/image.png
*
* This is needed because:
* 1. Next.js 16+ blocks image optimization for private IPs
* 2. Relative paths work with the new streaming endpoint
* 3. Self-hosted users can change their domain without breaking images
*
* Tables affected:
* - Survey: welcomeCard, questions, blocks, endings, styling, metadata
* - Project: styling, logo
* - Organization: whitelabel
* - Response: data (file upload responses)
*/
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
import type {
MigrationStats,
OrganizationRecord,
ProjectRecord,
ResponseRecord,
SurveyRecord,
} from "./types";
import {
containsAbsoluteStorageUrl,
getUrlConversionCount,
resetUrlConversionCount,
transformJsonUrls,
} from "./utils";
const BATCH_SIZE = 500;
export const migrateStorageUrlsToRelative: MigrationScript = {
type: "data",
id: "cm6xq8k2n0001l508storage01",
name: "20260204174943_migrate_storage_urls_to_relative",
run: async ({ tx }) => {
const stats: MigrationStats = {
surveysProcessed: 0,
surveysUpdated: 0,
projectsProcessed: 0,
projectsUpdated: 0,
organizationsProcessed: 0,
organizationsUpdated: 0,
responsesProcessed: 0,
responsesUpdated: 0,
urlsConverted: 0,
errors: 0,
};
resetUrlConversionCount();
// ==================== MIGRATE SURVEYS ====================
logger.info("Starting Survey migration...");
const surveyQuery = Prisma.sql`
SELECT id, "welcomeCard", questions, blocks, endings, styling, metadata
FROM "Survey"
WHERE "welcomeCard"::text LIKE '%/storage/%'
OR questions::text LIKE '%/storage/%'
OR blocks::text LIKE '%/storage/%'
OR endings::text LIKE '%/storage/%'
OR styling::text LIKE '%/storage/%'
OR metadata::text LIKE '%/storage/%'
`;
const surveysToMigrate: SurveyRecord[] = await tx.$queryRaw(surveyQuery);
logger.info(`Found ${surveysToMigrate.length} surveys with storage URLs`);
const surveyUpdates: { id: string; data: Partial<SurveyRecord> }[] = [];
for (const survey of surveysToMigrate) {
stats.surveysProcessed++;
const updates: Partial<SurveyRecord> = {};
let hasChanges = false;
// Transform each JSON column if it contains absolute storage URLs
if (containsAbsoluteStorageUrl(survey.welcomeCard)) {
updates.welcomeCard = transformJsonUrls(JSON.parse(JSON.stringify(survey.welcomeCard)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.questions)) {
updates.questions = transformJsonUrls(JSON.parse(JSON.stringify(survey.questions)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.blocks)) {
updates.blocks = transformJsonUrls(JSON.parse(JSON.stringify(survey.blocks))) as unknown[];
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.endings)) {
updates.endings = transformJsonUrls(JSON.parse(JSON.stringify(survey.endings))) as unknown[];
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.styling)) {
updates.styling = transformJsonUrls(JSON.parse(JSON.stringify(survey.styling)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(survey.metadata)) {
updates.metadata = transformJsonUrls(JSON.parse(JSON.stringify(survey.metadata)));
hasChanges = true;
}
if (hasChanges) {
surveyUpdates.push({ id: survey.id, data: updates });
stats.surveysUpdated++;
}
}
// Batch update surveys
for (let i = 0; i < surveyUpdates.length; i += BATCH_SIZE) {
const batch = surveyUpdates.slice(i, i + BATCH_SIZE);
for (const update of batch) {
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (update.data.welcomeCard !== undefined) {
setClauses.push(`"welcomeCard" = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.welcomeCard));
paramIndex++;
}
if (update.data.questions !== undefined) {
setClauses.push(`questions = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.questions));
paramIndex++;
}
if (update.data.blocks !== undefined) {
setClauses.push(
`blocks = (SELECT array_agg(elem) FROM jsonb_array_elements($${paramIndex}::jsonb) AS elem)`
);
values.push(JSON.stringify(update.data.blocks));
paramIndex++;
}
if (update.data.endings !== undefined) {
setClauses.push(
`endings = (SELECT array_agg(elem) FROM jsonb_array_elements($${paramIndex}::jsonb) AS elem)`
);
values.push(JSON.stringify(update.data.endings));
paramIndex++;
}
if (update.data.styling !== undefined) {
setClauses.push(`styling = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.styling));
paramIndex++;
}
if (update.data.metadata !== undefined) {
setClauses.push(`metadata = $${paramIndex}::jsonb`);
values.push(JSON.stringify(update.data.metadata));
paramIndex++;
}
values.push(update.id);
if (setClauses.length > 0) {
await tx.$executeRawUnsafe(
`UPDATE "Survey" SET ${setClauses.join(", ")}, updated_at = NOW() WHERE id = $${paramIndex}`,
...values
);
}
}
logger.info(
`Survey progress: ${Math.min(i + BATCH_SIZE, surveyUpdates.length)}/${surveyUpdates.length}`
);
}
logger.info(`Surveys migration complete: ${stats.surveysUpdated}/${stats.surveysProcessed} updated`);
// ==================== MIGRATE PROJECTS ====================
logger.info("Starting Project migration...");
const projectQuery = Prisma.sql`
SELECT id, styling, logo
FROM "Project"
WHERE styling::text LIKE '%/storage/%'
OR logo::text LIKE '%/storage/%'
`;
const projectsToMigrate: ProjectRecord[] = await tx.$queryRaw(projectQuery);
logger.info(`Found ${projectsToMigrate.length} projects with storage URLs`);
for (const project of projectsToMigrate) {
stats.projectsProcessed++;
const updates: Partial<ProjectRecord> = {};
let hasChanges = false;
if (containsAbsoluteStorageUrl(project.styling)) {
updates.styling = transformJsonUrls(JSON.parse(JSON.stringify(project.styling)));
hasChanges = true;
}
if (containsAbsoluteStorageUrl(project.logo)) {
updates.logo = transformJsonUrls(JSON.parse(JSON.stringify(project.logo)));
hasChanges = true;
}
if (hasChanges) {
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (updates.styling !== undefined) {
setClauses.push(`styling = $${paramIndex}::jsonb`);
values.push(JSON.stringify(updates.styling));
paramIndex++;
}
if (updates.logo !== undefined) {
setClauses.push(`logo = $${paramIndex}::jsonb`);
values.push(JSON.stringify(updates.logo));
paramIndex++;
}
values.push(project.id);
await tx.$executeRawUnsafe(
`UPDATE "Project" SET ${setClauses.join(", ")}, updated_at = NOW() WHERE id = $${paramIndex}`,
...values
);
stats.projectsUpdated++;
}
}
logger.info(`Projects migration complete: ${stats.projectsUpdated}/${stats.projectsProcessed} updated`);
// ==================== MIGRATE ORGANIZATIONS ====================
logger.info("Starting Organization migration...");
const orgQuery = Prisma.sql`
SELECT id, whitelabel
FROM "Organization"
WHERE whitelabel::text LIKE '%/storage/%'
`;
const orgsToMigrate: OrganizationRecord[] = await tx.$queryRaw(orgQuery);
logger.info(`Found ${orgsToMigrate.length} organizations with storage URLs`);
for (const org of orgsToMigrate) {
stats.organizationsProcessed++;
if (containsAbsoluteStorageUrl(org.whitelabel)) {
const updatedWhitelabel = transformJsonUrls(JSON.parse(JSON.stringify(org.whitelabel)));
await tx.$executeRawUnsafe(
`UPDATE "Organization" SET whitelabel = $1::jsonb, updated_at = NOW() WHERE id = $2`,
JSON.stringify(updatedWhitelabel),
org.id
);
stats.organizationsUpdated++;
}
}
logger.info(
`Organizations migration complete: ${stats.organizationsUpdated}/${stats.organizationsProcessed} updated`
);
// ==================== MIGRATE RESPONSES ====================
logger.info("Starting Response migration...");
// Responses can be numerous, so we process in batches using cursor pagination
let lastId: string | null = null;
let hasMore = true;
while (hasMore) {
const responseQuery = lastId
? Prisma.sql`
SELECT id, data
FROM "Response"
WHERE data::text LIKE '%/storage/%'
AND id > ${lastId}
ORDER BY id
LIMIT ${BATCH_SIZE}
`
: Prisma.sql`
SELECT id, data
FROM "Response"
WHERE data::text LIKE '%/storage/%'
ORDER BY id
LIMIT ${BATCH_SIZE}
`;
const responseBatch: ResponseRecord[] = await tx.$queryRaw(responseQuery);
if (responseBatch.length === 0) {
hasMore = false;
break;
}
for (const response of responseBatch) {
stats.responsesProcessed++;
if (containsAbsoluteStorageUrl(response.data)) {
const updatedData = transformJsonUrls(JSON.parse(JSON.stringify(response.data)));
await tx.$executeRawUnsafe(
`UPDATE "Response" SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
JSON.stringify(updatedData),
response.id
);
stats.responsesUpdated++;
}
lastId = response.id;
}
logger.info(
`Response progress: ${stats.responsesProcessed} processed, ${stats.responsesUpdated} updated`
);
if (responseBatch.length < BATCH_SIZE) {
hasMore = false;
}
}
logger.info(
`Responses migration complete: ${stats.responsesUpdated}/${stats.responsesProcessed} updated`
);
// ==================== FINAL STATS ====================
stats.urlsConverted = getUrlConversionCount();
logger.info("=== Migration Complete ===");
logger.info(`Surveys: ${stats.surveysUpdated}/${stats.surveysProcessed} updated`);
logger.info(`Projects: ${stats.projectsUpdated}/${stats.projectsProcessed} updated`);
logger.info(`Organizations: ${stats.organizationsUpdated}/${stats.organizationsProcessed} updated`);
logger.info(`Responses: ${stats.responsesUpdated}/${stats.responsesProcessed} updated`);
logger.info(`Total URLs converted: ${stats.urlsConverted}`);
if (stats.errors > 0) {
logger.warn(`Errors encountered: ${stats.errors}`);
}
},
};

View File

@@ -1,42 +0,0 @@
/**
* Types for the storage URL migration
*/
export interface SurveyRecord {
id: string;
welcomeCard: unknown;
questions: unknown;
blocks: unknown[];
endings: unknown[];
styling: unknown;
metadata: unknown;
}
export interface ProjectRecord {
id: string;
styling: unknown;
logo: unknown;
}
export interface OrganizationRecord {
id: string;
whitelabel: unknown;
}
export interface ResponseRecord {
id: string;
data: unknown;
}
export interface MigrationStats {
surveysProcessed: number;
surveysUpdated: number;
projectsProcessed: number;
projectsUpdated: number;
organizationsProcessed: number;
organizationsUpdated: number;
responsesProcessed: number;
responsesUpdated: number;
urlsConverted: number;
errors: number;
}

View File

@@ -1,98 +0,0 @@
/**
* Utility functions for converting absolute storage URLs to relative paths
*/
// Regex to match absolute storage URLs: http(s)://anything/storage/...
const ABSOLUTE_STORAGE_URL_REGEX = /^https?:\/\/[^/]+\/storage\//;
/**
* Convert an absolute storage URL to a relative path
* @param url The URL to convert
* @returns The relative path if it's an absolute storage URL, otherwise the original value
*/
export function convertStorageUrlToRelative(url: string): string {
if (!url || typeof url !== "string") {
return url;
}
// Check if it's an absolute storage URL
if (ABSOLUTE_STORAGE_URL_REGEX.test(url)) {
const storageIndex = url.indexOf("/storage/");
if (storageIndex !== -1) {
return url.substring(storageIndex); // Returns /storage/...
}
}
return url; // Return unchanged if not an absolute storage URL
}
/**
* Track statistics for URL conversions
*/
let urlConversionCount = 0;
export function resetUrlConversionCount(): void {
urlConversionCount = 0;
}
export function getUrlConversionCount(): number {
return urlConversionCount;
}
/**
* Recursively transform all string values in an object, converting absolute storage URLs to relative paths
* @param obj The object to transform
* @returns The transformed object (mutated in place for arrays/objects)
*/
export function transformJsonUrls(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === "string") {
const converted = convertStorageUrlToRelative(obj);
if (converted !== obj) {
urlConversionCount++;
}
return converted;
}
if (Array.isArray(obj)) {
return obj.map((item) => transformJsonUrls(item));
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = transformJsonUrls(value);
}
return result;
}
return obj;
}
/**
* Check if an object contains any absolute storage URLs
* @param obj The object to check
* @returns true if the object contains absolute storage URLs
*/
export function containsAbsoluteStorageUrl(obj: unknown): boolean {
if (obj === null || obj === undefined) {
return false;
}
if (typeof obj === "string") {
return ABSOLUTE_STORAGE_URL_REGEX.test(obj);
}
if (Array.isArray(obj)) {
return obj.some((item) => containsAbsoluteStorageUrl(item));
}
if (typeof obj === "object") {
return Object.values(obj as Record<string, unknown>).some((value) => containsAbsoluteStorageUrl(value));
}
return false;
}

View File

@@ -1,9 +1,2 @@
export {
deleteFile,
getSignedDownloadUrl,
getSignedUploadUrl,
deleteFilesByPrefix,
getFileStream,
type FileStreamResult,
} from "./service";
export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl, deleteFilesByPrefix } from "./service";
export { type StorageError, StorageErrorCode } from "../types/error";

View File

@@ -141,68 +141,6 @@ export const getSignedDownloadUrl = async (fileKey: string): Promise<Result<stri
}
};
export interface FileStreamResult {
body: ReadableStream<Uint8Array>;
contentType: string;
contentLength: number;
}
/**
* Get a file stream from S3
* Use this for streaming files directly to clients instead of redirecting to signed URLs
* @param fileKey - The key of the file in S3
* @returns A Result containing the file stream and metadata or an error: StorageError
*/
export const getFileStream = async (fileKey: string): Promise<Result<FileStreamResult, StorageError>> => {
try {
const s3Client = createS3Client();
if (!s3Client) {
return err({
code: StorageErrorCode.S3ClientError,
});
}
if (!S3_BUCKET_NAME) {
return err({
code: StorageErrorCode.S3CredentialsError,
});
}
const getObjectCommand = new GetObjectCommand({
Bucket: S3_BUCKET_NAME,
Key: fileKey,
});
const response = await s3Client.send(getObjectCommand);
if (!response.Body) {
return err({
code: StorageErrorCode.FileNotFoundError,
});
}
// Convert the SDK stream to a web ReadableStream
const webStream = response.Body.transformToWebStream();
return ok({
body: webStream,
contentType: response.ContentType || "application/octet-stream",
contentLength: response.ContentLength || 0,
});
} catch (error) {
if ((error as { name?: string }).name === "NoSuchKey") {
return err({
code: StorageErrorCode.FileNotFoundError,
});
}
logger.error({ error }, "Failed to get file stream");
return err({
code: StorageErrorCode.Unknown,
});
}
};
/**
* Delete a file from S3
* @param fileKey - The key of the file in S3 (e.g. "surveys/123/responses/456/file.pdf")

View File

@@ -148,15 +148,15 @@ function DropdownVariant({
<Button
variant="outline"
disabled={disabled}
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto"
align="start">
{options
.filter((option) => option.id !== "none")

View File

@@ -160,15 +160,15 @@ function SingleSelect({
<Button
variant="outline"
disabled={disabled}
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto"
align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options
@@ -193,9 +193,7 @@ function SingleSelect({
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
<span className="font-input font-input-weight text-input-text">{otherValue || otherOptionLabel}</span>
</DropdownMenuRadioItem>
) : null}
{options
@@ -281,7 +279,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("ml-3 mr-3 grow", optionLabelClassName)}
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>

View File

@@ -29,13 +29,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
"text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</div>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Portal >
);
}
@@ -58,7 +58,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -76,12 +76,12 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer",
className
)}
checked={checked}
{...props}>
<span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -104,11 +104,11 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer",
className
)}
{...props}>
<span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -175,12 +175,12 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<ChevronRightIcon className="label-headline ml-auto size-4" />
<ChevronRightIcon className="ml-auto label-headline size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}

View File

@@ -22,7 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
style={{ fontSize: "var(--fb-input-font-size)" }}
className={cn(
// Layout and behavior
"flex min-w-0 border outline-none transition-[color,box-shadow]",
"flex min-w-0 border transition-[color,box-shadow] outline-none",
// Customizable styles via CSS variables (using Tailwind theme extensions)
"w-input h-input",
"bg-input-bg border-input-border rounded-input",

View File

@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
className={cn(
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text field-sizing-content flex min-h-16 border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -21,8 +21,6 @@
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"retrying": "Retrying…",
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses…",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
@@ -30,7 +28,9 @@
"terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately",
"your_feedback_is_stuck": "Your feedback is stuck :("
"your_feedback_is_stuck": "Your feedback is stuck :(",
"select_option": "Select an option",
"select_options": "Select options"
},
"errors": {
"all_options_must_be_ranked": "Please rank all options",
@@ -78,4 +78,4 @@
"value_must_not_contain": "Value must not contain {value}",
"value_must_not_equal": "Value must not equal {value}"
}
}
}

View File

@@ -70,4 +70,4 @@
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
}
}
}

View File

@@ -6,28 +6,6 @@ export const ZString = z.string();
export const ZUrl = z.string().url();
/**
* Schema for storage URLs that can be either:
* - Full URLs (http:// or https://)
* - Relative storage paths (/storage/...)
*/
export const ZStorageUrl = z.string().refine(
(val) => {
// Allow relative storage paths
if (val.startsWith("/storage/")) {
return true;
}
// Otherwise validate as URL
try {
new URL(val);
return true;
} catch {
return false;
}
},
{ message: "Must be a valid URL or a relative storage path (/storage/...)" }
);
export const ZNumber = z.number();
export const ZOptionalNumber = z.number().optional();

View File

@@ -1,5 +1,4 @@
import { z } from "zod";
import { ZStorageUrl } from "./common";
import { ZUserLocale } from "./user";
export const ZLinkSurveyEmailData = z.object({
@@ -8,7 +7,7 @@ export const ZLinkSurveyEmailData = z.object({
suId: z.string().optional(),
surveyName: z.string(),
locale: ZUserLocale,
logoUrl: ZStorageUrl.optional(),
logoUrl: z.string().optional(),
});
export type TLinkSurveyEmailData = z.infer<typeof ZLinkSurveyEmailData>;

View File

@@ -1,5 +1,4 @@
import { z } from "zod";
import { ZStorageUrl } from "./common";
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "custom"]);
export type TOrganizationBillingPlan = z.infer<typeof ZOrganizationBillingPlan>;
@@ -35,8 +34,8 @@ export const ZOrganizationBilling = z.object({
export type TOrganizationBilling = z.infer<typeof ZOrganizationBilling>;
export const ZOrganizationWhitelabel = z.object({
logoUrl: ZStorageUrl.nullable(),
faviconUrl: ZStorageUrl.nullish(),
logoUrl: z.string().nullable(),
faviconUrl: z.string().url().nullish(),
});
export type TOrganizationWhitelabel = z.infer<typeof ZOrganizationWhitelabel>;

View File

@@ -1,5 +1,4 @@
import { z } from "zod";
import { ZStorageUrl } from "./common";
// Single source of truth for allowed file extensions
const ALLOWED_FILE_EXTENSIONS_TUPLE = [
@@ -126,7 +125,7 @@ export type TUploadPrivateFileRequest = z.infer<typeof ZUploadPrivateFileRequest
export const ZUploadFileResponse = z.object({
data: z.object({
signedUrl: z.string(),
fileUrl: ZStorageUrl,
fileUrl: z.string(),
signingData: z
.object({
signature: z.string(),

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZColor, ZStorageUrl } from "./common";
import { ZColor } from "./common";
export const ZStylingColor = z.object({
light: ZColor,
@@ -16,7 +16,7 @@ export const ZCardArrangement = z.object({
});
export const ZLogo = z.object({
url: ZStorageUrl.optional(),
url: z.string().optional(),
bgColor: z.string().optional(),
});
export type TLogo = z.infer<typeof ZLogo>;

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZStorageUrl, ZUrl } from "../common";
import { ZUrl } from "../common";
import { ZI18nString } from "../i18n";
import { ZAllowedFileExtension } from "../storage";
import { TSurveyElementTypeEnum } from "./constants";
@@ -61,8 +61,8 @@ export const ZSurveyElementBase = z.object({
type: z.nativeEnum(TSurveyElementTypeEnum),
headline: ZI18nString,
subheader: ZI18nString.optional(),
imageUrl: ZStorageUrl.optional(),
videoUrl: ZStorageUrl.optional(),
imageUrl: ZUrl.optional(),
videoUrl: ZUrl.optional(),
required: z.boolean(),
scale: z.enum(["number", "smiley", "star"]).optional(),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
@@ -237,7 +237,7 @@ export type TSurveyRatingElement = z.infer<typeof ZSurveyRatingElement>;
// Picture Selection Element
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: ZStorageUrl,
imageUrl: z.string(),
});
export type TSurveyPictureChoice = z.infer<typeof ZSurveyPictureChoice>;

View File

@@ -1,7 +1,7 @@
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZColor, ZEndingCardUrl, ZId, ZOverlay, ZPlacement, ZStorageUrl, getZSafeUrl } from "../common";
import { ZColor, ZEndingCardUrl, ZId, ZOverlay, ZPlacement, ZUrl, getZSafeUrl } from "../common";
import { ZContactAttributes } from "../contact-attribute";
import { type TI18nString, ZI18nString } from "../i18n";
import { ZLanguage } from "../project";
@@ -60,8 +60,8 @@ export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({
subheader: ZI18nString.optional(),
buttonLabel: ZI18nString.optional(),
buttonLink: ZEndingCardUrl.optional(),
imageUrl: ZStorageUrl.optional(),
videoUrl: ZStorageUrl.optional(),
imageUrl: ZUrl.optional(),
videoUrl: ZUrl.optional(),
});
export type TSurveyEndScreenCard = z.infer<typeof ZSurveyEndScreenCard>;
@@ -142,11 +142,11 @@ export const ZSurveyWelcomeCard = z
enabled: z.boolean(),
headline: ZI18nString.optional(),
subheader: ZI18nString.optional(),
fileUrl: ZStorageUrl.optional(),
fileUrl: ZUrl.optional(),
buttonLabel: ZI18nString.optional(),
timeToFinish: z.boolean().default(true),
showResponseCount: z.boolean().default(false),
videoUrl: ZStorageUrl.optional(),
videoUrl: ZUrl.optional(),
})
.refine((schema) => !(schema.enabled && !schema.headline), {
message: "Welcome card must have a headline",
@@ -277,7 +277,7 @@ export type TSurveyRecaptcha = z.infer<typeof ZSurveyRecaptcha>;
export const ZSurveyMetadata = z.object({
title: ZI18nString.optional(),
description: ZI18nString.optional(),
ogImage: ZStorageUrl.optional(),
ogImage: z.string().url().optional(),
});
export type TSurveyMetadata = z.infer<typeof ZSurveyMetadata>;
@@ -289,7 +289,7 @@ export const ZSurveyQuestionChoice = z.object({
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: ZStorageUrl,
imageUrl: z.string(),
});
export type TSurveyPictureChoice = z.infer<typeof ZSurveyPictureChoice>;
@@ -405,8 +405,8 @@ export const ZSurveyQuestionBase = z.object({
type: z.string(),
headline: ZI18nString,
subheader: ZI18nString.optional(),
imageUrl: ZStorageUrl.optional(),
videoUrl: ZStorageUrl.optional(),
imageUrl: z.string().optional(),
videoUrl: z.string().optional(),
required: z.boolean(),
buttonLabel: ZI18nString.optional(),
backButtonLabel: ZI18nString.optional(),
@@ -3932,7 +3932,7 @@ export const ZSurveyElementSummaryPictureSelection = z.object({
choices: z.array(
z.object({
id: z.string(),
imageUrl: ZStorageUrl,
imageUrl: z.string(),
count: z.number(),
percentage: z.number(),
})