Compare commits

...

4 Commits

Author SHA1 Message Date
Tiago 5ec8218666 fix: (backport) password hash visibility improvement (#7814) (#7833) 2026-04-24 14:33:26 +00:00
Tiago Farto e1a44817f2 fix: password hash visibility improvement
(cherry picked from commit 73ad130ece)
2026-04-24 13:10:40 +00:00
Dhruwang Jariwala 7f5b2bf69d fix: prevent split offline responses on restore (backport #7767) (#7777) 2026-04-20 12:00:34 +05:30
Dhruwang 60e7c7e8ee fix(surveys): prevent split offline responses on restore (backport #7767)
Backport of #7767 to release/4.9. Anchors displayId and responseId back
into saved survey progress as soon as they are created, recovers a
missing responseId from displayId on restore, and falls back to a
bootstrap create path that uses the full accumulated response state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:43:46 +05:30
16 changed files with 529 additions and 42 deletions
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,40 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { publicUserSelect } from "@/lib/user/public-user";
import { GET } from "./route";
const mocks = vi.hoisted(() => ({
headers: vi.fn(),
getSessionUser: vi.fn(),
parseApiKeyV2: vi.fn(),
hashSha256: vi.fn(),
verifySecret: vi.fn(),
applyRateLimit: vi.fn(),
notAuthenticatedResponse: vi.fn(
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
),
tooManyRequestsResponse: vi.fn(
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
),
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
}));
vi.mock("next/headers", () => ({
headers: mocks.headers,
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
apiKey: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
badRequestResponse: mocks.badRequestResponse,
},
}));
vi.mock("@/lib/crypto", () => ({
hashSha256: mocks.hashSha256,
parseApiKeyV2: mocks.parseApiKeyV2,
verifySecret: mocks.verifySecret,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v1: { windowMs: 60_000, max: 1000 },
},
},
}));
const getMockHeaders = (apiKey: string | null) => ({
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
});
describe("v1 management me route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.headers.mockResolvedValue(getMockHeaders(null));
mocks.getSessionUser.mockResolvedValue(undefined);
mocks.parseApiKeyV2.mockReturnValue(null);
mocks.hashSha256.mockReturnValue("hashed-api-key");
mocks.verifySecret.mockResolvedValue(false);
mocks.applyRateLimit.mockResolvedValue(undefined);
});
test("returns a sanitized authenticated user for session-based requests", async () => {
const publicUser = {
id: "user_123",
name: "Test User",
email: "test@example.com",
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
createdAt: new Date("2025-04-17T20:09:14.021Z"),
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
twoFactorEnabled: false,
identityProvider: "email" as const,
notificationSettings: {
alert: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as const,
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
isActive: true,
};
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: publicUser.id },
select: publicUserSelect,
});
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
});
test("returns the existing unauthenticated response when no session is present", async () => {
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({ message: "Not authenticated" });
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("preserves the API key response path", async () => {
const apiKeyData = {
id: "api_key_123",
organizationId: "org_123",
hashedKey: "stored-hash",
lastUsedAt: new Date(),
apiKeyEnvironments: [
{
permission: "manage",
environment: {
id: "env_123",
type: "development",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
projectId: "project_123",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
},
},
],
};
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual({
id: "env_123",
type: "development",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
});
expect(mocks.getSessionUser).not.toHaveBeenCalled();
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
});
});
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -13,6 +13,10 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
password: "$2b$12$hashedPassword",
twoFactorSecret: "encrypted-2fa-secret",
backupCodes: "encrypted-backup-codes",
identityProviderAccountId: "provider-account-id",
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
updatedAt: expect.any(Date),
},
]);
expect(result.data.data[0]).not.toHaveProperty("password");
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -151,6 +163,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -0,0 +1,38 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
test.describe("API Tests for Management Me", () => {
test("Authenticated v1 me endpoint never exposes secret auth fields", async ({ page, users }) => {
const name = `Security Me User ${Date.now()}`;
const email = `security-me-${Date.now()}@example.com`;
try {
const user = await users.create({ name, email });
await user.login();
const response = await page.context().request.get("/api/v1/management/me");
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: expect.any(String),
name,
email,
twoFactorEnabled: expect.any(Boolean),
identityProvider: expect.any(String),
notificationSettings: expect.any(Object),
locale: expect.any(String),
isActive: expect.any(Boolean),
});
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
} catch (error) {
logger.error(error, "Error during management me API security test");
throw error;
}
});
});
@@ -29,6 +29,7 @@ import {
type SerializedSurveyState,
clearSurveyProgress,
getSurveyProgress,
patchSurveyProgressSnapshot,
saveSurveyProgress,
} from "@/lib/offline-storage";
import { parseRecallInformation } from "@/lib/recall";
@@ -38,13 +39,28 @@ import { useOnlineStatus } from "@/lib/use-online-status";
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
const restoreSurveyStateFromSnapshot = (surveyState: SurveyState, snapshot: SerializedSurveyState): void => {
const restoreSurveyStateFromSnapshot = (
surveyState: SurveyState,
snapshot: SerializedSurveyState,
progress: {
responseData: TResponseData;
ttc: TResponseTtc;
currentVariables: TResponseVariables;
}
): void => {
if (snapshot.responseId) surveyState.updateResponseId(snapshot.responseId);
if (snapshot.displayId) surveyState.updateDisplayId(snapshot.displayId);
if (snapshot.userId) surveyState.updateUserId(snapshot.userId);
if (snapshot.contactId) surveyState.updateContactId(snapshot.contactId);
if (snapshot.singleUseId) surveyState.singleUseId = snapshot.singleUseId;
surveyState.responseAcc = snapshot.responseAcc;
surveyState.disableBootstrapResponseCreate();
surveyState.responseAcc = {
...snapshot.responseAcc,
data: progress.responseData,
ttc: progress.ttc,
variables: progress.currentVariables,
displayId: snapshot.displayId ?? snapshot.responseAcc.displayId,
};
};
interface VariableStackEntry {
@@ -127,6 +143,14 @@ export function Survey({
const offlinePersistEnabled =
offlineSupport && isLinkSurvey && !isPreviewMode && !!appUrl && !!environmentId;
const persistSurveyStateSnapshot = useCallback(
async (snapshotPatch: Partial<SerializedSurveyState>) => {
if (!offlinePersistEnabled) return;
await patchSurveyProgressSnapshot(survey.id, snapshotPatch);
},
[offlinePersistEnabled, survey.id]
);
const responseQueue = useMemo(() => {
if (appUrl && environmentId && surveyState) {
return new ResponseQueue(
@@ -160,6 +184,9 @@ export function Survey({
setBlockId(quotaInfo.endingCardId);
}
},
onResponseCreated: (responseId) => {
void persistSurveyStateSnapshot({ responseId });
},
},
surveyState
);
@@ -173,6 +200,7 @@ export function Survey({
getSetIsResponseSendingFinished,
surveyState,
offlinePersistEnabled,
persistSurveyStateSnapshot,
survey.id,
]);
@@ -319,6 +347,7 @@ export function Survey({
surveyState.updateDisplayId(display.data.id);
responseQueue.updateSurveyState(surveyState);
await persistSurveyStateSnapshot({ displayId: display.data.id });
if (onDisplayCreated) {
onDisplayCreated();
@@ -337,6 +366,7 @@ export function Survey({
onDisplayCreated,
isPreviewMode,
onDisplay,
persistSurveyStateSnapshot,
]);
// Create display on mount. When offline persistence is enabled, wait for progress
@@ -458,7 +488,36 @@ export function Survey({
// Restore survey state from snapshot
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
if (pendingCount === 0 && !progress.surveyStateSnapshot.responseId) {
if (progress.surveyStateSnapshot.displayId && apiClient) {
const responseLookup = await apiClient.getResponseIdByDisplayId(
progress.surveyStateSnapshot.displayId
);
if (responseLookup.ok && responseLookup.data.responseId) {
surveyState.updateResponseId(responseLookup.data.responseId);
await persistSurveyStateSnapshot({ responseId: responseLookup.data.responseId });
} else if (responseLookup.ok) {
surveyState.enableBootstrapResponseCreate();
} else if (responseLookup.error.status === 404) {
surveyState.updateDisplayId(null);
surveyState.enableBootstrapResponseCreate();
await persistSurveyStateSnapshot({ displayId: null });
} else {
console.error("Formbricks: Failed to recover responseId from displayId", {
displayId: progress.surveyStateSnapshot.displayId,
error: responseLookup.error,
});
surveyState.enableBootstrapResponseCreate();
}
} else {
surveyState.enableBootstrapResponseCreate();
}
}
responseQueue?.updateSurveyState(surveyState);
}
} else {
// Block no longer exists (survey structure changed) — discard UI progress
@@ -466,7 +525,8 @@ export function Survey({
await clearSurveyProgress(survey.id);
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
responseQueue?.updateSurveyState(surveyState);
}
}
+10
View File
@@ -46,6 +46,16 @@ export class ApiClient {
);
}
async getResponseIdByDisplayId(
displayId: string
): Promise<Result<{ responseId: string | null }, ApiErrorResponse>> {
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/displays/${displayId}/response`,
"GET"
);
}
async createResponse(
responseInput: Omit<TResponseInput, "environmentId"> & {
contactId: string | null;
@@ -241,6 +241,44 @@ export const getSurveyProgress = async (surveyId: string): Promise<SurveyProgres
}
};
export const patchSurveyProgressSnapshot = async (
surveyId: string,
snapshotPatch: Partial<SerializedSurveyState>
): Promise<void> => {
try {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_SURVEY_PROGRESS, "readwrite");
const store = tx.objectStore(STORE_SURVEY_PROGRESS);
const getRequest = store.get(surveyId);
getRequest.onsuccess = () => {
const existing = getRequest.result as SurveyProgressEntry | undefined;
if (!existing) {
resolve();
return;
}
const putRequest = store.put({
...existing,
surveyStateSnapshot: {
...existing.surveyStateSnapshot,
...snapshotPatch,
},
updatedAt: Date.now(),
});
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error ?? new Error("IndexedDB request failed"));
};
getRequest.onerror = () => reject(getRequest.error ?? new Error("IndexedDB request failed"));
});
} catch (e) {
console.warn("Formbricks: Failed to patch survey progress snapshot in IndexedDB", e);
}
};
export const clearSurveyProgress = async (surveyId: string): Promise<void> => {
try {
const db = await openDb();
+36 -2
View File
@@ -20,6 +20,7 @@ interface QueueConfig {
retryAttempts: number;
persistOffline?: boolean;
surveyId?: string;
onResponseCreated?: (responseId: string) => void;
onResponseSendingFailed?: (responseUpdate: TResponseUpdate, errorCode?: TResponseErrorCodesEnum) => void;
onResponseSendingFinished?: () => void;
onQuotaFull?: (quotaInfo: TQuotaFullResponse) => void;
@@ -359,6 +360,37 @@ export class ResponseQueue {
return error.details?.code === RECAPTCHA_VERIFICATION_ERROR_CODE;
}
private getCreatePayload(
responseUpdate: TResponseUpdate
): Omit<
Parameters<ApiClient["createResponse"]>[0],
"contactId" | "userId" | "singleUseId" | "surveyId" | "displayId" | "recaptchaToken"
> {
if (!this.surveyState.shouldCreateResponseFromState) {
return {
finished: responseUpdate.finished,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
ttc: responseUpdate.ttc,
variables: responseUpdate.variables,
language: responseUpdate.language,
meta: responseUpdate.meta,
endingId: responseUpdate.endingId,
};
}
const accumulatedResponse = this.surveyState.responseAcc;
return {
finished: accumulatedResponse.finished,
data: { ...accumulatedResponse.data, ...responseUpdate.hiddenFields },
ttc: accumulatedResponse.ttc,
variables: accumulatedResponse.variables,
language: accumulatedResponse.language ?? responseUpdate.language,
meta: accumulatedResponse.meta ?? responseUpdate.meta,
endingId: accumulatedResponse.endingId ?? responseUpdate.endingId,
};
}
private handleSuccessfulResponse(responseUpdate: TResponseUpdate, quotaFullResponse?: TQuotaFullResponse) {
if (responseUpdate.finished) {
this.config.onResponseSendingFinished?.();
@@ -399,13 +431,13 @@ export class ResponseQueue {
return err(response.error);
}
} else {
const createPayload = this.getCreatePayload(responseUpdate);
response = await this.api.createResponse({
...responseUpdate,
...createPayload,
surveyId: this.surveyState.surveyId,
contactId: this.surveyState.contactId || null,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
displayId: this.surveyState.displayId,
recaptchaToken: this.responseRecaptchaToken ?? undefined,
});
@@ -415,6 +447,8 @@ export class ResponseQueue {
}
this.surveyState.updateResponseId(response.data.id);
this.surveyState.disableBootstrapResponseCreate();
this.config.onResponseCreated?.(response.data.id);
if (this.config.setSurveyState) {
this.config.setSurveyState(this.surveyState);
}
@@ -38,11 +38,14 @@ const getSurveyState: () => SurveyState = () => ({
contactId: "contact1",
surveyId: "survey1",
singleUseId: "single1",
shouldCreateResponseFromState: false,
responseAcc: { finished: false, data: {}, ttc: {}, variables: {} },
updateResponseId: vi.fn(),
updateDisplayId: vi.fn(),
updateUserId: vi.fn(),
updateContactId: vi.fn(),
enableBootstrapResponseCreate: vi.fn(),
disableBootstrapResponseCreate: vi.fn(),
accumulateResponse: vi.fn(),
isResponseFinished: vi.fn(),
clear: vi.fn(),
@@ -191,6 +194,7 @@ describe("ResponseQueue", () => {
const result = await queue.sendResponse(responseUpdate);
expect(apiMock.createResponse).toHaveBeenCalled();
expect(surveyState.updateResponseId).toHaveBeenCalledWith("newid");
expect(surveyState.disableBootstrapResponseCreate).toHaveBeenCalled();
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
expect(result.ok).toBe(true);
});
@@ -14,6 +14,7 @@ describe("SurveyState", () => {
expect(surveyState.surveyId).toBe(initialSurveyId);
expect(surveyState.responseId).toBeNull();
expect(surveyState.displayId).toBeNull();
expect(surveyState.shouldCreateResponseFromState).toBe(false);
expect(surveyState.userId).toBeNull();
expect(surveyState.contactId).toBeNull();
expect(surveyState.singleUseId).toBeNull();
@@ -137,7 +138,7 @@ describe("SurveyState", () => {
expect(surveyState.responseAcc.finished).toBe(true);
expect(surveyState.responseAcc.data).toEqual({ q1: "newAns1", q2: "ans2" });
expect(surveyState.responseAcc.ttc).toEqual({ q2: 200 }); // ttc is overwritten
expect(surveyState.responseAcc.ttc).toEqual({ q1: 100, q2: 200 });
expect(surveyState.responseAcc.variables).toEqual({ varB: "valB" }); // variables are overwritten
expect(surveyState.responseAcc.displayId).toBe("display123");
});
@@ -158,9 +159,11 @@ describe("SurveyState", () => {
describe("clear", () => {
test("should reset responseId and responseAcc", () => {
surveyState.responseId = "someId";
surveyState.enableBootstrapResponseCreate();
surveyState.responseAcc = { finished: true, data: { q: "a" }, ttc: { q: 1 }, variables: { v: "1" } };
surveyState.clear();
expect(surveyState.responseId).toBeNull();
expect(surveyState.shouldCreateResponseFromState).toBe(false);
expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
});
});
+18 -4
View File
@@ -6,6 +6,7 @@ export class SurveyState {
userId: string | null = null;
contactId: string | null = null;
surveyId: string;
shouldCreateResponseFromState = false;
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
singleUseId: string | null;
@@ -59,7 +60,7 @@ export class SurveyState {
* Update the display ID after a successful display creation
* @param id - The display ID
*/
updateDisplayId(id: string) {
updateDisplayId(id: string | null) {
this.displayId = id;
}
@@ -79,6 +80,14 @@ export class SurveyState {
this.contactId = id;
}
enableBootstrapResponseCreate() {
this.shouldCreateResponseFromState = true;
}
disableBootstrapResponseCreate() {
this.shouldCreateResponseFromState = false;
}
/**
* Accumulate the responses
* @param responseUpdate - The new response data to add
@@ -86,10 +95,14 @@ export class SurveyState {
accumulateResponse(responseUpdate: TResponseUpdate) {
this.responseAcc = {
finished: responseUpdate.finished,
ttc: responseUpdate.ttc,
ttc: { ...this.responseAcc.ttc, ...responseUpdate.ttc },
data: { ...this.responseAcc.data, ...responseUpdate.data },
variables: responseUpdate.variables,
displayId: responseUpdate.displayId,
variables: responseUpdate.variables ?? this.responseAcc.variables,
displayId: responseUpdate.displayId ?? this.responseAcc.displayId,
language: responseUpdate.language ?? this.responseAcc.language,
meta: responseUpdate.meta ?? this.responseAcc.meta,
hiddenFields: responseUpdate.hiddenFields ?? this.responseAcc.hiddenFields,
endingId: responseUpdate.endingId,
};
}
@@ -105,6 +118,7 @@ export class SurveyState {
*/
clear() {
this.responseId = null;
this.shouldCreateResponseFromState = false;
this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
}
}