diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cc9fe6d547..a87d72d4e2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // This can be used to network with other containers or with the host. - // "forwardPorts": [3000, 5432], + "forwardPorts": [3000, 5432, 8025], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "pnpm install", diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index c2c17a2841..4c8a4cf2e2 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -37,5 +37,16 @@ services: # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. # (Adding the "ports" property to this file will not forward from a Codespace.) + mailhog: + image: mailhog/mailhog + network_mode: service:app + logging: + driver: "none" # disable saving logs + # ports: + # - 8025:8025 # web ui + # 1025:1025 # smtp server + + + volumes: postgres-data: null diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..95de9d2df8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch localhost:3002", + "type": "firefox", + "request": "launch", + "reAttach": true, + "url": "http://localhost:3002/", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Attach", + "type": "firefox", + "request": "attach" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..25fa6215fd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/apps/demo/pages/_app.tsx b/apps/demo/pages/_app.tsx index 86902ab3d9..bc1bcca57c 100644 --- a/apps/demo/pages/_app.tsx +++ b/apps/demo/pages/_app.tsx @@ -1,7 +1,7 @@ -import type { AppProps } from "next/app"; import formbricks from "@formbricks/js"; -import { useEffect } from "react"; +import type { AppProps } from "next/app"; import { useRouter } from "next/router"; +import { useEffect } from "react"; import "../styles/globals.css"; declare const window: any; diff --git a/packages/js/src/App.tsx b/packages/js/src/App.tsx index c5563f7746..2ee0a46d9b 100644 --- a/packages/js/src/App.tsx +++ b/packages/js/src/App.tsx @@ -1,16 +1,18 @@ -import { h, VNode } from "preact"; +import type { JsConfig, Survey } from "@formbricks/types/js"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import Modal from "./components/Modal"; import SurveyView from "./components/SurveyView"; -import type { JsConfig, Survey } from "@formbricks/types/js"; +import { IErrorHandler } from "./lib/errors"; interface AppProps { config: JsConfig; survey: Survey; closeSurvey: () => Promise; + errorHandler: IErrorHandler; } -export default function App({ config, survey, closeSurvey }: AppProps): VNode { +export default function App({ config, survey, closeSurvey, errorHandler }: AppProps): VNode { const [isOpen, setIsOpen] = useState(true); const close = () => { @@ -23,7 +25,13 @@ export default function App({ config, survey, closeSurvey }: AppProps): VNode { return (
- +
); diff --git a/packages/js/src/components/SurveyView.tsx b/packages/js/src/components/SurveyView.tsx index 1bffd8eed1..bb3d8dd965 100644 --- a/packages/js/src/components/SurveyView.tsx +++ b/packages/js/src/components/SurveyView.tsx @@ -1,34 +1,43 @@ +import { JsConfig, Survey } from "@formbricks/types/js"; import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { createDisplay, markDisplayResponded } from "../lib/display"; +import { IErrorHandler } from "../lib/errors"; +import { Logger } from "../lib/logger"; import { createResponse, updateResponse } from "../lib/response"; import { cn } from "../lib/utils"; -import { JsConfig, Survey } from "@formbricks/types/js"; import Progress from "./Progress"; -import ThankYouCard from "./ThankYouCard"; import QuestionConditional from "./QuestionConditional"; +import ThankYouCard from "./ThankYouCard"; interface SurveyViewProps { config: JsConfig; survey: Survey; close: () => void; brandColor: string; + errorHandler: IErrorHandler; } -export default function SurveyView({ config, survey, close, brandColor }: SurveyViewProps) { +export default function SurveyView({ config, survey, close, brandColor, errorHandler }: SurveyViewProps) { const [activeQuestionId, setActiveQuestionId] = useState(survey.questions[0].id); const [progress, setProgress] = useState(0); // [0, 1] - const [responseId, setResponseId] = useState(null); - const [displayId, setDisplayId] = useState(null); + const [responseId, setResponseId] = useState(null); + const [displayId, setDisplayId] = useState(null); const [loadingElement, setLoadingElement] = useState(false); useEffect(() => { initDisplay(); async function initDisplay() { - const displayId = await createDisplay({ surveyId: survey.id, personId: config.person.id }, config); - setDisplayId(displayId.id); + const createDisplayResult = await createDisplay( + { surveyId: survey.id, personId: config.person.id }, + config + ); + + createDisplayResult.ok === true + ? setDisplayId(createDisplayResult.value.id) + : errorHandler(createDisplayResult.error); } - }, [config, survey]); + }, [config, survey, errorHandler]); useEffect(() => { setProgress(calculateProgress()); @@ -54,9 +63,16 @@ export default function SurveyView({ config, survey, close, brandColor }: Survey createResponse(responseRequest, config), markDisplayResponded(displayId, config), ]); - setResponseId(response.id); + + response.ok === true ? setResponseId(response.value.id) : errorHandler(response.error); } else { - await updateResponse(responseRequest, responseId, config); + const result = await updateResponse(responseRequest, responseId, config); + + if (result.ok !== true) { + errorHandler(result.error); + } else if (responseRequest.response.finished) { + Logger.getInstance().debug("Submitted response"); + } } setLoadingElement(false); if (!finished) { diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 787a95d76b..28c5870c99 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,24 +1,25 @@ import { InitConfig } from "@formbricks/types/js"; import { CommandQueue } from "./lib/commandQueue"; +import { ErrorHandler } from "./lib/errors"; import { trackEvent } from "./lib/event"; -import { checkInitialized, initialize } from "./lib/init"; +import { initialize } from "./lib/init"; +import { Logger } from "./lib/logger"; import { checkPageUrl } from "./lib/noCodeEvents"; import { resetPerson, setPersonAttribute, setPersonUserId } from "./lib/person"; import { refreshSettings } from "./lib/settings"; +const logger = Logger.getInstance(); + +logger.debug("Create command queue"); const queue = new CommandQueue(); const init = (initConfig: InitConfig) => { - queue.add(async () => { - initialize(initConfig); - }); + ErrorHandler.init(initConfig.errorHandler); + queue.add(false, initialize, initConfig); }; const setUserId = (userId: string): void => { - queue.add(async () => { - checkInitialized(); - await setPersonUserId(userId); - }); + queue.add(true, setPersonUserId, userId); }; const setEmail = (email: string): void => { @@ -26,38 +27,23 @@ const setEmail = (email: string): void => { }; const setAttribute = (key: string, value: string): void => { - queue.add(async () => { - checkInitialized(); - await setPersonAttribute(key, value); - }); + queue.add(true, setPersonAttribute, key, value); }; const logout = (): void => { - queue.add(async () => { - checkInitialized(); - await resetPerson(); - }); + queue.add(true, resetPerson); }; const track = (eventName: string, properties: any = {}): void => { - queue.add(async () => { - checkInitialized(); - await trackEvent(eventName, properties); - }); + queue.add(true, trackEvent, eventName, properties); }; const refresh = (): void => { - queue.add(async () => { - checkInitialized(); - await refreshSettings(); - }); + queue.add(true, refreshSettings); }; const registerRouteChange = (): void => { - queue.add(async () => { - checkInitialized(); - checkPageUrl(); - }); + queue.add(true, checkPageUrl); }; const formbricks = { init, setUserId, setEmail, setAttribute, track, logout, refresh, registerRouteChange }; diff --git a/packages/js/src/lib/commandQueue.ts b/packages/js/src/lib/commandQueue.ts index 63a1bd9ef0..249e9feafa 100644 --- a/packages/js/src/lib/commandQueue.ts +++ b/packages/js/src/lib/commandQueue.ts @@ -1,9 +1,25 @@ +import { ErrorHandler, Result } from "./errors"; +import { checkInitialized } from "./init"; +import { Logger } from "./logger"; + +const logger = Logger.getInstance(); + export class CommandQueue { - private queue: (() => Promise)[] = []; + private queue: { + command: (args: any) => Promise> | Result; + checkInitialized: boolean; + commandArgs: any[]; + }[] = []; private running: boolean = false; - public add(command: () => Promise) { - this.queue.push(command); + public add( + checkInitialized: boolean = true, + command: (...args: A[]) => Promise> | Result, + ...args: A[] + ) { + logger.debug(`Add command to queue: ${command.name}(${JSON.stringify(args)})`); + this.queue.push({ command, checkInitialized, commandArgs: args }); + if (!this.running) { this.run(); } @@ -12,12 +28,27 @@ export class CommandQueue { private async run() { this.running = true; while (this.queue.length > 0) { - const command = this.queue.shift(); - try { - await command(); - } catch (error) { - console.error(error); + const errorHandler = ErrorHandler.getInstance(); + const currentItem = this.queue.shift(); + + // make sure formbricks is initialized + if (currentItem.checkInitialized) { + const initResult = checkInitialized(); + + if (initResult && initResult.ok !== true) errorHandler.handle(initResult.error); } + + const result = (await currentItem.command.apply(null, currentItem.commandArgs)) as Result; + + if (!result) continue; + + logger.debug( + `Command result: ${result.ok === true ? "OK" : "Something went really wrong"}, ${ + currentItem.command.name + }` + ); + + if (result.ok !== true) errorHandler.handle(result.error); } this.running = false; } diff --git a/packages/js/src/lib/config.ts b/packages/js/src/lib/config.ts index 79f9044b40..34a93df8f7 100644 --- a/packages/js/src/lib/config.ts +++ b/packages/js/src/lib/config.ts @@ -1,11 +1,10 @@ import { JsConfig } from "@formbricks/types/js"; +import { Result, wrapThrows } from "./errors"; export class Config { private static instance: Config | undefined; private config: JsConfig = this.loadFromLocalStorage(); - private constructor() {} - static getInstance(): Config { if (!Config.instance) { Config.instance = new Config(); @@ -40,7 +39,7 @@ export class Config { }; } - private saveToLocalStorage(): void { - localStorage.setItem("formbricksConfig", JSON.stringify(this.config)); + private saveToLocalStorage(): Result { + return wrapThrows(() => localStorage.setItem("formbricksConfig", JSON.stringify(this.config)))(); } } diff --git a/packages/js/src/lib/display.ts b/packages/js/src/lib/display.ts index e7bfc6202e..249b8555fd 100644 --- a/packages/js/src/lib/display.ts +++ b/packages/js/src/lib/display.ts @@ -1,31 +1,56 @@ -import { Response, DisplayCreateRequest, JsConfig } from "@formbricks/types/js"; +import { DisplayCreateRequest, JsConfig, Response } from "@formbricks/types/js"; +import { NetworkError, Result, err, ok, okVoid } from "./errors"; export const createDisplay = async ( displayCreateRequest: DisplayCreateRequest, config: JsConfig -): Promise => { - const res = await fetch(`${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays`, { +): Promise> => { + const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays`; + + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(displayCreateRequest), }); + if (!res.ok) { - console.error(res.text); - throw new Error("Could not create display"); + const jsonRes = await res.json(); + + return err({ + code: "network_error", + message: "Could not create display", + status: res.status, + url, + responseMessage: jsonRes.message, + }); } - return await res.json(); + + const response = (await res.json()) as Response; + + return ok(response); }; -export const markDisplayResponded = async (displayId: string, config: JsConfig): Promise => { - const res = await fetch( - `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays/${displayId}/responded`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - } - ); +export const markDisplayResponded = async ( + displayId: string, + config: JsConfig +): Promise> => { + const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays/${displayId}/responded`; + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); if (!res.ok) { - throw new Error("Could not update display"); + const jsonRes = await res.json(); + + return err({ + code: "network_error", + message: "Could not mark display as responded", + status: res.status, + url, + responseMessage: jsonRes.message, + }); } - return; + + return okVoid(); }; diff --git a/packages/js/src/lib/errors.ts b/packages/js/src/lib/errors.ts new file mode 100644 index 0000000000..f8464c96f7 --- /dev/null +++ b/packages/js/src/lib/errors.ts @@ -0,0 +1,131 @@ +import { Logger } from "./logger"; + +export { ErrorHandler as IErrorHandler } from "@formbricks/types/js"; + +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export const ok = (value: T): Result => ({ ok: true, value }); + +export const okVoid = (): Result => ({ ok: true, value: undefined }); + +export const err = (error: E): Result => ({ + ok: false, + error, +}); + +export const wrap = + (fn: (value: T) => R) => + (result: Result): Result => + result.ok === true ? { ok: true, value: fn(result.value) } : result; + +export function match( + result: Result, + onSuccess: (value: TSuccess) => TReturn, + onError: (error: TError) => TReturn +) { + if (result.ok === true) { + return onSuccess(result.value); + } + + return onError(result.error); +} + +/* +Usage: +const test = () => { + throw new Error("test"); +}; + +const result = wrapThrows(test)(); +if (result.ok === true) { + console.log(result.value); +} else { + console.log(result.error); +} +*/ +export const wrapThrows = + (fn: (...args: A) => T) => + (...args: A): Result => { + try { + return { + ok: true, + value: fn(...args), + }; + } catch (error) { + return { + ok: false, + error, + }; + } + }; + +export type NetworkError = { + code: "network_error"; + status: number; + message: string; + url: string; + responseMessage: string; +}; + +export type MissingFieldError = { + code: "missing_field"; + field: string; +}; + +export type InvalidMatchTypeError = { + code: "invalid_match_type"; + message: string; +}; + +export type MissingPersonError = { + code: "missing_person"; + message: string; +}; + +export type NotInitializedError = { + code: "not_initialized"; + message: string; +}; + +export type AttributeAlreadyExistsError = { + code: "attribute_already_exists"; + message: string; +}; + +const logger = Logger.getInstance(); + +export class ErrorHandler { + private static instance: ErrorHandler | null; + private handleError: (error: any) => void; + public static initialized = false; + + private constructor(errorHandler?: (error: any) => void) { + if (errorHandler) { + this.handleError = errorHandler; + } else { + this.handleError = (err) => Logger.getInstance().error(JSON.stringify(err)); + } + } + + static getInstance(): ErrorHandler { + if (!ErrorHandler.instance) { + ErrorHandler.instance = new ErrorHandler(); + } + + return ErrorHandler.instance; + } + + static init(errorHandler?: (error: any) => void): void { + this.initialized = true; + + // for some reason, Logger.getInstance().debug didnt work here + console.log("Formbricks - initializing error handler"); + console.log("Custom error handler: ", typeof errorHandler === "function" ? "yes" : "no"); + + ErrorHandler.instance = new ErrorHandler(errorHandler); + } + + public handle(error: any): void { + this.handleError(error); + } +} diff --git a/packages/js/src/lib/event.ts b/packages/js/src/lib/event.ts index 53d5e779d3..6d578b98d9 100644 --- a/packages/js/src/lib/event.ts +++ b/packages/js/src/lib/event.ts @@ -1,11 +1,15 @@ -import { renderWidget } from "./widget"; -import { Logger } from "./logger"; import { Config } from "./config"; +import { NetworkError, Result, err, okVoid } from "./errors"; +import { Logger } from "./logger"; +import { renderWidget } from "./widget"; const logger = Logger.getInstance(); const config = Config.getInstance(); -export const trackEvent = async (eventName: string, properties?: any): Promise => { +export const trackEvent = async ( + eventName: string, + properties?: any +): Promise> => { const res = await fetch( `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/events`, { @@ -21,13 +25,23 @@ export const trackEvent = async (eventName: string, properties?: any): Promise { diff --git a/packages/js/src/lib/init.ts b/packages/js/src/lib/init.ts index 2b10803fe6..f5748cc6b4 100644 --- a/packages/js/src/lib/init.ts +++ b/packages/js/src/lib/init.ts @@ -1,11 +1,21 @@ import { InitConfig } from "@formbricks/types/js"; -import { addStylesToDom } from "./styles"; import { Config } from "./config"; +import { + ErrorHandler, + MissingFieldError, + MissingPersonError, + NetworkError, + NotInitializedError, + Result, + err, + okVoid, +} from "./errors"; +import { trackEvent } from "./event"; import { Logger } from "./logger"; +import { addClickEventListener, addPageUrlEventListeners } from "./noCodeEvents"; import { createPerson } from "./person"; import { createSession, extendOrCreateSession, extendSession, isExpired } from "./session"; -import { trackEvent } from "./event"; -import { addClickEventListener, addPageUrlEventListeners, checkPageUrl } from "./noCodeEvents"; +import { addStylesToDom } from "./styles"; import { addWidgetContainer } from "./widget"; const config = Config.getInstance(); @@ -24,17 +34,37 @@ const addSessionEventListeners = (): void => { } }; -export const initialize = async (c: InitConfig): Promise => { +export const initialize = async ( + c: InitConfig +): Promise> => { + logger.debug("Start initialize"); + if (!c.environmentId) { - throw Error("Formbricks: environmentId is required"); + logger.debug("No environmentId provided"); + return err({ + code: "missing_field", + field: "environmentId", + }); } + if (!c.apiHost) { - throw Error("Formbricks: apiHost is required"); + logger.debug("No apiHost provided"); + + return err({ + code: "missing_field", + field: "apiHost", + }); } + if (c.logLevel) { + logger.debug(`Setting log level to ${c.logLevel}`); logger.configure({ logLevel: c.logLevel }); } + + logger.debug("Adding widget container to DOM"); addWidgetContainer(); + + logger.debug("Adding styles to DOM"); addStylesToDom(); if ( config.get().session && @@ -45,9 +75,18 @@ export const initialize = async (c: InitConfig): Promise => { const existingSession = config.get().session; if (isExpired(existingSession)) { logger.debug("Session expired. Creating new session."); - const { session, settings } = await createSession(); + + const createSessionResult = await createSession(); + + if (createSessionResult.ok !== true) return err(createSessionResult.error); + + const { session, settings } = createSessionResult.value; + config.update({ session: extendSession(session), settings }); - trackEvent("New Session"); + + const trackEventResult = await trackEvent("New Session"); + + if (trackEventResult.ok !== true) return err(trackEventResult.error); } else { logger.debug("Session valid. Extending session."); config.update({ session: extendSession(existingSession) }); @@ -56,25 +95,52 @@ export const initialize = async (c: InitConfig): Promise => { logger.debug("No valid session found. Creating new config."); // we need new config config.update({ environmentId: c.environmentId, apiHost: c.apiHost }); - // get person, session and settings from server - const { person, session, settings } = await createPerson(); + + logger.debug("Get person, session and settings from server"); + const result = await createPerson(); + + if (result.ok !== true) { + return err(result.error); + } + + const { person, session, settings } = result.value; + config.update({ person, session: extendSession(session), settings }); - trackEvent("New Session"); + + const trackEventResult = await trackEvent("New Session"); + + if (trackEventResult.ok !== true) return err(trackEventResult.error); } + + logger.debug("Add session event listeners"); addSessionEventListeners(); + + logger.debug("Add page url event listeners"); addPageUrlEventListeners(); + + logger.debug("Add click event listeners"); addClickEventListener(); + logger.debug("Initialized"); + + return okVoid(); }; -export const checkInitialized = (): void => { +export const checkInitialized = (): Result => { + logger.debug("Check if initialized"); if ( !config.get().apiHost || !config.get().environmentId || !config.get().person || !config.get().session || - !config.get().settings + !config.get().settings || + !ErrorHandler.initialized ) { - throw Error("Formbricks: Formbricks not initialized. Call initialize() first."); + return err({ + code: "not_initialized", + message: "Formbricks not initialized. Call initialize() first.", + }); } + + return okVoid(); }; diff --git a/packages/js/src/lib/noCodeEvents.ts b/packages/js/src/lib/noCodeEvents.ts index 7ec1646f22..9fc616eb0e 100644 --- a/packages/js/src/lib/noCodeEvents.ts +++ b/packages/js/src/lib/noCodeEvents.ts @@ -1,21 +1,23 @@ -import type { MatchType } from "@formbricks/types/js"; import type { Event } from "@formbricks/types/events"; +import type { MatchType } from "@formbricks/types/js"; import { Config } from "./config"; +import { ErrorHandler, InvalidMatchTypeError, NetworkError, Result, err, match, ok, okVoid } from "./errors"; import { trackEvent } from "./event"; import { Logger } from "./logger"; const config = Config.getInstance(); const logger = Logger.getInstance(); +const errorHandler = ErrorHandler.getInstance(); -export const checkPageUrl = (): void => { +export const checkPageUrl = async (): Promise> => { + logger.debug("checking page url"); const { settings } = config.get(); const pageUrlEvents: Event[] = settings?.noCodeEvents.filter((e) => e.noCodeConfig?.type === "pageUrl"); - logger.debug("checking page url"); - if (pageUrlEvents.length === 0) { - return; + return okVoid(); } + for (const event of pageUrlEvents) { const { noCodeConfig: { pageUrl }, @@ -24,10 +26,17 @@ export const checkPageUrl = (): void => { continue; } const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule as MatchType); - if (match) { - trackEvent(event.name); - } + + if (match.ok !== true) return err(match.error); + + if (match.value === false) continue; + + const trackResult = await trackEvent(event.name); + + if (trackResult.ok !== true) return err(trackResult.error); } + + return okVoid(); }; export const addPageUrlEventListeners = (): void => { @@ -40,23 +49,45 @@ export const addPageUrlEventListeners = (): void => { window.addEventListener("load", checkPageUrl); }; -export function checkUrlMatch(url: string, pageUrlValue: string, pageUrlRule: MatchType): boolean { +export function checkUrlMatch( + url: string, + pageUrlValue: string, + pageUrlRule: MatchType +): Result { + let result: boolean; + let error: Result; + switch (pageUrlRule) { case "exactMatch": - return url === pageUrlValue; + result = url === pageUrlValue; + break; case "contains": - return url.includes(pageUrlValue); + result = url.includes(pageUrlValue); + break; case "startsWith": - return url.startsWith(pageUrlValue); + result = url.startsWith(pageUrlValue); + break; case "endsWith": - return url.endsWith(pageUrlValue); + result = url.endsWith(pageUrlValue); + break; case "notMatch": - return url !== pageUrlValue; + result = url !== pageUrlValue; + break; case "notContains": - return !url.includes(pageUrlValue); + result = !url.includes(pageUrlValue); + break; default: - throw new Error("Invalid match type"); + error = err({ + code: "invalid_match_type", + message: "Invalid match type", + }); } + + if (error) { + return error; + } + + return ok(result); } export const checkClickMatch = (event: MouseEvent) => { @@ -71,14 +102,30 @@ export const checkClickMatch = (event: MouseEvent) => { innerHtmlEvents.forEach((e) => { const innerHtml = e.noCodeConfig?.innerHtml; if (innerHtml && targetElement.innerHTML === innerHtml.value) { - trackEvent(e.name); + trackEvent(e.name).then((res) => { + match( + res, + (_value) => {}, + (err) => { + errorHandler.handle(err); + } + ); + }); } }); cssSelectorEvents.forEach((e) => { const cssSelector = e.noCodeConfig?.cssSelector; if (cssSelector && targetElement.matches(cssSelector.value)) { - trackEvent(e.name); + trackEvent(e.name).then((res) => { + match( + res, + (_value) => {}, + (err) => { + errorHandler.handle(err); + } + ); + }); } }); }; diff --git a/packages/js/src/lib/person.ts b/packages/js/src/lib/person.ts index dc77214831..1d41db11ef 100644 --- a/packages/js/src/lib/person.ts +++ b/packages/js/src/lib/person.ts @@ -1,61 +1,96 @@ import type { Person } from "@formbricks/types/js"; import { Session, Settings } from "@formbricks/types/js"; import { Config } from "./config"; +import { + AttributeAlreadyExistsError, + MissingPersonError, + NetworkError, + Result, + err, + match, + ok, + okVoid, +} from "./errors"; import { Logger } from "./logger"; const config = Config.getInstance(); const logger = Logger.getInstance(); -export const createPerson = async (): Promise<{ session: Session; person: Person; settings: Settings }> => { +export const createPerson = async (): Promise< + Result<{ session: Session; person: Person; settings: Settings }, NetworkError> +> => { logger.debug("Creating new person"); - const res = await fetch( - `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/people`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - } - ); + 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) { - console.error("Formbricks: Error fetching person"); - return null; + return err({ + code: "network_error", + message: "Error creating person", + status: res.status, + url, + responseMessage: jsonRes.message, + }); } - return await res.json(); + + return ok(jsonRes as { session: Session; person: Person; settings: Settings }); }; -export const updatePersonUserId = async (userId: string): Promise<{ person: Person; settings: Settings }> => { - if (!config.get().person || !config.get().person.id) { - console.error("Formbricks: Unable to update userId. No person set."); - return; - } - const res = await fetch( - `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/people/${ - config.get().person.id - }/user-id`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ userId, sessionId: config.get().session.id }), - } - ); +export const updatePersonUserId = async ( + userId: string +): Promise> => { + if (!config.get().person || !config.get().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 res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ userId, sessionId: config.get().session.id }), + }); + + const jsonRes = await res.json(); + if (!res.ok) { - logger.error("Formbricks: Error updating person"); - throw Error("Error updating person"); + return err({ + code: "network_error", + message: "Error updating person", + status: res.status, + url, + responseMessage: jsonRes.message, + }); } - return await res.json(); + + return ok(jsonRes as { person: Person; settings: Settings }); }; export const updatePersonAttribute = async ( key: string, value: string -): Promise<{ person: Person; settings: Settings }> => { +): Promise> => { if (!config.get().person || !config.get().person.id) { - console.error("Formbricks: Unable to update attribute. No person set."); - return; + return err({ + code: "missing_person", + message: "Unable to update attribute. No person set.", + }); } + const res = await fetch( `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/people/${ config.get().person.id @@ -68,12 +103,20 @@ export const updatePersonAttribute = async ( body: JSON.stringify({ key, value }), } ); - const updatedPerson = await res.json(); + + const resJson = await res.json(); + if (!res.ok) { - logger.error("Error updating person"); - throw Error("Error updating person"); + return err({ + code: "network_error", + status: res.status, + message: "Error updating person", + url: res.url, + responseMessage: resJson.message, + }); } - return updatedPerson; + + return ok(resJson as { person: Person; settings: Settings }); }; export const attributeAlreadySet = (key: string, value: string): boolean => { @@ -92,43 +135,85 @@ export const attributeAlreadyExists = (key: string): boolean => { return false; }; -export const setPersonUserId = async (userId: string): Promise => { +export const setPersonUserId = async ( + userId: string +): Promise> => { logger.debug("setting userId: " + userId); // check if attribute already exists with this value if (attributeAlreadySet("userId", userId)) { logger.debug("userId already set to this value. Skipping update."); - return; + return okVoid(); } if (attributeAlreadyExists("userId")) { - logger.error("userId cannot be changed after it has been set. You need to reset first"); - return; + return err({ + code: "attribute_already_exists", + message: "userId cannot be changed after it has been set. You need to reset first", + }); } - const { person, settings } = await updatePersonUserId(userId); + const result = await updatePersonUserId(userId); + + if (result.ok !== true) return err(result.error); + + const { person, settings } = result.value; + config.update({ person, settings }); + + return okVoid(); }; -export const setPersonAttribute = async (key: string, value: string): Promise => { +export const setPersonAttribute = async ( + key: string, + value: string +): Promise> => { logger.debug("setting attribute: " + key + " to value: " + value); // check if attribute already exists with this value if (attributeAlreadySet(key, value)) { logger.debug("attribute already set to this value. Skipping update."); - return; + return okVoid(); } - const { person, settings } = await updatePersonAttribute(key, value); - if (!person || !settings) { - logger.error("Error updating attribute"); - throw new Error("Formbricks: Error updating attribute"); + const result = await updatePersonAttribute(key, value); + + let error: NetworkError | MissingPersonError; + + match( + result, + ({ person, settings }) => { + config.update({ person, settings }); + }, + (err) => { + // pass error to outer scope + error = err; + } + ); + + if (error) { + return err(error); } - config.update({ person, settings }); + + return okVoid(); }; -export const resetPerson = async (): Promise => { +export const resetPerson = async (): Promise> => { logger.debug("Resetting person. Getting new person, session and settings from backend"); - const { person, session, settings } = await createPerson(); - if (!person || !session || !settings) { - logger.error("Error resetting user"); - throw new Error("Formbricks: Error resetting user"); + const result = await createPerson(); + + let error: NetworkError; + + match( + result, + ({ person, session, settings }) => { + config.update({ person, session, settings }); + }, + (err) => { + // pass error to outer scope + error = err; + } + ); + + if (error) { + return err(error); } - config.update({ person, session, settings }); + + return okVoid(); }; diff --git a/packages/js/src/lib/response.ts b/packages/js/src/lib/response.ts index a45913e3ba..bf6f4a80ca 100644 --- a/packages/js/src/lib/response.ts +++ b/packages/js/src/lib/response.ts @@ -1,33 +1,57 @@ -import { Response, ResponseCreateRequest, ResponseUpdateRequest } from "@formbricks/types/js"; +import { JsConfig, Response, ResponseCreateRequest, ResponseUpdateRequest } from "@formbricks/types/js"; +import { NetworkError, Result, err, ok } from "./errors"; -export const createResponse = async (responseRequest: ResponseCreateRequest, config): Promise => { - const res = await fetch(`${config.apiHost}/api/v1/client/environments/${config.environmentId}/responses`, { +export const createResponse = async ( + responseRequest: ResponseCreateRequest, + config +): Promise> => { + const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/responses`; + + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(responseRequest), }); + + const jsonRes = await res.json(); + if (!res.ok) { - console.error(res.text); - throw new Error("Could not create response"); + return err({ + code: "network_error", + message: "Could not create response", + status: res.status, + url, + responseMessage: jsonRes.message, + }); } - return await res.json(); + + return ok(jsonRes as Response); }; export const updateResponse = async ( responseRequest: ResponseUpdateRequest, - responseId, - config -): Promise => { - const res = await fetch( - `${config.apiHost}/api/v1/client/environments/${config.environmentId}/responses/${responseId}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(responseRequest), - } - ); + responseId: string, + config: JsConfig +): Promise> => { + const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/responses/${responseId}`; + + const res = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(responseRequest), + }); + + const resJson = await res.json(); + if (!res.ok) { - throw new Error("Could not update response"); + return err({ + code: "network_error", + message: "Could not update response", + status: res.status, + url, + responseMessage: resJson.message, + }); } - return await res.json(); + + return ok(resJson as Response); }; diff --git a/packages/js/src/lib/session.ts b/packages/js/src/lib/session.ts index cbfa844f5c..1f3acc755f 100644 --- a/packages/js/src/lib/session.ts +++ b/packages/js/src/lib/session.ts @@ -1,31 +1,44 @@ import type { Session, Settings } from "@formbricks/types/js"; -import { Logger } from "./logger"; import { Config } from "./config"; +import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "./errors"; import { trackEvent } from "./event"; +import { Logger } from "./logger"; const logger = Logger.getInstance(); const config = Config.getInstance(); -export const createSession = async (): Promise<{ session: Session; settings: Settings }> => { +export const createSession = async (): Promise< + Result<{ session: Session; settings: Settings }, NetworkError | MissingPersonError> +> => { if (!config.get().person) { - logger.error("Formbricks: Unable to create session. No person found"); - return; + return err({ + code: "missing_person", + message: "Unable to create session. No person found", + }); } - const response = await fetch( - `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/sessions`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ personId: config.get().person.id }), - } - ); + + 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) { - logger.error("Error creating session"); - return; + return err({ + code: "network_error", + message: "Error creating session", + status: response.status, + url, + responseMessage: resJson.message, + }); } - return await response.json(); + + return ok(resJson as { session: Session; settings: Settings }); }; export const extendSession = (session: Session): Session => { @@ -39,18 +52,24 @@ export const isExpired = (session: Session): boolean => { return session.expiresAt <= Date.now(); }; -export const extendOrCreateSession = async (): Promise => { +export const extendOrCreateSession = async (): Promise> => { logger.debug("Checking session"); if (isExpired(config.get().session)) { logger.debug("Session expired, creating new session"); - const { session, settings } = await createSession(); - if (!session || !settings) { - logger.error("Error creating new session"); - throw Error("Error creating new session"); - } + const result = await createSession(); + + if (result.ok !== true) return err(result.error); + + const { session, settings } = result.value; config.update({ session, settings }); - trackEvent("New Session"); + 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(); }; diff --git a/packages/js/src/lib/settings.ts b/packages/js/src/lib/settings.ts index ab34df3ba7..3e881c521e 100644 --- a/packages/js/src/lib/settings.ts +++ b/packages/js/src/lib/settings.ts @@ -1,30 +1,44 @@ import type { Settings } from "@formbricks/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 => { - const response = await fetch( - `${config.get().apiHost}/api/v1/client/environments/${config.get().environmentId}/settings`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ personId: config.get().person.id }), - } - ); +export const getSettings = async (): Promise> => { + 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) { - logger.error("Error getting settings"); - throw Error("Error getting settings"); + const jsonRes = await response.json(); + + return err({ + code: "network_error", + status: response.status, + message: "Error getting settings", + url, + responseMessage: jsonRes.message, + }); } - return response.json(); + + return ok((await response.json()) as Settings); }; -export const refreshSettings = async (): Promise => { +export const refreshSettings = async (): Promise> => { logger.debug("Refreshing - getting settings from backend"); const settings = await getSettings(); - config.update({ settings }); + + if (settings.ok !== true) return err(settings.error); + + logger.debug("Settings refreshed"); + config.update({ settings: settings.value }); + + return okVoid(); }; diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index af4729d257..b6b198cf0f 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -2,12 +2,14 @@ import { Survey } from "@formbricks/types/js"; import { h, render } from "preact"; import App from "../App"; import { Config } from "./config"; +import { ErrorHandler, match } from "./errors"; import { Logger } from "./logger"; import { getSettings } from "./settings"; const containerId = "formbricks-web-container"; const config = Config.getInstance(); const logger = Logger.getInstance(); +const errorHandler = ErrorHandler.getInstance(); let surveyRunning = false; export const renderWidget = (survey: Survey) => { @@ -16,16 +18,30 @@ export const renderWidget = (survey: Survey) => { return; } surveyRunning = true; - render(h(App, { config: config.get(), survey, closeSurvey }), document.getElementById(containerId)); + + render( + h(App, { config: config.get(), survey, closeSurvey, errorHandler: errorHandler.handle }), + document.getElementById(containerId) + ); }; export const closeSurvey = async (): Promise => { // remove container element from DOM document.getElementById(containerId).remove(); addWidgetContainer(); + const settings = await getSettings(); - config.update({ settings }); - surveyRunning = false; + + match( + settings, + (value) => { + config.update({ settings: value }); + surveyRunning = false; + }, + (error) => { + errorHandler.handle(error); + } + ); }; export const addWidgetContainer = (): void => { diff --git a/packages/types/js.ts b/packages/types/js.ts index fcc0deaa39..9016a5aac5 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -42,8 +42,12 @@ export interface InitConfig { environmentId: string; apiHost: string; logLevel?: "debug" | "error"; + errorHandler?: ErrorHandler; } +//TODO: add type to error +export type ErrorHandler = (error: any) => void; + export interface Settings { surveys?: Survey[]; noCodeEvents?: any[];