mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-17 19:49:36 -05:00
Compare commits
2 Commits
4.9.2
...
4.9.3-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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."),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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