Sync surveys and events with formbricks-js automatically (#537)

* add first part of sync service

* add actionClasses to js sync

* fix errors, add product to sync

* rewrite formbricks-js for new states and types

* fix tests

* fix build errors

* add cors

* fix cors errors and other bugs

* comment test in checks until working again
This commit is contained in:
Matti Nannt
2023-07-13 00:49:58 +02:00
committed by GitHub
parent cfedd0f4b8
commit 8ea6016cf5
54 changed files with 1320 additions and 630 deletions

View File

@@ -33,5 +33,5 @@ jobs:
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
#- name: Test
# run: pnpm test

View File

@@ -12,10 +12,9 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
logLevel: "debug",
debug: true,
});
window.formbricks = formbricks;
formbricks.refresh();
}
}

View File

@@ -47,7 +47,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
debug: true, // remove when in production
});
}

View File

@@ -49,7 +49,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "your-environment-id",
apiHost: "your-api-host", // e.g. https://app.formbricks.com
logLevel: "debug", // remove when in production
debug: true, // remove when in production
});
}

View File

@@ -9,7 +9,7 @@ import { useEffect } from "react";
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
logLevel: "debug",
debug: true,
});
} */

View File

@@ -0,0 +1,75 @@
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { ZJsActionInput } from "@formbricks/types/v1/js";
import { EventType } from "@prisma/client";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsActionInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, sessionId, name, properties } = inputValidation.data;
let eventType: EventType = EventType.code;
if (name === "Exit Intent (Desktop)" || name === "50% Scroll") {
eventType = EventType.automatic;
}
await prisma.event.create({
data: {
properties,
session: {
connect: {
id: sessionId,
},
},
eventClass: {
connectOrCreate: {
where: {
name_environmentId: {
name,
environmentId,
},
},
create: {
name,
type: eventType,
environment: {
connect: {
id: environmentId,
},
},
},
},
},
},
select: {
id: true,
},
});
return responses.successResponse({}, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -0,0 +1,128 @@
import { getSurveys } from "@/app/api/v1/js/surveys";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getPerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/v1/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, sessionId, key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
if (!existingPerson) {
return responses.notFoundResponse("Person", personId, true);
}
// find attribute class
let attributeClass = await prisma.attributeClass.findUnique({
where: {
name_environmentId: {
name: key,
environmentId,
},
},
select: {
id: true,
},
});
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await prisma.attributeClass.create({
data: {
name: key,
type: "code",
environment: {
connect: {
id: environmentId,
},
},
},
select: {
id: true,
},
});
}
// upsert attribute (update or create)
const attribute = await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
attributeClassId: attributeClass.id,
personId,
},
},
update: {
value,
},
create: {
attributeClass: {
connect: {
id: attributeClass.id,
},
},
person: {
connect: {
id: personId,
},
},
value,
},
select: {
person: {
select,
},
},
});
const person = transformPrismaPerson(attribute.person);
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
extendSession(sessionId),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -0,0 +1,120 @@
import { getSurveys } from "@/app/api/v1/js/surveys";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { deletePerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/v1/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId, sessionId } = inputValidation.data;
let returnedPerson;
// check if person with this userId exists
const existingPerson = await prisma.person.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeClass: {
name: "userId",
},
value: userId,
},
},
},
select,
});
// if person exists, reconnect session and delete old user
if (existingPerson) {
// reconnect session to new person
await prisma.session.update({
where: {
id: sessionId,
},
data: {
person: {
connect: {
id: existingPerson.id,
},
},
},
});
// delete old person
await deletePerson(personId);
returnedPerson = existingPerson;
} else {
// update person with userId
returnedPerson = await prisma.person.update({
where: {
id: personId,
},
data: {
attributes: {
create: {
value: userId,
attributeClass: {
connect: {
name_environmentId: {
name: "userId",
environmentId,
},
},
},
},
},
},
select,
});
}
const person = transformPrismaPerson(returnedPerson);
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
extendSession(sessionId),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -0,0 +1,153 @@
import { prisma } from "@formbricks/database";
import { select } from "@formbricks/lib/services/survey";
import { TPerson } from "@formbricks/types/v1/people";
import { TSurvey } from "@formbricks/types/v1/surveys";
export const getSurveys = async (environmentId: string, person: TPerson): Promise<TSurvey[]> => {
// get recontactDays from product
const product = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: {
recontactDays: true,
},
});
if (!product) {
throw new Error("Product not found");
}
// get all surveys that meet the displayOption criteria
const potentialSurveys = await prisma.survey.findMany({
where: {
OR: [
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "respondMultiple",
},
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "displayOnce",
displays: { none: { personId: person.id } },
},
{
environmentId,
type: "web",
status: "inProgress",
displayOption: "displayMultiple",
displays: { none: { personId: person.id, status: "responded" } },
},
],
},
select: {
...select,
attributeFilters: {
select: {
id: true,
condition: true,
value: true,
attributeClass: {
select: {
id: true,
name: true,
},
},
},
},
displays: {
where: {
personId: person.id,
},
orderBy: {
createdAt: "desc",
},
take: 1,
select: {
createdAt: true,
},
},
},
});
// get last display for this person
const lastDisplayPerson = await prisma.display.findFirst({
where: {
personId: person.id,
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = potentialSurveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const personAttributeValue = person.attributes[attributeFilter.attributeClass.name];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
// filter surveys that meet the recontactDays criteria
const surveys: TSurvey[] = potentialSurveysWithAttributes
.filter((survey) => {
if (!lastDisplayPerson) {
// no display yet - always display
return true;
} else if (survey.recontactDays !== null) {
// if recontactDays is set on survey, use that
const lastDisplaySurvey = survey.displays[0];
if (!lastDisplaySurvey) {
// no display yet - always display
return true;
}
const lastDisplayDate = new Date(lastDisplaySurvey.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate.getTime() - lastDisplayDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= survey.recontactDays;
} else if (product.recontactDays !== null) {
// if recontactDays is not set in survey, use product recontactDays
const lastDisplayDate = new Date(lastDisplayPerson.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate.getTime() - lastDisplayDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= product.recontactDays;
} else {
// if recontactDays is not set in survey or product, always display
return true;
}
})
.map((survey) => ({
...survey,
triggers: survey.triggers.map((trigger) => trigger.eventClass),
attributeFilters: survey.attributeFilters.map((af) => ({
...af,
attributeClassId: af.attributeClass.id,
attributeClass: undefined,
})),
}));
return surveys;
};

View File

@@ -0,0 +1,139 @@
import { getSurveys } from "@/app/api/v1/js/surveys";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { createPerson, getPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { createSession, extendSession, getSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsSyncInput } from "@formbricks/types/v1/js";
import { TPerson } from "@formbricks/types/v1/people";
import { TSession } from "@formbricks/types/v1/sessions";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsSyncInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, personId, sessionId } = inputValidation.data;
if (!personId) {
// create a new person
const person = await createPerson(environmentId);
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
createSession(person.id),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
}
if (!sessionId) {
let person: TPerson | null;
// check if person exists
person = await getPerson(personId);
if (!person) {
// create a new person
person = await createPerson(environmentId);
}
// get/create rest of the state
const [session, surveys, noCodeActionClasses, product] = await Promise.all([
createSession(person.id),
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
}
// person & session exists
// check if session exists
let person: TPerson | null;
let session: TSession | null;
session = await getSession(sessionId);
if (!session) {
// check if person exits
person = await getPerson(personId);
if (!person) {
// create a new person
person = await createPerson(environmentId);
}
// create a new session
session = await createSession(person.id);
} else {
// session exists
// check if person exists (should always exist, but just in case)
person = await getPerson(personId);
if (!person) {
// create a new person & session
person = await createPerson(environmentId);
session = await createSession(person.id);
} else {
// check if session is expired
if (session.expiresAt < new Date()) {
// create a new session
session = await createSession(person.id);
} else {
// extend session
session = await extendSession(sessionId);
}
}
}
// get/create rest of the state
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
// return state
const state: TJsState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(
"Unable to complete response. See server logs for details.",
true
);
}
}

View File

@@ -2,11 +2,12 @@ export const revalidate = 0;
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { truncateMiddle } from "@/lib/utils";
import { TransformPersonOutput, getPeople } from "@formbricks/lib/services/person";
import { getPeople } from "@formbricks/lib/services/person";
import { TPerson } from "@formbricks/types/v1/people";
import { PersonAvatar } from "@formbricks/ui";
import Link from "next/link";
const getAttributeValue = (person: TransformPersonOutput, attributeName: string) =>
const getAttributeValue = (person: TPerson, attributeName: string) =>
person.attributes[attributeName]?.toString();
export default async function PeoplePage({ params }) {

View File

@@ -36,7 +36,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${window.location.protocol}//${window.location.host}",
logLevel: "debug", // remove when in production
debug: true, // remove when in production
});
}`}</CodeBlock>

View File

@@ -15,10 +15,14 @@ services:
dockerfile: ./apps/web/Dockerfile
depends_on:
- postgres
ports:
- 3000:3000
env_file:
- .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.formbricks.rule=Host(`api.example.com`)" # TODO: Change with your own domain
- "traefik.http.routers.formbricks.tls.certresolver=default"
- "traefik.http.routers.formbricks.entrypoints=websecure"
- "traefik.http.services.formbricks.loadbalancer.server.port=3000"
volumes:
postgres:

View File

@@ -1,4 +1,4 @@
import { TEventClassNoCodeConfig } from "@formbricks/types/v1/eventClasses";
import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { TResponsePersonAttributes, TResponseData } from "@formbricks/types/v1/responses";
import { TSurveyClosedMessage, TSurveyQuestions, TSurveyThankYouCard } from "@formbricks/types/v1/surveys";
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
@@ -6,7 +6,7 @@ import { TUserNotificationSettings } from "@formbricks/types/v1/users";
declare global {
namespace PrismaJson {
export type EventProperties = { [key: string]: string };
export type EventClassNoCodeConfig = TEventClassNoCodeConfig;
export type EventClassNoCodeConfig = TActionClassNoCodeConfig;
export type ResponseData = TResponseData;
export type ResponseMeta = { [key: string]: string };
export type ResponsePersonAttributes = TResponsePersonAttributes;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Session" ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -270,6 +270,7 @@ model EventClass {
type EventType
events Event[]
/// @zod.custom(imports.ZEventClassNoCodeConfig)
/// [EventClassNoCodeConfig]
noCodeConfig Json?
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
@@ -282,6 +283,7 @@ model Session {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
expiresAt DateTime @default(now())
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String
events Event[]

View File

@@ -15,7 +15,7 @@
window.formbricks.init({
environmentId: "clhkhwyc60003yz5rpgsgrebq",
apiHost: "http://localhost:3000",
logLevel: "debug",
debug: true,
});
}, 500);
})();

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "0.1.22",
"version": "1.0.0",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",

View File

@@ -1,4 +1,5 @@
import type { JsConfig, Survey } from "../../types/js";
import type { TJsConfig } from "../../types/v1/js";
import type { TSurvey } from "../../types/v1/surveys";
import { VNode, h } from "preact";
import { useState } from "preact/hooks";
import Modal from "./components/Modal";
@@ -6,8 +7,8 @@ import SurveyView from "./components/SurveyView";
import { IErrorHandler } from "./lib/errors";
interface AppProps {
config: JsConfig;
survey: Survey;
config: TJsConfig;
survey: TSurvey;
closeSurvey: () => Promise<void>;
errorHandler: IErrorHandler;
}
@@ -27,9 +28,9 @@ export default function App({ config, survey, closeSurvey, errorHandler }: AppPr
<Modal
isOpen={isOpen}
close={close}
placement={config.settings.placement}
darkOverlay={config.settings.darkOverlay}
clickOutside={config.settings.clickOutsideClose}>
placement={config.state.product.placement}
darkOverlay={config.state.product.darkOverlay}
clickOutside={config.state.product.clickOutsideClose}>
<SurveyView config={config} survey={survey} close={close} errorHandler={errorHandler} />
</Modal>
</div>

View File

@@ -1,11 +1,11 @@
import type { CTAQuestion } from "../../../types/questions";
import type { TSurveyCTAQuestion } from "../../../types/v1/surveys";
import { h } from "preact";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
interface CTAQuestionProps {
question: CTAQuestion;
question: TSurveyCTAQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,11 +1,11 @@
import type { ConsentQuestion } from "../../../types/questions";
import type { TSurveyConsentQuestion } from "../../../types/v1/surveys";
import { h } from "preact";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
interface ConsentQuestionProps {
question: ConsentQuestion;
question: TSurveyConsentQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,4 +1,4 @@
import type { MultipleChoiceMultiQuestion } from "../../../types/questions";
import type { TSurveyMultipleChoiceMultiQuestion } from "../../../types/v1/surveys";
import { h } from "preact";
import { useState, useRef, useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
@@ -7,7 +7,7 @@ import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
question: TSurveyMultipleChoiceMultiQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,13 +1,13 @@
import { h } from "preact";
import { useRef, useState, useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import type { MultipleChoiceSingleQuestion } from "../../../types/questions";
import type { TSurveyMultipleChoiceSingleQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface MultipleChoiceSingleProps {
question: MultipleChoiceSingleQuestion;
question: TSurveyMultipleChoiceSingleQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,13 +1,13 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { cn } from "../lib/utils";
import type { NPSQuestion } from "../../../types/questions";
import type { TSurveyNPSQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface NPSQuestionProps {
question: NPSQuestion;
question: TSurveyNPSQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,11 +1,11 @@
import type { OpenTextQuestion } from "../../../types/questions";
import type { TSurveyOpenTextQuestion } from "../../../types/v1/surveys";
import { h } from "preact";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface OpenTextQuestionProps {
question: OpenTextQuestion;
question: TSurveyOpenTextQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,5 +1,6 @@
import { h } from "preact";
import { QuestionType, type Question } from "@formbricks/types/questions";
import { QuestionType } from "../../../types/questions";
import { TSurveyQuestion } from "../../../types/v1/surveys";
import OpenTextQuestion from "./OpenTextQuestion";
import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
@@ -9,7 +10,7 @@ import RatingQuestion from "./RatingQuestion";
import ConsentQuestion from "./ConsentQuestion";
interface QuestionConditionalProps {
question: Question;
question: TSurveyQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,7 +1,7 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { cn } from "../lib/utils";
import type { RatingQuestion } from "../../../types/questions";
import type { TSurveyRatingQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import Subheader from "./Subheader";
import {
@@ -19,7 +19,7 @@ import {
import SubmitButton from "./SubmitButton";
interface RatingQuestionProps {
question: RatingQuestion;
question: TSurveyRatingQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;

View File

@@ -1,5 +1,6 @@
import type { JsConfig, Survey } from "../../../types/js";
import type { Logic } from "../../../types/questions";
import type { TJsConfig } from "../../../types/v1/js";
import type { TSurvey } from "../../../types/v1/surveys";
import type { TSurveyLogic } from "../../../types/v1/surveys";
import { h } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { createDisplay, markDisplayResponded } from "../lib/display";
@@ -14,8 +15,8 @@ import FormbricksSignature from "./FormbricksSignature";
import type { TResponseInput } from "../../../types/v1/responses";
interface SurveyViewProps {
config: JsConfig;
survey: Survey;
config: TJsConfig;
survey: TSurvey;
close: () => void;
errorHandler: IErrorHandler;
}
@@ -73,7 +74,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
initDisplay();
async function initDisplay() {
const createDisplayResult = await createDisplay(
{ surveyId: survey.id, personId: config.person.id },
{ surveyId: survey.id, personId: config.state.person.id },
config
);
@@ -92,7 +93,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
}
}, [activeQuestionId, survey]);
function evaluateCondition(logic: Logic, answerValue: any): boolean {
function evaluateCondition(logic: TSurveyLogic, answerValue: any): boolean {
switch (logic.condition) {
case "equals":
return (
@@ -175,7 +176,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
// build response
const responseRequest: TResponseInput = {
surveyId: survey.id,
personId: config.person.id,
personId: config.state.person.id,
finished,
data,
};
@@ -215,7 +216,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
return (
<div>
{!countdownStop && survey.autoClose && (
<Progress progress={countdownProgress} brandColor={config.settings?.brandColor} />
<Progress progress={countdownProgress} brandColor={config.state?.product?.brandColor} />
)}
<div
className={cn(
@@ -228,7 +229,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
<ThankYouCard
headline={survey.thankYouCard.headline}
subheader={survey.thankYouCard.subheader}
brandColor={config.settings?.brandColor}
brandColor={config.state.product?.brandColor}
/>
) : (
survey.questions.map(
@@ -236,7 +237,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
activeQuestionId === question.id && (
<QuestionConditional
key={question.id}
brandColor={config.settings?.brandColor}
brandColor={config.state?.product?.brandColor}
lastQuestion={idx === survey.questions.length - 1}
onSubmit={submitResponse}
question={question}
@@ -245,8 +246,8 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
)
)}
</div>
{config.settings?.formbricksSignature && <FormbricksSignature />}
<Progress progress={progress} brandColor={config.settings?.brandColor} />
{config.state?.product?.formbricksSignature && <FormbricksSignature />}
<Progress progress={progress} brandColor={config.state?.product.brandColor} />
</div>
);
}

View File

@@ -2,12 +2,11 @@ import type { InitConfig } from "../../types/js";
import { getApi } from "./lib/api";
import { CommandQueue } from "./lib/commandQueue";
import { ErrorHandler } from "./lib/errors";
import { trackEvent } from "./lib/event";
import { trackAction } from "./lib/actions";
import { initialize } from "./lib/init";
import { Logger } from "./lib/logger";
import { checkPageUrl } from "./lib/noCodeEvents";
import { resetPerson, setPersonAttribute, setPersonUserId, getPerson } from "./lib/person";
import { refreshSettings } from "./lib/settings";
export type { EnvironmentId, KeyValueData, PersonId, ResponseId, SurveyId } from "@formbricks/api";
@@ -42,13 +41,8 @@ const logout = async (): Promise<void> => {
await queue.wait();
};
const track = async (eventName: string, properties: any = {}): Promise<void> => {
queue.add(true, trackEvent, eventName, properties);
await queue.wait();
};
const refresh = async (): Promise<void> => {
queue.add(true, refreshSettings);
const track = async (name: string, properties: any = {}): Promise<void> => {
queue.add(true, trackAction, name, properties);
await queue.wait();
};
@@ -64,7 +58,6 @@ const formbricks = {
setAttribute,
track,
logout,
refresh,
registerRouteChange,
getApi,
getPerson,

View File

@@ -1,30 +1,31 @@
import { TJsActionInput } from "@formbricks/types/v1/js";
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { renderWidget } from "./widget";
import { Survey } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/v1/surveys";
const logger = Logger.getInstance();
const config = Config.getInstance();
export const trackEvent = async (
eventName: string,
properties?: any
export const trackAction = async (
name: string,
properties: TJsActionInput["properties"] = {}
): Promise<Result<void, NetworkError>> => {
const res = await fetch(
`${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/events`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
const input: TJsActionInput = {
environmentId: config.get().environmentId,
sessionId: config.get().state?.session?.id,
name,
properties: properties || {},
};
body: JSON.stringify({
sessionId: config.get().session.id,
eventName,
properties,
}),
}
);
const res = await fetch(`${config.get().apiHost}/api/v1/js/actions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
const error = await res.json();
@@ -38,13 +39,13 @@ export const trackEvent = async (
});
}
logger.debug(`Formbricks: Event "${eventName}" tracked`);
logger.debug(`Formbricks: Event "${name}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = config.get().settings?.surveys;
const activeSurveys = config.get().state?.surveys;
if (activeSurveys.length > 0) {
triggerSurvey(eventName, activeSurveys);
triggerSurvey(name, activeSurveys);
} else {
logger.debug("No active surveys to display");
}
@@ -52,11 +53,11 @@ export const trackEvent = async (
return okVoid();
};
export const triggerSurvey = (eventName: string, activeSurveys: Survey[]): void => {
export const triggerSurvey = (actionName: string, activeSurveys: TSurvey[]): void => {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.eventClass?.name === eventName) {
logger.debug(`Formbricks: survey ${survey.id} triggered by event "${eventName}"`);
if (trigger.name === actionName) {
logger.debug(`Formbricks: survey ${survey.id} triggered by action "${actionName}"`);
renderWidget(survey);
return;
}

View File

@@ -1,11 +1,11 @@
import { trackEvent } from "./event";
import { trackAction } from "./actions";
import { err } from "./errors";
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined") {
const exitIntentListener = async function (e: MouseEvent) {
if (e.clientY <= 0) {
const trackResult = await trackEvent("Exit Intent (Desktop)");
const trackResult = await trackAction("Exit Intent (Desktop)");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
@@ -29,7 +29,7 @@ export const addScrollDepthListener = (): void => {
}
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
scrollDepthTriggered = true;
const trackResult = await trackEvent("50% Scroll");
const trackResult = await trackAction("50% Scroll");
if (trackResult.ok !== true) {
return err(trackResult.error);
}

View File

@@ -1,9 +1,11 @@
import type { JsConfig } from "../../../types/js";
import { TJsConfig } from "@formbricks/types/v1/js";
import { Result, wrapThrows } from "./errors";
const LOCAL_STORAGE_KEY = "formbricks-js";
export class Config {
private static instance: Config | undefined;
private config: JsConfig = this.loadFromLocalStorage();
private config: TJsConfig = this.loadFromLocalStorage();
static getInstance(): Config {
if (!Config.instance) {
@@ -12,7 +14,7 @@ export class Config {
return Config.instance;
}
public update(newConfig: Partial<JsConfig>): void {
public update(newConfig: Partial<TJsConfig>): void {
if (newConfig) {
this.config = {
...this.config,
@@ -22,13 +24,13 @@ export class Config {
}
}
public get(): JsConfig {
public get(): TJsConfig {
return this.config;
}
private loadFromLocalStorage(): JsConfig {
private loadFromLocalStorage(): TJsConfig {
if (typeof window !== "undefined") {
const savedConfig = localStorage.getItem("formbricksConfig");
const savedConfig = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedConfig) {
return JSON.parse(savedConfig);
}
@@ -40,6 +42,6 @@ export class Config {
}
private saveToLocalStorage(): Result<void, Error> {
return wrapThrows(() => localStorage.setItem("formbricksConfig", JSON.stringify(this.config)))();
return wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
}
}

View File

@@ -1,10 +1,10 @@
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
import type { JsConfig } from "../../../types/js";
import type { TDisplay, TDisplayInput } from "../../../types/v1/displays";
import type { TJsConfig } from "../../../types/v1/js";
import { NetworkError, Result, err, ok, okVoid } from "./errors";
export const createDisplay = async (
displayCreateRequest: TDisplayInput,
config: JsConfig
config: TJsConfig
): Promise<Result<TDisplay, NetworkError>> => {
const url = `${config.apiHost}/api/v1/client/displays`;
@@ -31,7 +31,7 @@ export const createDisplay = async (
export const markDisplayResponded = async (
displayId: string,
config: JsConfig
config: TJsConfig
): Promise<Result<void, NetworkError>> => {
const url = `${config.apiHost}/api/v1/client/displays/${displayId}/responded`;

View File

@@ -1,5 +1,5 @@
import type { InitConfig } from "../../../types/js";
import { addExitIntentListener, addScrollDepthListener } from "./automaticEvents";
import { addExitIntentListener, addScrollDepthListener } from "./automaticActions";
import { Config } from "./config";
import {
ErrorHandler,
@@ -11,26 +11,42 @@ import {
err,
okVoid,
} from "./errors";
import { trackEvent } from "./event";
import { trackAction } from "./actions";
import { Logger } from "./logger";
import { addClickEventListener, addPageUrlEventListeners, checkPageUrl } from "./noCodeEvents";
import { createPerson, resetPerson } from "./person";
import { createSession, extendOrCreateSession, extendSession, isExpired } from "./session";
import { resetPerson } from "./person";
import { isExpired } from "./session";
import { addStylesToDom } from "./styles";
import { sync } from "./sync";
import { addWidgetContainer } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
const addSessionEventListeners = (): void => {
// add event listener to check the session every minute
let syncIntervalId: number | null = null;
const addSyncEventListener = (debug?: boolean): void => {
const updateInverval = debug ? 1000 * 30 : 1000 * 60 * 2; // 2 minutes in production, 30 seconds in debug mode
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined") {
const intervalId = window.setInterval(async () => {
await extendOrCreateSession();
}, 1000 * 60 * 5); // check every 5 minutes
// clear any existing interval
if (syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
}
syncIntervalId = window.setInterval(async () => {
logger.debug("Syncing.");
const syncResult = await sync();
if (syncResult.ok !== true) {
return err(syncResult.error);
}
const state = syncResult.value;
config.update({ state });
}, updateInverval);
// clear interval on page unload
window.addEventListener("beforeunload", () => {
clearInterval(intervalId);
if (syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
}
});
}
};
@@ -38,9 +54,9 @@ const addSessionEventListeners = (): void => {
export const initialize = async (
c: InitConfig
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (c.logLevel) {
logger.debug(`Setting log level to ${c.logLevel}`);
logger.configure({ logLevel: c.logLevel });
if (c.debug) {
logger.debug(`Setting log level to debug`);
logger.configure({ logLevel: "debug" });
}
ErrorHandler.getInstance().printStatus();
@@ -70,57 +86,57 @@ export const initialize = async (
logger.debug("Adding styles to DOM");
addStylesToDom();
if (
config.get().session &&
config.get().state &&
config.get().environmentId === c.environmentId &&
config.get().apiHost === c.apiHost
) {
logger.debug("Found existing configuration. Checking session.");
const existingSession = config.get().session;
const existingSession = config.get().state.session;
if (isExpired(existingSession)) {
logger.debug("Session expired. Creating new session.");
logger.debug("Session expired. Resyncing.");
const createSessionResult = await createSession();
const syncResult = await sync();
// if create session fails, clear config and start from scratch
if (createSessionResult.ok !== true) {
// if create sync fails, clear config and start from scratch
if (syncResult.ok !== true) {
await resetPerson();
return await initialize(c);
}
const { session, settings } = createSessionResult.value;
const state = syncResult.value;
config.update({ session: extendSession(session), settings });
config.update({ state });
const trackEventResult = await trackEvent("New Session");
const trackActionResult = await trackAction("New Session");
if (trackEventResult.ok !== true) return err(trackEventResult.error);
if (trackActionResult.ok !== true) return err(trackActionResult.error);
} else {
logger.debug("Session valid. Extending session.");
config.update({ session: extendSession(existingSession) });
logger.debug("Session valid. Continuing.");
// continue for now - next sync will check complete state
}
} else {
logger.debug("No valid session found. Creating new config.");
// we need new config
config.update({ environmentId: c.environmentId, apiHost: c.apiHost });
logger.debug("Get person, session and settings from server");
const result = await createPerson();
logger.debug("Syncing.");
const syncResult = await sync();
if (result.ok !== true) {
return err(result.error);
if (syncResult.ok !== true) {
return err(syncResult.error);
}
const { person, session, settings } = result.value;
const state = syncResult.value;
config.update({ person, session: extendSession(session), settings });
config.update({ state });
const trackEventResult = await trackEvent("New Session");
const trackActionResult = await trackAction("New Session");
if (trackEventResult.ok !== true) return err(trackEventResult.error);
if (trackActionResult.ok !== true) return err(trackActionResult.error);
}
logger.debug("Add session event listeners");
addSessionEventListeners();
addSyncEventListener(c.debug);
logger.debug("Add page url event listeners");
addPageUrlEventListeners();
@@ -146,9 +162,7 @@ export const checkInitialized = (): Result<void, NotInitializedError> => {
if (
!config.get().apiHost ||
!config.get().environmentId ||
!config.get().person ||
!config.get().session ||
!config.get().settings ||
!config.get().state ||
!ErrorHandler.initialized
) {
return err({

View File

@@ -1,8 +1,8 @@
import type { Event } from "../../../types/events";
import type { TActionClass } from "../../../types/v1/actionClasses";
import type { MatchType } from "../../../types/js";
import { Config } from "./config";
import { ErrorHandler, InvalidMatchTypeError, NetworkError, Result, err, match, ok, okVoid } from "./errors";
import { trackEvent } from "./event";
import { trackAction } from "./actions";
import { Logger } from "./logger";
const config = Config.getInstance();
@@ -11,12 +11,14 @@ const errorHandler = ErrorHandler.getInstance();
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
logger.debug(`Checking page url: ${window.location.href}`);
const { settings } = config.get();
if (settings?.noCodeEvents === undefined) {
const { state } = config.get();
if (state?.noCodeActionClasses === undefined) {
return okVoid();
}
const pageUrlEvents: Event[] = settings?.noCodeEvents.filter((e) => e.noCodeConfig?.type === "pageUrl");
const pageUrlEvents: TActionClass[] = state?.noCodeActionClasses.filter(
(e) => e.noCodeConfig?.type === "pageUrl"
);
if (pageUrlEvents.length === 0) {
return okVoid();
@@ -35,7 +37,7 @@ export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError
if (match.value === false) continue;
const trackResult = await trackEvent(event.name);
const trackResult = await trackAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
}
@@ -95,9 +97,11 @@ export function checkUrlMatch(
}
export const checkClickMatch = (event: MouseEvent) => {
const { settings } = config.get();
const innerHtmlEvents: Event[] = settings?.noCodeEvents.filter((e) => e.noCodeConfig?.type === "innerHtml");
const cssSelectorEvents: Event[] = settings?.noCodeEvents.filter(
const { state } = config.get();
const innerHtmlEvents: TActionClass[] = state?.noCodeActionClasses.filter(
(e) => e.noCodeConfig?.type === "innerHtml"
);
const cssSelectorEvents: TActionClass[] = state?.noCodeActionClasses.filter(
(e) => e.noCodeConfig?.type === "cssSelector"
);
@@ -106,7 +110,7 @@ export const checkClickMatch = (event: MouseEvent) => {
innerHtmlEvents.forEach((e) => {
const innerHtml = e.noCodeConfig?.innerHtml;
if (innerHtml && targetElement.innerHTML === innerHtml.value) {
trackEvent(e.name).then((res) => {
trackAction(e.name).then((res) => {
match(
res,
(_value) => {},
@@ -121,7 +125,7 @@ export const checkClickMatch = (event: MouseEvent) => {
cssSelectorEvents.forEach((e) => {
const cssSelector = e.noCodeConfig?.cssSelector;
if (cssSelector && targetElement.matches(cssSelector.value)) {
trackEvent(e.name).then((res) => {
trackAction(e.name).then((res) => {
match(
res,
(_value) => {},

View File

@@ -1,3 +1,4 @@
import { TJsPeopleAttributeInput, TJsPeopleUserIdInput, TJsState } from "@formbricks/types/v1/js";
import type { Person } from "../../../types/js";
import type { Session, Settings } from "../../../types/js";
import { Config } from "./config";
@@ -12,57 +13,35 @@ import {
okVoid,
} from "./errors";
import { Logger } from "./logger";
import { sync } from "./sync";
import { TPerson } from "@formbricks/types/v1/people";
const config = Config.getInstance();
const logger = Logger.getInstance();
export const createPerson = async (): Promise<
Result<{ session: Session; person: Person; settings: Settings }, NetworkError>
> => {
logger.debug("Creating new person");
const url = `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/people`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const jsonRes = await res.json();
if (!res.ok) {
return err({
code: "network_error",
message: "Error creating person",
status: res.status,
url,
responseMessage: jsonRes.message,
});
}
return ok(jsonRes as { session: Session; person: Person; settings: Settings });
};
export const updatePersonUserId = async (
userId: string
): Promise<Result<{ person: Person; settings: Settings }, NetworkError | MissingPersonError>> => {
if (!config.get().person || !config.get().person.id)
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
if (!config.get().state.person || !config.get().state.person.id)
return err({
code: "missing_person",
message: "Unable to update userId. No person set.",
});
const url = `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/people/${
config.get().person.id
}/user-id`;
const url = `${config.get().apiHost}/api/v1/js/people/${config.get().state.person.id}/set-user-id`;
const input: TJsPeopleUserIdInput = {
environmentId: config.get().environmentId,
userId,
sessionId: config.get().state.session.id,
};
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, sessionId: config.get().session.id }),
body: JSON.stringify(input),
});
const jsonRes = await res.json();
@@ -77,30 +56,35 @@ export const updatePersonUserId = async (
});
}
return ok(jsonRes as { person: Person; settings: Settings });
return ok(jsonRes.data as TJsState);
};
export const updatePersonAttribute = async (
key: string,
value: string
): Promise<Result<{ person: Person; settings: Settings }, NetworkError | MissingPersonError>> => {
if (!config.get().person || !config.get().person.id) {
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
if (!config.get().state.person || !config.get().state.person.id) {
return err({
code: "missing_person",
message: "Unable to update attribute. No person set.",
});
}
const input: TJsPeopleAttributeInput = {
environmentId: config.get().environmentId,
sessionId: config.get().state.session.id,
key,
value,
};
const res = await fetch(
`${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/people/${
config.get().person.id
}/attribute`,
`${config.get().apiHost}/api/v1/js/people/${config.get().state.person.id}/set-attribute`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key, value }),
body: JSON.stringify(input),
}
);
@@ -116,20 +100,18 @@ export const updatePersonAttribute = async (
});
}
return ok(resJson as { person: Person; settings: Settings });
return ok(resJson.data as TJsState);
};
export const attributeAlreadySet = (key: string, value: string): boolean => {
const existingAttribute = config.get().person.attributes.find((a) => a.attributeClass?.name === key);
if (existingAttribute && existingAttribute.value === value) {
export const hasAttributeValue = (key: string, value: string): boolean => {
if (config.get().state.person?.attributes?.[key] === value) {
return true;
}
return false;
};
export const attributeAlreadyExists = (key: string): boolean => {
const existingAttribute = config.get().person.attributes.find((a) => a.attributeClass?.name === key);
if (existingAttribute) {
export const hasAttributeKey = (key: string): boolean => {
if (config.get().state.person?.attributes?.[key]) {
return true;
}
return false;
@@ -140,11 +122,11 @@ export const setPersonUserId = async (
): Promise<Result<void, NetworkError | MissingPersonError | AttributeAlreadyExistsError>> => {
logger.debug("setting userId: " + userId);
// check if attribute already exists with this value
if (attributeAlreadySet("userId", userId)) {
if (hasAttributeValue("userId", userId)) {
logger.debug("userId already set to this value. Skipping update.");
return okVoid();
}
if (attributeAlreadyExists("userId")) {
if (hasAttributeKey("userId")) {
return err({
code: "attribute_already_exists",
message: "userId cannot be changed after it has been set. You need to reset first",
@@ -154,9 +136,9 @@ export const setPersonUserId = async (
if (result.ok !== true) return err(result.error);
const { person, settings } = result.value;
const state = result.value;
config.update({ person, settings });
config.update({ state });
return okVoid();
};
@@ -167,7 +149,7 @@ export const setPersonAttribute = async (
): Promise<Result<void, NetworkError | MissingPersonError>> => {
logger.debug("setting attribute: " + key + " to value: " + value);
// check if attribute already exists with this value
if (attributeAlreadySet(key, value)) {
if (hasAttributeValue(key, value)) {
logger.debug("attribute already set to this value. Skipping update.");
return okVoid();
}
@@ -178,8 +160,8 @@ export const setPersonAttribute = async (
match(
result,
({ person, settings }) => {
config.update({ person, settings });
(state) => {
config.update({ state });
},
(err) => {
// pass error to outer scope
@@ -196,14 +178,15 @@ export const setPersonAttribute = async (
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting person. Getting new person, session and settings from backend");
const result = await createPerson();
config.update({ state: undefined });
const syncResult = await sync();
let error: NetworkError;
match(
result,
({ person, session, settings }) => {
config.update({ person, session, settings });
syncResult,
(state) => {
config.update({ state });
},
(err) => {
// pass error to outer scope
@@ -218,6 +201,6 @@ export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
return okVoid();
};
export const getPerson = (): Person => {
return config.get().person;
export const getPerson = (): TPerson => {
return config.get().state.person;
};

View File

@@ -1,4 +1,4 @@
import type { JsConfig, Response } from "../../../types/js";
import type { TJsConfig } from "../../../types/v1/js";
import type { TResponse, TResponseInput } from "../../../types/v1/responses";
import { NetworkError, Result, err, ok } from "./errors";
@@ -32,7 +32,7 @@ export const createResponse = async (
export const updateResponse = async (
responseInput: TResponseInput,
responseId: string,
config: JsConfig
config: TJsConfig
): Promise<Result<TResponse, NetworkError>> => {
const url = `${config.apiHost}/api/v1/client/responses/${responseId}`;

View File

@@ -1,75 +1,6 @@
import type { Session, Settings } from "../../../types/js";
import { Config } from "./config";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "./errors";
import { trackEvent } from "./event";
import { Logger } from "./logger";
import { TSession } from "@formbricks/types/v1/sessions";
const logger = Logger.getInstance();
const config = Config.getInstance();
export const createSession = async (): Promise<
Result<{ session: Session; settings: Settings }, NetworkError | MissingPersonError>
> => {
if (!config.get().person) {
return err({
code: "missing_person",
message: "Unable to create session. No person found",
});
}
const url = `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/sessions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ personId: config.get().person.id }),
});
const resJson = await response.json();
if (!response.ok) {
return err({
code: "network_error",
message: "Error creating session",
status: response.status,
url,
responseMessage: resJson.message,
});
}
return ok(resJson as { session: Session; settings: Settings });
};
export const extendSession = (session: Session): Session => {
const updatedSession = { ...session };
updatedSession.expiresAt = Date.now() + 1000 * 60 * 60; // extend session for 60 minutes
return updatedSession;
};
export const isExpired = (session: Session): boolean => {
export const isExpired = (session: TSession): boolean => {
if (!session) return true;
return session.expiresAt <= Date.now();
};
export const extendOrCreateSession = async (): Promise<Result<void, NetworkError | MissingPersonError>> => {
logger.debug("Checking session");
if (isExpired(config.get().session)) {
logger.debug("Session expired, creating new session");
const result = await createSession();
if (result.ok !== true) return err(result.error);
const { session, settings } = result.value;
config.update({ session, settings });
const trackResult = await trackEvent("New Session");
if (trackResult.ok !== true) return err(trackResult.error);
return okVoid();
}
logger.debug("Session not expired, extending session");
config.update({ session: extendSession(config.get().session) });
return okVoid();
return session.expiresAt < new Date();
};

View File

@@ -1,44 +0,0 @@
import type { Settings } from "../../../types/js";
import { Config } from "./config";
import { NetworkError, Result, err, ok, okVoid } from "./errors";
import { Logger } from "./logger";
const logger = Logger.getInstance();
const config = Config.getInstance();
export const getSettings = async (): Promise<Result<Settings, NetworkError>> => {
const url = `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/settings`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ personId: config.get().person.id }),
});
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error getting settings",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()) as Settings);
};
export const refreshSettings = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Refreshing - getting settings from backend");
const settings = await getSettings();
if (settings.ok !== true) return err(settings.error);
logger.debug("Settings refreshed");
config.update({ settings: settings.value });
return okVoid();
};

View File

@@ -0,0 +1,36 @@
import { TJsState } from "@formbricks/types/v1/js";
import { Config } from "./config";
import { NetworkError, Result, err, ok } from "./errors";
import { Logger } from "./logger";
const logger = Logger.getInstance();
const config = Config.getInstance();
export const sync = async (): Promise<Result<TJsState, NetworkError>> => {
const url = `${config.get().apiHost}/api/v1/js/sync`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
environmentId: config.get().environmentId,
personId: config.get().state?.person.id,
sessionId: config.get().state?.session.id,
}),
});
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()).data as TJsState);
};

View File

@@ -1,10 +1,10 @@
import type { Survey } from "../../../types/js";
import { h, render } from "preact";
import type { TSurvey } from "../../../types/v1/surveys";
import App from "../App";
import { Config } from "./config";
import { ErrorHandler, match } from "./errors";
import { Logger } from "./logger";
import { getSettings } from "./settings";
import { sync } from "./sync";
const containerId = "formbricks-web-container";
const config = Config.getInstance();
@@ -12,7 +12,7 @@ const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let surveyRunning = false;
export const renderWidget = (survey: Survey) => {
export const renderWidget = (survey: TSurvey) => {
if (surveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
@@ -36,12 +36,12 @@ export const closeSurvey = async (): Promise<void> => {
document.getElementById(containerId).remove();
addWidgetContainer();
const settings = await getSettings();
const syncResult = await sync();
match(
settings,
syncResult,
(value) => {
config.update({ settings: value });
config.update({ state: value });
surveyRunning = false;
},
(error) => {

View File

@@ -21,70 +21,68 @@ const {
customAttributeKey,
customAttributeValue,
eventIdForEventTracking,
userIdAttributeId,
userInitialEmailAttributeId,
userCustomAttrAttributeId,
userUpdatedEmailAttributeId,
} = constants;
export const mockInitResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
apiHost,
environmentId,
person: {
id: initialPersonUid,
environmentId,
attributes: [],
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
attributes: {},
},
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
settings: {
surveys: [
{
id: surveyId,
questions: [
{
id: questionOneId,
type: "multipleChoiceSingle",
choices: [
{
id: choiceOneId,
label: "Not at all disappointed",
},
{
id: choiceTwoId,
label: "Somewhat disappointed",
},
{
id: choiceThreeId,
label: "Very disappointed",
},
],
headline: "How disappointed would you be if you could no longer use Test-Formbricks?",
required: true,
subheader: "Please select one of the following options:",
},
{
id: questionTwoId,
type: "openText",
headline: "How can we improve Test-Formbricks for you?",
required: true,
subheader: "Please be as specific as possible.",
},
],
triggers: [],
thankYouCard: {
enabled: true,
headline: "Thank you!",
subheader: "We appreciate your feedback.",
surveys: [
{
id: surveyId,
questions: [
{
id: questionOneId,
type: "multipleChoiceSingle",
choices: [
{
id: choiceOneId,
label: "Not at all disappointed",
},
{
id: choiceTwoId,
label: "Somewhat disappointed",
},
{
id: choiceThreeId,
label: "Very disappointed",
},
],
headline: "How disappointed would you be if you could no longer use Test-Formbricks?",
required: true,
subheader: "Please select one of the following options:",
},
autoClose: null,
delay: 0,
{
id: questionTwoId,
type: "openText",
headline: "How can we improve Test-Formbricks for you?",
required: true,
subheader: "Please be as specific as possible.",
},
],
triggers: [],
thankYouCard: {
enabled: true,
headline: "Thank you!",
subheader: "We appreciate your feedback.",
},
],
autoClose: null,
delay: 0,
},
],
noCodeActionClasses: [],
product: {
noCodeEvents: [],
brandColor: "#20b398",
formbricksSignature: true,
@@ -99,25 +97,18 @@ export const mockInitResponse = () => {
export const mockSetUserIdResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
apiHost,
environmentId,
settings: {
surveys: [],
noCodeEvents: [],
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: [
{
id: userIdAttributeId,
value: initialUserId,
attributeClass: {
id: environmentId,
name: "userId",
},
},
],
attributes: { userId: initialUserId },
},
})
);
@@ -126,33 +117,18 @@ export const mockSetUserIdResponse = () => {
export const mockSetEmailIdResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
apiHost,
environmentId,
settings: {
surveys: [],
noCodeEvents: [],
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: [
{
id: userIdAttributeId,
value: initialUserId,
attributeClass: {
id: environmentId,
name: "userId",
},
},
{
id: userInitialEmailAttributeId,
value: initialUserEmail,
attributeClass: {
id: environmentId,
name: "email",
},
},
],
attributes: { userId: initialUserId, email: initialUserEmail },
},
})
);
@@ -161,41 +137,22 @@ export const mockSetEmailIdResponse = () => {
export const mockSetCustomAttributeResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
apiHost,
environmentId,
settings: {
surveys: [],
noCodeEvents: [],
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: [
{
id: userIdAttributeId,
value: initialUserId,
attributeClass: {
id: environmentId,
name: "userId",
},
},
{
id: userInitialEmailAttributeId,
value: initialUserEmail,
attributeClass: {
id: environmentId,
name: "email",
},
},
{
id: userCustomAttrAttributeId,
value: customAttributeValue,
attributeClass: {
id: environmentId,
name: customAttributeKey,
},
},
],
attributes: {
userId: initialUserId,
email: initialUserEmail,
[customAttributeKey]: customAttributeValue,
},
},
})
);
@@ -204,41 +161,22 @@ export const mockSetCustomAttributeResponse = () => {
export const mockUpdateEmailResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
apiHost,
environmentId,
settings: {
surveys: [],
noCodeEvents: [],
surveys: [],
noCodeActionClasses: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
person: {
id: initialPersonUid,
environmentId,
attributes: [
{
id: userIdAttributeId,
value: initialUserId,
attributeClass: {
id: environmentId,
name: "userId",
},
},
{
id: userUpdatedEmailAttributeId,
value: updatedUserEmail,
attributeClass: {
id: environmentId,
name: "email",
},
},
{
id: userCustomAttrAttributeId,
value: customAttributeValue,
attributeClass: {
id: environmentId,
name: customAttributeKey,
},
},
],
attributes: {
userId: initialUserId,
email: updatedUserEmail,
[customAttributeKey]: customAttributeValue,
},
},
})
);
@@ -279,7 +217,13 @@ export const mockLogoutResponse = () => {
environmentId,
attributes: [],
},
session: {},
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
})
);
console.log("Resetting person. Getting new person, session and settings from backend");

View File

@@ -1,20 +1,19 @@
/**
* @jest-environment jsdom
*/
import { TPersonAttributes } from "@formbricks/types/v1/people";
import formbricks from "../src/index";
import { constants } from "./constants";
import { Attribute } from "./types";
import {
mockEventTrackResponse,
mockInitResponse,
mockLogoutResponse,
mockRefreshResponse,
mockRegisterRouteChangeResponse,
mockSetCustomAttributeResponse,
mockSetEmailIdResponse,
mockSetUserIdResponse,
mockUpdateEmailResponse,
} from "./__mocks__/apiMock";
import { constants } from "./constants";
const consoleLogMock = jest.spyOn(console, "log").mockImplementation();
@@ -44,7 +43,7 @@ test("Formbricks should Initialise", async () => {
apiHost,
});
const configFromBrowser = localStorage.getItem("formbricksConfig");
const configFromBrowser = localStorage.getItem("formbricks-js");
expect(configFromBrowser).toBeTruthy();
if (configFromBrowser) {
@@ -55,114 +54,77 @@ test("Formbricks should Initialise", async () => {
});
test("Formbricks should get the current person with no attributes", () => {
const currentState = formbricks.getPerson();
const currentStatePerson = formbricks.getPerson();
const currentStateAttributes: Array<Attribute> = currentState.attributes as Array<Attribute>;
expect(currentStateAttributes).toHaveLength(0);
const currentStatePersonAttributes: TPersonAttributes = currentStatePerson.attributes;
expect(Object.keys(currentStatePersonAttributes)).toHaveLength(0);
});
test("Formbricks should set userId", async () => {
mockSetUserIdResponse();
await formbricks.setUserId(initialUserId);
const currentState = formbricks.getPerson();
expect(currentState.environmentId).toStrictEqual(environmentId);
const currentStatePerson = formbricks.getPerson();
const currentStateAttributes: Array<Attribute> = currentState.attributes as Array<Attribute>;
const numberOfUserAttributes = currentStateAttributes.length;
const currentStatePersonAttributes = currentStatePerson.attributes;
const numberOfUserAttributes = Object.keys(currentStatePersonAttributes).length;
expect(numberOfUserAttributes).toStrictEqual(1);
currentStateAttributes.forEach((attribute) => {
switch (attribute.attributeClass.name) {
case "userId":
expect(attribute.value).toStrictEqual(initialUserId);
break;
default:
expect(0).toStrictEqual(1);
}
});
const userId = currentStatePersonAttributes.userId;
expect(userId).toStrictEqual(initialUserId);
});
test("Formbricks should set email", async () => {
mockSetEmailIdResponse();
await formbricks.setEmail(initialUserEmail);
const currentState = formbricks.getPerson();
expect(currentState.environmentId).toStrictEqual(environmentId);
const currentStatePerson = formbricks.getPerson();
const currentStateAttributes: Array<Attribute> = currentState.attributes as Array<Attribute>;
const numberOfUserAttributes = currentStateAttributes.length;
const currentStatePersonAttributes = currentStatePerson.attributes;
const numberOfUserAttributes = Object.keys(currentStatePersonAttributes).length;
expect(numberOfUserAttributes).toStrictEqual(2);
currentStateAttributes.forEach((attribute) => {
switch (attribute.attributeClass.name) {
case "userId":
expect(attribute.value).toStrictEqual(initialUserId);
break;
case "email":
expect(attribute.value).toStrictEqual(initialUserEmail);
break;
default:
expect(0).toStrictEqual(1);
}
});
const userId = currentStatePersonAttributes.userId;
expect(userId).toStrictEqual(initialUserId);
const email = currentStatePersonAttributes.email;
expect(email).toStrictEqual(initialUserEmail);
});
test("Formbricks should set custom attribute", async () => {
mockSetCustomAttributeResponse();
await formbricks.setAttribute(customAttributeKey, customAttributeValue);
const currentState = formbricks.getPerson();
expect(currentState.environmentId).toStrictEqual(environmentId);
const currentStatePerson = formbricks.getPerson();
const currentStateAttributes: Array<Attribute> = currentState.attributes as Array<Attribute>;
const numberOfUserAttributes = currentStateAttributes.length;
const currentStatePersonAttributes = currentStatePerson.attributes;
const numberOfUserAttributes = Object.keys(currentStatePersonAttributes).length;
expect(numberOfUserAttributes).toStrictEqual(3);
currentStateAttributes.forEach((attribute) => {
switch (attribute.attributeClass.name) {
case "userId":
expect(attribute.value).toStrictEqual(initialUserId);
break;
case "email":
expect(attribute.value).toStrictEqual(initialUserEmail);
break;
case customAttributeKey:
expect(attribute.value).toStrictEqual(customAttributeValue);
break;
default:
expect(0).toStrictEqual(1);
}
});
const userId = currentStatePersonAttributes.userId;
expect(userId).toStrictEqual(initialUserId);
const email = currentStatePersonAttributes.email;
expect(email).toStrictEqual(initialUserEmail);
const customAttribute = currentStatePersonAttributes[customAttributeKey];
expect(customAttribute).toStrictEqual(customAttributeValue);
});
test("Formbricks should update attribute", async () => {
mockUpdateEmailResponse();
await formbricks.setEmail(updatedUserEmail);
const currentState = formbricks.getPerson();
expect(currentState.environmentId).toStrictEqual(environmentId);
const currentStatePerson = formbricks.getPerson();
const currentStateAttributes: Array<Attribute> = currentState.attributes as Array<Attribute>;
const currentStatePersonAttributes = currentStatePerson.attributes;
const numberOfUserAttributes = currentStateAttributes.length;
const numberOfUserAttributes = Object.keys(currentStatePersonAttributes).length;
expect(numberOfUserAttributes).toStrictEqual(3);
currentStateAttributes.forEach((attribute) => {
switch (attribute.attributeClass.name) {
case "email":
expect(attribute.value).toStrictEqual(updatedUserEmail);
break;
case "userId":
expect(attribute.value).toStrictEqual(initialUserId);
break;
case customAttributeKey:
expect(attribute.value).toStrictEqual(customAttributeValue);
break;
default:
expect(0).toStrictEqual(1);
}
});
const userId = currentStatePersonAttributes.userId;
expect(userId).toStrictEqual(initialUserId);
const email = currentStatePersonAttributes.email;
expect(email).toStrictEqual(updatedUserEmail);
const customAttribute = currentStatePersonAttributes[customAttributeKey];
expect(customAttribute).toStrictEqual(customAttributeValue);
});
test("Formbricks should track event", async () => {
@@ -177,12 +139,6 @@ test("Formbricks should track event", async () => {
);
});
test("Formbricks should refresh", async () => {
mockRefreshResponse();
await formbricks.refresh();
expect(consoleLogMock).toHaveBeenCalledWith(expect.stringMatching(/Settings refreshed/));
});
test("Formbricks should register for route change", async () => {
mockRegisterRouteChangeResponse();
await formbricks.registerRouteChange();
@@ -192,9 +148,8 @@ test("Formbricks should register for route change", async () => {
test("Formbricks should logout", async () => {
mockLogoutResponse();
await formbricks.logout();
const currentState = formbricks.getPerson();
const currentStateAttributes: Array<Attribute> = currentState.attributes as Array<Attribute>;
const currentStatePerson = formbricks.getPerson();
const currentStatePersonAttributes = currentStatePerson.attributes;
expect(currentState.environmentId).toStrictEqual(environmentId);
expect(currentStateAttributes.length).toBe(0);
expect(Object.keys(currentStatePersonAttributes).length).toBe(0);
});

View File

@@ -0,0 +1,49 @@
"use server";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/errors";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import "server-only";
const select = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
noCodeConfig: true,
environmentId: true,
};
export const getActionClasses = async (environmentId: string): Promise<TActionClass[]> => {
try {
let actionClasses = await prisma.eventClass.findMany({
where: {
environmentId: environmentId,
},
select,
orderBy: {
createdAt: "asc",
},
});
return actionClasses;
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
}
};
export const createActionClassServerAction = async (environmentId: string, actionClass) => {
try {
const result = await prisma.eventClass.create({
data: {
...actionClass,
environment: { connect: { id: environmentId } },
},
});
return result;
} catch (error) {
throw new DatabaseError(`Database error when creating an action for environment ${environmentId}`);
}
};

View File

@@ -53,7 +53,7 @@ export const createDisplay = async (displayInput: TDisplayInput): Promise<TDispl
const display: TDisplay = {
...displayPrisma,
person: transformPrismaPerson(displayPrisma.person),
person: displayPrisma.person ? transformPrismaPerson(displayPrisma.person) : null,
};
return display;
@@ -86,7 +86,7 @@ export const markDisplayResponded = async (displayId: string): Promise<TDisplay>
const display: TDisplay = {
...displayPrisma,
person: transformPrismaPerson(displayPrisma.person),
person: displayPrisma.person ? transformPrismaPerson(displayPrisma.person) : null,
};
return display;

View File

@@ -1,9 +1,25 @@
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TPerson } from "@formbricks/types/v1/people";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { cache } from "react";
export const select = {
id: true,
createdAt: true,
updatedAt: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
};
type TransformPersonInput = {
id: string;
attributes: {
@@ -16,18 +32,7 @@ type TransformPersonInput = {
updatedAt: Date;
};
export type TransformPersonOutput = {
id: string;
attributes: Record<string, string | number>;
createdAt: Date;
updatedAt: Date;
};
export const transformPrismaPerson = (person: TransformPersonInput | null): TransformPersonOutput | null => {
if (person === null) {
return null;
}
export const transformPrismaPerson = (person: TransformPersonInput): TPerson => {
const attributes = person.attributes.reduce((acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
@@ -47,21 +52,11 @@ export const getPerson = async (personId: string): Promise<TPerson | null> => {
where: {
id: personId,
},
include: {
attributes: {
include: {
attributeClass: {
select: {
name: true,
},
},
},
},
},
select,
});
if (!personPrisma) {
throw new ResourceNotFoundError("Person", personId);
return null;
}
const person = transformPrismaPerson(personPrisma);
@@ -82,29 +77,15 @@ export const getPeople = cache(async (environmentId: string): Promise<TPerson[]>
where: {
environmentId: environmentId,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
select,
});
if (!personsPrisma) {
throw new ResourceNotFoundError("Persons", "All Persons");
}
const transformedPersons: TransformPersonOutput[] = personsPrisma
const transformedPersons: TPerson[] = personsPrisma
.map(transformPrismaPerson)
.filter((person: TransformPersonOutput | null): person is TransformPersonOutput => person !== null);
.filter((person: TPerson | null): person is TPerson => person !== null);
return transformedPersons;
} catch (error) {
@@ -115,3 +96,44 @@ export const getPeople = cache(async (environmentId: string): Promise<TPerson[]>
throw error;
}
});
export const createPerson = async (environmentId: string): Promise<TPerson> => {
try {
const personPrisma = await prisma.person.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
},
select,
});
const person = transformPrismaPerson(personPrisma);
return person;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deletePerson = async (personId: string): Promise<void> => {
try {
await prisma.person.delete({
where: {
id: personId,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -4,8 +4,9 @@ import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/typ
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import "server-only";
import { TransformPersonOutput, getPerson, transformPrismaPerson } from "./person";
import { getPerson, transformPrismaPerson } from "./person";
import { cache } from "react";
import { TPerson } from "@formbricks/types/v1/people";
const responseSelection = {
id: true,
@@ -64,7 +65,7 @@ const responseSelection = {
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
try {
let person: TransformPersonOutput | null = null;
let person: TPerson | null = null;
if (responseInput.personId) {
person = await getPerson(responseInput.personId);
@@ -94,7 +95,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
const response: TResponse = {
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -123,7 +124,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
const response: TResponse = {
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
@@ -157,7 +158,7 @@ export const getSurveyResponses = cache(async (surveyId: string): Promise<TRespo
const responses: TResponse[] = responsesPrisma.map((responsePrisma) => ({
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
@@ -193,7 +194,7 @@ export const getEnvironmentResponses = cache(async (environmentId: string): Prom
const responses: TResponse[] = responsesPrisma.map((responsePrisma) => ({
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
@@ -237,7 +238,7 @@ export const updateResponse = async (
const response: TResponse = {
...responsePrisma,
person: transformPrismaPerson(responsePrisma.person),
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};

View File

@@ -0,0 +1,79 @@
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/errors";
import { TSession } from "@formbricks/types/v1/sessions";
import { Prisma } from "@prisma/client";
const select = {
id: true,
createdAt: true,
updatedAt: true,
expiresAt: true,
personId: true,
};
const oneHour = 1000 * 60 * 60;
export const getSession = async (sessionId: string): Promise<TSession | null> => {
try {
const session = await prisma.session.findUnique({
where: {
id: sessionId,
},
select,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const createSession = async (personId: string): Promise<TSession> => {
try {
const session = await prisma.session.create({
data: {
person: {
connect: {
id: personId,
},
},
expiresAt: new Date(Date.now() + oneHour),
},
select,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const extendSession = async (sessionId: string): Promise<TSession> => {
try {
const session = await prisma.session.update({
where: {
id: sessionId,
},
data: {
expiresAt: new Date(Date.now() + oneHour),
},
select,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -7,7 +7,7 @@ import { Prisma } from "@prisma/client";
import "server-only";
import { cache } from "react";
const selectSurvey = {
export const select = {
id: true,
createdAt: true,
updatedAt: true,
@@ -28,6 +28,9 @@ const selectSurvey = {
eventClass: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
name: true,
description: true,
type: true,
@@ -57,7 +60,7 @@ export const getSurvey = cache(async (surveyId: string): Promise<TSurveyWithAnal
where: {
id: surveyId,
},
select: selectSurvey,
select,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -114,7 +117,7 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
where: {
environmentId,
},
select: selectSurvey,
select,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -41,7 +41,7 @@ export interface Response {
export interface InitConfig {
environmentId: string;
apiHost: string;
logLevel?: "debug" | "error";
debug?: boolean;
errorHandler?: ErrorHandler;
}

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import z from "zod";
export const ZEventClassNoCodeConfig = z.object({
export const ZActionClassNoCodeConfig = z.object({
type: z.union([z.literal("innerHtml"), z.literal("pageUrl"), z.literal("cssSelector")]),
pageUrl: z.optional(
z.object({
@@ -19,14 +19,17 @@ export const ZEventClassNoCodeConfig = z.object({
cssSelector: z.optional(z.object({ value: z.string() })),
});
export type TEventClassNoCodeConfig = z.infer<typeof ZEventClassNoCodeConfig>;
export type TActionClassNoCodeConfig = z.infer<typeof ZActionClassNoCodeConfig>;
export const ZEventClass = z.object({
export const ZActionClass = z.object({
id: z.string().cuid2(),
name: z.string(),
description: z.union([z.string(), z.null()]),
noCodeConfig: z.union([ZEventClassNoCodeConfig, z.null()]),
description: z.string().nullable(),
type: z.enum(["code", "noCode", "automatic"]),
noCodeConfig: z.union([ZActionClassNoCodeConfig, z.null()]),
environmentId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TEventClass = z.infer<typeof ZEventClass>;
export type TActionClass = z.infer<typeof ZActionClass>;

58
packages/types/v1/js.ts Normal file
View File

@@ -0,0 +1,58 @@
import z from "zod";
import { ZPerson } from "./people";
import { ZSession } from "./sessions";
import { ZSurvey } from "./surveys";
import { ZActionClass } from "./actionClasses";
import { ZProduct } from "./product";
export const ZJsState = z.object({
person: ZPerson,
session: ZSession,
surveys: z.array(ZSurvey),
noCodeActionClasses: z.array(ZActionClass),
product: ZProduct,
});
export type TJsState = z.infer<typeof ZJsState>;
export const ZJsSyncInput = z.object({
environmentId: z.string().cuid2(),
personId: z.string().cuid2().optional(),
sessionId: z.string().cuid2().optional(),
});
export type TJsSyncInput = z.infer<typeof ZJsSyncInput>;
export const ZJsConfig = z.object({
environmentId: z.string().cuid2(),
apiHost: z.string(),
state: ZJsState,
});
export type TJsConfig = z.infer<typeof ZJsConfig>;
export const ZJsPeopleUserIdInput = z.object({
environmentId: z.string().cuid2(),
userId: z.string().min(1).max(255),
sessionId: z.string().cuid2(),
});
export type TJsPeopleUserIdInput = z.infer<typeof ZJsPeopleUserIdInput>;
export const ZJsPeopleAttributeInput = z.object({
environmentId: z.string().cuid2(),
sessionId: z.string().cuid2(),
key: z.string(),
value: z.string(),
});
export type TJsPeopleAttributeInput = z.infer<typeof ZJsPeopleAttributeInput>;
export const ZJsActionInput = z.object({
environmentId: z.string().cuid2(),
sessionId: z.string().cuid2(),
name: z.string(),
properties: z.record(z.string()),
});
export type TJsActionInput = z.infer<typeof ZJsActionInput>;

View File

@@ -0,0 +1,11 @@
import z from "zod";
export const ZSession = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
expiresAt: z.date(),
personId: z.string().cuid2(),
});
export type TSession = z.infer<typeof ZSession>;

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZEventClass } from "./eventClasses";
import { ZActionClass } from "./actionClasses";
import { QuestionType } from "../questions";
export const ZSurveyThankYouCard = z.object({
@@ -111,6 +111,8 @@ export const ZSurveyLogic = z.union([
ZSurveyRatingLogic,
]);
export type TSurveyLogic = z.infer<typeof ZSurveyLogic>;
const ZSurveyQuestionBase = z.object({
id: z.string(),
type: z.string(),
@@ -130,24 +132,35 @@ export const ZSurveyOpenTextQuestion = ZSurveyQuestionBase.extend({
logic: z.array(ZSurveyOpenTextLogic).optional(),
});
export type TSurveyOpenTextQuestion = z.infer<typeof ZSurveyOpenTextQuestion>;
export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.Consent),
html: z.string().optional(),
label: z.string(),
dismissButtonLabel: z.string().optional(),
placeholder: z.string().optional(),
logic: z.array(ZSurveyConsentLogic).optional(),
});
export type TSurveyConsentQuestion = z.infer<typeof ZSurveyConsentQuestion>;
export const ZSurveyMultipleChoiceSingleQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.MultipleChoiceSingle),
choices: z.array(ZSurveyChoice),
logic: z.array(ZSurveyMultipleChoiceSingleLogic).optional(),
});
export type TSurveyMultipleChoiceSingleQuestion = z.infer<typeof ZSurveyMultipleChoiceSingleQuestion>;
export const ZSurveyMultipleChoiceMultiQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.MultipleChoiceMulti),
choices: z.array(ZSurveyChoice),
logic: z.array(ZSurveyMultipleChoiceMultiLogic).optional(),
});
export type TSurveyMultipleChoiceMultiQuestion = z.infer<typeof ZSurveyMultipleChoiceMultiQuestion>;
export const ZSurveyNPSQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.NPS),
lowerLabel: z.string(),
@@ -155,6 +168,8 @@ export const ZSurveyNPSQuestion = ZSurveyQuestionBase.extend({
logic: z.array(ZSurveyNPSLogic).optional(),
});
export type TSurveyNPSQuestion = z.infer<typeof ZSurveyNPSQuestion>;
export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.CTA),
html: z.string().optional(),
@@ -164,6 +179,8 @@ export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
logic: z.array(ZSurveyCTALogic).optional(),
});
export type TSurveyCTAQuestion = z.infer<typeof ZSurveyCTAQuestion>;
export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.Rating),
scale: z.enum(["number", "smiley", "star"]),
@@ -173,6 +190,8 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
logic: z.array(ZSurveyRatingLogic).optional(),
});
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
export const ZSurveyQuestion = z.union([
ZSurveyOpenTextQuestion,
ZSurveyConsentQuestion,
@@ -207,7 +226,7 @@ export const ZSurvey = z.object({
attributeFilters: z.array(ZSurveyAttributeFilter),
displayOption: z.enum(["displayOnce", "displayMultiple", "respondMultiple"]),
autoClose: z.union([z.number(), z.null()]),
triggers: z.array(ZEventClass),
triggers: z.array(ZActionClass),
redirectUrl: z.string().url().optional(),
recontactDays: z.union([z.number(), z.null()]),
questions: ZSurveyQuestions,