feat: surveys package integration with v2 apis (#4882)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-03-07 18:11:14 +05:30
committed by GitHub
parent fe54ef66c6
commit f099a46f83
18 changed files with 254 additions and 147 deletions

View File

@@ -0,0 +1,26 @@
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
export const doesContactExist = reactCache(
(id: string): Promise<boolean> =>
cache(
async () => {
const contact = await prisma.contact.findFirst({
where: {
id,
},
select: {
id: true,
},
});
return !!contact;
},
[`doesContactExistDisplaysApiV2-${id}`],
{
tags: [contactCache.tag.byId(id)],
}
)()
);

View File

@@ -0,0 +1,54 @@
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { displayCache } from "@formbricks/lib/display/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { DatabaseError } from "@formbricks/types/errors";
import { doesContactExist } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
const { environmentId, contactId, surveyId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const display = await prisma.display.create({
data: {
survey: {
connect: {
id: surveyId,
},
},
...(contactExists && {
contact: {
connect: {
id: contactId,
},
},
}),
},
select: { id: true, contactId: true, surveyId: true },
});
displayCache.revalidate({
id: display.id,
contactId: display.contactId,
surveyId: display.surveyId,
environmentId,
});
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,3 +1,55 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/displays/route";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
import { InvalidInputError } from "@formbricks/types/errors";
import { createDisplay } from "./lib/display";
export { OPTIONS, POST };
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (inputValidation.data.contactId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
};

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
export const ZDisplayCreateInputV2 = ZDisplayCreateInput.omit({ userId: true }).extend({
contactId: ZId.optional(),
});
export type TDisplayCreateInputV2 = z.infer<typeof ZDisplayCreateInputV2>;

View File

@@ -18,6 +18,7 @@ export class UserAPI {
expiresAt: Date | null;
data: {
userId: string | null;
contactId: string | null;
segments: string[];
displays: { surveyId: string; createdAt: Date }[];
responses: string[];
@@ -36,7 +37,7 @@ export class UserAPI {
attributes[key] = String(userUpdateInput.attributes[key]);
}
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/user`, "POST", {
return makeRequest(this.apiHost, `/api/v2/client/${this.environmentId}/user`, "POST", {
userId: userUpdateInput.userId,
attributes,
});

View File

@@ -4,11 +4,7 @@ import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsConfig, type TJsConfigInput } from "@formbricks/types/js";
import { updateAttributes } from "./attributes";
import { Config } from "./config";
import {
JS_LOCAL_STORAGE_KEY,
LEGACY_JS_APP_LOCAL_STORAGE_KEY,
LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY,
} from "./constants";
import { JS_LOCAL_STORAGE_KEY } from "./constants";
import { fetchEnvironmentState } from "./environment-state";
import {
ErrorHandler,
@@ -34,105 +30,21 @@ export const setIsInitialized = (value: boolean): void => {
isInitialized = value;
};
const migrateLocalStorage = (): { changed: boolean; newState?: TJsConfig } => {
const oldWebsiteConfig = localStorage.getItem(LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY);
const oldAppConfig = localStorage.getItem(LEGACY_JS_APP_LOCAL_STORAGE_KEY);
// If the js sdk is being used with user identification but there is no contactId, we can just resync
export const migrateUserStateAddContactId = (): { changed: boolean } => {
const existingConfigString = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
if (oldWebsiteConfig) {
localStorage.removeItem(LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY);
const parsedOldConfig = JSON.parse(oldWebsiteConfig) as Partial<{
environmentId: string;
apiHost: string;
environmentState: TJsConfig["environmentState"];
personState: TJsConfig["personState"];
filteredSurveys: TJsConfig["filteredSurveys"];
}>;
if (existingConfigString) {
const existingConfig = JSON.parse(existingConfigString) as Partial<TJsConfig>;
if (
parsedOldConfig.environmentId &&
parsedOldConfig.apiHost &&
parsedOldConfig.environmentState &&
parsedOldConfig.personState &&
parsedOldConfig.filteredSurveys
) {
const newLocalStorageConfig = { ...parsedOldConfig };
return {
changed: true,
newState: newLocalStorageConfig as TJsConfig,
};
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
if (existingConfig.personState?.data?.contactId) {
return { changed: false };
}
}
if (oldAppConfig) {
localStorage.removeItem(LEGACY_JS_APP_LOCAL_STORAGE_KEY);
const parsedOldConfig = JSON.parse(oldAppConfig) as Partial<{
environmentId: string;
apiHost: string;
environmentState: TJsConfig["environmentState"];
personState: TJsConfig["personState"];
filteredSurveys: TJsConfig["filteredSurveys"];
}>;
if (
parsedOldConfig.environmentId &&
parsedOldConfig.apiHost &&
parsedOldConfig.environmentState &&
parsedOldConfig.personState &&
parsedOldConfig.filteredSurveys
) {
return {
changed: true,
};
}
}
return {
changed: false,
};
};
const migrateProductToProject = (): { changed: boolean; newState?: TJsConfig } => {
const existingConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
if (existingConfig) {
const parsedConfig = JSON.parse(existingConfig) as TJsConfig;
// @ts-expect-error - product is not in the type
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- environmentState could be undefined in an error state
if (parsedConfig.environmentState?.data?.product) {
const { environmentState: _, filteredSurveys, ...restConfig } = parsedConfig;
const fixedFilteredSurveys = filteredSurveys.map((survey) => {
// @ts-expect-error - productOverwrites is not in the type
const { productOverwrites, ...rest } = survey;
return {
...rest,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- productOverwrites is not in the type
projectOverwrites: productOverwrites,
};
});
// @ts-expect-error - product is not in the type
const { product, ...rest } = parsedConfig.environmentState.data;
const newLocalStorageConfig = {
...restConfig,
environmentState: {
...parsedConfig.environmentState,
data: {
...rest,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- product is not in the type
project: product,
},
},
filteredSurveys: fixedFilteredSurveys,
};
return {
changed: true,
newState: newLocalStorageConfig,
};
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
if (!existingConfig.personState?.data?.contactId && existingConfig.personState?.data?.userId) {
return { changed: true };
}
}
@@ -149,28 +61,11 @@ export const initialize = async (
let config = Config.getInstance();
const { changed, newState } = migrateLocalStorage();
const { changed } = migrateUserStateAddContactId();
if (changed) {
config.resetConfig();
config = Config.getInstance();
// If the js sdk is being used for non identified users, and we have a new state to update to after migrating, we update the state
// otherwise, we just sync again!
if (!configInput.userId && newState) {
config.update(newState);
}
}
const { changed: migrated, newState: updatedLocalState } = migrateProductToProject();
if (migrated) {
config.resetConfig();
config = Config.getInstance();
if (updatedLocalState) {
config.update(updatedLocalState);
}
}
if (isInitialized) {

View File

@@ -88,7 +88,7 @@ const renderWidget = async (
formbricksSurveys.renderSurvey({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().personState.data.userId ?? undefined,
contactId: config.get().personState.data.contactId ?? undefined,
action,
survey,
isBrandingEnabled,

View File

@@ -105,7 +105,7 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
html: renderHtml({
apiHost: appConfig.get().appUrl,
environmentId: appConfig.get().environmentId,
userId: appConfig.get().user.data.userId ?? undefined,
contactId: appConfig.get().user.data.contactId ?? undefined,
survey,
isBrandingEnabled,
styling,

View File

@@ -27,12 +27,39 @@ export const setIsSetup = (state: boolean): void => {
isSetup = state;
};
export const migrateUserStateAddContactId = async (): Promise<{ changed: boolean }> => {
const existingConfigString = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
if (existingConfigString) {
const existingConfig = JSON.parse(existingConfigString) as Partial<TConfig>;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
if (existingConfig.user?.data?.contactId) {
return { changed: false };
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined
if (!existingConfig.user?.data?.contactId && existingConfig.user?.data?.userId) {
return { changed: true };
}
}
return { changed: false };
};
export const setup = async (
configInput: TConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
const appConfig = RNConfig.getInstance();
let appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
const { changed } = await migrateUserStateAddContactId();
if (changed) {
await appConfig.resetConfig();
appConfig = RNConfig.getInstance();
}
if (isSetup) {
logger.debug("Already set up, skipping setup.");
return okVoid();

View File

@@ -1,4 +1,5 @@
// utils.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mockProjectId, mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock";
import {
diffInDays,
@@ -16,7 +17,6 @@ import type {
TSurveyStyling,
TUserState,
} from "@/types/config";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
const mockSurveyId2 = "qo9rwjmms42hoy3k85fp8vgu";
@@ -120,6 +120,7 @@ describe("utils.ts", () => {
expiresAt: null,
data: {
userId: null,
contactId: null,
segments: [],
displays: [],
responses: [],

View File

@@ -7,6 +7,7 @@ export const DEFAULT_USER_STATE_NO_USER_ID: TUserState = {
expiresAt: null,
data: {
userId: null,
contactId: null,
segments: [],
displays: [],
responses: [],

View File

@@ -53,6 +53,7 @@ export interface TUserState {
expiresAt: Date | null;
data: {
userId: string | null;
contactId: string | null;
segments: string[];
displays: { surveyId: string; createdAt: Date }[];
responses: string[];

View File

@@ -1,3 +1,4 @@
import { type TJsFileUploadParams } from "../../../types/js";
import type { TEnvironmentStateSurvey, TProjectStyling, TSurveyStyling } from "@/types/config";
import type { TResponseData, TResponseUpdate } from "@/types/response";
import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage";
@@ -37,15 +38,20 @@ export interface SurveyInlineProps extends SurveyBaseProps {
}
export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUpload"> {
apiHost: string;
environmentId: string;
apiHost?: string;
environmentId?: string;
userId?: string;
onDisplayCreated?: () => void;
onResponseCreated?: () => void;
contactId?: string;
onDisplayCreated?: () => void | Promise<void>;
onResponseCreated?: () => void | Promise<void>;
onFileUpload?: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
onOpenExternalURL?: (url: string) => void | Promise<void>;
mode?: "modal" | "inline";
containerId?: string;
clickOutside?: boolean;
darkOverlay?: boolean;
placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
action?: string;
singleUseId?: string;
singleUseResponseId?: string;
}

View File

@@ -34,6 +34,11 @@ interface VariableStackEntry {
}
export function Survey({
apiHost,
environmentId,
userId,
contactId,
mode,
survey,
styling,
isBrandingEnabled,
@@ -43,6 +48,9 @@ export function Survey({
onClose,
onFinished,
onRetry,
onDisplayCreated,
onResponseCreated,
onOpenExternalURL,
isRedirectDisabled = false,
prefillResponseData,
skipPrefilled,
@@ -58,16 +66,9 @@ export function Survey({
shouldResetQuestionId,
fullSizeCards = false,
autoFocus,
apiHost,
environmentId,
userId,
action,
onDisplayCreated,
onResponseCreated,
singleUseId,
singleUseResponseId,
mode,
onOpenExternalURL,
}: SurveyContainerProps) {
let apiClient: ApiClient | null = null;
@@ -81,13 +82,13 @@ export function Survey({
const surveyState = useMemo(() => {
if (apiHost && environmentId) {
if (mode === "inline") {
return new SurveyState(survey.id, singleUseId, singleUseResponseId, userId);
return new SurveyState(survey.id, singleUseId, singleUseResponseId, userId, contactId);
}
return new SurveyState(survey.id, null, null, userId);
return new SurveyState(survey.id, null, null, userId, contactId);
}
return null;
}, [survey.id, userId, apiHost, environmentId, singleUseId, singleUseResponseId, mode]);
}, [apiHost, environmentId, mode, survey.id, userId, singleUseId, singleUseResponseId, contactId]);
// Update the responseQueue to use the stored responseId
const responseQueue = useMemo(() => {
@@ -208,6 +209,7 @@ export function Survey({
const display = await apiClient.createDisplay({
surveyId: survey.id,
...(userId && { userId }),
...(contactId && { contactId }),
});
if (!display.ok) {
@@ -225,7 +227,7 @@ export function Survey({
console.error("error creating display: ", err);
}
}
}, [apiClient, survey, userId, onDisplayCreated, surveyState, responseQueue]);
}, [apiClient, surveyState, responseQueue, survey.id, userId, contactId, onDisplayCreated]);
useEffect(() => {
// call onDisplay when component is mounted
@@ -382,6 +384,10 @@ export function Survey({
const onResponseCreateOrUpdate = useCallback(
(responseUpdate: TResponseUpdate) => {
if (surveyState && responseQueue) {
if (contactId) {
surveyState.updateContactId(contactId);
}
if (userId) {
surveyState.updateUserId(userId);
}
@@ -407,7 +413,7 @@ export function Survey({
}
}
},
[surveyState, responseQueue, userId, survey, action, hiddenFieldsRecord, onResponseCreated]
[surveyState, responseQueue, contactId, userId, survey, action, hiddenFieldsRecord, onResponseCreated]
);
useEffect(() => {

View File

@@ -16,15 +16,29 @@ export class ApiClient {
}
async createDisplay(
displayInput: Omit<TDisplayCreateInput, "environmentId">
displayInput: Omit<TDisplayCreateInput, "environmentId"> & { contactId?: string }
): Promise<Result<{ id: string }, ApiErrorResponse>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
const fromV1 = !!displayInput.userId;
return makeRequest(
this.apiHost,
`/api/${fromV1 ? "v1" : "v2"}/client/${this.environmentId}/displays`,
"POST",
displayInput
);
}
async createResponse(
responseInput: Omit<TResponseInput, "environmentId">
responseInput: Omit<TResponseInput, "environmentId"> & { contactId: string | null }
): Promise<Result<{ id: string }, ApiErrorResponse>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
const fromV1 = !!responseInput.userId;
return makeRequest(
this.apiHost,
`/api/${fromV1 ? "v1" : "v2"}/client/${this.environmentId}/responses`,
"POST",
responseInput
);
}
async updateResponse({

View File

@@ -89,6 +89,7 @@ export class ResponseQueue {
const response = await this.api.createResponse({
...responseUpdate,
surveyId: this.surveyState.surveyId,
contactId: this.surveyState.contactId || null,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },

View File

@@ -4,6 +4,7 @@ export class SurveyState {
responseId: string | null = null;
displayId: string | null = null;
userId: string | null = null;
contactId: string | null = null;
surveyId: string;
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
singleUseId: string | null;
@@ -12,12 +13,14 @@ export class SurveyState {
surveyId: string,
singleUseId?: string | null,
responseId?: string | null,
userId?: string | null
userId?: string | null,
contactId?: string | null
) {
this.surveyId = surveyId;
this.userId = userId ?? null;
this.singleUseId = singleUseId ?? null;
this.responseId = responseId ?? null;
this.contactId = contactId ?? null;
}
/**
@@ -36,7 +39,8 @@ export class SurveyState {
this.surveyId,
this.singleUseId ?? undefined,
this.responseId ?? undefined,
this.userId ?? undefined
this.userId ?? undefined,
this.contactId ?? undefined
);
copyInstance.responseId = this.responseId;
copyInstance.responseAcc = this.responseAcc;
@@ -67,6 +71,14 @@ export class SurveyState {
this.userId = id;
}
/**
* Update the contact ID
* @param id - The contact ID
*/
updateContactId(id: string) {
this.contactId = id;
}
/**
* Accumulate the responses
* @param responseUpdate - The new response data to add

View File

@@ -46,6 +46,7 @@ export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUploa
apiHost?: string;
environmentId?: string;
userId?: string;
contactId?: string;
onDisplayCreated?: () => void | Promise<void>;
onResponseCreated?: () => void | Promise<void>;
onFileUpload?: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;