mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-08 18:59:05 -06:00
Compare commits
3 Commits
fix/allow-
...
fix/7165-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
250195c04d | ||
|
|
a83893f598 | ||
|
|
186eb7d051 |
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
@@ -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)) {
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,4 +70,4 @@
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user