mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-09 02:28:38 -05:00
Compare commits
4 Commits
4.9.2-rc.1
...
4.9.4-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec8218666 | |||
| e1a44817f2 | |||
| 7f5b2bf69d | |||
| 60e7c7e8ee |
+44
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user