chore: handle people and attributes separately to improve sync performance (#2476)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Matti Nannt
2024-04-25 18:16:52 +02:00
committed by GitHub
parent 5ff6e88b3b
commit 183d32784f
46 changed files with 825 additions and 602 deletions
-1
View File
@@ -1,4 +1,3 @@
import { MonitorIcon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
@@ -1,9 +1,10 @@
import { getAttributes } from "@formbricks/lib/attribute/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
export default async function AttributesSection({ personId }: { personId: string }) {
const person = await getPerson(personId);
const [person, attributes] = await Promise.all([getPerson(personId), getAttributes(personId)]);
if (!person) {
throw new Error("No such person found");
}
@@ -18,8 +19,8 @@ export default async function AttributesSection({ personId }: { personId: string
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.email ? (
<span>{person.attributes.email}</span>
{attributes.email ? (
<span>{attributes.email}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
@@ -28,8 +29,8 @@ export default async function AttributesSection({ personId }: { personId: string
<div>
<dt className="text-sm font-medium text-slate-500">Language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.language ? (
<span>{person.attributes.language}</span>
{attributes.language ? (
<span>{attributes.language}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
@@ -50,8 +51,8 @@ export default async function AttributesSection({ personId }: { personId: string
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
{Object.entries(person.attributes)
.filter(([key, _]) => key !== "email" && key !== "language")
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { getAttributes } from "@formbricks/lib/attribute/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -31,6 +32,9 @@ export default async function HeadingSection({ environmentId, personId }: Headin
if (!person) {
throw new Error("No such person found");
}
const personAttributes = await getAttributes(person.id);
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
@@ -39,7 +43,7 @@ export default async function HeadingSection({ environmentId, personId }: Headin
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span>{getPersonIdentifier(person)}</span>
<span>{getPersonIdentifier(person, personAttributes)}</span>
</h1>
{!isViewer && (
<div className="flex items-center space-x-3">
@@ -0,0 +1,39 @@
import Link from "next/link";
import React from "react";
import { getAttributes } from "@formbricks/lib/attribute/service";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { TPerson } from "@formbricks/types/people";
import { PersonAvatar } from "@formbricks/ui/Avatars";
export const PersonCard = async ({ person }: { person: TPerson }) => {
const attributes = await getAttributes(person.id);
return (
<Link
href={`/environments/${person.environmentId}/people/${person.id}`}
key={person.id}
className="w-full">
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
<PersonAvatar personId={person.id} />
</div>
<div className="ml-4">
<div className="ph-no-capture font-medium text-slate-900">
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{person.userId}</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
</div>
</div>
</Link>
);
};
@@ -1,16 +1,13 @@
import HowToAddPeopleButton from "@/app/(app)/environments/[environmentId]/components/HowToAddPeopleButton";
import Link from "next/link";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getPeople, getPeopleCount } from "@formbricks/lib/person/service";
import { TPerson } from "@formbricks/types/people";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { Pagination } from "@formbricks/ui/Pagination";
const getAttributeValue = (person: TPerson, attributeName: string) =>
person.attributes[attributeName]?.toString();
import { PersonCard } from "./components/PersonCard";
export default async function PeoplePage({
params,
@@ -60,35 +57,7 @@ export default async function PeoplePage({
<div className="col-span-2 hidden text-center sm:block">Email</div>
</div>
{people.map((person) => (
<Link
href={`/environments/${params.environmentId}/people/${person.id}`}
key={person.id}
className="w-full">
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
<PersonAvatar personId={person.id} />
</div>
<div className="ml-4">
<div className="ph-no-capture font-medium text-slate-900">
{getAttributeValue(person, "email") ? (
<span>{getAttributeValue(person, "email")}</span>
) : (
<span>{person.id}</span>
)}
</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{person.userId}</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{getAttributeValue(person, "email")}</div>
</div>
</div>
</Link>
<PersonCard person={person} />
))}
</div>
)}
@@ -38,7 +38,7 @@ export const AddressSummary = ({ questionSummary, environmentId }: AddressSummar
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
@@ -48,7 +48,7 @@ export const DateQuestionSummary = ({ questionSummary, environmentId }: DateQues
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
@@ -49,7 +49,7 @@ export const FileUploadSummary = ({ questionSummary, environmentId }: FileUpload
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
@@ -59,7 +59,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary }: HiddenFiel
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
@@ -97,7 +97,7 @@ export const MultipleChoiceSummary = ({
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.person.id && <PersonAvatar personId={otherValue.person.id} />}
<span>{getPersonIdentifier(otherValue.person)}</span>
<span>{getPersonIdentifier(otherValue.person, otherValue.personAttributes)}</span>
</div>
</Link>
)}
@@ -47,7 +47,7 @@ export const OpenTextSummary = ({ questionSummary, environmentId }: OpenTextSumm
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
@@ -1,8 +1,12 @@
// Deprecated since 2024-04-13
// last supported js version 1.6.5
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { z } from "zod";
import { createPerson, getPersonByUserId, updatePerson } from "@formbricks/lib/person/service";
import { ZPersonUpdateInput } from "@formbricks/types/people";
import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { ZAttributes } from "@formbricks/types/attributes";
interface Context {
params: {
@@ -21,7 +25,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZPersonUpdateInput.safeParse(jsonInput);
const inputValidation = z.object({ attributes: ZAttributes }).safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -42,8 +46,8 @@ export async function POST(req: Request, context: Context): Promise<Response> {
person = await createPerson(environmentId, userId);
}
// Check if the person is already up to date
const oldAttributes = person.attributes;
const oldAttributes = await getAttributesByUserId(environmentId, userId);
let isUpToDate = true;
for (const key in updatedAttributes) {
if (updatedAttributes[key] !== oldAttributes[key]) {
@@ -51,6 +55,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
break;
}
}
if (isUpToDate) {
return responses.successResponse(
{
@@ -61,7 +66,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
);
}
await updatePerson(person.id, inputValidation.data);
await updateAttributes(person.id, updatedAttributes);
return responses.successResponse(
{
@@ -2,14 +2,14 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { updateAttributes } from "@formbricks/lib/attribute/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TJsAppStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
interface Context {
params: {
@@ -47,19 +47,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
await updateAttributes(personId, { [key]: value });
personCache.revalidate({
id: personId,
@@ -87,7 +75,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
}
// return state
const state: TJsAppStateSync = {
const state = {
person: { id: person.id, userId: person.userId },
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
@@ -2,14 +2,14 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { updateAttributes } from "@formbricks/lib/attribute/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TJsAppStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
interface Context {
params: {
@@ -46,19 +46,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
await updateAttributes(personId, { [key]: value });
personCache.revalidate({
id: personId,
@@ -86,7 +74,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
}
// return state
const state: TJsAppStateSync = {
const state = {
person: { id: person.id, userId: person.userId },
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
@@ -2,12 +2,11 @@ import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { updateAttributes } from "@formbricks/lib/attribute/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getPerson } from "@formbricks/lib/person/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleLegacyAttributeInput } from "@formbricks/types/js";
import { TPersonClient } from "@formbricks/types/people";
export async function OPTIONS(): Promise<Response> {
return responses.successResponse({}, true);
@@ -42,19 +41,7 @@ export async function POST(req: Request, { params }): Promise<Response> {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
await updateAttributes(personId, { [key]: value });
personCache.revalidate({
id: personId,
@@ -67,7 +54,7 @@ export async function POST(req: Request, { params }): Promise<Response> {
const state = await getUpdatedState(environmentId, personId);
let person: TPersonClient | null = null;
let person: { id: string; userId: string } | null = null;
if (state.person && "id" in state.person && "userId" in state.person) {
person = {
id: state.person.id,
@@ -3,7 +3,6 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ZJsSyncLegacyInput } from "@formbricks/types/js";
import { TPersonClient } from "@formbricks/types/people";
export async function OPTIONS(): Promise<Response> {
return responses.successResponse({}, true);
@@ -28,7 +27,7 @@ export async function POST(req: Request): Promise<Response> {
const state = await getUpdatedState(environmentId, personId);
let person: TPersonClient | null = null;
let person: { id: string; userId: string } | null = null;
if (state.person && "id" in state.person && "userId" in state.person) {
person = {
id: state.person.id,
@@ -5,6 +5,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest, userAgent } from "next/server";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttribute } from "@formbricks/lib/attribute/service";
import {
IS_FORMBRICKS_CLOUD,
PRICING_APPSURVEYS_FREE_RESPONSES,
@@ -149,18 +150,6 @@ export async function GET(
if (!product) {
throw new Error("Product not found");
}
const languageAttribute = person.attributes.language;
const isLanguageAvailable = Boolean(languageAttribute);
const personData = version
? {
...(isLanguageAvailable && { attributes: { language: languageAttribute } }),
}
: {
id: person.id,
userId: person.userId,
...(isLanguageAvailable && { attributes: { language: languageAttribute } }),
};
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
let transformedSurveys: TLegacySurvey[] | TSurvey[];
@@ -189,11 +178,14 @@ export async function GET(
}),
};
const language = await getAttribute("language", person.id);
// return state
const state: TJsAppStateSync = {
person: personData,
...(version && !isVersionGreaterThanOrEqualTo(version, "2.0.0") && { person }),
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
language,
product: updatedProduct,
};
@@ -0,0 +1,79 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest } from "next/server";
import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { ZJsPeopleUpdateAttributeInput } from "@formbricks/types/js";
export async function OPTIONS() {
// cors headers
return responses.successResponse({}, true);
}
export async function PUT(req: NextRequest, context: { params: { environmentId: string; userId: string } }) {
try {
const environmentId = context.params.environmentId;
if (!environmentId) {
return responses.badRequestResponse("environmentId is required", { environmentId }, true);
}
const userId = context.params.userId;
if (!userId) {
return responses.badRequestResponse("userId is required", { userId }, true);
}
const jsonInput = await req.json();
const parsedInput = ZJsPeopleUpdateAttributeInput.safeParse(jsonInput);
if (!parsedInput.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(parsedInput.error),
true
);
}
const { userId: userIdAttr, ...updatedAttributes } = parsedInput.data.attributes;
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
// return responses.notFoundResponse("PersonByUserId", userId, true);
// HOTFIX: create person if not found to work around caching issue
person = await createPerson(environmentId, userId);
}
const oldAttributes = await getAttributesByUserId(environmentId, userId);
let isUpToDate = true;
for (const key in updatedAttributes) {
if (updatedAttributes[key] !== oldAttributes[key]) {
isUpToDate = false;
break;
}
}
if (isUpToDate) {
return responses.successResponse(
{
changed: false,
message: "No updates were necessary; the person is already up to date.",
},
true
);
}
await updateAttributes(person.id, updatedAttributes);
return responses.successResponse(
{
changed: true,
message: "The person was successfully updated.",
},
true
);
} catch (err) {
return responses.internalServerErrorResponse("Something went wrong", true);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { TAttributeUpdateInput } from "@formbricks/types/attributes";
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/makeRequest";
export class AttributeAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async update(
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
): Promise<Result<{ changed: boolean; message: string }, NetworkError | Error>> {
return makeRequest(
this.apiHost,
`/api/v1/client/${this.environmentId}/people/${attributeUpdateInput.userId}/attributes`,
"PUT",
{ attributes: attributeUpdateInput.attributes }
);
}
}
+3
View File
@@ -1,5 +1,6 @@
import { ApiConfig } from "../../types";
import { ActionAPI } from "./action";
import { AttributeAPI } from "./attribute";
import { DisplayAPI } from "./display";
import { PeopleAPI } from "./people";
import { ResponseAPI } from "./response";
@@ -11,6 +12,7 @@ export class Client {
action: ActionAPI;
people: PeopleAPI;
storage: StorageAPI;
attribute: AttributeAPI;
constructor(options: ApiConfig) {
const { apiHost, environmentId } = options;
@@ -19,6 +21,7 @@ export class Client {
this.display = new DisplayAPI(apiHost, environmentId);
this.action = new ActionAPI(apiHost, environmentId);
this.people = new PeopleAPI(apiHost, environmentId);
this.attribute = new AttributeAPI(apiHost, environmentId);
this.storage = new StorageAPI(apiHost, environmentId);
}
}
-13
View File
@@ -1,6 +1,5 @@
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { TPersonUpdateInput } from "@formbricks/types/people";
import { makeRequest } from "../../utils/makeRequest";
@@ -19,16 +18,4 @@ export class PeopleAPI {
userId,
});
}
async update(
userId: string,
personInput: TPersonUpdateInput
): Promise<Result<{ changed: boolean; message: string }, NetworkError | Error>> {
return makeRequest(
this.apiHost,
`/api/v1/client/${this.environmentId}/people/${userId}`,
"POST",
personInput
);
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ export const sendResponseFinishedEmail = async (
response: TResponse,
responseCount: number
) => {
const personEmail = response.person?.attributes["email"];
const personEmail = response.personAttributes?.email;
const team = await getTeamByEnvironmentId(environmentId);
await sendEmail({
+3 -2
View File
@@ -5,9 +5,10 @@ import { ErrorHandler } from "../shared/errors";
import { Logger } from "../shared/logger";
import { trackAction } from "./lib/actions";
import { getApi } from "./lib/api";
import { setAttributeInApp } from "./lib/attributes";
import { initialize } from "./lib/initialize";
import { checkPageUrl } from "./lib/noCodeActions";
import { logoutPerson, resetPerson, setPersonAttribute } from "./lib/person";
import { logoutPerson, resetPerson } from "./lib/person";
const logger = Logger.getInstance();
@@ -26,7 +27,7 @@ const setEmail = async (email: string): Promise<void> => {
};
const setAttribute = async (key: string, value: any): Promise<void> => {
queue.add(true, "app", setPersonAttribute, key, value);
queue.add(true, "app", setAttributeInApp, key, value);
await queue.wait();
};
+1
View File
@@ -68,6 +68,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId,
attributes: inAppConfig.get().state.attributes,
},
true
);
+132
View File
@@ -0,0 +1,132 @@
import { FormbricksAPI } from "@formbricks/api";
import { TAttributes } from "@formbricks/types/attributes";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { AppConfig } from "./config";
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
export const updateAttribute = async (key: string, value: string): Promise<Result<void, NetworkError>> => {
const { apiHost, environmentId, userId } = appConfig.get();
const api = new FormbricksAPI({
apiHost,
environmentId,
});
const res = await api.client.attribute.update({ userId, attributes: { [key]: value } });
if (!res.ok) {
return err({
code: "network_error",
status: 500,
message: `Error updating person with userId ${userId}`,
url: `${appConfig.get().apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
responseMessage: res.error.message,
});
}
if (res.data.changed) {
logger.debug("Attribute updated in Formbricks");
}
return okVoid();
};
export const updateAttributes = async (
apiHost: string,
environmentId: string,
userId: string,
attributes: TAttributes
): Promise<Result<TAttributes, NetworkError>> => {
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
try {
const existingAttributes = appConfig.get()?.state?.attributes;
if (existingAttributes) {
for (const [key, value] of Object.entries(existingAttributes)) {
if (updatedAttributes[key] === value) {
delete updatedAttributes[key];
}
}
}
} catch (e) {
logger.debug("config not set; sending all attributes to backend");
}
// send to backend if updatedAttributes is not empty
if (Object.keys(updatedAttributes).length === 0) {
logger.debug("No attributes to update. Skipping update.");
return ok(updatedAttributes);
}
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
const api = new FormbricksAPI({
apiHost,
environmentId,
});
const res = await api.client.attribute.update({ userId, attributes: updatedAttributes });
if (res.ok) {
return ok(updatedAttributes);
}
return err({
code: "network_error",
status: 500,
message: `Error updating person with userId ${userId}`,
url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
responseMessage: res.error.message,
});
};
export const isExistingAttribute = (key: string, value: string): boolean => {
if (appConfig.get().state.attributes[key] === value) {
return true;
}
return false;
};
export const setAttributeInApp = async (
key: string,
value: any
): Promise<Result<void, NetworkError | MissingPersonError>> => {
if (key === "userId") {
logger.error("Setting userId is no longer supported. Please set the userId in the init call instead.");
return okVoid();
}
logger.debug("Setting attribute: " + key + " to value: " + value);
// check if attribute already exists with this value
if (isExistingAttribute(key, value.toString())) {
logger.debug("Attribute already set to this value. Skipping update.");
return okVoid();
}
const result = await updateAttribute(key, value.toString());
if (result.ok) {
// udpdate attribute in config
appConfig.update({
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
state: {
...appConfig.get().state,
attributes: {
...appConfig.get().state.attributes,
[key]: value.toString(),
},
},
expiresAt: appConfig.get().expiresAt,
});
return okVoid();
}
return err(result.error);
};
+25 -32
View File
@@ -1,5 +1,5 @@
import { TAttributes } from "@formbricks/types/attributes";
import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js";
import { TPersonAttributes } from "@formbricks/types/people";
import {
ErrorHandler,
@@ -15,14 +15,14 @@ import {
import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { trackAction } from "./actions";
import { updateAttributes } from "./attributes";
import { AppConfig, IN_APP_LOCAL_STORAGE_KEY } from "./config";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { checkPageUrl } from "./noCodeActions";
import { updatePersonAttributes } from "./person";
import { sync } from "./sync";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const inAppConfig = AppConfig.getInstance();
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
let isInitialized = false;
@@ -45,7 +45,7 @@ export const initialize = async (
let existingConfig: TJSAppConfig | undefined;
try {
existingConfig = inAppConfig.get();
existingConfig = appConfig.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
@@ -95,25 +95,18 @@ export const initialize = async (
logger.debug("Adding widget container to DOM");
addWidgetContainer();
let updatedAttributes: TPersonAttributes | null = null;
let updatedAttributes: TAttributes | null = null;
if (configInput.attributes) {
if (!configInput.userId) {
// Allow setting attributes for unidentified users
updatedAttributes = { ...configInput.attributes };
}
// If userId is available, update attributes in backend
else {
const res = await updatePersonAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (res.ok !== true) {
return err(res.error);
}
updatedAttributes = res.value;
const res = await updateAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (res.ok !== true) {
return err(res.error);
}
updatedAttributes = res.value;
}
if (
@@ -139,13 +132,13 @@ export const initialize = async (
}
} else {
logger.debug("Configuration not expired. Extending expiration.");
inAppConfig.update(existingConfig);
appConfig.update(existingConfig);
}
} else {
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
inAppConfig.resetConfig();
appConfig.resetConfig();
logger.debug("Syncing.");
try {
@@ -162,15 +155,15 @@ export const initialize = async (
}
// update attributes in config
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
inAppConfig.update({
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId: inAppConfig.get().userId,
appConfig.update({
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
state: {
...inAppConfig.get().state,
attributes: { ...inAppConfig.get().state.attributes, ...configInput.attributes },
...appConfig.get().state,
attributes: { ...appConfig.get().state.attributes, ...configInput.attributes },
},
expiresAt: inAppConfig.get().expiresAt,
expiresAt: appConfig.get().expiresAt,
});
}
@@ -221,8 +214,8 @@ export const deinitalize = (): void => {
export const putFormbricksInErrorState = (): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
inAppConfig.update({
...inAppConfig.get(),
appConfig.update({
...appConfig.get(),
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
});
+7 -146
View File
@@ -1,164 +1,25 @@
import { FormbricksAPI } from "@formbricks/api";
import { TPersonAttributes, TPersonUpdateInput } from "@formbricks/types/people";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { AppConfig } from "./config";
import { deinitalize, initialize } from "./initialize";
import { closeSurvey } from "./widget";
const inAppConfig = AppConfig.getInstance();
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
export const updatePersonAttribute = async (
key: string,
value: string
): Promise<Result<void, NetworkError | MissingPersonError>> => {
const { apiHost, environmentId, userId } = inAppConfig.get();
const input: TPersonUpdateInput = {
attributes: {
[key]: value,
},
};
const api = new FormbricksAPI({
apiHost,
environmentId,
});
const res = await api.client.people.update(userId, input);
if (!res.ok) {
return err({
code: "network_error",
status: 500,
message: `Error updating person with userId ${userId}`,
url: `${inAppConfig.get().apiHost}/api/v1/client/${environmentId}/people/${userId}`,
responseMessage: res.error.message,
});
}
if (res.data.changed) {
logger.debug("Attribute updated in Formbricks");
}
return okVoid();
};
export const updatePersonAttributes = async (
apiHost: string,
environmentId: string,
userId: string,
attributes: TPersonAttributes
): Promise<Result<TPersonAttributes, NetworkError | MissingPersonError>> => {
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
try {
const existingAttributes = inAppConfig.get()?.state?.attributes;
if (existingAttributes) {
for (const [key, value] of Object.entries(existingAttributes)) {
if (updatedAttributes[key] === value) {
delete updatedAttributes[key];
}
}
}
} catch (e) {
logger.debug("config not set; sending all attributes to backend");
}
// send to backend if updatedAttributes is not empty
if (Object.keys(updatedAttributes).length === 0) {
logger.debug("No attributes to update. Skipping update.");
return ok(updatedAttributes);
}
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
const input: TPersonUpdateInput = {
attributes: updatedAttributes,
};
const api = new FormbricksAPI({
apiHost,
environmentId,
});
const res = await api.client.people.update(userId, input);
if (res.ok) {
return ok(updatedAttributes);
}
return err({
code: "network_error",
status: 500,
message: `Error updating person with userId ${userId}`,
url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}`,
responseMessage: res.error.message,
});
};
export const isExistingAttribute = (key: string, value: string): boolean => {
if (inAppConfig.get().state.attributes[key] === value) {
return true;
}
return false;
};
export const setPersonAttribute = async (
key: string,
value: any
): Promise<Result<void, NetworkError | MissingPersonError>> => {
if (key === "userId") {
logger.error("Setting userId is no longer supported. Please set the userId in the init call instead.");
return okVoid();
}
logger.debug("Setting attribute: " + key + " to value: " + value);
// check if attribute already exists with this value
if (isExistingAttribute(key, value.toString())) {
logger.debug("Attribute already set to this value. Skipping update.");
return okVoid();
}
const result = await updatePersonAttribute(key, value.toString());
if (result.ok) {
// udpdate attribute in config
inAppConfig.update({
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId: inAppConfig.get().userId,
state: {
...inAppConfig.get().state,
attributes: {
...inAppConfig.get().state.attributes,
[key]: value.toString(),
},
},
expiresAt: inAppConfig.get().expiresAt,
});
return okVoid();
}
return err(result.error);
};
export const logoutPerson = async (): Promise<void> => {
deinitalize();
inAppConfig.resetConfig();
appConfig.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const syncParams = {
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId: inAppConfig.get().userId,
attributes: inAppConfig.get().state.attributes,
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
attributes: appConfig.get().state.attributes,
};
await logoutPerson();
try {
+17 -9
View File
@@ -1,3 +1,4 @@
import { TAttributes } from "@formbricks/types/attributes";
import { TJsAppState, TJsAppStateSync, TJsAppSyncParams } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
@@ -6,7 +7,7 @@ import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { AppConfig } from "./config";
const config = AppConfig.getInstance();
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
@@ -56,17 +57,23 @@ export const sync = async (params: TJsAppSyncParams, noCache = false): Promise<v
throw syncResult.error;
}
let attributes: TAttributes = params.attributes || {};
if (syncResult.value.language) {
attributes.language = syncResult.value.language;
}
let state: TJsAppState = {
surveys: syncResult.value.surveys as TSurvey[],
noCodeActionClasses: syncResult.value.noCodeActionClasses,
product: syncResult.value.product,
attributes: syncResult.value.person?.attributes || {},
attributes,
};
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
config.update({
appConfig.update({
apiHost: params.apiHost,
environmentId: params.environmentId,
userId: params.userId,
@@ -86,20 +93,21 @@ export const addExpiryCheckListener = (): void => {
syncIntervalId = window.setInterval(async () => {
try {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
if (appConfig.get().expiresAt && new Date(appConfig.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
userId: appConfig.get().userId,
attributes: appConfig.get().state.attributes,
});
} catch (e) {
console.error(`Error during expiry check: ${e}`);
logger.debug("Extending config and try again later.");
const existingConfig = config.get();
config.update(existingConfig);
const existingConfig = appConfig.get();
appConfig.update(existingConfig);
}
}, updateInterval);
}
+1
View File
@@ -194,6 +194,7 @@ export const closeSurvey = async (): Promise<void> => {
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
userId: inAppConfig.get().userId,
attributes: inAppConfig.get().state.attributes,
},
true
);
+2 -2
View File
@@ -1,9 +1,9 @@
import { TPersonAttributes } from "@formbricks/types/people";
import { TAttributes } from "@formbricks/types/attributes";
import { TSurvey } from "@formbricks/types/surveys";
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
export const getLanguageCode = (survey: TSurvey, attributes: TPersonAttributes): string | undefined => {
export const getLanguageCode = (survey: TSurvey, attributes: TAttributes): string | undefined => {
const language = attributes.language;
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
if (!language) return "default";
+34
View File
@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
userId?: string;
personId?: string;
name: string;
}
export const attributeCache = {
tag: {
byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
return `environments-${environmentId}-personByUserId-${userId}-attributes`;
},
byPersonId(personId: string): string {
return `person-${personId}-attributes`;
},
byNameAndPersonId(name: string, personId: string): string {
return `person-${personId}-attribute-${name}`;
},
},
revalidate({ environmentId, userId, personId, name }: RevalidateProps): void {
if (environmentId && userId) {
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
}
if (personId) {
revalidateTag(this.tag.byPersonId(personId));
}
if (personId && name) {
revalidateTag(this.tag.byNameAndPersonId(name, personId));
}
},
};
+235
View File
@@ -0,0 +1,235 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { prisma } from "@formbricks/database";
import { TAttributes, ZAttributes } from "@formbricks/types/attributes";
import { ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError } from "@formbricks/types/errors";
import { attributeCache } from "../attribute/cache";
import { attributeClassCache } from "../attributeClass/cache";
import { getAttributeClassByName } from "../attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { getPerson, getPersonByUserId } from "../person/service";
import { validateInputs } from "../utils/validate";
export const selectAttribute: Prisma.AttributeSelect = {
value: true,
attributeClass: {
select: {
name: true,
id: true,
},
},
};
// convert prisma attributes to a key-value object
const convertPrismaAttributes = (prismaAttributes: any): TAttributes => {
return prismaAttributes.reduce(
(acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
},
{} as Record<string, string | number>
);
};
export const getAttributes = async (personId: string): Promise<TAttributes> => {
return await unstable_cache(
async () => {
validateInputs([personId, ZId]);
try {
const prismaAttributes = await prisma.attribute.findMany({
where: {
personId,
},
select: selectAttribute,
});
return convertPrismaAttributes(prismaAttributes);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getAttributes-${personId}`],
{
tags: [attributeCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
};
export const getAttributesByUserId = async (environmentId: string, userId: string): Promise<TAttributes> => {
return await unstable_cache(
async () => {
validateInputs([environmentId, ZId], [userId, ZString]);
const person = await getPersonByUserId(environmentId, userId);
if (!person) {
throw new Error("Person not found");
}
try {
const prismaAttributes = await prisma.attribute.findMany({
where: {
personId: person.id,
},
select: selectAttribute,
});
return convertPrismaAttributes(prismaAttributes);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getAttributesByUserId-${environmentId}-${userId}`],
{
tags: [attributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
};
export const getAttribute = async (name: string, personId: string): Promise<string | undefined> => {
return await unstable_cache(
async () => {
validateInputs([name, ZString], [personId, ZId]);
const person = await getPerson(personId);
if (!person) {
throw new Error("Person not found");
}
const attributeClass = await getAttributeClassByName(person?.environmentId, name);
if (!attributeClass) {
return undefined;
}
try {
const prismaAttributes = await prisma.attribute.findFirst({
where: {
attributeClassId: attributeClass.id,
personId,
},
select: { value: true },
});
return prismaAttributes?.value;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getAttribute-${name}-${personId}`],
{
tags: [attributeCache.tag.byNameAndPersonId(name, personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
};
export const updateAttributes = async (personId: string, attributes: TAttributes): Promise<boolean> => {
validateInputs([personId, ZId], [attributes, ZAttributes]);
const person = await getPerson(personId);
if (!person) {
throw new Error("Person not found");
}
const environmentId = person.environmentId;
const userId = person.userId;
const attributeClassNames = Object.keys(attributes);
const existingClasses = await prisma.attributeClass.findMany({
where: {
name: { in: attributeClassNames },
},
});
const attributeClassMap = new Map(existingClasses.map((ac) => [ac.name, ac.id]));
const upsertOperations: Promise<any>[] = [];
for (const [name, value] of Object.entries(attributes)) {
const attributeClassId = attributeClassMap.get(name);
if (attributeClassId) {
// Class exists, perform an upsert operation
upsertOperations.push(
prisma.attribute
.upsert({
select: {
id: true,
},
where: {
personId_attributeClassId: {
personId,
attributeClassId,
},
},
update: {
value,
},
create: {
personId,
attributeClassId,
value,
},
})
.then(() => {
attributeCache.revalidate({ environmentId, personId, userId, name });
})
);
} else {
// Class does not exist, create new class and attribute
upsertOperations.push(
prisma.attributeClass
.create({
select: { id: true },
data: {
name,
type: "code",
environment: {
connect: {
id: environmentId,
},
},
attributes: {
create: {
personId,
value,
},
},
},
})
.then(({ id }) => {
attributeClassCache.revalidate({ environmentId, name });
attributeCache.revalidate({ id, environmentId, personId, userId, name });
})
);
}
}
// Execute all upsert operations concurrently
await Promise.all(upsertOperations);
return true;
};
+20 -168
View File
@@ -7,9 +7,8 @@ import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError } from "@formbricks/types/errors";
import { TPerson, TPersonUpdateInput, ZPerson, ZPersonUpdateInput } from "@formbricks/types/people";
import { TPerson, ZPerson } from "@formbricks/types/people";
import { createAttributeClass, getAttributeClassByName } from "../attributeClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
@@ -21,22 +20,6 @@ export const selectPerson = {
createdAt: true,
updatedAt: true,
environmentId: true,
attributes: {
where: {
attributeClass: {
archived: false,
},
},
select: {
value: true,
attributeClass: {
select: {
name: true,
id: true,
},
},
},
},
};
type TransformPersonInput = {
@@ -96,7 +79,7 @@ export const getPerson = async (personId: string): Promise<TPerson | null> => {
{ tags: [personCache.tag.byId(personId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
return prismaPerson ? formatDateFields(transformPrismaPerson(prismaPerson), ZPerson) : null;
return prismaPerson ? formatDateFields(prismaPerson, ZPerson) : null;
};
export const getPeople = async (environmentId: string, page?: number): Promise<TPerson[]> => {
@@ -129,7 +112,7 @@ export const getPeople = async (environmentId: string, page?: number): Promise<T
)();
return peoplePrisma
.map((prismaPerson) => formatDateFields(transformPrismaPerson(prismaPerson), ZPerson))
.map((prismaPerson) => formatDateFields(prismaPerson, ZPerson))
.filter((person: TPerson | null): person is TPerson => person !== null);
};
@@ -175,15 +158,13 @@ export const createPerson = async (environmentId: string, userId: string): Promi
select: selectPerson,
});
const transformedPerson = transformPrismaPerson(person);
personCache.revalidate({
id: transformedPerson.id,
id: person.id,
environmentId,
userId,
});
return transformedPerson;
return person;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// If the person already exists, return it
@@ -199,7 +180,7 @@ export const createPerson = async (environmentId: string, userId: string): Promi
});
if (existingPerson) {
return transformPrismaPerson(existingPerson);
return existingPerson;
}
}
throw new DatabaseError(error.message);
@@ -219,85 +200,14 @@ export const deletePerson = async (personId: string): Promise<TPerson | null> =>
},
select: selectPerson,
});
const transformedPerson = transformPrismaPerson(person);
personCache.revalidate({
id: transformedPerson.id,
userId: transformedPerson.userId,
environmentId: transformedPerson.environmentId,
});
return transformedPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updatePerson = async (personId: string, personInput: TPersonUpdateInput): Promise<TPerson> => {
validateInputs([personId, ZId], [personInput, ZPersonUpdateInput]);
try {
const person = await getPerson(personId);
if (!person) {
throw new Error(`Person ${personId} not found`);
}
// Process each attribute
const attributeUpdates = Object.entries(personInput.attributes).map(async ([attributeName, value]) => {
let attributeClass = await getAttributeClassByName(person.environmentId, attributeName);
// Create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(person.environmentId, attributeName, "code");
}
// Now perform the upsert for the attribute with the found or created attributeClassId
await prisma.attribute.upsert({
where: {
personId_attributeClassId: {
attributeClassId: attributeClass!.id,
personId,
},
},
update: {
value: value.toString(),
},
create: {
attributeClass: {
connect: {
id: attributeClass!.id,
},
},
person: {
connect: {
id: personId,
},
},
value: value.toString(),
},
});
});
// Execute all attribute updates
await Promise.all(attributeUpdates);
personCache.revalidate({
id: personId,
id: person.id,
userId: person.userId,
environmentId: person.environmentId,
});
const updatedPerson = await getPerson(personId);
if (!updatedPerson) {
throw new Error(`Person ${personId} not found`);
}
return updatedPerson;
return person;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -312,28 +222,20 @@ export const getPersonByUserId = async (environmentId: string, userId: string):
async () => {
validateInputs([environmentId, ZId], [userId, ZString]);
try {
// check if userId exists as a column
const personWithUserId = await prisma.person.findFirst({
where: {
environmentId,
userId,
},
select: selectPerson,
});
// check if userId exists as a column
const personWithUserId = await prisma.person.findFirst({
where: {
environmentId,
userId,
},
select: selectPerson,
});
if (personWithUserId) {
return transformPrismaPerson(personWithUserId);
}
return null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
if (personWithUserId) {
return personWithUserId;
}
return null;
},
[`getPersonByUserId-${environmentId}-${userId}`],
{
@@ -344,56 +246,6 @@ export const getPersonByUserId = async (environmentId: string, userId: string):
return person ? formatDateFields(person, ZPerson) : null;
};
/**
* @deprecated This function is deprecated and only used in legacy endpoints. Use `updatePerson` instead.
*/
export const updatePersonAttribute = async (
personId: string,
attributeClassId: string,
value: string
): Promise<Partial<TPerson>> => {
validateInputs([personId, ZId], [attributeClassId, ZId], [value, ZString]);
try {
const attributes = await prisma.attribute.upsert({
where: {
personId_attributeClassId: {
attributeClassId,
personId,
},
},
update: {
value,
},
create: {
attributeClass: {
connect: {
id: attributeClassId,
},
},
person: {
connect: {
id: personId,
},
},
value,
},
});
personCache.revalidate({
id: personId,
});
return attributes;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getIsPersonMonthlyActive = async (personId: string): Promise<boolean> =>
unstable_cache(
async () => {
+7 -3
View File
@@ -1,5 +1,9 @@
import { TPerson } from "@formbricks/types/people";
import { TAttributes } from "@formbricks/types/attributes";
import { TResponsePerson } from "@formbricks/types/responses";
export const getPersonIdentifier = (person: TPerson): string | number | null => {
return person.attributes.email || person.userId;
export const getPersonIdentifier = (
person: TResponsePerson | null,
personAttributes: TAttributes | null
): string => {
return personAttributes?.email || person?.userId || "";
};
+18 -25
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { prisma } from "@formbricks/database";
import { TAttributes } from "@formbricks/types/attributes";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -26,10 +27,11 @@ import {
import { TSurveySummary } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { getAttributes } from "../attribute/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL, WEBAPP_URL } from "../constants";
import { displayCache } from "../display/cache";
import { deleteDisplayByResponseId, getDisplayCountBySurveyId } from "../display/service";
import { createPerson, getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service";
import { createPerson, getPerson, getPersonByUserId } from "../person/service";
import {
buildWhereClause,
calculateTtcTotal,
@@ -69,19 +71,6 @@ export const responseSelection = {
select: {
id: true,
userId: true,
createdAt: true,
updatedAt: true,
environmentId: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
tags: {
@@ -148,7 +137,6 @@ export const getResponsesByPersonId = async (
responses.push({
...response,
notes: responseNotes,
person: response.person ? transformPrismaPerson(response.person) : null,
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
})
@@ -198,7 +186,6 @@ export const getResponseBySingleUseId = async (
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -243,6 +230,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
} = responseInput;
try {
let person: TPerson | null = null;
let attributes: TAttributes | null = null;
if (userId) {
person = await getPersonByUserId(environmentId, userId);
@@ -252,6 +240,10 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
}
}
if (person?.id) {
attributes = await getAttributes(person?.id as string);
}
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const responsePrisma = await prisma.response.create({
@@ -270,7 +262,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
id: person.id,
},
},
personAttributes: person?.attributes,
personAttributes: attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
@@ -281,7 +273,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -313,6 +304,7 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput):
try {
let person: TPerson | null = null;
let attributes: TAttributes | null = null;
if (responseInput.personId) {
person = await getPerson(responseInput.personId);
@@ -326,6 +318,11 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput):
_total: ttcTemp[questionId], // Add _total property with the same value
}
: ttcTemp;
if (person?.id) {
attributes = await getAttributes(person?.id as string);
}
const responsePrisma = await prisma.response.create({
data: {
survey: {
@@ -342,7 +339,7 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput):
id: responseInput.personId,
},
},
personAttributes: person?.attributes,
personAttributes: attributes,
}),
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
@@ -351,9 +348,9 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput):
},
select: responseSelection,
});
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -395,7 +392,6 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -571,7 +567,6 @@ export const getResponses = async (
responses.map((responsePrisma) => {
return {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
@@ -592,6 +587,7 @@ export const getResponses = async (
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return responses.map((response) => ({
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
@@ -755,7 +751,6 @@ export const getResponsesByEnvironmentId = async (
responses.map(async (responsePrisma) => {
return {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
@@ -830,7 +825,6 @@ export const updateResponse = async (
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -868,7 +862,6 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
const response: TResponse = {
...responsePrisma,
notes: responseNotes,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -8,10 +8,9 @@ import {
TResponseUpdateInput,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
import { TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { transformPrismaPerson } from "../../../person/service";
import { responseNoteSelect } from "../../../responseNote/service";
import { responseSelection } from "../../service";
import { constantsForTests } from "../constants";
@@ -22,9 +21,6 @@ type ResponseMock = Prisma.ResponseGetPayload<{
type ResponseNoteMock = Prisma.ResponseNoteGetPayload<{
include: typeof responseNoteSelect;
}>;
type ResponsePersonMock = Prisma.PersonGetPayload<{
select: typeof responseSelection.person.select;
}>;
export const mockEnvironmentId = "ars2tjk8hsi8oqk1uac00mo7";
export const mockPersonId = "lhwy39ga2zy8by1ol1bnaiso";
@@ -63,20 +59,9 @@ export const mockResponseNote: ResponseNoteMock = {
},
};
export const mockPerson: ResponsePersonMock = {
export const mockPerson = {
id: mockPersonId,
userId: mockUserId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
attributes: [
{
value: "attribute1",
attributeClass: {
name: "attributeClass1",
},
},
],
};
export const mockTags = [
@@ -442,7 +427,7 @@ export const getFilteredMockResponses = (
if (format) {
return result.map((response) => ({
...response,
person: response.person ? transformPrismaPerson(response.person) : null,
person: response.person ? { id: response.person.id, userId: response.person.userId } : null,
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
}
@@ -471,7 +456,7 @@ export const getMockUpdateResponseInput = (finished: boolean = false): TResponse
finished,
});
export const mockSurveySummaryOutput: TSurveySummary = {
export const mockSurveySummaryOutput = {
dropOff: [
{
dropOffCount: 0,
+5 -2
View File
@@ -33,7 +33,7 @@ import {
} from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { selectPerson, transformPrismaPerson } from "../../person/service";
import { selectPerson } from "../../person/service";
import { mockSurveyOutput } from "../../survey/tests/__mock__/survey.mock";
import {
createResponse,
@@ -61,7 +61,7 @@ const expectedResponseWithoutPerson: TResponse = {
const expectedResponseWithPerson: TResponse = {
...mockResponse,
person: transformPrismaPerson(mockPerson),
person: mockPerson,
tags: mockTags?.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -209,6 +209,7 @@ describe("Tests for getResponsesBySingleUseId", () => {
describe("Tests for createResponse service", () => {
describe("Happy Path", () => {
it("Creates a response linked to an existing user", async () => {
prisma.attribute.findMany.mockResolvedValue([]);
const response = await createResponse(mockResponseInputWithUserId);
expect(response).toEqual(expectedResponseWithPerson);
});
@@ -221,6 +222,7 @@ describe("Tests for createResponse service", () => {
it("Creates a new person and response when the person does not exist", async () => {
prisma.person.findFirst.mockResolvedValue(null);
prisma.person.create.mockResolvedValue(mockPerson);
prisma.attribute.findMany.mockResolvedValue([]);
const response = await createResponse(mockResponseInputWithUserId);
expect(response).toEqual(expectedResponseWithPerson);
@@ -249,6 +251,7 @@ describe("Tests for createResponse service", () => {
});
prisma.response.create.mockRejectedValue(errToThrow);
prisma.attribute.findMany.mockResolvedValue([]);
await expect(createResponse(mockResponseInputWithUserId)).rejects.toThrow(DatabaseError);
});
+9 -2
View File
@@ -2,7 +2,6 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { TPerson } from "@formbricks/types/people";
import { TResponse, TResponseFilterCriteria, TResponseTtc } from "@formbricks/types/responses";
import {
TSurvey,
@@ -703,6 +702,7 @@ export const getQuestionWiseSummary = (
updatedAt: response.updatedAt,
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
});
@@ -734,7 +734,8 @@ export const getQuestionWiseSummary = (
acc[choice] = 0;
return acc;
}, {});
const otherValues: { value: string; person: TPerson | null }[] = [];
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
responses.forEach((response) => {
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
@@ -752,6 +753,7 @@ export const getQuestionWiseSummary = (
otherValues.push({
value,
person: response.person,
personAttributes: response.personAttributes,
});
}
});
@@ -763,6 +765,7 @@ export const getQuestionWiseSummary = (
otherValues.push({
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
}
@@ -1021,6 +1024,7 @@ export const getQuestionWiseSummary = (
updatedAt: response.updatedAt,
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
});
@@ -1045,6 +1049,7 @@ export const getQuestionWiseSummary = (
updatedAt: response.updatedAt,
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
});
@@ -1159,6 +1164,7 @@ export const getQuestionWiseSummary = (
updatedAt: response.updatedAt,
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
});
@@ -1185,6 +1191,7 @@ export const getQuestionWiseSummary = (
updatedAt: response.updatedAt,
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
});
+9 -3
View File
@@ -20,6 +20,8 @@ import {
import { getActionsByPersonId } from "../action/service";
import { getActionClasses } from "../actionClass/service";
import { attributeCache } from "../attribute/cache";
import { getAttributes } from "../attribute/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { displayCache } from "../display/cache";
import { getDisplaysByPersonId } from "../display/service";
@@ -965,7 +967,9 @@ export const getSyncSurveys = async (
const personActionClassIds = Array.from(
new Set(personActions?.map((action) => action.actionClass?.id ?? ""))
);
const personUserId = person.userId ?? person.attributes?.userId ?? "";
const attributes = await getAttributes(person.id);
const personUserId = person.userId;
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
@@ -992,7 +996,7 @@ export const getSyncSurveys = async (
// we check if the person meets the attribute filters for all the attribute filters
const isEligible = attributeFilters.every((attributeFilter) => {
const personAttributeValue = person?.attributes?.[attributeFilter.attributeClassName];
const personAttributeValue = attributes[attributeFilter.attributeClassName];
if (!personAttributeValue) {
return false;
}
@@ -1013,7 +1017,7 @@ export const getSyncSurveys = async (
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: person.attributes ?? {},
attributes: attributes ?? {},
actionIds: personActionClassIds,
deviceType,
environmentId,
@@ -1050,6 +1054,8 @@ export const getSyncSurveys = async (
displayCache.tag.byPersonId(personId),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
// ? Should this be included?
attributeCache.tag.byPersonId(personId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
-19
View File
@@ -3,7 +3,6 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TPerson } from "@formbricks/types/people";
import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys";
export const formatSurveyDateFields = (survey: TSurvey): TSurvey => {
@@ -99,21 +98,3 @@ export const anySurveyHasFilters = (surveys: TSurvey[] | TLegacySurvey[]): boole
return false;
});
};
export const determineLanguageCode = (person: TPerson, survey: TSurvey) => {
// Default to 'default' if person.attributes.language is not set or not a string
if (!person.attributes?.language) return "default";
const languageCodeOrAlias =
typeof person.attributes?.language === "string" ? person.attributes.language : "default";
// Find the matching language in the survey
const selectedLanguage = survey.languages.find(
(surveyLanguage) =>
surveyLanguage.language.code === languageCodeOrAlias ||
surveyLanguage.language.alias === languageCodeOrAlias
);
if (!selectedLanguage) return;
// Determine and return the language code to use
return selectedLanguage.default ? "default" : selectedLanguage.language.code;
};
+15
View File
@@ -0,0 +1,15 @@
import { z } from "zod";
import { ZId } from "./environment";
export const ZAttributes = z.record(z.string());
export type TAttributes = z.infer<typeof ZAttributes>;
export const ZAttributeUpdateInput = z.object({
environmentId: ZId,
userId: z.string(),
attributes: ZAttributes,
});
export type TAttributeUpdateInput = z.infer<typeof ZAttributeUpdateInput>;
+23 -6
View File
@@ -2,10 +2,18 @@ import z from "zod";
import { ZLegacySurvey } from "./LegacySurvey";
import { ZActionClass } from "./actionClasses";
import { ZPerson, ZPersonAttributes, ZPersonClient } from "./people";
import { ZAttributes } from "./attributes";
import { ZPerson } from "./people";
import { ZProduct } from "./product";
import { ZSurvey } from "./surveys";
export const ZJsPerson = z.object({
id: z.string().cuid2().optional(),
userId: z.string().optional(),
});
export type TJsPerson = z.infer<typeof ZJsPerson>;
const ZSurveyWithTriggers = ZSurvey.extend({
triggers: z.array(ZActionClass).or(z.array(z.string())),
});
@@ -21,10 +29,12 @@ export const ZJSWebsiteStateDisplay = z.object({
export type TJSWebsiteStateDisplay = z.infer<typeof ZJSWebsiteStateDisplay>;
export const ZJsAppStateSync = z.object({
person: ZPersonClient.nullish(),
person: ZJsPerson.nullish(),
userId: z.string().optional(),
surveys: z.union([z.array(ZSurvey), z.array(ZLegacySurvey)]),
noCodeActionClasses: z.array(ZActionClass),
product: ZProduct,
language: z.string().optional(),
});
export type TJsAppStateSync = z.infer<typeof ZJsAppStateSync>;
@@ -34,7 +44,7 @@ export const ZJsWebsiteStateSync = ZJsAppStateSync.omit({ person: true });
export type TJsWebsiteStateSync = z.infer<typeof ZJsWebsiteStateSync>;
export const ZJsAppState = z.object({
attributes: ZPersonAttributes,
attributes: ZAttributes,
surveys: z.array(ZSurvey),
noCodeActionClasses: z.array(ZActionClass),
product: ZProduct,
@@ -47,7 +57,7 @@ export const ZJsWebsiteState = z.object({
noCodeActionClasses: z.array(ZActionClass),
product: ZProduct,
displays: z.array(ZJSWebsiteStateDisplay),
attributes: ZPersonAttributes.optional(),
attributes: ZAttributes.optional(),
});
export type TJsWebsiteState = z.infer<typeof ZJsWebsiteState>;
@@ -125,7 +135,7 @@ export const ZJsWebsiteConfigInput = z.object({
environmentId: z.string().cuid(),
apiHost: z.string(),
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
attributes: ZPersonAttributes.optional(),
attributes: ZAttributes.optional(),
});
export type TJsWebsiteConfigInput = z.infer<typeof ZJsWebsiteConfigInput>;
@@ -135,7 +145,7 @@ export const ZJsAppConfigInput = z.object({
apiHost: z.string(),
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
userId: z.string(),
attributes: ZPersonAttributes.optional(),
attributes: ZAttributes.optional(),
});
export type TJsAppConfigInput = z.infer<typeof ZJsAppConfigInput>;
@@ -146,6 +156,12 @@ export const ZJsPeopleUserIdInput = z.object({
version: z.string().optional(),
});
export const ZJsPeopleUpdateAttributeInput = z.object({
attributes: ZAttributes,
});
export type TJsPeopleUpdateAttributeInput = z.infer<typeof ZJsPeopleUpdateAttributeInput>;
export type TJsPeopleUserIdInput = z.infer<typeof ZJsPeopleUserIdInput>;
export const ZJsPeopleAttributeInput = z.object({
@@ -179,6 +195,7 @@ export const ZJsAppSyncParams = z.object({
environmentId: z.string().cuid(),
apiHost: z.string(),
userId: z.string(),
attributes: ZAttributes.optional(),
});
export type TJsAppSyncParams = z.infer<typeof ZJsAppSyncParams>;
-18
View File
@@ -1,29 +1,11 @@
import z from "zod";
export const ZPersonAttributes = z.record(z.union([z.string(), z.number()]));
export type TPersonAttributes = z.infer<typeof ZPersonAttributes>;
export const ZPerson = z.object({
id: z.string().cuid2(),
userId: z.string(),
attributes: ZPersonAttributes,
createdAt: z.date(),
updatedAt: z.date(),
environmentId: z.string().cuid2(),
});
export type TPerson = z.infer<typeof ZPerson>;
export const ZPersonUpdateInput = z.object({
attributes: ZPersonAttributes,
});
export type TPersonUpdateInput = z.infer<typeof ZPersonUpdateInput>;
export const ZPersonClient = z.object({
id: z.string().cuid2().optional(),
userId: z.string().optional(),
attributes: ZPersonAttributes.optional(),
});
export type TPersonClient = z.infer<typeof ZPersonClient>;
+11 -3
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { ZPerson, ZPersonAttributes } from "./people";
import { ZAttributes } from "./attributes";
import { ZId } from "./environment";
import { ZSurvey, ZSurveyLogicCondition } from "./surveys";
import { ZTag } from "./tags";
@@ -14,7 +15,7 @@ export const ZResponseTtc = z.record(z.number());
export type TResponseTtc = z.infer<typeof ZResponseTtc>;
export const ZResponsePersonAttributes = ZPersonAttributes.nullable();
export const ZResponsePersonAttributes = ZAttributes.nullable();
export type TResponsePersonAttributes = z.infer<typeof ZResponsePersonAttributes>;
@@ -166,6 +167,13 @@ export const ZResponseFilterCriteria = z.object({
.optional(),
});
export const ZResponsePerson = z.object({
id: ZId,
userId: z.string(),
});
export type TResponsePerson = z.infer<typeof ZResponsePerson>;
export type TResponseFilterCriteria = z.infer<typeof ZResponseFilterCriteria>;
export const ZResponseNoteUser = z.object({
@@ -208,7 +216,7 @@ export const ZResponse = z.object({
createdAt: z.date(),
updatedAt: z.date(),
surveyId: z.string().cuid2(),
person: ZPerson.nullable(),
person: ZResponsePerson.nullable(),
personAttributes: ZResponsePersonAttributes,
finished: z.boolean(),
data: ZResponseData,
+44 -8
View File
@@ -1,8 +1,9 @@
import { z } from "zod";
import { ZNoCodeConfig } from "./actionClasses";
import { ZAttributes } from "./attributes";
import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
import { ZPerson } from "./people";
import { ZId } from "./environment";
import { ZLanguage } from "./product";
import { ZSegment } from "./segment";
import { ZBaseStyling } from "./styling";
@@ -584,7 +585,13 @@ export const ZSurveyQuestionSummaryOpenText = z.object({
id: z.string(),
updatedAt: z.date(),
value: z.string(),
person: ZPerson.nullable(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
),
});
@@ -604,7 +611,13 @@ export const ZSurveyQuestionSummaryMultipleChoice = z.object({
.array(
z.object({
value: z.string(),
person: ZPerson.nullable(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
)
.optional(),
@@ -716,7 +729,13 @@ export const ZSurveyQuestionSummaryDate = z.object({
id: z.string(),
updatedAt: z.date(),
value: z.string(),
person: ZPerson.nullable(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
),
});
@@ -732,7 +751,13 @@ export const ZSurveyQuestionSummaryFileUpload = z.object({
id: z.string(),
updatedAt: z.date(),
value: z.array(z.string()),
person: ZPerson.nullable(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
),
});
@@ -778,7 +803,13 @@ export const ZSurveyQuestionSummaryHiddenFields = z.object({
z.object({
updatedAt: z.date(),
value: z.string(),
person: ZPerson.nullable(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
),
});
@@ -792,10 +823,15 @@ export const ZSurveyQuestionSummaryAddress = z.object({
samples: z.array(
z.object({
id: z.string(),
updatedAt: z.date(),
value: z.array(z.string()),
person: ZPerson.nullable(),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
),
});
+3 -1
View File
@@ -100,7 +100,9 @@ export default function SingleResponseCard({
}: SingleResponseCardProps) {
const environmentId = survey.environmentId;
const router = useRouter();
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
const displayIdentifier = response.person
? getPersonIdentifier(response.person, response.personAttributes)
: null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isOpen, setIsOpen] = useState(false);