mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-29 11:30:11 -05:00
feat: react native sdk v2 (#4616)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
EXPO_PUBLIC_API_HOST=http://192.168.178.20:3000
|
||||
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clzr04nkd000bcdl110j0ijyq
|
||||
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
|
||||
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"name": "react-native-demo",
|
||||
"newArchEnabled": true,
|
||||
"orientation": "portrait",
|
||||
"slug": "react-native-demo",
|
||||
"splash": {
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/react-native": "workspace:*",
|
||||
"expo": "52.0.18",
|
||||
"expo-status-bar": "2.0.0",
|
||||
"@react-native-async-storage/async-storage": "2.1.0",
|
||||
"expo": "52.0.28",
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.5",
|
||||
"react-native": "0.76.6",
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react": "18.3.18",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React, { type JSX } from "react";
|
||||
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
|
||||
import Formbricks, { track } from "@formbricks/react-native";
|
||||
import Formbricks, {
|
||||
logout,
|
||||
setAttribute,
|
||||
setAttributes,
|
||||
setLanguage,
|
||||
setUserId,
|
||||
track,
|
||||
} from "@formbricks/react-native";
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
|
||||
@@ -10,35 +17,92 @@ export default function App(): JSX.Element {
|
||||
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
|
||||
}
|
||||
|
||||
if (!process.env.EXPO_PUBLIC_API_HOST) {
|
||||
throw new Error("EXPO_PUBLIC_API_HOST is required");
|
||||
if (!process.env.EXPO_PUBLIC_APP_URL) {
|
||||
throw new Error("EXPO_PUBLIC_APP_URL is required");
|
||||
}
|
||||
|
||||
const config = {
|
||||
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string,
|
||||
apiHost: process.env.EXPO_PUBLIC_API_HOST as string,
|
||||
userId: "random-user-id",
|
||||
attributes: {
|
||||
language: "en",
|
||||
testAttr: "attr-test",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Formbricks React Native SDK Demo</Text>
|
||||
|
||||
<Button
|
||||
title="Trigger Code Action"
|
||||
onPress={() => {
|
||||
track("code").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error tracking event:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}>
|
||||
<Button
|
||||
title="Trigger Code Action"
|
||||
onPress={() => {
|
||||
track("code").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error tracking event:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Id"
|
||||
onPress={() => {
|
||||
setUserId("random-user-id").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user id:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributess (multiple)"
|
||||
onPress={() => {
|
||||
setAttributes({
|
||||
testAttr: "attr-test",
|
||||
testAttr2: "attr-test-2",
|
||||
testAttr3: "attr-test-3",
|
||||
testAttr4: "attr-test-4",
|
||||
}).catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributes (single)"
|
||||
onPress={() => {
|
||||
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Logout"
|
||||
onPress={() => {
|
||||
logout().catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error logging out:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set Language (de)"
|
||||
onPress={() => {
|
||||
setLanguage("de").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting language:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<StatusBar style="auto" />
|
||||
<Formbricks initConfig={config} />
|
||||
|
||||
<Formbricks
|
||||
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
|
||||
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,17 +357,11 @@ Now, update your App.js/App.tsx file to initialize Formbricks:
|
||||
// other imports
|
||||
import Formbricks from "@formbricks/react-native";
|
||||
|
||||
const config = {
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user-id>", // optional
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
{/* Your app content */}
|
||||
<Formbricks initConfig={config} />
|
||||
<Formbricks appUrl="https://app.formbricks.com" environmentId="your-environment-id" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -381,7 +375,7 @@ export default function App() {
|
||||
<Property name="environment-id" type="string">
|
||||
Formbricks Environment ID.
|
||||
</Property>
|
||||
<Property name="api-host" type="string">
|
||||
<Property name="app-url" type="string">
|
||||
URL of the hosted Formbricks instance.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getSurveysForEnvironmentState } from "./survey";
|
||||
*/
|
||||
export const getEnvironmentState = async (
|
||||
environmentId: string
|
||||
): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
|
||||
): Promise<{ data: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
|
||||
cache(
|
||||
async () => {
|
||||
let revalidateEnvironment = false;
|
||||
@@ -102,14 +102,14 @@ export const getEnvironmentState = async (
|
||||
(survey) => survey.type === "app" && survey.status === "inProgress"
|
||||
);
|
||||
|
||||
const state: TJsEnvironmentState["data"] = {
|
||||
const data: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
|
||||
actionClasses,
|
||||
project: project,
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
data,
|
||||
revalidateEnvironment,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -36,16 +36,20 @@ export const GET = async (
|
||||
|
||||
try {
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data, revalidateEnvironment } = environmentState;
|
||||
|
||||
if (environmentState.revalidateEnvironment) {
|
||||
if (revalidateEnvironment) {
|
||||
environmentCache.revalidate({
|
||||
id: inputValidation.data.environmentId,
|
||||
projectId: environmentState.state.project.id,
|
||||
projectId: data.project.id,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
environmentState.state,
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
|
||||
},
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
||||
|
||||
export { POST, OPTIONS };
|
||||
@@ -102,7 +102,6 @@ export const PricingTable = ({
|
||||
throw new Error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,12 +45,12 @@ export const getContactsAction = authenticatedActionClient
|
||||
return getContacts(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue);
|
||||
});
|
||||
|
||||
const ZPersonDeleteAction = z.object({
|
||||
const ZContactDeleteAction = z.object({
|
||||
contactId: ZId,
|
||||
});
|
||||
|
||||
export const deleteContactAction = authenticatedActionClient
|
||||
.schema(ZPersonDeleteAction)
|
||||
.schema(ZContactDeleteAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export const getContactByUserIdWithAttributes = reactCache(
|
||||
|
||||
return contact;
|
||||
},
|
||||
[`getContactByUserId-${environmentId}-${userId}-${JSON.stringify(updatedAttributes)}`],
|
||||
[`getContactByUserIdWithAttributes-${environmentId}-${userId}-${JSON.stringify(updatedAttributes)}`],
|
||||
{
|
||||
tags: [
|
||||
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
|
||||
+4
-25
@@ -1,10 +1,10 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsContactsUpdateAttributeInput } from "@formbricks/types/js";
|
||||
import { updateAttributes } from "./lib/attributes";
|
||||
import { getContactByUserIdWithAttributes } from "./lib/contact";
|
||||
|
||||
export const OPTIONS = async () => {
|
||||
@@ -74,36 +74,15 @@ export const PUT = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { details: updateAttrDetails } = await updateAttributes(
|
||||
contact.id,
|
||||
userId,
|
||||
environmentId,
|
||||
updatedAttributes
|
||||
);
|
||||
|
||||
// if userIdAttr or idAttr was in the payload, we need to inform the user that it was ignored
|
||||
const details: Record<string, string> = {};
|
||||
if (userIdAttr) {
|
||||
details.userId = "updating userId is ignored as it is a reserved field and cannot be updated.";
|
||||
}
|
||||
|
||||
if (idAttr) {
|
||||
details.id = "updating id is ignored as it is a reserved field and cannot be updated.";
|
||||
}
|
||||
|
||||
if (updateAttrDetails && Object.keys(updateAttrDetails).length > 0) {
|
||||
Object.entries(updateAttrDetails).forEach(([key, value]) => {
|
||||
details[key] = value;
|
||||
});
|
||||
}
|
||||
const { messages } = await updateAttributes(contact.id, userId, environmentId, updatedAttributes);
|
||||
|
||||
return responses.successResponse(
|
||||
{
|
||||
changed: true,
|
||||
message: "The person was successfully updated.",
|
||||
...(Object.keys(details).length > 0
|
||||
...(messages && messages.length > 0
|
||||
? {
|
||||
details,
|
||||
messages,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { contactCache } from "@/lib/cache/contact";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsPersonIdentifyInput } from "@formbricks/types/js";
|
||||
import { ZJsUserIdentifyInput } from "@formbricks/types/js";
|
||||
import { getPersonState } from "./lib/personState";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
@@ -21,7 +21,7 @@ export const GET = async (
|
||||
const { environmentId, userId } = params;
|
||||
|
||||
// Validate input
|
||||
const syncInputValidation = ZJsPersonIdentifyInput.safeParse({
|
||||
const syncInputValidation = ZJsUserIdentifyInput.safeParse({
|
||||
environmentId,
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
|
||||
export const getContactByUserIdWithAttributes = reactCache((environmentId: string, userId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
environmentId,
|
||||
attributes: { some: { attributeKey: { key: "userId", environmentId }, value: userId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
},
|
||||
[`getContactByUserIdWithAttributes-${environmentId}-${userId}`],
|
||||
{
|
||||
tags: [
|
||||
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
contactAttributeKeyCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TBaseFilter } from "@formbricks/types/segment";
|
||||
|
||||
const getSegments = reactCache((environmentId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
return prisma.segment.findMany({
|
||||
where: { environmentId },
|
||||
select: { id: true, filters: true },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getSegments-environmentId-${environmentId}`],
|
||||
{
|
||||
tags: [segmentCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getPersonSegmentIds = (
|
||||
environmentId: string,
|
||||
contactId: string,
|
||||
contactUserId: string,
|
||||
attributes: Record<string, string>,
|
||||
deviceType: "phone" | "desktop"
|
||||
): Promise<string[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [contactId, ZId], [contactUserId, ZString]);
|
||||
|
||||
const segments = await getSegments(environmentId);
|
||||
|
||||
// fast path; if there are no segments, return an empty array
|
||||
if (!segments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const personSegments: { id: string; filters: TBaseFilter[] }[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const isIncluded = await evaluateSegment(
|
||||
{
|
||||
attributes,
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId: contactId,
|
||||
userId: contactUserId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
if (isIncluded) {
|
||||
personSegments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return personSegments.map((segment) => segment.id);
|
||||
},
|
||||
[`getPersonSegmentIds-${environmentId}-${contactId}-${deviceType}`],
|
||||
{
|
||||
tags: [
|
||||
segmentCache.tag.byEnvironmentId(environmentId),
|
||||
contactAttributeCache.tag.byContactId(contactId),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getContactByUserIdWithAttributes } from "./contact";
|
||||
import { getUserState } from "./user-state";
|
||||
|
||||
export const updateUser = async (
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
device: "phone" | "desktop",
|
||||
attributes?: Record<string, string>
|
||||
): Promise<{ state: TJsPersonState; messages?: string[] }> => {
|
||||
const environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(`environment`, environmentId);
|
||||
}
|
||||
|
||||
let contact = await getContactByUserIdWithAttributes(environmentId, userId);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
create: [
|
||||
{
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key: "userId", environmentId } },
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
contactCache.revalidate({
|
||||
environmentId,
|
||||
userId,
|
||||
id: contact.id,
|
||||
});
|
||||
}
|
||||
|
||||
let contactAttributes = contact.attributes.reduce(
|
||||
(acc, ctx) => {
|
||||
acc[ctx.attributeKey.key] = ctx.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
// update the contact attributes if needed:
|
||||
let messages: string[] = [];
|
||||
let language = contactAttributes.language;
|
||||
|
||||
if (attributes && Object.keys(attributes).length > 0) {
|
||||
let shouldUpdate = false;
|
||||
const oldAttributes = contact.attributes.reduce(
|
||||
(acc, ctx) => {
|
||||
acc[ctx.attributeKey.key] = ctx.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (value !== oldAttributes[key]) {
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
const { success, messages: updateAttrMessages } = await updateAttributes(
|
||||
contact.id,
|
||||
userId,
|
||||
environmentId,
|
||||
attributes
|
||||
);
|
||||
|
||||
messages = updateAttrMessages ?? [];
|
||||
|
||||
// If the attributes update was successful and the language attribute was provided, set the language
|
||||
if (success) {
|
||||
contactAttributes = {
|
||||
...contactAttributes,
|
||||
...attributes,
|
||||
};
|
||||
|
||||
if (attributes.language) {
|
||||
language = attributes.language;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userState = await getUserState({
|
||||
environmentId,
|
||||
userId,
|
||||
contactId: contact.id,
|
||||
attributes: contactAttributes,
|
||||
device,
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
data: {
|
||||
...userState,
|
||||
language,
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
|
||||
},
|
||||
messages,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @param attributes - The contact attributes
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
*/
|
||||
export const getUserState = async ({
|
||||
environmentId,
|
||||
userId,
|
||||
contactId,
|
||||
device,
|
||||
attributes,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
contactId: string;
|
||||
device: "phone" | "desktop";
|
||||
attributes: Record<string, string>;
|
||||
}): Promise<TJsPersonState["data"]> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contactResponses = await prisma.response.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: {
|
||||
surveyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const contactDisplays = await prisma.display.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: {
|
||||
surveyId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const segments = await getPersonSegmentIds(environmentId, contactId, userId, attributes, device);
|
||||
|
||||
// If the person exists, return the persons's state
|
||||
const userState: TJsPersonState["data"] = {
|
||||
userId,
|
||||
segments,
|
||||
displays:
|
||||
contactDisplays?.map((display) => ({
|
||||
surveyId: display.surveyId,
|
||||
createdAt: display.createdAt,
|
||||
})) ?? [],
|
||||
responses: contactResponses?.map((response) => response.surveyId) ?? [],
|
||||
lastDisplayAt:
|
||||
contactDisplays.length > 0
|
||||
? contactDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt
|
||||
: null,
|
||||
};
|
||||
|
||||
return userState;
|
||||
},
|
||||
[`personState-${environmentId}-${userId}-${device}`],
|
||||
{
|
||||
tags: [
|
||||
environmentCache.tag.byId(environmentId),
|
||||
organizationCache.tag.byEnvironmentId(environmentId),
|
||||
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
responseCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
|
||||
segmentCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -0,0 +1,92 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState, ZJsUserIdentifyInput, ZJsUserUpdateInput } from "@formbricks/types/js";
|
||||
import { updateUser } from "./lib/update-user";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ environmentId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
const { environmentId } = params;
|
||||
const jsonInput = await request.json();
|
||||
|
||||
// Validate input
|
||||
const syncInputValidation = ZJsUserIdentifyInput.pick({ environmentId: true }).safeParse({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!syncInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(syncInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const parsedInput = ZJsUserUpdateInput.safeParse(jsonInput);
|
||||
if (!parsedInput.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(parsedInput.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { userId, attributes } = parsedInput.data;
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
|
||||
let attributeUpdatesToSend: TContactAttributes | null = null;
|
||||
if (attributes) {
|
||||
// remove userId and id from attributes
|
||||
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = attributes;
|
||||
attributeUpdatesToSend = updatedAttributes;
|
||||
}
|
||||
|
||||
const { device } = userAgent(request);
|
||||
const deviceType = device ? "phone" : "desktop";
|
||||
|
||||
try {
|
||||
const { state: userState, messages } = await updateUser(
|
||||
environmentId,
|
||||
userId,
|
||||
deviceType,
|
||||
attributeUpdatesToSend ?? undefined
|
||||
);
|
||||
|
||||
let responseJson: { state: TJsPersonState; messages?: string[] } = {
|
||||
state: userState,
|
||||
};
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
responseJson.messages = messages;
|
||||
}
|
||||
|
||||
return responses.successResponse(responseJson, true);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
|
||||
}
|
||||
};
|
||||
+17
-43
@@ -1,44 +1,18 @@
|
||||
import "server-only";
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@formbricks/lib/constants";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContactAttributeKeys = reactCache((environmentId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
const contactAttributes = await prisma.contactAttributeKey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
return contactAttributes;
|
||||
},
|
||||
[`getContactAttributeKeys-attributes-api-${environmentId}`],
|
||||
{
|
||||
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
import { getContactAttributeKeys } from "./contacts";
|
||||
|
||||
export const updateAttributes = async (
|
||||
contactId: string,
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
contactAttributesParam: TContactAttributes
|
||||
): Promise<{ success: boolean; details?: Record<string, string> }> => {
|
||||
): Promise<{ success: boolean; messages?: string[] }> => {
|
||||
validateInputs(
|
||||
[contactId, ZId],
|
||||
[userId, ZString],
|
||||
@@ -96,9 +70,9 @@ export const updateAttributes = async (
|
||||
}
|
||||
);
|
||||
|
||||
let details: Record<string, string> = emailExists
|
||||
? { email: "The email already exists for this environment and was not updated." }
|
||||
: {};
|
||||
let messages: string[] = emailExists
|
||||
? ["The email already exists for this environment and was not updated."]
|
||||
: [];
|
||||
|
||||
// First, update all existing attributes
|
||||
if (existingAttributes.length > 0) {
|
||||
@@ -122,9 +96,9 @@ export const updateAttributes = async (
|
||||
);
|
||||
|
||||
// Revalidate cache for existing attributes
|
||||
existingAttributes.map(({ key }) =>
|
||||
contactAttributeCache.revalidate({ environmentId, contactId, userId, key })
|
||||
);
|
||||
for (const attribute of existingAttributes) {
|
||||
contactAttributeCache.revalidate({ environmentId, contactId, userId, key: attribute.key });
|
||||
}
|
||||
}
|
||||
|
||||
// Then, try to create new attributes if any exist
|
||||
@@ -133,10 +107,9 @@ export const updateAttributes = async (
|
||||
|
||||
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
|
||||
// Add warning to details about skipped attributes
|
||||
details = {
|
||||
...details,
|
||||
newAttributes: `Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`,
|
||||
};
|
||||
messages.push(
|
||||
`Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`
|
||||
);
|
||||
} else {
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
@@ -155,16 +128,17 @@ export const updateAttributes = async (
|
||||
);
|
||||
|
||||
// Batch revalidate caches for new attributes
|
||||
newAttributes.forEach(({ key }) => {
|
||||
contactAttributeKeyCache.revalidate({ environmentId, key });
|
||||
contactAttributeCache.revalidate({ environmentId, contactId, userId, key });
|
||||
});
|
||||
for (const attribute of newAttributes) {
|
||||
contactAttributeKeyCache.revalidate({ environmentId, key: attribute.key });
|
||||
contactAttributeCache.revalidate({ environmentId, contactId, userId, key: attribute.key });
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({ environmentId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...(Object.keys(details).length > 0 ? { details } : {}),
|
||||
messages,
|
||||
};
|
||||
};
|
||||
@@ -148,9 +148,12 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
|
||||
select: selectContact,
|
||||
});
|
||||
|
||||
const contactUserId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
|
||||
|
||||
contactCache.revalidate({
|
||||
id: contact.id,
|
||||
environmentId: contact.environmentId,
|
||||
userId: contactUserId,
|
||||
});
|
||||
|
||||
return contact;
|
||||
|
||||
-1
@@ -56,7 +56,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
setIsDeleting(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
setIsDeleting(false);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user