mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-16 11:38:38 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62b4c85a10 | |||
| 49fbf097f8 | |||
| 3a40568366 | |||
| f8c8b8c45d | |||
| eaeaa74ba8 | |||
| 5f90968e61 | |||
| d05a7c6d98 | |||
| 0c6c554cef | |||
| bb3ff6829d | |||
| 425edf4cac | |||
| 8052ee0aaf | |||
| 2f15312d5c | |||
| 5196c77277 | |||
| bd9efff3ff | |||
| 93907263a6 | |||
| 3ed35523be | |||
| 8da23c2e41 | |||
| cea7139b40 | |||
| d873e5b759 | |||
| cda1109ffc | |||
| b120de550f | |||
| 3f9c1c57f9 | |||
| 9abb07deba | |||
| f665e05723 | |||
| ed870ea0ce | |||
| b5212e0e0e | |||
| a16dcee01d | |||
| af9dfe63ca | |||
| e12d6a5d2d | |||
| f8bd0902d2 |
+20
-20
@@ -11,30 +11,30 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"eslint-plugin-react-refresh": "0.4.16",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@chromatic-com/storybook": "3.2.2",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "8.6.11",
|
||||
"@storybook/addon-essentials": "8.6.11",
|
||||
"@storybook/addon-interactions": "8.6.11",
|
||||
"@storybook/addon-links": "8.6.11",
|
||||
"@storybook/addon-onboarding": "8.6.11",
|
||||
"@storybook/blocks": "8.6.11",
|
||||
"@storybook/react": "8.6.11",
|
||||
"@storybook/react-vite": "8.6.11",
|
||||
"@storybook/test": "8.6.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||
"@typescript-eslint/parser": "8.29.0",
|
||||
"@storybook/addon-a11y": "8.4.7",
|
||||
"@storybook/addon-essentials": "8.4.7",
|
||||
"@storybook/addon-interactions": "8.4.7",
|
||||
"@storybook/addon-links": "8.4.7",
|
||||
"@storybook/addon-onboarding": "8.4.7",
|
||||
"@storybook/blocks": "8.4.7",
|
||||
"@storybook/react": "8.4.7",
|
||||
"@storybook/react-vite": "8.4.7",
|
||||
"@storybook/test": "8.4.7",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.0",
|
||||
"@typescript-eslint/parser": "8.18.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"esbuild": "0.25.1",
|
||||
"eslint-plugin-storybook": "0.11.1",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.11",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.4"
|
||||
"storybook": "8.4.7",
|
||||
"tsup": "8.3.5",
|
||||
"vite": "6.0.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
||||
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="ph-provider">{children}</div>
|
||||
),
|
||||
PostHogPageview: () => <div data-testid="ph-pageview" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="toaster-client" />,
|
||||
}));
|
||||
@@ -68,6 +74,8 @@ describe("(app) AppLayout", () => {
|
||||
render(element);
|
||||
|
||||
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("RootLayout", () => {
|
||||
console.log("vercel", process.env.VERCEL);
|
||||
|
||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
@@ -6,7 +7,7 @@ import { TolgeeStaticData } from "@tolgee/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import "../modules/ui/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -28,9 +29,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</PHProvider>
|
||||
</SentryProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getFile = async (
|
||||
headers: {
|
||||
"Content-Type": metaData.contentType,
|
||||
"Content-Disposition": "attachment",
|
||||
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
|
||||
Vary: "Accept-Encoding",
|
||||
},
|
||||
});
|
||||
@@ -35,7 +35,10 @@ export const getFile = async (
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: signedUrl,
|
||||
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||
"Cache-Control":
|
||||
accessType === "public"
|
||||
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
|
||||
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -229,51 +229,52 @@ export const upsertBulkContacts = async (
|
||||
|
||||
try {
|
||||
// Execute everything in ONE transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
|
||||
acc[keyObj.key] = keyObj.id;
|
||||
return acc;
|
||||
}, {});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
|
||||
acc[keyObj.key] = keyObj.id;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Check for missing attribute keys and create them if needed.
|
||||
const missingKeysMap = new Map<string, { key: string; name: string }>();
|
||||
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
|
||||
// Check for missing attribute keys and create them if needed.
|
||||
const missingKeysMap = new Map<string, { key: string; name: string }>();
|
||||
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
|
||||
if (keysToUpsert.size > 0) {
|
||||
const keysArray = Array.from(keysToUpsert.values());
|
||||
const BATCH_SIZE = 10000;
|
||||
if (keysToUpsert.size > 0) {
|
||||
const keysArray = Array.from(keysToUpsert.values());
|
||||
const BATCH_SIZE = 10000;
|
||||
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE);
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
@@ -289,59 +290,59 @@ export const upsertBulkContacts = async (
|
||||
RETURNING "id", "key"
|
||||
`;
|
||||
|
||||
// Update attribute key map with upserted keys
|
||||
for (const key of upsertedKeys) {
|
||||
attributeKeyMap[key.key] = key.id;
|
||||
// Update attribute key map with upserted keys
|
||||
for (const key of upsertedKeys) {
|
||||
attributeKeyMap[key.key] = key.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new contacts -- should be at most 1000, no need to batch
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
id: createId(),
|
||||
environmentId,
|
||||
}));
|
||||
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
// Create new contacts -- should be at most 1000, no need to batch
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
environmentId,
|
||||
}));
|
||||
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
}
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
if (attributesToUpsert.length > 0) {
|
||||
// Process attributes in batches of 10,000
|
||||
const BATCH_SIZE = 10000;
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
if (attributesToUpsert.length > 0) {
|
||||
// Process attributes in batches of 10,000
|
||||
const BATCH_SIZE = 10000;
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
|
||||
)
|
||||
@@ -356,33 +357,35 @@ export const upsertBulkContacts = async (
|
||||
"value" = EXCLUDED."value",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contactCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
// revalidate all the new contacts:
|
||||
for (const newContact of newContacts) {
|
||||
contactCache.revalidate({
|
||||
id: newContact.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
// revalidate all the existing contacts:
|
||||
for (const existingContact of existingContactsByEmail) {
|
||||
contactCache.revalidate({
|
||||
id: existingContact.id,
|
||||
// revalidate all the new contacts:
|
||||
for (const newContact of newContacts) {
|
||||
contactCache.revalidate({
|
||||
id: newContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
// revalidate all the existing contacts:
|
||||
for (const existingContact of existingContactsByEmail) {
|
||||
contactCache.revalidate({
|
||||
id: existingContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
contactAttributeCache.revalidate({ environmentId });
|
||||
});
|
||||
contactAttributeCache.revalidate({ environmentId });
|
||||
},
|
||||
{ timeout: 60 * 1000 }
|
||||
);
|
||||
|
||||
return ok({
|
||||
contactIdxWithConflictingUserIds,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
---
|
||||
openapi: put /api/v1/client/{environmentId}/responses/{responseId}
|
||||
---
|
||||
openapi: put /api/v1/client/responses/{responseId}
|
||||
---
|
||||
+202
-202
@@ -6,6 +6,208 @@
|
||||
},
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/api/v1/client/responses/{responseId}": {
|
||||
"put": {
|
||||
"description": "Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "responseId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"example": {
|
||||
"data": {
|
||||
"hs8yd14l9h8u353tjmv6rzawqqq": "clicked",
|
||||
"tcgls0063n8ri7dtrbnepcmz": "Who? Who? Who?"
|
||||
},
|
||||
"finished": true
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": {}
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Access-Control-Allow-Credentials": {
|
||||
"schema": {
|
||||
"example": "true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"Access-Control-Allow-Origin": {
|
||||
"schema": {
|
||||
"example": "*",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Connection": {
|
||||
"schema": {
|
||||
"example": "keep-alive",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Date": {
|
||||
"schema": {
|
||||
"example": "Tue, 23 Apr 2024 08:09:19 GMT",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Keep-Alive": {
|
||||
"schema": {
|
||||
"example": "timeout=5",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Transfer-Encoding": {
|
||||
"schema": {
|
||||
"example": "chunked",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-headers": {
|
||||
"schema": {
|
||||
"example": "Content-Type, Authorization",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-methods": {
|
||||
"schema": {
|
||||
"example": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cache-control": {
|
||||
"schema": {
|
||||
"example": "private, no-store",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"content-type": {
|
||||
"schema": {
|
||||
"example": "application/json",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vary": {
|
||||
"schema": {
|
||||
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"code": "not_found",
|
||||
"details": {
|
||||
"resource_id": "nonexistentid",
|
||||
"resource_type": "Response"
|
||||
},
|
||||
"message": "Response not found"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Access-Control-Allow-Credentials": {
|
||||
"schema": {
|
||||
"example": "true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"Access-Control-Allow-Origin": {
|
||||
"schema": {
|
||||
"example": "*",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Connection": {
|
||||
"schema": {
|
||||
"example": "keep-alive",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Date": {
|
||||
"schema": {
|
||||
"example": "Tue, 23 Apr 2024 08:13:50 GMT",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Keep-Alive": {
|
||||
"schema": {
|
||||
"example": "timeout=5",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Transfer-Encoding": {
|
||||
"schema": {
|
||||
"example": "chunked",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-headers": {
|
||||
"schema": {
|
||||
"example": "Content-Type, Authorization",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-methods": {
|
||||
"schema": {
|
||||
"example": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cache-control": {
|
||||
"schema": {
|
||||
"example": "private, no-store",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"content-type": {
|
||||
"schema": {
|
||||
"example": "application/json",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vary": {
|
||||
"schema": {
|
||||
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "Update Response",
|
||||
"tags": ["Client API > Response"]
|
||||
}
|
||||
},
|
||||
"/api/v1/client/{environmentId}/contacts/{userId}/attributes": {
|
||||
"put": {
|
||||
"description": "Update a contact's attributes in Formbricks to keep them in sync with your app or when you want to set a custom attribute in Formbricks.",
|
||||
@@ -1512,208 +1714,6 @@
|
||||
"tags": ["Client API > Response"]
|
||||
}
|
||||
},
|
||||
"/api/v1/client/{environmentId}/responses/{responseId}": {
|
||||
"put": {
|
||||
"description": "Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "responseId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"example": {
|
||||
"data": {
|
||||
"hs8yd14l9h8u353tjmv6rzawqqq": "clicked",
|
||||
"tcgls0063n8ri7dtrbnepcmz": "Who? Who? Who?"
|
||||
},
|
||||
"finished": true
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"data": {}
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Access-Control-Allow-Credentials": {
|
||||
"schema": {
|
||||
"example": "true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"Access-Control-Allow-Origin": {
|
||||
"schema": {
|
||||
"example": "*",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Connection": {
|
||||
"schema": {
|
||||
"example": "keep-alive",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Date": {
|
||||
"schema": {
|
||||
"example": "Tue, 23 Apr 2024 08:09:19 GMT",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Keep-Alive": {
|
||||
"schema": {
|
||||
"example": "timeout=5",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Transfer-Encoding": {
|
||||
"schema": {
|
||||
"example": "chunked",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-headers": {
|
||||
"schema": {
|
||||
"example": "Content-Type, Authorization",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-methods": {
|
||||
"schema": {
|
||||
"example": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cache-control": {
|
||||
"schema": {
|
||||
"example": "private, no-store",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"content-type": {
|
||||
"schema": {
|
||||
"example": "application/json",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vary": {
|
||||
"schema": {
|
||||
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"code": "not_found",
|
||||
"details": {
|
||||
"resource_id": "nonexistentid",
|
||||
"resource_type": "Response"
|
||||
},
|
||||
"message": "Response not found"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Access-Control-Allow-Credentials": {
|
||||
"schema": {
|
||||
"example": "true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"Access-Control-Allow-Origin": {
|
||||
"schema": {
|
||||
"example": "*",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Connection": {
|
||||
"schema": {
|
||||
"example": "keep-alive",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Date": {
|
||||
"schema": {
|
||||
"example": "Tue, 23 Apr 2024 08:13:50 GMT",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Keep-Alive": {
|
||||
"schema": {
|
||||
"example": "timeout=5",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Transfer-Encoding": {
|
||||
"schema": {
|
||||
"example": "chunked",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-headers": {
|
||||
"schema": {
|
||||
"example": "Content-Type, Authorization",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"access-control-allow-methods": {
|
||||
"schema": {
|
||||
"example": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cache-control": {
|
||||
"schema": {
|
||||
"example": "private, no-store",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"content-type": {
|
||||
"schema": {
|
||||
"example": "application/json",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vary": {
|
||||
"schema": {
|
||||
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "Update Response",
|
||||
"tags": ["Client API > Response"]
|
||||
}
|
||||
},
|
||||
"/api/v1/client/{environmentId}/user": {
|
||||
"post": {
|
||||
"description": "Endpoint for creating or identifying a user within the specified environment. If the user already exists, this will identify them and potentially update user attributes. If they don't exist, it will create a new user.\n",
|
||||
|
||||
@@ -77,8 +77,8 @@ deployment:
|
||||
limits:
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 1Gi
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
env:
|
||||
DOCKER_CRON_ENABLED:
|
||||
value: "0"
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ class FormbricksViewModel : ViewModel() {
|
||||
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { type TAttributeUpdateInput } from "@formbricks/types/attributes";
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
|
||||
export class AttributeAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async update(
|
||||
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
|
||||
): Promise<Result<{ changed: boolean; message: string; messages?: string[] }, ApiErrorResponse>> {
|
||||
// transform all attributes to string if attributes are present into a new attributes copy
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const key in attributeUpdateInput.attributes) {
|
||||
attributes[key] = String(attributeUpdateInput.attributes[key]);
|
||||
}
|
||||
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/contacts/${attributeUpdateInput.userId}/attributes`,
|
||||
"PUT",
|
||||
{ attributes }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type TDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
|
||||
export class DisplayAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async create(
|
||||
displayInput: Omit<TDisplayCreateInput, "environmentId">
|
||||
): Promise<Result<{ id: string }, ApiErrorResponse>> {
|
||||
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,13 @@ import { makeRequest } from "../../utils/make-request";
|
||||
export class EnvironmentAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
private isDebug: boolean;
|
||||
|
||||
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
this.isDebug = isDebug;
|
||||
}
|
||||
|
||||
async getState(): Promise<Result<TJsEnvironmentState, ApiErrorResponse>> {
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/environment`,
|
||||
"GET",
|
||||
undefined,
|
||||
this.isDebug
|
||||
);
|
||||
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/environment`, "GET");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { type ApiConfig } from "../../types";
|
||||
import { AttributeAPI } from "./attribute";
|
||||
import { DisplayAPI } from "./display";
|
||||
import { EnvironmentAPI } from "./environment";
|
||||
import { ResponseAPI } from "./response";
|
||||
import { StorageAPI } from "./storage";
|
||||
import { UserAPI } from "./user";
|
||||
|
||||
export class Client {
|
||||
response: ResponseAPI;
|
||||
display: DisplayAPI;
|
||||
storage: StorageAPI;
|
||||
attribute: AttributeAPI;
|
||||
user: UserAPI;
|
||||
environment: EnvironmentAPI;
|
||||
|
||||
constructor(options: ApiConfig) {
|
||||
const { appUrl, environmentId, isDebug } = options;
|
||||
const isDebugMode = isDebug ?? false;
|
||||
const { appUrl, environmentId } = options;
|
||||
|
||||
this.user = new UserAPI(appUrl, environmentId, isDebugMode);
|
||||
this.environment = new EnvironmentAPI(appUrl, environmentId, isDebugMode);
|
||||
this.response = new ResponseAPI(appUrl, environmentId);
|
||||
this.display = new DisplayAPI(appUrl, environmentId);
|
||||
this.attribute = new AttributeAPI(appUrl, environmentId);
|
||||
this.storage = new StorageAPI(appUrl, environmentId);
|
||||
this.user = new UserAPI(appUrl, environmentId);
|
||||
this.environment = new EnvironmentAPI(appUrl, environmentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { type TResponseInput, type TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
|
||||
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
|
||||
|
||||
export class ResponseAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async create(
|
||||
responseInput: Omit<TResponseInput, "environmentId">
|
||||
): Promise<Result<{ id: string }, ApiErrorResponse>> {
|
||||
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
|
||||
}
|
||||
|
||||
async update({
|
||||
responseId,
|
||||
finished,
|
||||
endingId,
|
||||
data,
|
||||
ttc,
|
||||
variables,
|
||||
language,
|
||||
}: TResponseUpdateInputWithResponseId): Promise<Result<object, ApiErrorResponse>> {
|
||||
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
|
||||
finished,
|
||||
endingId,
|
||||
data,
|
||||
ttc,
|
||||
variables,
|
||||
language,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable no-console -- used for error logging */
|
||||
import type { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
|
||||
|
||||
export class StorageAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
file: {
|
||||
type: string;
|
||||
name: string;
|
||||
base64: string;
|
||||
},
|
||||
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
|
||||
): Promise<string> {
|
||||
if (!file.name || !file.type || !file.base64) {
|
||||
throw new Error(`Invalid file object`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
allowedFileExtensions,
|
||||
surveyId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status: ${String(response.status)}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TUploadFileResponse;
|
||||
|
||||
const { data } = json;
|
||||
|
||||
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
|
||||
|
||||
let localUploadDetails: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
localUploadDetails = {
|
||||
fileType: file.type,
|
||||
fileName: encodeURIComponent(updatedFileName),
|
||||
surveyId: surveyId ?? "",
|
||||
signature,
|
||||
timestamp: String(timestamp),
|
||||
uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const formData: Record<string, string> = {};
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.entries(presignedFields).forEach(([key, value]) => {
|
||||
formDataForS3.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const binaryString = atob(file.base64.split(",")[1]);
|
||||
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
|
||||
const blob = new Blob([uint8Array], { type: file.type });
|
||||
|
||||
formDataForS3.append("file", blob);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error("Error uploading file");
|
||||
}
|
||||
}
|
||||
|
||||
formData.fileBase64String = file.base64;
|
||||
|
||||
let uploadResponse: Response = {} as Response;
|
||||
|
||||
const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl);
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrlCopy, {
|
||||
method: "POST",
|
||||
body: presignedFields
|
||||
? formDataForS3
|
||||
: JSON.stringify({
|
||||
...formData,
|
||||
...localUploadDetails,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error uploading file", err);
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
// if local storage is used, we'll use the json response:
|
||||
if (signingData) {
|
||||
const uploadJson = (await uploadResponse.json()) as { message: string };
|
||||
const error = new Error(uploadJson.message);
|
||||
error.name = "FileTooLargeError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
// if s3 is used, we'll use the text response:
|
||||
const errorText = await uploadResponse.text();
|
||||
if (presignedFields && errorText.includes("EntityTooLarge")) {
|
||||
const error = new Error("File size exceeds the size limit for your plan");
|
||||
error.name = "FileTooLargeError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
}
|
||||
|
||||
return fileUrl;
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,10 @@ import { makeRequest } from "../../utils/make-request";
|
||||
export class UserAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
private isDebug: boolean;
|
||||
|
||||
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
this.isDebug = isDebug;
|
||||
}
|
||||
|
||||
async createOrUpdate(userUpdateInput: { userId: string; attributes?: Record<string, string> }): Promise<
|
||||
@@ -39,15 +37,9 @@ export class UserAPI {
|
||||
attributes[key] = String(userUpdateInput.attributes[key]);
|
||||
}
|
||||
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v2/client/${this.environmentId}/user`,
|
||||
"POST",
|
||||
{
|
||||
userId: userUpdateInput.userId,
|
||||
attributes,
|
||||
},
|
||||
this.isDebug
|
||||
);
|
||||
return makeRequest(this.appUrl, `/api/v2/client/${this.environmentId}/user`, "POST", {
|
||||
userId: userUpdateInput.userId,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
export interface ApiConfig {
|
||||
environmentId: string;
|
||||
appUrl: string;
|
||||
isDebug?: boolean;
|
||||
}
|
||||
|
||||
export type ApiResponse = ApiSuccessResponse | ApiErrorResponse;
|
||||
|
||||
@@ -6,16 +6,15 @@ export const makeRequest = async <T>(
|
||||
appUrl: string,
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
data?: unknown,
|
||||
isDebug?: boolean
|
||||
data?: unknown
|
||||
): Promise<Result<T, ApiErrorResponse>> => {
|
||||
const url = new URL(appUrl + endpoint);
|
||||
const body = data ? JSON.stringify(data) : undefined;
|
||||
|
||||
const res = await wrapThrowsAsync(fetch)(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(isDebug && { "Cache-Control": "no-cache" }),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ private extension FormbricksViewModel {
|
||||
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { checkPageUrl } from "@/lib/survey/no-code-action";
|
||||
import * as Attribute from "@/lib/user/attribute";
|
||||
import * as User from "@/lib/user/user";
|
||||
import { type TConfigInput, type TLegacyConfigInput } from "@/types/config";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
const queue = new CommandQueue();
|
||||
|
||||
@@ -68,12 +67,8 @@ const logout = async (): Promise<void> => {
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param code - The code of the action to track
|
||||
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
|
||||
*/
|
||||
const track = async (code: string, properties?: TTrackProperties): Promise<void> => {
|
||||
queue.add<string | TTrackProperties | undefined>(Action.trackCodeAction, true, code, properties);
|
||||
const track = async (code: string): Promise<void> => {
|
||||
queue.add(Action.trackCodeAction, true, code);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
@@ -96,6 +91,5 @@ const formbricks = {
|
||||
registerRouteChange,
|
||||
};
|
||||
|
||||
type TFormbricks = typeof formbricks;
|
||||
export type { TFormbricks };
|
||||
export type TFormbricks = typeof formbricks;
|
||||
export default formbricks;
|
||||
|
||||
@@ -4,13 +4,9 @@ import { checkSetup } from "@/lib/common/setup";
|
||||
import { wrapThrowsAsync } from "@/lib/common/utils";
|
||||
import type { Result } from "@/types/error";
|
||||
|
||||
export type TCommand = (
|
||||
...args: any[]
|
||||
) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
|
||||
|
||||
export class CommandQueue {
|
||||
private queue: {
|
||||
command: TCommand;
|
||||
command: (...args: any[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
|
||||
checkSetup: boolean;
|
||||
commandArgs: any[];
|
||||
}[] = [];
|
||||
@@ -18,7 +14,11 @@ export class CommandQueue {
|
||||
private resolvePromise: (() => void) | null = null;
|
||||
private commandPromise: Promise<void> | null = null;
|
||||
|
||||
public add<A>(command: TCommand, shouldCheckSetup = true, ...args: A[]): void {
|
||||
public add<A>(
|
||||
command: (...args: A[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>,
|
||||
shouldCheckSetup = true,
|
||||
...args: A[]
|
||||
): void {
|
||||
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
|
||||
|
||||
if (!this.running) {
|
||||
|
||||
@@ -179,11 +179,7 @@ export const setup = async (
|
||||
let environmentState: TEnvironmentState = existingConfig.environment;
|
||||
let userState: TUserState = existingConfig.user;
|
||||
|
||||
if (isEnvironmentStateExpired || isDebug) {
|
||||
if (isDebug) {
|
||||
logger.debug("Debug mode is active, refetching environment state");
|
||||
}
|
||||
|
||||
if (isEnvironmentStateExpired) {
|
||||
const environmentStateResponse = await fetchEnvironmentState({
|
||||
appUrl: configInput.appUrl,
|
||||
environmentId: configInput.environmentId,
|
||||
@@ -205,14 +201,10 @@ export const setup = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserStateExpired || isDebug) {
|
||||
if (isUserStateExpired) {
|
||||
// If the existing person state (expired) has a userId, we need to fetch the person state
|
||||
// If the existing person state (expired) has no userId, we need to set the person state to the default
|
||||
|
||||
if (isDebug) {
|
||||
logger.debug("Debug mode is active, refetching user state");
|
||||
}
|
||||
|
||||
if (userState.data.userId) {
|
||||
const updatesResponse = await sendUpdatesToBackend({
|
||||
appUrl: configInput.appUrl,
|
||||
@@ -238,7 +230,7 @@ export const setup = async (
|
||||
responseMessage: "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (!isDebug) {
|
||||
} else {
|
||||
userState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
}
|
||||
}
|
||||
@@ -279,6 +271,7 @@ export const setup = async (
|
||||
throw environmentStateResponse.error;
|
||||
}
|
||||
|
||||
// const personState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
let userState: TUserState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
|
||||
if ("userId" in configInput && configInput.userId) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import type {
|
||||
TEnvironmentState,
|
||||
TEnvironmentStateActionClass,
|
||||
@@ -9,11 +8,7 @@ import type {
|
||||
TUserState,
|
||||
} from "@/types/config";
|
||||
import type { Result } from "@/types/error";
|
||||
import {
|
||||
type TActionClassNoCodeConfig,
|
||||
type TActionClassPageUrlRule,
|
||||
type TTrackProperties,
|
||||
} from "@/types/survey";
|
||||
import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
|
||||
|
||||
// Helper function to calculate difference in days between two dates
|
||||
export const diffInDays = (date1: Date, date2: Date): number => {
|
||||
@@ -230,38 +225,6 @@ export const handleUrlFilters = (urlFilters: TActionClassNoCodeConfig["urlFilter
|
||||
return isMatch;
|
||||
};
|
||||
|
||||
export const handleHiddenFields = (
|
||||
hiddenFieldsConfig: TEnvironmentStateSurvey["hiddenFields"],
|
||||
hiddenFields?: TTrackProperties["hiddenFields"]
|
||||
): TTrackProperties["hiddenFields"] => {
|
||||
const logger = Logger.getInstance();
|
||||
const { enabled: enabledHiddenFields, fieldIds: surveyHiddenFieldIds } = hiddenFieldsConfig;
|
||||
|
||||
let hiddenFieldsObject: TTrackProperties["hiddenFields"] = {};
|
||||
|
||||
if (!enabledHiddenFields) {
|
||||
logger.error("Hidden fields are not enabled for this survey");
|
||||
} else if (surveyHiddenFieldIds && hiddenFields) {
|
||||
const unknownHiddenFields: string[] = [];
|
||||
hiddenFieldsObject = Object.keys(hiddenFields).reduce<TTrackProperties["hiddenFields"]>((acc, key) => {
|
||||
if (surveyHiddenFieldIds.includes(key)) {
|
||||
acc[key] = hiddenFields[key];
|
||||
} else {
|
||||
unknownHiddenFields.push(key);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (unknownHiddenFields.length > 0) {
|
||||
logger.error(
|
||||
`Unknown hidden fields: ${unknownHiddenFields.join(", ")}. Please add them to the survey hidden fields.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return hiddenFieldsObject;
|
||||
};
|
||||
|
||||
export const evaluateNoCodeConfigClick = (
|
||||
targetElement: HTMLElement,
|
||||
action: TEnvironmentStateActionClass
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { filterSurveys, getIsDebug } from "@/lib/common/utils";
|
||||
import { filterSurveys } from "@/lib/common/utils";
|
||||
import type { TConfigInput, TEnvironmentState } from "@/types/config";
|
||||
import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
|
||||
|
||||
@@ -20,7 +20,7 @@ export const fetchEnvironmentState = async ({
|
||||
environmentId,
|
||||
}: TConfigInput): Promise<Result<TEnvironmentState, ApiErrorResponse>> => {
|
||||
const url = `${appUrl}/api/v1/client/${environmentId}/environment`;
|
||||
const api = new FormbricksAPI({ appUrl, environmentId, isDebug: getIsDebug() });
|
||||
const api = new FormbricksAPI({ appUrl, environmentId });
|
||||
|
||||
try {
|
||||
const response = await api.client.environment.getState();
|
||||
|
||||
@@ -37,7 +37,6 @@ vi.mock("@/lib/common/logger", () => ({
|
||||
// Mock filterSurveys
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Config
|
||||
|
||||
@@ -2,20 +2,14 @@ import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { triggerSurvey } from "@/lib/survey/widget";
|
||||
import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "@/types/error";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
/**
|
||||
* Tracks an action name and triggers associated surveys
|
||||
* @param name - The name of the action to track
|
||||
* @param alias - Optional alias for the action name
|
||||
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
|
||||
* @returns Result indicating success or network error
|
||||
*/
|
||||
export const trackAction = async (
|
||||
name: string,
|
||||
alias?: string,
|
||||
properties?: TTrackProperties
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
|
||||
const logger = Logger.getInstance();
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
@@ -30,7 +24,7 @@ export const trackAction = async (
|
||||
for (const survey of activeSurveys) {
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger.actionClass.name === name) {
|
||||
await triggerSurvey(survey, name, properties);
|
||||
await triggerSurvey(survey, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +38,10 @@ export const trackAction = async (
|
||||
/**
|
||||
* Tracks an action by its code and triggers associated surveys (used for code actions only)
|
||||
* @param code - The action code to track
|
||||
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
|
||||
* @returns Result indicating success, network error, or invalid code error
|
||||
*/
|
||||
export const trackCodeAction = async (
|
||||
code: string,
|
||||
properties?: TTrackProperties
|
||||
code: string
|
||||
): Promise<Result<void, NetworkError> | Result<void, InvalidCodeError>> => {
|
||||
const appConfig = Config.getInstance();
|
||||
|
||||
@@ -69,7 +61,7 @@ export const trackCodeAction = async (
|
||||
});
|
||||
}
|
||||
|
||||
return trackAction(actionClass.name, code, properties);
|
||||
return trackAction(actionClass.name, code);
|
||||
};
|
||||
|
||||
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {
|
||||
|
||||
@@ -33,7 +33,6 @@ vi.mock("@/lib/common/logger", () => ({
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
handleHiddenFields: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/widget", () => ({
|
||||
@@ -101,10 +100,10 @@ describe("survey/action.ts", () => {
|
||||
filteredSurveys: [mockSurvey],
|
||||
});
|
||||
|
||||
const result = await trackAction("testAction", undefined);
|
||||
const result = await trackAction("testAction");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(triggerSurvey).toHaveBeenCalledWith(mockSurvey, "testAction", undefined);
|
||||
expect(triggerSurvey).toHaveBeenCalledWith(mockSurvey, "testAction");
|
||||
});
|
||||
|
||||
test("handles multiple matching surveys", async () => {
|
||||
|
||||
@@ -40,7 +40,6 @@ vi.mock("@/lib/common/utils", () => ({
|
||||
getStyling: vi.fn(),
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
wrapThrowsAsync: vi.fn(),
|
||||
handleHiddenFields: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("widget-file", () => {
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
filterSurveys,
|
||||
getLanguageCode,
|
||||
getStyling,
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
} from "@/lib/common/utils";
|
||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
let isSurveyRunning = false;
|
||||
|
||||
@@ -19,11 +17,7 @@ export const setIsSurveyRunning = (value: boolean): void => {
|
||||
isSurveyRunning = value;
|
||||
};
|
||||
|
||||
export const triggerSurvey = async (
|
||||
survey: TEnvironmentStateSurvey,
|
||||
action?: string,
|
||||
properties?: TTrackProperties
|
||||
): Promise<void> => {
|
||||
export const triggerSurvey = async (survey: TEnvironmentStateSurvey, action?: string): Promise<void> => {
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
// Check if the survey should be displayed based on displayPercentage
|
||||
@@ -35,19 +29,10 @@ export const triggerSurvey = async (
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenFieldsObject: TTrackProperties["hiddenFields"] = handleHiddenFields(
|
||||
survey.hiddenFields,
|
||||
properties?.hiddenFields
|
||||
);
|
||||
|
||||
await renderWidget(survey, action, hiddenFieldsObject);
|
||||
await renderWidget(survey, action);
|
||||
};
|
||||
|
||||
export const renderWidget = async (
|
||||
survey: TEnvironmentStateSurvey,
|
||||
action?: string,
|
||||
hiddenFieldsObject?: TTrackProperties["hiddenFields"]
|
||||
): Promise<void> => {
|
||||
export const renderWidget = async (survey: TEnvironmentStateSurvey, action?: string): Promise<void> => {
|
||||
const logger = Logger.getInstance();
|
||||
const config = Config.getInstance();
|
||||
const timeoutStack = TimeoutStack.getInstance();
|
||||
@@ -102,7 +87,6 @@ export const renderWidget = async (
|
||||
languageCode,
|
||||
placement,
|
||||
styling: getStyling(project, survey),
|
||||
hiddenFieldsRecord: hiddenFieldsObject,
|
||||
onDisplayCreated: () => {
|
||||
const existingDisplays = config.get().user.data.displays;
|
||||
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
|
||||
|
||||
@@ -30,7 +30,6 @@ vi.mock("@/lib/common/logger", () => ({
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/api", () => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { filterSurveys, getIsDebug } from "@/lib/common/utils";
|
||||
import { filterSurveys } from "@/lib/common/utils";
|
||||
import { type TUpdates, type TUserState } from "@/types/config";
|
||||
import { type ApiErrorResponse, type Result, type ResultError, err, ok, okVoid } from "@/types/error";
|
||||
|
||||
@@ -26,7 +26,7 @@ export const sendUpdatesToBackend = async ({
|
||||
const url = `${appUrl}/api/v1/client/${environmentId}/user`;
|
||||
|
||||
try {
|
||||
const api = new FormbricksAPI({ appUrl, environmentId, isDebug: getIsDebug() });
|
||||
const api = new FormbricksAPI({ appUrl, environmentId });
|
||||
|
||||
const response = await api.client.user.createOrUpdate({
|
||||
userId: updates.userId,
|
||||
|
||||
@@ -75,7 +75,3 @@ export type TActionClassNoCodeConfig =
|
||||
rule: TActionClassPageUrlRule;
|
||||
}[];
|
||||
};
|
||||
|
||||
export interface TTrackProperties {
|
||||
hiddenFields: Record<string, string | number | string[]>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "4.1.0",
|
||||
"version": "4.0.0",
|
||||
"description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { type TFormbricks as TFormbricksCore } from "@formbricks/js-core";
|
||||
import type Formbricks from "@formbricks/js-core";
|
||||
import { loadFormbricksToProxy } from "./lib/load-formbricks";
|
||||
|
||||
type TFormbricks = Omit<TFormbricksCore, "track"> & {
|
||||
track: (code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type TFormbricks = typeof Formbricks;
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricks: TFormbricks | undefined;
|
||||
@@ -17,7 +14,7 @@ const formbricksProxyHandler: ProxyHandler<TFormbricks> = {
|
||||
},
|
||||
};
|
||||
|
||||
const formbricks: TFormbricksCore = new Proxy({} as TFormbricks, formbricksProxyHandler);
|
||||
const formbricks: TFormbricks = new Proxy({} as TFormbricks, formbricksProxyHandler);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- Required for UMD
|
||||
export default formbricks;
|
||||
|
||||
@@ -104,6 +104,9 @@ type TGetSignedUrlResponse =
|
||||
};
|
||||
|
||||
const getS3SignedUrl = async (fileKey: string): Promise<string> => {
|
||||
const [_, accessType] = fileKey.split("/");
|
||||
const expiresIn = accessType === "public" ? 60 * 60 : 10 * 60;
|
||||
|
||||
const getObjectCommand = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET_NAME,
|
||||
Key: fileKey,
|
||||
@@ -111,7 +114,7 @@ const getS3SignedUrl = async (fileKey: string): Promise<string> => {
|
||||
|
||||
try {
|
||||
const s3Client = getS3Client();
|
||||
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 30 * 60 });
|
||||
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn });
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ const renderHtml = (options: Partial<SurveyContainerProps> & { appUrl?: string }
|
||||
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body style="overflow: hidden; height: 100vh; margin: 0;">
|
||||
</body>
|
||||
|
||||
@@ -135,6 +135,7 @@ export function ConsentQuestion({
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -107,6 +107,7 @@ export function CTAQuestion({
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: "" }, updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
Generated
+418
-661
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user