mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 01:00:05 -06:00
Compare commits
6 Commits
4.7.2-rc.1
...
4.7.4-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be38c41c6d | ||
|
|
f4598c3db5 | ||
|
|
0831c8c31d | ||
|
|
20ce0f9f34 | ||
|
|
56d6d201f8 | ||
|
|
9a32e60441 |
@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { truncateText } from "@/lib/utils/strings";
|
||||
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
|
||||
|
||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
let result: string[] = [];
|
||||
@@ -256,10 +257,16 @@ const processElementResponse = (
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
return element.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl)
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
|
||||
return responseValue
|
||||
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
return processResponseData(responseValue);
|
||||
};
|
||||
|
||||
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
|
||||
|
||||
responses[resp] = (pictureElement as any)?.choices
|
||||
.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 { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
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 body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
data: resolvedResponseData,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TJsEnvironmentStateSurvey,
|
||||
} from "@formbricks/types/js";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
overlay: environmentData.project.overlay,
|
||||
placement: environmentData.project.placement,
|
||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||
styling: environmentData.project.styling,
|
||||
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
id: environmentData.project.organization.id,
|
||||
billing: environmentData.project.organization.billing,
|
||||
},
|
||||
surveys: transformedSurveys,
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
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";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.response),
|
||||
response: responses.successResponse({
|
||||
...result.response,
|
||||
data: resolveStorageUrlsInObject(result.response.data),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -189,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updated),
|
||||
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
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,
|
||||
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(allResponses),
|
||||
response: responses.successResponse(
|
||||
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
|
||||
const fetchAndAuthorizeSurvey = async (
|
||||
surveyId: string,
|
||||
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
if (shouldTransformToQuestions) {
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
}),
|
||||
response: responses.successResponse(
|
||||
resolveStorageUrlsInObject({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.survey),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyWithQuestions),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updatedSurvey),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveysWithQuestions),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
|
||||
if (survey.isVerifyEmailEnabled) {
|
||||
headers.push("Verified Email");
|
||||
}
|
||||
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
|
||||
const jsonData = getResponsesJson(
|
||||
survey,
|
||||
responses,
|
||||
resolvedResponses,
|
||||
elements,
|
||||
userAttributes,
|
||||
hiddenFields,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
|
||||
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 responses.successResponse(response);
|
||||
return responses.successResponse({
|
||||
...response,
|
||||
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -243,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
auditLog.newObject = response.data;
|
||||
}
|
||||
|
||||
return responses.successResponse(response);
|
||||
return responses.successResponse({
|
||||
...response,
|
||||
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||
});
|
||||
},
|
||||
action: "updated",
|
||||
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 { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
|
||||
|
||||
environmentResponses.push(...res.data.data);
|
||||
|
||||
return responses.successResponse({ data: environmentResponses });
|
||||
return responses.successResponse({
|
||||
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ import { validateInputs } from "@/lib/utils/validate";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { getPersonSegmentIds, getSegments } from "./segments";
|
||||
|
||||
// Mock the cache functions
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests
|
||||
withCache: vi.fn(async (fn) => await fn()),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -30,15 +29,15 @@ vi.mock("@formbricks/database", () => ({
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock React cache
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
|
||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -97,22 +96,20 @@ describe("segments lib", () => {
|
||||
});
|
||||
|
||||
describe("getPersonSegmentIds", () => {
|
||||
const mockWhereClause = { AND: [{ environmentId: mockEnvironmentId }, {}] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||
);
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } },
|
||||
data: { whereClause: mockWhereClause },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return person segment IDs successfully", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
||||
typeof prisma.contact,
|
||||
unknown,
|
||||
"findFirst"
|
||||
>);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
@@ -128,12 +125,12 @@ describe("segments lib", () => {
|
||||
});
|
||||
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
||||
});
|
||||
|
||||
test("should return empty array if no segments exist", async () => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
@@ -144,10 +141,11 @@ describe("segments lib", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return empty array if segments exist but none match", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([null, null]);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
@@ -155,16 +153,14 @@ describe("segments lib", () => {
|
||||
mockContactUserId,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should call validateInputs with correct parameters", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
||||
typeof prisma.contact,
|
||||
unknown,
|
||||
"findFirst"
|
||||
>);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
|
||||
|
||||
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
@@ -175,14 +171,7 @@ describe("segments lib", () => {
|
||||
});
|
||||
|
||||
test("should return only matching segment IDs", async () => {
|
||||
// First segment matches, second doesn't
|
||||
vi.mocked(prisma.contact.findFirst)
|
||||
.mockResolvedValueOnce({ id: mockContactId } as Prisma.Result<
|
||||
typeof prisma.contact,
|
||||
unknown,
|
||||
"findFirst"
|
||||
>) // First segment matches
|
||||
.mockResolvedValueOnce(null); // Second segment does not match
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, null]);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
@@ -193,6 +182,66 @@ describe("segments lib", () => {
|
||||
|
||||
expect(result).toEqual([mockSegmentsData[0].id]);
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should include segments with no filters as always-matching", async () => {
|
||||
const segmentsWithEmptyFilters = [
|
||||
{ id: "segment-no-filter", filters: [] },
|
||||
{ id: "segment-with-filter", filters: [{}] as TBaseFilter[] },
|
||||
];
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||
segmentsWithEmptyFilters as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||
);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toContain("segment-no-filter");
|
||||
expect(result).toContain("segment-with-filter");
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should skip segments where filter query building fails", async () => {
|
||||
vi.mocked(segmentFilterToPrismaQuery)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { whereClause: mockWhereClause },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { type: "bad_request", message: "Invalid filters", details: [] },
|
||||
});
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual(["segment1"]);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should return empty array on unexpected error", async () => {
|
||||
vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected"));
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,47 +37,6 @@ export const getSegments = reactCache(
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if a contact matches a segment using Prisma query
|
||||
* This leverages native DB types (valueDate, valueNumber) for accurate comparisons
|
||||
* Device filters are evaluated at query build time using the provided deviceType
|
||||
*/
|
||||
const isContactInSegment = async (
|
||||
contactId: string,
|
||||
segmentId: string,
|
||||
filters: TBaseFilters,
|
||||
environmentId: string,
|
||||
deviceType: "phone" | "desktop"
|
||||
): Promise<boolean> => {
|
||||
// If no filters, segment matches all contacts
|
||||
if (!filters || filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { whereClause } = queryResult.data;
|
||||
|
||||
// Check if this specific contact matches the segment filters
|
||||
const matchingContact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id: contactId,
|
||||
...whereClause,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return matchingContact !== null;
|
||||
};
|
||||
|
||||
export const getPersonSegmentIds = async (
|
||||
environmentId: string,
|
||||
contactId: string,
|
||||
@@ -89,23 +48,70 @@ export const getPersonSegmentIds = async (
|
||||
|
||||
const segments = await getSegments(environmentId);
|
||||
|
||||
// fast path; if there are no segments, return an empty array
|
||||
if (!segments || !Array.isArray(segments)) {
|
||||
if (!segments || !Array.isArray(segments) || segments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Device filters are evaluated at query build time using the provided deviceType
|
||||
const segmentPromises = segments.map(async (segment) => {
|
||||
const filters = segment.filters;
|
||||
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
|
||||
return isIncluded ? segment.id : null;
|
||||
});
|
||||
// Phase 1: Build all Prisma where clauses concurrently.
|
||||
// This converts segment filters into where clauses without per-contact DB queries.
|
||||
const segmentWithClauses = await Promise.all(
|
||||
segments.map(async (segment) => {
|
||||
const filters = segment.filters as TBaseFilters | null;
|
||||
|
||||
const results = await Promise.all(segmentPromises);
|
||||
if (!filters || filters.length === 0) {
|
||||
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
||||
}
|
||||
|
||||
return results.filter((id): id is string => id !== null);
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment"
|
||||
);
|
||||
return { segmentId: segment.id, whereClause: null };
|
||||
}
|
||||
|
||||
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
|
||||
})
|
||||
);
|
||||
|
||||
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
|
||||
const alwaysMatchIds: string[] = [];
|
||||
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
|
||||
for (const item of segmentWithClauses) {
|
||||
if (item.whereClause === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(item.whereClause).length === 0) {
|
||||
alwaysMatchIds.push(item.segmentId);
|
||||
} else {
|
||||
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
||||
}
|
||||
}
|
||||
|
||||
if (toCheck.length === 0) {
|
||||
return alwaysMatchIds;
|
||||
}
|
||||
|
||||
// Phase 2: Batch all contact-match checks into a single DB transaction.
|
||||
// Replaces N individual findFirst queries with one batched round-trip.
|
||||
const batchResults = await prisma.$transaction(
|
||||
toCheck.map(({ whereClause }) =>
|
||||
prisma.contact.findFirst({
|
||||
where: { id: contactId, ...whereClause },
|
||||
select: { id: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Phase 3: Collect matching segment IDs
|
||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
|
||||
return [...alwaysMatchIds, ...dbMatchIds];
|
||||
} catch (error) {
|
||||
// Log error for debugging but don't throw to prevent "segments is not iterable" error
|
||||
logger.warn(
|
||||
{
|
||||
environmentId,
|
||||
|
||||
@@ -54,7 +54,6 @@ export const prepareNewSDKAttributeForStorage = (
|
||||
};
|
||||
|
||||
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
|
||||
// String type - only use value column
|
||||
let stringValue: string;
|
||||
|
||||
if (value instanceof Date) {
|
||||
|
||||
@@ -130,7 +130,12 @@ export const updateAttributes = async (
|
||||
const messages: TAttributeUpdateMessage[] = [];
|
||||
const errors: TAttributeUpdateMessage[] = [];
|
||||
|
||||
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
|
||||
// Coerce boolean values to strings (SDK may send booleans for string attributes)
|
||||
const coercedAttributes: Record<string, string | number> = {};
|
||||
for (const [key, value] of Object.entries(contactAttributesParam)) {
|
||||
coercedAttributes[key] = typeof value === "boolean" ? String(value) : value;
|
||||
}
|
||||
|
||||
const emailValue =
|
||||
contactAttributesParam.email === null || contactAttributesParam.email === undefined
|
||||
? null
|
||||
@@ -154,7 +159,7 @@ export const updateAttributes = async (
|
||||
const userIdExists = !!existingUserIdAttribute;
|
||||
|
||||
// Remove email and/or userId from attributes if they already exist on another contact
|
||||
let contactAttributes = { ...contactAttributesParam };
|
||||
let contactAttributes = { ...coercedAttributes };
|
||||
|
||||
// Determine what the final email and userId values will be after this update
|
||||
// Only consider a value as "submitted" if it was explicitly included in the attributes
|
||||
|
||||
@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param url The storage URL (relative or absolute)
|
||||
* @param accessType The access type to determine which base URL to use (defaults to "public")
|
||||
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
|
||||
): string => {
|
||||
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://")) {
|
||||
return url;
|
||||
}
|
||||
@@ -176,3 +176,41 @@ export const resolveStorageUrl = (
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -118,7 +118,7 @@ Scaling:
|
||||
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||
```
|
||||
{{- else }}
|
||||
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
|
||||
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.deployment.replicas }}` replicas.
|
||||
Manually scale using:
|
||||
```sh
|
||||
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
|
||||
@@ -127,6 +127,34 @@ Scaling:
|
||||
|
||||
---
|
||||
|
||||
Pod Disruption Budget:
|
||||
|
||||
{{- if .Values.pdb.enabled }}
|
||||
A PodDisruptionBudget is active to protect against voluntary disruptions.
|
||||
{{- if not (kindIs "invalid" .Values.pdb.minAvailable) }}
|
||||
- **Min Available**: `{{ .Values.pdb.minAvailable }}`
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" .Values.pdb.maxUnavailable) }}
|
||||
- **Max Unavailable**: `{{ .Values.pdb.maxUnavailable }}`
|
||||
{{- end }}
|
||||
|
||||
Check PDB status:
|
||||
```sh
|
||||
kubectl get pdb -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||
```
|
||||
{{- if and .Values.autoscaling.enabled (eq (int .Values.autoscaling.minReplicas) 1) }}
|
||||
|
||||
WARNING: autoscaling.minReplicas is 1. With minAvailable: 1, the PDB
|
||||
will block all node drains when only 1 replica is running. Set
|
||||
autoscaling.minReplicas to at least 2 for proper HA protection.
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
PDB is **not enabled**. Voluntary disruptions (node drains, upgrades) may
|
||||
take down all pods simultaneously.
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
|
||||
External Secrets:
|
||||
{{- if .Values.externalSecret.enabled }}
|
||||
External secrets are enabled.
|
||||
|
||||
37
charts/formbricks/templates/pdb.yaml
Normal file
37
charts/formbricks/templates/pdb.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
{{- if .Values.pdb.enabled }}
|
||||
{{- $hasMinAvailable := not (kindIs "invalid" .Values.pdb.minAvailable) -}}
|
||||
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.pdb.maxUnavailable) -}}
|
||||
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
||||
{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive; set only one" }}
|
||||
{{- end }}
|
||||
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
||||
{{- fail "pdb.enabled is true but neither pdb.minAvailable nor pdb.maxUnavailable is set; set exactly one" }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ template "formbricks.name" . }}
|
||||
labels:
|
||||
{{- include "formbricks.labels" . | nindent 4 }}
|
||||
{{- with .Values.pdb.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.pdb.annotations }}
|
||||
annotations:
|
||||
{{- toYaml .Values.pdb.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if $hasMinAvailable }}
|
||||
minAvailable: {{ .Values.pdb.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if $hasMaxUnavailable }}
|
||||
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .Values.pdb.unhealthyPodEvictionPolicy }}
|
||||
unhealthyPodEvictionPolicy: {{ .Values.pdb.unhealthyPodEvictionPolicy }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "formbricks.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -214,6 +214,42 @@ autoscaling:
|
||||
value: 2
|
||||
periodSeconds: 60 # Add at most 2 pods every minute
|
||||
|
||||
##########################################################
|
||||
# Pod Disruption Budget (PDB)
|
||||
#
|
||||
# Ensures a minimum number of pods remain available during
|
||||
# voluntary disruptions (node drains, cluster upgrades, etc.).
|
||||
#
|
||||
# IMPORTANT:
|
||||
# - minAvailable and maxUnavailable are MUTUALLY EXCLUSIVE.
|
||||
# Setting both will cause a helm install/upgrade failure.
|
||||
# To switch, set the unused one to null in your override file.
|
||||
# - Accepts an integer (e.g., 1) or a percentage string (e.g., "25%").
|
||||
# - For PDB to provide real HA protection, ensure
|
||||
# autoscaling.minReplicas >= 2 (or deployment.replicas >= 2
|
||||
# if HPA is disabled). With only 1 replica and minAvailable: 1,
|
||||
# the PDB will block ALL node drains and cluster upgrades.
|
||||
##########################################################
|
||||
pdb:
|
||||
enabled: true
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
|
||||
# Minimum pods that must remain available during disruptions.
|
||||
# Set to null and configure maxUnavailable instead if preferred.
|
||||
minAvailable: 1
|
||||
|
||||
# Maximum pods that can be unavailable during disruptions.
|
||||
# Mutually exclusive with minAvailable — uncomment and set
|
||||
# minAvailable to null to use this instead.
|
||||
# maxUnavailable: 1
|
||||
|
||||
# Eviction policy for unhealthy pods (Kubernetes 1.27+).
|
||||
# "IfHealthy" — unhealthy pods count toward the budget (default).
|
||||
# "AlwaysAllow" — unhealthy pods can always be evicted,
|
||||
# preventing them from blocking node drain.
|
||||
# unhealthyPodEvictionPolicy: AlwaysAllow
|
||||
|
||||
##########################################################
|
||||
# Service Configuration
|
||||
##########################################################
|
||||
|
||||
@@ -16,5 +16,5 @@ export type TContactAttribute = z.infer<typeof ZContactAttribute>;
|
||||
export const ZContactAttributes = z.record(z.string());
|
||||
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
|
||||
|
||||
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()]));
|
||||
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number(), z.boolean()]));
|
||||
export type TContactAttributesInput = z.infer<typeof ZContactAttributesInput>;
|
||||
|
||||
@@ -20,6 +20,9 @@ sonar.scm.exclusions.disabled=false
|
||||
# Encoding of the source code
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Node.js memory limit for JS/TS analysis (in MB)
|
||||
sonar.javascript.node.maxspace=8192
|
||||
|
||||
# Coverage
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
|
||||
|
||||
Reference in New Issue
Block a user