mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 18:18:48 -06:00
Compare commits
15 Commits
feat/crud-
...
patch-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0f6a0be1 | ||
|
|
4fadc54b4e | ||
|
|
f4ac9a8292 | ||
|
|
7c8a7606b7 | ||
|
|
225217330b | ||
|
|
589c04a530 | ||
|
|
aa538a3a51 | ||
|
|
f8d5f8df14 | ||
|
|
817e108ff5 | ||
|
|
0922b3dfc4 | ||
|
|
9c799e0942 | ||
|
|
ddd494c612 | ||
|
|
a486e8450a | ||
|
|
6e61eb0d4a | ||
|
|
9a18a76735 |
@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { truncateText } from "@/lib/utils/strings";
|
import { truncateText } from "@/lib/utils/strings";
|
||||||
|
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
|
||||||
|
|
||||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||||
let result: string[] = [];
|
let result: string[] = [];
|
||||||
@@ -256,10 +257,16 @@ const processElementResponse = (
|
|||||||
const selectedChoiceIds = responseValue as string[];
|
const selectedChoiceIds = responseValue as string[];
|
||||||
return element.choices
|
return element.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => choice.imageUrl)
|
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
|
||||||
|
return responseValue
|
||||||
|
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
return processResponseData(responseValue);
|
return processResponseData(responseValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
|
|||||||
|
|
||||||
responses[resp] = (pictureElement as any)?.choices
|
responses[resp] = (pictureElement as any)?.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => choice.imageUrl);
|
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
|
|||||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||||
@@ -95,12 +96,15 @@ export const POST = async (request: Request) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||||
|
|
||||||
const webhookPromises = webhooks.map((webhook) => {
|
const webhookPromises = webhooks.map((webhook) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
webhookId: webhook.id,
|
webhookId: webhook.id,
|
||||||
event,
|
event,
|
||||||
data: {
|
data: {
|
||||||
...response,
|
...response,
|
||||||
|
data: resolvedResponseData,
|
||||||
survey: {
|
survey: {
|
||||||
title: survey.name,
|
title: survey.name,
|
||||||
type: survey.type,
|
type: survey.type,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TJsEnvironmentStateSurvey,
|
TJsEnvironmentStateSurvey,
|
||||||
} from "@formbricks/types/js";
|
} from "@formbricks/types/js";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
|||||||
overlay: environmentData.project.overlay,
|
overlay: environmentData.project.overlay,
|
||||||
placement: environmentData.project.placement,
|
placement: environmentData.project.placement,
|
||||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||||
styling: environmentData.project.styling,
|
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
organization: {
|
organization: {
|
||||||
id: environmentData.project.organization.id,
|
id: environmentData.project.organization.id,
|
||||||
billing: environmentData.project.organization.billing,
|
billing: environmentData.project.organization.billing,
|
||||||
},
|
},
|
||||||
surveys: transformedSurveys,
|
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -44,13 +44,10 @@ const validateResponse = (
|
|||||||
...responseUpdateInput.data,
|
...responseUpdateInput.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFinished = responseUpdateInput.finished ?? false;
|
|
||||||
|
|
||||||
const validationErrors = validateResponseData(
|
const validationErrors = validateResponseData(
|
||||||
survey.blocks,
|
survey.blocks,
|
||||||
mergedData,
|
mergedData,
|
||||||
responseUpdateInput.language ?? response.language ?? "en",
|
responseUpdateInput.language ?? response.language ?? "en",
|
||||||
isFinished,
|
|
||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
|||||||
survey.blocks,
|
survey.blocks,
|
||||||
responseInputData.data,
|
responseInputData.data,
|
||||||
responseInputData.language ?? "en",
|
responseInputData.language ?? "en",
|
||||||
responseInputData.finished,
|
|
||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|
||||||
async function fetchAndAuthorizeResponse(
|
async function fetchAndAuthorizeResponse(
|
||||||
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.response),
|
response: responses.successResponse({
|
||||||
|
...result.response,
|
||||||
|
data: resolveStorageUrlsInObject(result.response.data),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
|
|||||||
result.survey.blocks,
|
result.survey.blocks,
|
||||||
responseUpdate.data,
|
responseUpdate.data,
|
||||||
responseUpdate.language ?? "en",
|
responseUpdate.language ?? "en",
|
||||||
responseUpdate.finished,
|
|
||||||
result.survey.questions
|
result.survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updated),
|
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import {
|
import {
|
||||||
createResponseWithQuotaEvaluation,
|
createResponseWithQuotaEvaluation,
|
||||||
getResponses,
|
getResponses,
|
||||||
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
|
|||||||
allResponses.push(...environmentResponses);
|
allResponses.push(...environmentResponses);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(allResponses),
|
response: responses.successResponse(
|
||||||
|
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
surveyResult.survey.blocks,
|
surveyResult.survey.blocks,
|
||||||
responseInput.data,
|
responseInput.data,
|
||||||
responseInput.language ?? "en",
|
responseInput.language ?? "en",
|
||||||
responseInput.finished,
|
|
||||||
surveyResult.survey.questions
|
surveyResult.survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
|
|
||||||
const fetchAndAuthorizeSurvey = async (
|
const fetchAndAuthorizeSurvey = async (
|
||||||
surveyId: string,
|
surveyId: string,
|
||||||
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
|
|||||||
|
|
||||||
if (shouldTransformToQuestions) {
|
if (shouldTransformToQuestions) {
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse({
|
response: responses.successResponse(
|
||||||
...result.survey,
|
resolveStorageUrlsInObject({
|
||||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
...result.survey,
|
||||||
blocks: [],
|
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||||
}),
|
blocks: [],
|
||||||
|
})
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.survey),
|
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveyWithQuestions),
|
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updatedSurvey),
|
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { createSurvey } from "@/lib/survey/service";
|
import { createSurvey } from "@/lib/survey/service";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { getSurveys } from "./lib/surveys";
|
import { getSurveys } from "./lib/surveys";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveysWithQuestions),
|
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
survey.blocks,
|
survey.blocks,
|
||||||
responseInputData.data,
|
responseInputData.data,
|
||||||
responseInputData.language ?? "en",
|
responseInputData.language ?? "en",
|
||||||
responseInputData.finished,
|
|
||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||||
|
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/c")).toBe(false);
|
expect(isPublicDomainRoute("/c")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should return true for pretty URL survey routes", () => {
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return false for malformed pretty URL survey routes", () => {
|
||||||
|
expect(isPublicDomainRoute("/p/")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/p")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("should return true for client API routes", () => {
|
test("should return true for client API routes", () => {
|
||||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||||
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
||||||
|
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
|
||||||
|
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should allow public routes on public domain", () => {
|
test("should allow public routes on public domain", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||||
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with query parameters and fragments", () => {
|
test("should handle paths with query parameters and fragments", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
|
||||||
|
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
|
|||||||
describe("URL parsing edge cases", () => {
|
describe("URL parsing edge cases", () => {
|
||||||
test("should handle paths with query parameters", () => {
|
test("should handle paths with query parameters", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||||
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with fragments", () => {
|
test("should handle paths with fragments", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle trailing slashes", () => {
|
test("should handle trailing slashes", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle nested client API routes", () => {
|
test("should handle nested client API routes", () => {
|
||||||
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle special characters in survey IDs", () => {
|
test("should handle special characters in survey IDs", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should properly validate malicious or injection-like URLs", () => {
|
test("should properly validate malicious or injection-like URLs", () => {
|
||||||
// SQL injection-like attempts
|
// SQL injection-like attempts
|
||||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
||||||
|
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||||
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
|
|||||||
|
|
||||||
// Path traversal attempts
|
// Path traversal attempts
|
||||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||||
|
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||||
|
|
||||||
// XSS-like attempts
|
// XSS-like attempts
|
||||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
|
||||||
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||||
isClientSideApi: true,
|
isClientSideApi: true,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle URL encoding", () => {
|
test("should handle URL encoding", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
|
|||||||
// These should not match due to case sensitivity
|
// These should not match due to case sensitivity
|
||||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||||
isClientSideApi: false,
|
isClientSideApi: false,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
|
|||||||
SURVEY_ROUTES: [
|
SURVEY_ROUTES: [
|
||||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||||
|
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||||
],
|
],
|
||||||
|
|
||||||
// API routes accessible from public domain
|
// API routes accessible from public domain
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||||
import { deleteFile } from "@/modules/storage/service";
|
import { deleteFile } from "@/modules/storage/service";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
import { ITEMS_PER_PAGE } from "../constants";
|
import { ITEMS_PER_PAGE } from "../constants";
|
||||||
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
|
|||||||
if (survey.isVerifyEmailEnabled) {
|
if (survey.isVerifyEmailEnabled) {
|
||||||
headers.push("Verified Email");
|
headers.push("Verified Email");
|
||||||
}
|
}
|
||||||
|
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
|
||||||
const jsonData = getResponsesJson(
|
const jsonData = getResponsesJson(
|
||||||
survey,
|
survey,
|
||||||
responses,
|
resolvedResponses,
|
||||||
elements,
|
elements,
|
||||||
userAttributes,
|
userAttributes,
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
|
|||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData([], mockResponseData, "en", true, mockQuestions);
|
validateResponseData([], mockResponseData, "en", mockQuestions);
|
||||||
|
|
||||||
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
||||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
||||||
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
|
|||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
|
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
|
||||||
|
|
||||||
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return null when both blocks and questions are empty", () => {
|
test("should return null when both blocks and questions are empty", () => {
|
||||||
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
|
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
|
||||||
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
|
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
|
||||||
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
|
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should use default language code", () => {
|
test("should use default language code", () => {
|
||||||
@@ -125,25 +125,58 @@ describe("validateResponseData", () => {
|
|||||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate only present fields when finished is false", () => {
|
test("should validate only fields present in responseData", () => {
|
||||||
const partialResponseData: TResponseData = { element1: "test" };
|
const partialResponseData: TResponseData = { element1: "test" };
|
||||||
const partialElements = [mockElements[0]];
|
const elementsToValidate = [mockElements[0]];
|
||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData(mockBlocks, partialResponseData, "en", false);
|
validateResponseData(mockBlocks, partialResponseData, "en");
|
||||||
|
|
||||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
|
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate all fields when finished is true", () => {
|
test("should never validate elements not in responseData", () => {
|
||||||
const partialResponseData: TResponseData = { element1: "test" };
|
const blocksWithTwoElements: TSurveyBlock[] = [
|
||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
...mockBlocks,
|
||||||
|
{
|
||||||
|
id: "block2",
|
||||||
|
name: "Block 2",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: "element2",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Q2" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: { enabled: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const allElements = [
|
||||||
|
...mockElements,
|
||||||
|
{
|
||||||
|
id: "element2",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Q2" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: { enabled: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const responseDataWithOnlyElement1: TResponseData = { element1: "test" };
|
||||||
|
mockGetElementsFromBlocks.mockReturnValue(allElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData(mockBlocks, partialResponseData, "en", true);
|
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
|
||||||
|
|
||||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
|
// Only element1 should be validated, not element2 (even though it's required)
|
||||||
|
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
|
||||||
|
[allElements[0]],
|
||||||
|
responseDataWithOnlyElement1,
|
||||||
|
"en"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates response data against survey validation rules
|
* Validates response data against survey validation rules.
|
||||||
* Handles partial responses (in-progress) by only validating present fields when finished is false
|
* Only validates elements that have data in responseData - never validates
|
||||||
|
* all survey elements regardless of completion status.
|
||||||
*
|
*
|
||||||
* @param blocks - Survey blocks containing elements with validation rules (preferred)
|
* @param blocks - Survey blocks containing elements with validation rules (preferred)
|
||||||
* @param responseData - Response data to validate (keyed by element ID)
|
* @param responseData - Response data to validate (keyed by element ID)
|
||||||
* @param languageCode - Language code for error messages (defaults to "en")
|
* @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)
|
* @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
|
* @returns Validation error map keyed by element ID, or null if validation passes
|
||||||
*/
|
*/
|
||||||
@@ -23,7 +23,6 @@ export const validateResponseData = (
|
|||||||
blocks: TSurveyBlock[] | undefined | null,
|
blocks: TSurveyBlock[] | undefined | null,
|
||||||
responseData: TResponseData,
|
responseData: TResponseData,
|
||||||
languageCode: string = "en",
|
languageCode: string = "en",
|
||||||
finished: boolean = true,
|
|
||||||
questions?: TSurveyQuestion[] | undefined | null
|
questions?: TSurveyQuestion[] | undefined | null
|
||||||
): TValidationErrorMap | null => {
|
): TValidationErrorMap | null => {
|
||||||
// Use blocks if available, otherwise transform questions to blocks
|
// Use blocks if available, otherwise transform questions to blocks
|
||||||
@@ -42,11 +41,8 @@ export const validateResponseData = (
|
|||||||
// Extract elements from blocks
|
// Extract elements from blocks
|
||||||
const allElements = getElementsFromBlocks(blocksToUse);
|
const allElements = getElementsFromBlocks(blocksToUse);
|
||||||
|
|
||||||
// If response is not finished, only validate elements that are present in the response data
|
// Always validate only elements that are present in responseData
|
||||||
// This prevents "required" errors for fields the user hasn't reached yet
|
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
|
||||||
const elementsToValidate = finished
|
|
||||||
? allElements
|
|
||||||
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
|
|
||||||
|
|
||||||
// Validate selected elements
|
// Validate selected elements
|
||||||
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
|
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||||
|
|
||||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||||
@@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
|||||||
return handleApiError(request, response.error as ApiErrorResponseV2);
|
return handleApiError(request, response.error as ApiErrorResponseV2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses.successResponse(response);
|
return responses.successResponse({
|
||||||
|
...response,
|
||||||
|
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +201,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
|||||||
questionsResponse.data.blocks,
|
questionsResponse.data.blocks,
|
||||||
body.data,
|
body.data,
|
||||||
body.language ?? "en",
|
body.language ?? "en",
|
||||||
body.finished,
|
|
||||||
questionsResponse.data.questions
|
questionsResponse.data.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
|||||||
auditLog.newObject = response.data;
|
auditLog.newObject = response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses.successResponse(response);
|
return responses.successResponse({
|
||||||
|
...response,
|
||||||
|
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
action: "updated",
|
action: "updated",
|
||||||
targetType: "response",
|
targetType: "response",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
|
|||||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||||
|
|
||||||
export const GET = async (request: NextRequest) =>
|
export const GET = async (request: NextRequest) =>
|
||||||
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
|
|||||||
|
|
||||||
environmentResponses.push(...res.data.data);
|
environmentResponses.push(...res.data.data);
|
||||||
|
|
||||||
return responses.successResponse({ data: environmentResponses });
|
return responses.successResponse({
|
||||||
|
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +136,6 @@ export const POST = async (request: Request) =>
|
|||||||
surveyQuestions.data.blocks,
|
surveyQuestions.data.blocks,
|
||||||
body.data,
|
body.data,
|
||||||
body.language ?? "en",
|
body.language ?? "en",
|
||||||
body.finished,
|
|
||||||
surveyQuestions.data.questions
|
surveyQuestions.data.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { getSegment } from "../segments";
|
|||||||
import { segmentFilterToPrismaQuery } from "./prisma-query";
|
import { segmentFilterToPrismaQuery } from "./prisma-query";
|
||||||
|
|
||||||
const mockQueryRawUnsafe = vi.fn();
|
const mockQueryRawUnsafe = vi.fn();
|
||||||
|
const mockFindFirst = vi.fn();
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
|
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
|
||||||
|
contactAttribute: {
|
||||||
|
findFirst: (...args: unknown[]) => mockFindFirst(...args),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -26,7 +30,9 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Default mock: number filter raw SQL returns one matching contact
|
// Default: backfill is complete, no un-migrated rows
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
// Fallback path mock: raw SQL returns one matching contact when un-migrated rows exist
|
||||||
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +151,16 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
OR: [{ id: { in: ["mock-contact-1"] } }],
|
OR: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "age" },
|
||||||
|
valueNumber: { gt: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -757,7 +772,12 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(subgroup.AND[0].AND[2]).toStrictEqual({
|
expect(subgroup.AND[0].AND[2]).toStrictEqual({
|
||||||
id: { in: ["mock-contact-1"] },
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "age" },
|
||||||
|
valueNumber: { gte: 18 },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Segment inclusion
|
// Segment inclusion
|
||||||
@@ -1158,10 +1178,23 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
|
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
|
||||||
const secondSubgroup = whereClause.AND?.[0];
|
const secondSubgroup = whereClause.AND?.[0];
|
||||||
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
||||||
id: { in: ["mock-contact-1"] },
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "loginCount" },
|
||||||
|
valueNumber: { gt: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "purchaseAmount" },
|
||||||
|
valueNumber: { lte: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Third subgroup (negation operators in OR clause)
|
// Third subgroup (negation operators in OR clause)
|
||||||
@@ -1196,6 +1229,104 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("number filter falls back to raw SQL when un-migrated rows exist", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ id: "unmigrated-row-1" });
|
||||||
|
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
||||||
|
|
||||||
|
const filters: TBaseFilters = [
|
||||||
|
{
|
||||||
|
id: "filter_1",
|
||||||
|
connector: null,
|
||||||
|
resource: {
|
||||||
|
id: "attr_1",
|
||||||
|
root: {
|
||||||
|
type: "attribute" as const,
|
||||||
|
contactAttributeKey: "age",
|
||||||
|
},
|
||||||
|
value: 25,
|
||||||
|
qualifier: {
|
||||||
|
operator: "greaterThan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
const filterClause = result.data.whereClause.AND?.[1] as any;
|
||||||
|
expect(filterClause.AND[0]).toEqual({
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "age" },
|
||||||
|
valueNumber: { gt: 25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: { in: ["mock-contact-1"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockFindFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
attributeKey: {
|
||||||
|
key: "age",
|
||||||
|
environmentId: mockEnvironmentId,
|
||||||
|
dataType: "number",
|
||||||
|
},
|
||||||
|
valueNumber: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockQueryRawUnsafe).toHaveBeenCalled();
|
||||||
|
const sqlCall = mockQueryRawUnsafe.mock.calls[0];
|
||||||
|
expect(sqlCall[0]).toContain('cak."environmentId" = $4');
|
||||||
|
expect(sqlCall[4]).toBe(mockEnvironmentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("number filter uses clean Prisma query when backfill is complete", async () => {
|
||||||
|
const filters: TBaseFilters = [
|
||||||
|
{
|
||||||
|
id: "filter_1",
|
||||||
|
connector: null,
|
||||||
|
resource: {
|
||||||
|
id: "attr_1",
|
||||||
|
root: {
|
||||||
|
type: "attribute" as const,
|
||||||
|
contactAttributeKey: "score",
|
||||||
|
},
|
||||||
|
value: 100,
|
||||||
|
qualifier: {
|
||||||
|
operator: "lessEqual",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
const filterClause = result.data.whereClause.AND?.[1] as any;
|
||||||
|
expect(filterClause.AND[0]).toEqual({
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "score" },
|
||||||
|
valueNumber: { lte: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockFindFirst).toHaveBeenCalled();
|
||||||
|
expect(mockQueryRawUnsafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// DATE FILTER TESTS
|
// DATE FILTER TESTS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1232,7 +1363,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: "purchaseDate" },
|
attributeKey: { key: "purchaseDate", dataType: "date" },
|
||||||
OR: [
|
OR: [
|
||||||
{ valueDate: { lt: new Date(targetDate) } },
|
{ valueDate: { lt: new Date(targetDate) } },
|
||||||
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
|
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
|
||||||
@@ -1276,7 +1407,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: "signupDate" },
|
attributeKey: { key: "signupDate", dataType: "date" },
|
||||||
OR: [
|
OR: [
|
||||||
{ valueDate: { gt: new Date(targetDate) } },
|
{ valueDate: { gt: new Date(targetDate) } },
|
||||||
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
|
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
|
||||||
@@ -1321,7 +1452,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: "lastActivityDate" },
|
attributeKey: { key: "lastActivityDate", dataType: "date" },
|
||||||
OR: [
|
OR: [
|
||||||
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
|
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
|
||||||
{
|
{
|
||||||
@@ -1638,8 +1769,15 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
mode: "insensitive",
|
mode: "insensitive",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Number filter uses raw SQL subquery (transition code) returning contact IDs
|
// Number filter uses clean Prisma filter post-backfill
|
||||||
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
|
expect(andConditions[1]).toEqual({
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "purchaseCount" },
|
||||||
|
valueNumber: { gt: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Date filter uses OR fallback with 'valueDate' and string 'value'
|
// Date filter uses OR fallback with 'valueDate' and string 'value'
|
||||||
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");
|
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
|
|||||||
return {
|
return {
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: contactAttributeKey },
|
attributeKey: { key: contactAttributeKey, dataType: "date" },
|
||||||
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
|
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -116,59 +116,102 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Prisma where clause for number attribute filters.
|
* Builds a Prisma where clause for number attribute filters.
|
||||||
* Uses a raw SQL subquery to handle both migrated rows (valueNumber populated)
|
* Uses a clean Prisma query when all rows have valueNumber populated (post-backfill).
|
||||||
* and un-migrated rows (valueNumber NULL, value contains numeric string).
|
* Falls back to a raw SQL subquery for un-migrated rows (valueNumber NULL, value contains numeric string).
|
||||||
* This is transition code for the deferred value backfill.
|
|
||||||
*
|
*
|
||||||
* TODO: After the backfill script has been run and all valueNumber columns are populated,
|
* TODO: After the backfill script has been run and all valueNumber columns are populated,
|
||||||
* revert this to the clean Prisma-only version that queries valueNumber directly.
|
* remove the un-migrated fallback path entirely.
|
||||||
*/
|
*/
|
||||||
const buildNumberAttributeFilterWhereClause = async (
|
const buildNumberAttributeFilterWhereClause = async (
|
||||||
filter: TSegmentAttributeFilter
|
filter: TSegmentAttributeFilter,
|
||||||
|
environmentId: string
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root, qualifier, value } = filter;
|
const { root, qualifier, value } = filter;
|
||||||
const { contactAttributeKey } = root;
|
const { contactAttributeKey } = root;
|
||||||
const { operator } = qualifier;
|
const { operator } = qualifier;
|
||||||
|
|
||||||
const numericValue = typeof value === "number" ? value : Number(value);
|
const numericValue = typeof value === "number" ? value : Number(value);
|
||||||
const sqlOp = SQL_OPERATORS[operator];
|
|
||||||
|
|
||||||
if (!sqlOp) {
|
let valueNumberCondition: Prisma.FloatNullableFilter;
|
||||||
return {};
|
|
||||||
|
switch (operator) {
|
||||||
|
case "greaterThan":
|
||||||
|
valueNumberCondition = { gt: numericValue };
|
||||||
|
break;
|
||||||
|
case "greaterEqual":
|
||||||
|
valueNumberCondition = { gte: numericValue };
|
||||||
|
break;
|
||||||
|
case "lessThan":
|
||||||
|
valueNumberCondition = { lt: numericValue };
|
||||||
|
break;
|
||||||
|
case "lessEqual":
|
||||||
|
valueNumberCondition = { lte: numericValue };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingContactIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
|
const migratedFilter: Prisma.ContactWhereInput = {
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: contactAttributeKey },
|
||||||
|
valueNumber: valueNumberCondition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUnmigratedRows = await prisma.contactAttribute.findFirst({
|
||||||
|
where: {
|
||||||
|
attributeKey: {
|
||||||
|
key: contactAttributeKey,
|
||||||
|
environmentId,
|
||||||
|
dataType: "number",
|
||||||
|
},
|
||||||
|
valueNumber: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasUnmigratedRows) {
|
||||||
|
return migratedFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlOp = SQL_OPERATORS[operator];
|
||||||
|
const unmigratedMatchingIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
|
||||||
`
|
`
|
||||||
SELECT DISTINCT ca."contactId"
|
SELECT DISTINCT ca."contactId"
|
||||||
FROM "ContactAttribute" ca
|
FROM "ContactAttribute" ca
|
||||||
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
|
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
|
||||||
WHERE cak.key = $1
|
WHERE cak.key = $1
|
||||||
AND (
|
AND cak."environmentId" = $4
|
||||||
(ca."valueNumber" IS NOT NULL AND ca."valueNumber" ${sqlOp} $2)
|
AND cak."dataType" = 'number'
|
||||||
OR
|
AND ca."valueNumber" IS NULL
|
||||||
(ca."valueNumber" IS NULL AND ca.value ~ $3 AND ca.value::double precision ${sqlOp} $2)
|
AND ca.value ~ $3
|
||||||
)
|
AND ca.value::double precision ${sqlOp} $2
|
||||||
`,
|
`,
|
||||||
contactAttributeKey,
|
contactAttributeKey,
|
||||||
numericValue,
|
numericValue,
|
||||||
NUMBER_PATTERN_SQL
|
NUMBER_PATTERN_SQL,
|
||||||
|
environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
const contactIds = matchingContactIds.map((r) => r.contactId);
|
if (unmigratedMatchingIds.length === 0) {
|
||||||
|
return migratedFilter;
|
||||||
if (contactIds.length === 0) {
|
|
||||||
// Return an impossible condition so the filter correctly excludes all contacts
|
|
||||||
return { id: "__NUMBER_FILTER_NO_MATCH__" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id: { in: contactIds } };
|
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
OR: [migratedFilter, { id: { in: contactIds } }],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Prisma where clause from a segment attribute filter
|
* Builds a Prisma where clause from a segment attribute filter
|
||||||
*/
|
*/
|
||||||
const buildAttributeFilterWhereClause = async (
|
const buildAttributeFilterWhereClause = async (
|
||||||
filter: TSegmentAttributeFilter
|
filter: TSegmentAttributeFilter,
|
||||||
|
environmentId: string
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root, qualifier, value } = filter;
|
const { root, qualifier, value } = filter;
|
||||||
const { contactAttributeKey } = root;
|
const { contactAttributeKey } = root;
|
||||||
@@ -215,7 +258,7 @@ const buildAttributeFilterWhereClause = async (
|
|||||||
|
|
||||||
// Handle number operators
|
// Handle number operators
|
||||||
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
|
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
|
||||||
return await buildNumberAttributeFilterWhereClause(filter);
|
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For string operators, ensure value is a primitive (not an object or array)
|
// For string operators, ensure value is a primitive (not an object or array)
|
||||||
@@ -253,7 +296,8 @@ const buildAttributeFilterWhereClause = async (
|
|||||||
* Builds a Prisma where clause from a person filter
|
* Builds a Prisma where clause from a person filter
|
||||||
*/
|
*/
|
||||||
const buildPersonFilterWhereClause = async (
|
const buildPersonFilterWhereClause = async (
|
||||||
filter: TSegmentPersonFilter
|
filter: TSegmentPersonFilter,
|
||||||
|
environmentId: string
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { personIdentifier } = filter.root;
|
const { personIdentifier } = filter.root;
|
||||||
|
|
||||||
@@ -265,7 +309,7 @@ const buildPersonFilterWhereClause = async (
|
|||||||
contactAttributeKey: personIdentifier,
|
contactAttributeKey: personIdentifier,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return await buildAttributeFilterWhereClause(personFilter);
|
return await buildAttributeFilterWhereClause(personFilter, environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -314,6 +358,7 @@ const buildDeviceFilterWhereClause = (
|
|||||||
const buildSegmentFilterWhereClause = async (
|
const buildSegmentFilterWhereClause = async (
|
||||||
filter: TSegmentSegmentFilter,
|
filter: TSegmentSegmentFilter,
|
||||||
segmentPath: Set<string>,
|
segmentPath: Set<string>,
|
||||||
|
environmentId: string,
|
||||||
deviceType?: "phone" | "desktop"
|
deviceType?: "phone" | "desktop"
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root } = filter;
|
const { root } = filter;
|
||||||
@@ -337,7 +382,7 @@ const buildSegmentFilterWhereClause = async (
|
|||||||
const newPath = new Set(segmentPath);
|
const newPath = new Set(segmentPath);
|
||||||
newPath.add(segmentId);
|
newPath.add(segmentId);
|
||||||
|
|
||||||
return processFilters(segment.filters, newPath, deviceType);
|
return processFilters(segment.filters, newPath, environmentId, deviceType);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,19 +391,25 @@ const buildSegmentFilterWhereClause = async (
|
|||||||
const processSingleFilter = async (
|
const processSingleFilter = async (
|
||||||
filter: TSegmentFilter,
|
filter: TSegmentFilter,
|
||||||
segmentPath: Set<string>,
|
segmentPath: Set<string>,
|
||||||
|
environmentId: string,
|
||||||
deviceType?: "phone" | "desktop"
|
deviceType?: "phone" | "desktop"
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root } = filter;
|
const { root } = filter;
|
||||||
|
|
||||||
switch (root.type) {
|
switch (root.type) {
|
||||||
case "attribute":
|
case "attribute":
|
||||||
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
|
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter, environmentId);
|
||||||
case "person":
|
case "person":
|
||||||
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
|
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
|
||||||
case "device":
|
case "device":
|
||||||
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
|
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
|
||||||
case "segment":
|
case "segment":
|
||||||
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
|
return await buildSegmentFilterWhereClause(
|
||||||
|
filter as TSegmentSegmentFilter,
|
||||||
|
segmentPath,
|
||||||
|
environmentId,
|
||||||
|
deviceType
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -370,6 +421,7 @@ const processSingleFilter = async (
|
|||||||
const processFilters = async (
|
const processFilters = async (
|
||||||
filters: TBaseFilters,
|
filters: TBaseFilters,
|
||||||
segmentPath: Set<string>,
|
segmentPath: Set<string>,
|
||||||
|
environmentId: string,
|
||||||
deviceType?: "phone" | "desktop"
|
deviceType?: "phone" | "desktop"
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
if (filters.length === 0) return {};
|
if (filters.length === 0) return {};
|
||||||
@@ -386,10 +438,10 @@ const processFilters = async (
|
|||||||
// Process the resource based on its type
|
// Process the resource based on its type
|
||||||
if (isResourceFilter(resource)) {
|
if (isResourceFilter(resource)) {
|
||||||
// If it's a single filter, process it directly
|
// If it's a single filter, process it directly
|
||||||
whereClause = await processSingleFilter(resource, segmentPath, deviceType);
|
whereClause = await processSingleFilter(resource, segmentPath, environmentId, deviceType);
|
||||||
} else {
|
} else {
|
||||||
// If it's a group of filters, process it recursively
|
// If it's a group of filters, process it recursively
|
||||||
whereClause = await processFilters(resource, segmentPath, deviceType);
|
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(whereClause).length === 0) continue;
|
if (Object.keys(whereClause).length === 0) continue;
|
||||||
@@ -432,7 +484,7 @@ export const segmentFilterToPrismaQuery = reactCache(
|
|||||||
|
|
||||||
// Initialize an empty stack for tracking the current evaluation path
|
// Initialize an empty stack for tracking the current evaluation path
|
||||||
const segmentPath = new Set<string>([segmentId]);
|
const segmentPath = new Set<string>([segmentId]);
|
||||||
const filtersWhereClause = await processFilters(filters, segmentPath, deviceType);
|
const filtersWhereClause = await processFilters(filters, segmentPath, environmentId, deviceType);
|
||||||
|
|
||||||
const whereClause = {
|
const whereClause = {
|
||||||
AND: [baseWhereClause, filtersWhereClause],
|
AND: [baseWhereClause, filtersWhereClause],
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
survey: {
|
survey: {
|
||||||
@@ -206,6 +207,73 @@ describe("Segment Service Tests", () => {
|
|||||||
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
|
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
|
||||||
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
|
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should upsert a private segment without surveyId", async () => {
|
||||||
|
const privateInput: TSegmentCreateInput = {
|
||||||
|
...mockSegmentCreateInput,
|
||||||
|
isPrivate: true,
|
||||||
|
};
|
||||||
|
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
|
||||||
|
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
|
||||||
|
const segment = await createSegment(privateInput);
|
||||||
|
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
|
||||||
|
expect(prisma.segment.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
environmentId_title: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInput.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInput.title,
|
||||||
|
description: undefined,
|
||||||
|
isPrivate: true,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description: undefined,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
select: selectSegment,
|
||||||
|
});
|
||||||
|
expect(prisma.segment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should upsert a private segment with surveyId", async () => {
|
||||||
|
const privateInputWithSurvey: TSegmentCreateInput = {
|
||||||
|
...mockSegmentCreateInput,
|
||||||
|
isPrivate: true,
|
||||||
|
surveyId,
|
||||||
|
};
|
||||||
|
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
|
||||||
|
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
|
||||||
|
const segment = await createSegment(privateInputWithSurvey);
|
||||||
|
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
|
||||||
|
expect(prisma.segment.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
environmentId_title: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInputWithSurvey.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInputWithSurvey.title,
|
||||||
|
description: undefined,
|
||||||
|
isPrivate: true,
|
||||||
|
filters: [],
|
||||||
|
surveys: { connect: { id: surveyId } },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description: undefined,
|
||||||
|
filters: [],
|
||||||
|
surveys: { connect: { id: surveyId } },
|
||||||
|
},
|
||||||
|
select: selectSegment,
|
||||||
|
});
|
||||||
|
expect(prisma.segment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cloneSegment", () => {
|
describe("cloneSegment", () => {
|
||||||
|
|||||||
@@ -136,28 +136,48 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
|
|||||||
|
|
||||||
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
|
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
|
||||||
|
|
||||||
let data: Prisma.SegmentCreateArgs["data"] = {
|
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
|
||||||
environmentId,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
isPrivate,
|
|
||||||
filters,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (surveyId) {
|
|
||||||
data = {
|
|
||||||
...data,
|
|
||||||
surveys: {
|
|
||||||
connect: {
|
|
||||||
id: surveyId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Private segments use upsert because auto-save may have already created a
|
||||||
|
// default (empty-filter) segment via connectOrCreate before the user publishes.
|
||||||
|
// Without upsert the second create hits the (environmentId, title) unique constraint.
|
||||||
|
if (isPrivate) {
|
||||||
|
const segment = await prisma.segment.upsert({
|
||||||
|
where: {
|
||||||
|
environmentId_title: {
|
||||||
|
environmentId,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
environmentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
isPrivate,
|
||||||
|
filters,
|
||||||
|
...surveyConnect,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description,
|
||||||
|
filters,
|
||||||
|
...surveyConnect,
|
||||||
|
},
|
||||||
|
select: selectSegment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformPrismaSegment(segment);
|
||||||
|
}
|
||||||
|
|
||||||
const segment = await prisma.segment.create({
|
const segment = await prisma.segment.create({
|
||||||
data,
|
data: {
|
||||||
|
environmentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
isPrivate,
|
||||||
|
filters,
|
||||||
|
...surveyConnect,
|
||||||
|
},
|
||||||
select: selectSegment,
|
select: selectSegment,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
isValidFileTypeForExtension,
|
isValidFileTypeForExtension,
|
||||||
isValidImageFile,
|
isValidImageFile,
|
||||||
resolveStorageUrl,
|
resolveStorageUrl,
|
||||||
|
resolveStorageUrlAuto,
|
||||||
|
resolveStorageUrlsInObject,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
validateFileUploads,
|
validateFileUploads,
|
||||||
validateSingleFile,
|
validateSingleFile,
|
||||||
@@ -406,7 +408,7 @@ describe("storage utils", () => {
|
|||||||
expect(resolveStorageUrl("")).toBe("");
|
expect(resolveStorageUrl("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return absolute URL unchanged (backward compatibility)", () => {
|
test("should return absolute URL unchanged", () => {
|
||||||
const httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
|
const httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
|
||||||
const httpUrl = "http://example.com/storage/env-123/public/image.jpg";
|
const httpUrl = "http://example.com/storage/env-123/public/image.jpg";
|
||||||
|
|
||||||
@@ -415,14 +417,12 @@ describe("storage utils", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should resolve relative /storage/ path to absolute URL", async () => {
|
test("should resolve relative /storage/ path to absolute URL", async () => {
|
||||||
// Use actual implementation with mocked dependencies
|
|
||||||
const { resolveStorageUrl: actualResolveStorageUrl } =
|
const { resolveStorageUrl: actualResolveStorageUrl } =
|
||||||
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
const relativePath = "/storage/env-123/public/image.jpg";
|
const relativePath = "/storage/env-123/public/image.jpg";
|
||||||
const result = actualResolveStorageUrl(relativePath);
|
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).toContain("/storage/env-123/public/image.jpg");
|
||||||
expect(result.startsWith("http")).toBe(true);
|
expect(result.startsWith("http")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -432,4 +432,209 @@ describe("storage utils", () => {
|
|||||||
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
|
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveStorageUrlAuto", () => {
|
||||||
|
test("should return non-storage strings unchanged", () => {
|
||||||
|
expect(resolveStorageUrlAuto("hello world")).toBe("hello world");
|
||||||
|
expect(resolveStorageUrlAuto("/some/other/path")).toBe("/some/other/path");
|
||||||
|
expect(resolveStorageUrlAuto("https://example.com/image.jpg")).toBe("https://example.com/image.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should NOT transform free-text values that merely start with /storage/", () => {
|
||||||
|
expect(resolveStorageUrlAuto("/storage/help")).toBe("/storage/help");
|
||||||
|
expect(resolveStorageUrlAuto("/storage/")).toBe("/storage/");
|
||||||
|
expect(resolveStorageUrlAuto("/storage/some-text")).toBe("/storage/some-text");
|
||||||
|
expect(resolveStorageUrlAuto("/storage/foo/bar")).toBe("/storage/foo/bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve public storage URL", async () => {
|
||||||
|
const { resolveStorageUrlAuto: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const result = actual("/storage/env-123/public/image.jpg");
|
||||||
|
expect(result).toContain("/storage/env-123/public/image.jpg");
|
||||||
|
expect(result.startsWith("http")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should detect private access type from URL path", () => {
|
||||||
|
const privateUrl = "/storage/env-123/private/file.pdf";
|
||||||
|
const publicUrl = "/storage/env-123/public/image.jpg";
|
||||||
|
|
||||||
|
expect(privateUrl.includes("/private/")).toBe(true);
|
||||||
|
expect(publicUrl.includes("/private/")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveStorageUrlsInObject", () => {
|
||||||
|
test("should return null and undefined as-is", () => {
|
||||||
|
expect(resolveStorageUrlsInObject(null)).toBeNull();
|
||||||
|
expect(resolveStorageUrlsInObject(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return primitive values unchanged", () => {
|
||||||
|
expect(resolveStorageUrlsInObject(42)).toBe(42);
|
||||||
|
expect(resolveStorageUrlsInObject(true)).toBe(true);
|
||||||
|
expect(resolveStorageUrlsInObject("hello")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should NOT transform free-text that merely starts with /storage/", () => {
|
||||||
|
expect(resolveStorageUrlsInObject("/storage/help")).toBe("/storage/help");
|
||||||
|
expect(resolveStorageUrlsInObject("/storage/")).toBe("/storage/");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
questionId1: "/storage/",
|
||||||
|
questionId2: "/storage/help",
|
||||||
|
questionId3: "/storage/some-text",
|
||||||
|
questionId4: "/storage/foo/bar",
|
||||||
|
realUrl: "/storage/env-123/public/image.jpg",
|
||||||
|
};
|
||||||
|
const result = resolveStorageUrlsInObject(input);
|
||||||
|
expect(result.questionId1).toBe("/storage/");
|
||||||
|
expect(result.questionId2).toBe("/storage/help");
|
||||||
|
expect(result.questionId3).toBe("/storage/some-text");
|
||||||
|
expect(result.questionId4).toBe("/storage/foo/bar");
|
||||||
|
// realUrl still gets resolved because it matches the actual format
|
||||||
|
expect(result.realUrl).not.toBe("/storage/env-123/public/image.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve Date instances", () => {
|
||||||
|
const date = new Date("2026-01-01");
|
||||||
|
expect(resolveStorageUrlsInObject(date)).toBe(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve storage URL strings", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const result = actual("/storage/env-123/public/image.jpg");
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toContain("/storage/env-123/public/image.jpg");
|
||||||
|
expect((result as string).startsWith("http")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve URLs in arrays", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const input = ["/storage/env-123/public/a.jpg", "plain text"];
|
||||||
|
const result = actual(input);
|
||||||
|
|
||||||
|
expect(result[0]).toContain("/storage/env-123/public/a.jpg");
|
||||||
|
expect(result[0].startsWith("http")).toBe(true);
|
||||||
|
expect(result[1]).toBe("plain text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve URLs in nested objects", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: "Test Survey",
|
||||||
|
welcomeCard: {
|
||||||
|
fileUrl: "/storage/env-123/public/welcome.png",
|
||||||
|
headline: "Hello",
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
imageUrl: "/storage/env-123/public/q1.jpg",
|
||||||
|
choices: [
|
||||||
|
{ id: "c1", imageUrl: "/storage/env-123/public/choice1.jpg" },
|
||||||
|
{ id: "c2", imageUrl: "https://external.com/image.jpg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
count: 5,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = actual(input);
|
||||||
|
|
||||||
|
expect(result.welcomeCard.fileUrl.startsWith("http")).toBe(true);
|
||||||
|
expect(result.welcomeCard.headline).toBe("Hello");
|
||||||
|
expect(result.elements[0].imageUrl.startsWith("http")).toBe(true);
|
||||||
|
expect(result.elements[0].choices[0].imageUrl.startsWith("http")).toBe(true);
|
||||||
|
expect(result.elements[0].choices[1].imageUrl).toBe("https://external.com/image.jpg");
|
||||||
|
expect(result.count).toBe(5);
|
||||||
|
expect(result.createdAt).toEqual(new Date("2026-01-01"));
|
||||||
|
expect(result.name).toBe("Test Survey");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve URLs in deeply nested objects", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
level4: {
|
||||||
|
level5: {
|
||||||
|
imageUrl: "/storage/env-123/public/deep.png",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
nested: {
|
||||||
|
url: "/storage/env-123/public/nested.jpg",
|
||||||
|
label: "keep me",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plain string",
|
||||||
|
42,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sibling: "/storage/env-123/public/sibling.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
untouched: { a: { b: { c: "no change" } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = actual(input);
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.level4.level5.imageUrl).toContain(
|
||||||
|
"/storage/env-123/public/deep.png"
|
||||||
|
);
|
||||||
|
expect(result.level1.level2.level3.level4.level5.imageUrl.startsWith("http")).toBe(true);
|
||||||
|
|
||||||
|
// @ts-expect-error - items is an array of unknown types
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[0].nested.url).toContain(
|
||||||
|
"/storage/env-123/public/nested.jpg"
|
||||||
|
);
|
||||||
|
// @ts-expect-error - items is an array of unknown types
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[0].nested.url.startsWith("http")).toBe(true);
|
||||||
|
// @ts-expect-error - items is an array of unknown types
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[0].nested.label).toBe("keep me");
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[1]).toBe("plain string");
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[2]).toBe(42);
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[3]).toBeNull();
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.sibling).toContain("/storage/env-123/public/sibling.png");
|
||||||
|
expect(result.level1.level2.level3.sibling.startsWith("http")).toBe(true);
|
||||||
|
|
||||||
|
expect(result.level1.untouched.a.b.c).toBe("no change");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle response data with file upload URLs", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
questionId1: "text answer",
|
||||||
|
questionId2: 42,
|
||||||
|
fileUploadId: ["/storage/env-123/public/doc.pdf", "/storage/env-123/public/img.png"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = actual(responseData);
|
||||||
|
|
||||||
|
expect(result.questionId1).toBe("text answer");
|
||||||
|
expect(result.questionId2).toBe(42);
|
||||||
|
const fileUrls = result.fileUploadId;
|
||||||
|
expect(fileUrls[0]).toContain("/storage/env-123/public/doc.pdf");
|
||||||
|
expect(fileUrls[0].startsWith("http")).toBe(true);
|
||||||
|
expect(fileUrls[1]).toContain("/storage/env-123/public/img.png");
|
||||||
|
expect(fileUrls[1].startsWith("http")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a storage URL to an absolute URL.
|
* Resolves a storage URL to an absolute URL.
|
||||||
* - If already absolute, returns as-is (backward compatibility for old data)
|
* - If already absolute, returns as-is
|
||||||
* - If relative (/storage/...), prepends the appropriate base URL
|
* - If relative (/storage/...), prepends the appropriate base URL
|
||||||
* @param url The storage URL (relative or absolute)
|
* @param url The storage URL (relative or absolute)
|
||||||
* @param accessType The access type to determine which base URL to use (defaults to "public")
|
* @param accessType The access type to determine which base URL to use (defaults to "public")
|
||||||
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
|
|||||||
): string => {
|
): string => {
|
||||||
if (!url) return "";
|
if (!url) return "";
|
||||||
|
|
||||||
// Already absolute URL - return as-is (backward compatibility for old data)
|
// Already absolute URL - return as-is
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -176,3 +176,41 @@ export const resolveStorageUrl = (
|
|||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...}
|
||||||
|
const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/;
|
||||||
|
|
||||||
|
const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value);
|
||||||
|
|
||||||
|
export const resolveStorageUrlAuto = (url: string): string => {
|
||||||
|
if (!isStorageUrl(url)) return url;
|
||||||
|
const accessType = url.includes("/private/") ? "private" : "public";
|
||||||
|
return resolveStorageUrl(url, accessType);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walks an object/array and resolves all relative storage URLs
|
||||||
|
* Preserves the original structure; skips Date instances and non-object primitives.
|
||||||
|
*/
|
||||||
|
export const resolveStorageUrlsInObject = <T>(obj: T): T => {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
return resolveStorageUrlAuto(obj) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj !== "object") return obj;
|
||||||
|
|
||||||
|
if (obj instanceof Date) return obj;
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => resolveStorageUrlsInObject(item)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
result[key] = resolveStorageUrlsInObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,12 +4,182 @@ description: "Formbricks Self-hosted version migration"
|
|||||||
icon: "arrow-right"
|
icon: "arrow-right"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v4.7
|
||||||
|
|
||||||
|
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
|
||||||
|
|
||||||
|
### What Happens Automatically
|
||||||
|
|
||||||
|
When Formbricks v4.7 starts for the first time, the data migration will:
|
||||||
|
|
||||||
|
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
|
||||||
|
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
|
||||||
|
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
|
||||||
|
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
|
||||||
|
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
|
||||||
|
handles everything automatically and you can skip the manual backfill step below.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
### Steps to Migrate
|
||||||
|
|
||||||
|
**1. Backup your Database**
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||||
|
`formbricks_postgres_1`.
|
||||||
|
</Info>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
**2. Upgrade to Formbricks v4.7**
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
```bash
|
||||||
|
# Pull the latest version
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# Stop the current instance
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Start with Formbricks v4.7
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
```bash
|
||||||
|
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
|
||||||
|
-n formbricks \
|
||||||
|
--set deployment.image.tag=v4.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
|
||||||
|
PreSync hook before the new pods start. No manual migration step is needed.
|
||||||
|
</Info>
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
**3. Check the Migration Logs**
|
||||||
|
|
||||||
|
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
```bash
|
||||||
|
docker compose logs formbricks | grep -i "backfill"
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
```bash
|
||||||
|
# Check the application pod logs
|
||||||
|
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the Helm migration Job ran, you can also inspect its logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl logs -n formbricks job/formbricks-migration
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
|
||||||
|
|
||||||
|
**4. Run the Backfill Script (large datasets only)**
|
||||||
|
|
||||||
|
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
```bash
|
||||||
|
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
```bash
|
||||||
|
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
|
||||||
|
</Info>
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
The script will output progress as it runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
========================================
|
||||||
|
Attribute Value Backfill Script
|
||||||
|
========================================
|
||||||
|
|
||||||
|
Fetching number-type attribute keys...
|
||||||
|
Found 12 number-type keys. Backfilling valueNumber...
|
||||||
|
Number backfill progress: 10/12 keys (48230 rows updated)
|
||||||
|
Number backfill progress: 12/12 keys (52104 rows updated)
|
||||||
|
|
||||||
|
Fetching date-type attribute keys...
|
||||||
|
Found 5 date-type keys. Backfilling valueDate...
|
||||||
|
Date backfill progress: 5/5 keys (31200 rows updated)
|
||||||
|
|
||||||
|
========================================
|
||||||
|
Backfill Complete!
|
||||||
|
========================================
|
||||||
|
valueNumber rows updated: 52104
|
||||||
|
valueDate rows updated: 31200
|
||||||
|
Duration: 42.3s
|
||||||
|
========================================
|
||||||
|
```
|
||||||
|
|
||||||
|
Key characteristics of the backfill script:
|
||||||
|
|
||||||
|
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
|
||||||
|
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
|
||||||
|
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
|
||||||
|
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
|
||||||
|
|
||||||
|
**5. Verify the Upgrade**
|
||||||
|
|
||||||
|
- Access your Formbricks instance at the same URL as before
|
||||||
|
- If you use contact segments with number or date filters, verify they return the expected results
|
||||||
|
- Check that existing surveys and response data are intact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v4.0
|
## v4.0
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
**Important: Migration Required**
|
**Important: Migration Required**
|
||||||
|
|
||||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||||
|
|
||||||
</Warning>
|
</Warning>
|
||||||
|
|
||||||
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
||||||
@@ -17,9 +187,11 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
|
|||||||
### What's New in Formbricks 4.0
|
### What's New in Formbricks 4.0
|
||||||
|
|
||||||
**🚀 New Enterprise Features:**
|
**🚀 New Enterprise Features:**
|
||||||
|
|
||||||
- **Quotas Management**: Advanced quota controls for enterprise users
|
- **Quotas Management**: Advanced quota controls for enterprise users
|
||||||
|
|
||||||
**🏗️ Technical Foundation Improvements:**
|
**🏗️ Technical Foundation Improvements:**
|
||||||
|
|
||||||
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
||||||
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
||||||
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
||||||
@@ -39,7 +211,8 @@ These services are already included in the updated one-click setup for self-host
|
|||||||
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
||||||
|
|
||||||
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
||||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
|
||||||
|
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||||
- **Advanced features** that require sophisticated caching and file processing
|
- **Advanced features** that require sophisticated caching and file processing
|
||||||
- **Better performance** through optimized, dedicated services
|
- **Better performance** through optimized, dedicated services
|
||||||
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
||||||
@@ -52,7 +225,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
|
|||||||
|
|
||||||
### One-Click Setup
|
### One-Click Setup
|
||||||
|
|
||||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download the latest script
|
# Download the latest script
|
||||||
@@ -67,11 +240,11 @@ chmod +x migrate-to-v4.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
This script guides you through the steps for the infrastructure migration and does the following:
|
This script guides you through the steps for the infrastructure migration and does the following:
|
||||||
|
|
||||||
- Adds a Redis service to your setup and configures it
|
- Adds a Redis service to your setup and configures it
|
||||||
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
||||||
- Pulls the latest Formbricks image and updates your instance
|
- Pulls the latest Formbricks image and updates your instance
|
||||||
|
|
||||||
|
|
||||||
### Manual Setup
|
### Manual Setup
|
||||||
|
|
||||||
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
||||||
@@ -87,6 +260,7 @@ You need to configure the `REDIS_URL` environment variable and point it to your
|
|||||||
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
|
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
|
||||||
|
|
||||||
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
||||||
|
|
||||||
- AWS S3
|
- AWS S3
|
||||||
- Digital Ocean Spaces
|
- Digital Ocean Spaces
|
||||||
- Hetzner Object Storage
|
- Hetzner Object Storage
|
||||||
@@ -101,6 +275,7 @@ Please make sure to set up a storage bucket with one of these solutions and then
|
|||||||
S3_BUCKET_NAME: formbricks-uploads
|
S3_BUCKET_NAME: formbricks-uploads
|
||||||
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Upgrade Process
|
#### Upgrade Process
|
||||||
|
|
||||||
**1. Backup your Database**
|
**1. Backup your Database**
|
||||||
@@ -112,8 +287,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Info>
|
<Info>
|
||||||
If you run into "**No such container**", use `docker ps` to find your container name,
|
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||||
e.g. `formbricks_postgres_1`.
|
`formbricks_postgres_1`.
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
**2. Upgrade to Formbricks 4.0**
|
**2. Upgrade to Formbricks 4.0**
|
||||||
@@ -134,6 +309,7 @@ docker compose up -d
|
|||||||
**3. Automatic Database Migration**
|
**3. Automatic Database Migration**
|
||||||
|
|
||||||
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
||||||
|
|
||||||
- Detect and apply required database schema updates
|
- Detect and apply required database schema updates
|
||||||
- Remove unused database tables and fields
|
- Remove unused database tables and fields
|
||||||
- Optimize the database structure for better performance
|
- Optimize the database structure for better performance
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ When PUBLIC_URL is configured, the following routes are automatically served fro
|
|||||||
|
|
||||||
- `/s/{surveyId}` - Individual survey access
|
- `/s/{surveyId}` - Individual survey access
|
||||||
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
||||||
|
- `/p/{survey-slug}` - Pretty URL survey access
|
||||||
- Embedded survey endpoints
|
- Embedded survey endpoints
|
||||||
|
|
||||||
#### API Routes
|
#### API Routes
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ const checkRequiredField = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CTA elements never block progression (informational only)
|
||||||
|
if (element.type === TSurveyElementTypeEnum.CTA) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (element.type === TSurveyElementTypeEnum.Ranking) {
|
if (element.type === TSurveyElementTypeEnum.Ranking) {
|
||||||
return validateRequiredRanking(value, t);
|
return validateRequiredRanking(value, t);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user