mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-05 00:48:03 -06:00
Merge branch 'epic/connectors' into fix/polish-formbricks-connector
This commit is contained in:
@@ -1,317 +0,0 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { HUB_API_KEY, HUB_API_URL } from "@/lib/constants";
|
||||
|
||||
// Hub field types (from OpenAPI spec)
|
||||
export type THubFieldType =
|
||||
| "text"
|
||||
| "categorical"
|
||||
| "nps"
|
||||
| "csat"
|
||||
| "ces"
|
||||
| "rating"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date";
|
||||
|
||||
// Create FeedbackRecord input
|
||||
export interface TCreateFeedbackRecordInput {
|
||||
collected_at?: string;
|
||||
source_type: string;
|
||||
field_id: string;
|
||||
field_type: THubFieldType;
|
||||
field_label?: string;
|
||||
field_group_id?: string;
|
||||
field_group_label?: string;
|
||||
tenant_id?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
value_text?: string;
|
||||
value_number?: number;
|
||||
value_boolean?: boolean;
|
||||
value_date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
language?: string;
|
||||
user_identifier?: string;
|
||||
}
|
||||
|
||||
// FeedbackRecord data (response from Hub)
|
||||
export interface TFeedbackRecordData {
|
||||
id: string;
|
||||
collected_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
source_type: string;
|
||||
field_id: string;
|
||||
field_type: THubFieldType;
|
||||
field_label?: string;
|
||||
field_group_id?: string;
|
||||
field_group_label?: string;
|
||||
tenant_id?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
value_text?: string;
|
||||
value_number?: number;
|
||||
value_boolean?: boolean;
|
||||
value_date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
language?: string;
|
||||
user_identifier?: string;
|
||||
}
|
||||
|
||||
// List FeedbackRecords response
|
||||
export interface TListFeedbackRecordsResponse {
|
||||
data: TFeedbackRecordData[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Update FeedbackRecord input
|
||||
export interface TUpdateFeedbackRecordInput {
|
||||
value_text?: string;
|
||||
value_number?: number;
|
||||
value_boolean?: boolean;
|
||||
value_date?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
language?: string;
|
||||
user_identifier?: string;
|
||||
}
|
||||
|
||||
// List FeedbackRecords filters
|
||||
export interface TListFeedbackRecordsFilters {
|
||||
tenant_id?: string;
|
||||
source_type?: string;
|
||||
source_id?: string;
|
||||
field_id?: string;
|
||||
field_group_id?: string;
|
||||
field_type?: THubFieldType;
|
||||
user_identifier?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Error response from Hub
|
||||
export interface THubErrorResponse {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
instance?: string;
|
||||
errors?: Array<{
|
||||
location?: string;
|
||||
message?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Hub API Error class
|
||||
export class HubApiError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
errors?: THubErrorResponse["errors"];
|
||||
|
||||
constructor(response: THubErrorResponse) {
|
||||
super(response.detail || response.title);
|
||||
this.name = "HubApiError";
|
||||
this.status = response.status;
|
||||
this.detail = response.detail;
|
||||
this.errors = response.errors;
|
||||
}
|
||||
}
|
||||
|
||||
// Make authenticated request to Hub API
|
||||
async function hubFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: T | null; error: HubApiError | null }> {
|
||||
const url = `${HUB_API_URL}${path}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(HUB_API_KEY && { Authorization: `Bearer ${HUB_API_KEY}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle no content response (e.g., DELETE)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error response
|
||||
if (contentType?.includes("application/problem+json") || contentType?.includes("application/json")) {
|
||||
const errorBody = (await response.json()) as THubErrorResponse;
|
||||
return { data: null, error: new HubApiError(errorBody) };
|
||||
}
|
||||
|
||||
// Fallback for non-JSON errors
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
data: null,
|
||||
error: new HubApiError({
|
||||
title: "Error",
|
||||
status: response.status,
|
||||
detail: errorText || `HTTP ${response.status}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
if (contentType?.includes("application/json")) {
|
||||
const data = (await response.json()) as T;
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
return { data: null, error: null };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ url, error: error instanceof Error ? error.message : "Unknown error" },
|
||||
"Hub API request failed"
|
||||
);
|
||||
return {
|
||||
data: null,
|
||||
error: new HubApiError({
|
||||
title: "Network Error",
|
||||
status: 0,
|
||||
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new FeedbackRecord in the Hub
|
||||
*/
|
||||
export async function createFeedbackRecord(
|
||||
input: TCreateFeedbackRecordInput
|
||||
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
|
||||
return hubFetch<TFeedbackRecordData>("/v1/feedback-records", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple FeedbackRecords in the Hub (batch)
|
||||
*/
|
||||
export async function createFeedbackRecordsBatch(
|
||||
inputs: TCreateFeedbackRecordInput[]
|
||||
): Promise<{ results: Array<{ data: TFeedbackRecordData | null; error: HubApiError | null }> }> {
|
||||
// Hub doesn't have a batch endpoint, so we'll do parallel requests
|
||||
// In production, you might want to implement rate limiting or chunking
|
||||
const results = await Promise.all(inputs.map((input) => createFeedbackRecord(input)));
|
||||
return { results };
|
||||
}
|
||||
|
||||
/**
|
||||
* List FeedbackRecords from the Hub with optional filters
|
||||
*/
|
||||
export async function listFeedbackRecords(
|
||||
filters: TListFeedbackRecordsFilters = {}
|
||||
): Promise<{ data: TListFeedbackRecordsResponse | null; error: HubApiError | null }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.tenant_id) searchParams.set("tenant_id", filters.tenant_id);
|
||||
if (filters.source_type) searchParams.set("source_type", filters.source_type);
|
||||
if (filters.source_id) searchParams.set("source_id", filters.source_id);
|
||||
if (filters.field_id) searchParams.set("field_id", filters.field_id);
|
||||
if (filters.field_group_id) searchParams.set("field_group_id", filters.field_group_id);
|
||||
if (filters.field_type) searchParams.set("field_type", filters.field_type);
|
||||
if (filters.user_identifier) searchParams.set("user_identifier", filters.user_identifier);
|
||||
if (filters.since) searchParams.set("since", filters.since);
|
||||
if (filters.until) searchParams.set("until", filters.until);
|
||||
if (filters.limit !== undefined) searchParams.set("limit", String(filters.limit));
|
||||
if (filters.offset !== undefined) searchParams.set("offset", String(filters.offset));
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const path = queryString ? `/v1/feedback-records?${queryString}` : "/v1/feedback-records";
|
||||
|
||||
return hubFetch<TListFeedbackRecordsResponse>(path, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single FeedbackRecord from the Hub by ID
|
||||
*/
|
||||
export async function getFeedbackRecord(
|
||||
id: string
|
||||
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
|
||||
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a FeedbackRecord in the Hub
|
||||
*/
|
||||
export async function updateFeedbackRecord(
|
||||
id: string,
|
||||
input: TUpdateFeedbackRecordInput
|
||||
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
|
||||
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a FeedbackRecord from the Hub
|
||||
*/
|
||||
export async function deleteFeedbackRecord(id: string): Promise<{ error: HubApiError | null }> {
|
||||
const result = await hubFetch<null>(`/v1/feedback-records/${id}`, { method: "DELETE" });
|
||||
return { error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete FeedbackRecords by user identifier (GDPR compliance)
|
||||
*/
|
||||
export async function bulkDeleteFeedbackRecordsByUser(
|
||||
userIdentifier: string,
|
||||
tenantId?: string
|
||||
): Promise<{ data: { deleted_count: number; message: string } | null; error: HubApiError | null }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("user_identifier", userIdentifier);
|
||||
if (tenantId) searchParams.set("tenant_id", tenantId);
|
||||
|
||||
return hubFetch<{ deleted_count: number; message: string }>(
|
||||
`/v1/feedback-records?${searchParams.toString()}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Hub API health
|
||||
*/
|
||||
export async function checkHubHealth(): Promise<{ healthy: boolean; error: HubApiError | null }> {
|
||||
try {
|
||||
const response = await fetch(`${HUB_API_URL}/health`);
|
||||
if (response.ok) {
|
||||
return { healthy: true, error: null };
|
||||
}
|
||||
return {
|
||||
healthy: false,
|
||||
error: new HubApiError({
|
||||
title: "Health Check Failed",
|
||||
status: response.status,
|
||||
detail: "Hub API health check failed",
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: new HubApiError({
|
||||
title: "Network Error",
|
||||
status: 0,
|
||||
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
225
apps/web/lib/connector/pipeline-handler.test.ts
Normal file
225
apps/web/lib/connector/pipeline-handler.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const mockCreateFeedbackRecordsBatch = vi.fn();
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: (...args: unknown[]) => mockCreateFeedbackRecordsBatch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
getConnectorsBySurveyId: vi.fn(),
|
||||
updateConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transform", () => ({
|
||||
transformResponseToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getConnectorsBySurveyId, updateConnector } = await import("./service");
|
||||
const { transformResponseToFeedbackRecords } = await import("./transform");
|
||||
const { handleConnectorPipeline } = await import("./pipeline-handler");
|
||||
|
||||
const mockResponse = {
|
||||
id: "resp-1",
|
||||
createdAt: new Date("2026-02-24T10:00:00.000Z"),
|
||||
surveyId: "survey-1",
|
||||
data: { "el-1": "answer" },
|
||||
} as unknown as TResponse;
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
blocks: [{ id: "block-1", name: "Block", elements: [{ id: "el-1", headline: { default: "Question?" } }] }],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
function createConnector(
|
||||
overrides: Partial<Pick<TConnectorWithMappings, "id" | "formbricksMappings">> = {}
|
||||
): TConnectorWithMappings {
|
||||
return {
|
||||
id: "conn-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
status: "active",
|
||||
environmentId: "env-1",
|
||||
lastSyncAt: null,
|
||||
errorMessage: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "map-1",
|
||||
createdAt: new Date(),
|
||||
connectorId: "conn-1",
|
||||
environmentId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
elementId: "el-1",
|
||||
hubFieldType: "rating",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
...overrides,
|
||||
} as TConnectorWithMappings;
|
||||
}
|
||||
|
||||
const oneFeedbackRecord = [
|
||||
{
|
||||
field_id: "el-1",
|
||||
field_type: "rating" as const,
|
||||
source_type: "formbricks",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
value_number: 5,
|
||||
collected_at: "2026-02-24T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const noConfigError = {
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
};
|
||||
|
||||
describe("handleConnectorPipeline", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns early when no connectors for survey", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([]);
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(transformResponseToFeedbackRecords).not.toHaveBeenCalled();
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues when transform returns no feedback records", async () => {
|
||||
const connector = createConnector();
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([connector]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue([]);
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(transformResponseToFeedbackRecords).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
mockSurvey,
|
||||
connector.formbricksMappings,
|
||||
"env-1"
|
||||
);
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates connector to error when Hub returns no-config (HUB_API_KEY not set)", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: oneFeedbackRecord.map(() => ({ data: null, error: noConfigError })),
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
status: "error",
|
||||
errorMessage: expect.stringContaining("HUB_API_KEY"),
|
||||
});
|
||||
});
|
||||
|
||||
test("sends records to Hub and updates connector to active on full success", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "hub-1", ...oneFeedbackRecord[0] }, error: null }],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
status: "active",
|
||||
errorMessage: null,
|
||||
lastSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
test("updates connector to error when all Hub creates fail", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: null, error: { status: 500, message: "Hub unavailable", detail: "Hub unavailable" } },
|
||||
],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
status: "error",
|
||||
errorMessage: expect.stringContaining("Failed to send FeedbackRecords"),
|
||||
});
|
||||
});
|
||||
|
||||
test("updates connector to active with partial message when some creates fail", async () => {
|
||||
const twoRecords = [...oneFeedbackRecord, { ...oneFeedbackRecord[0], field_id: "el-2", value_number: 3 }];
|
||||
const baseMapping = {
|
||||
createdAt: new Date(),
|
||||
connectorId: "conn-1",
|
||||
environmentId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
hubFieldType: "rating" as const,
|
||||
customFieldLabel: null as string | null,
|
||||
};
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([
|
||||
createConnector({
|
||||
formbricksMappings: [
|
||||
{ ...baseMapping, id: "m1", elementId: "el-1" },
|
||||
{ ...baseMapping, id: "m2", elementId: "el-2" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(twoRecords as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "hub-1" }, error: null },
|
||||
{ data: null, error: { status: 429, message: "Rate limited", detail: "Rate limited" } },
|
||||
],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
status: "active",
|
||||
errorMessage: "Partial failure: 1/2 records sent",
|
||||
lastSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
test("updates connector to error when transform throws", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockImplementation(() => {
|
||||
throw new Error("Transform failed");
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
status: "error",
|
||||
errorMessage: "Transform failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createFeedbackRecordsBatch } from "./hub-client";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { getConnectorsBySurveyId, updateConnector } from "./service";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { TCreateFeedbackRecordInput } from "./hub-client";
|
||||
import type { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
const getHeadlineFromElement = (element?: TSurveyElement): string => {
|
||||
if (!element?.headline) return "Untitled";
|
||||
@@ -23,7 +23,7 @@ const convertValueToHubFields = (
|
||||
value: TResponseDataValue,
|
||||
hubFieldType: THubFieldType
|
||||
): Partial<
|
||||
Pick<TCreateFeedbackRecordInput, "value_text" | "value_number" | "value_boolean" | "value_date">
|
||||
Pick<FeedbackRecordCreateParams, "value_text" | "value_number" | "value_boolean" | "value_date">
|
||||
> => {
|
||||
if (value === undefined || value === null) {
|
||||
return {};
|
||||
@@ -82,14 +82,14 @@ export function transformResponseToFeedbackRecords(
|
||||
survey: TSurvey,
|
||||
mappings: TConnectorFormbricksMapping[],
|
||||
tenantId?: string
|
||||
): TCreateFeedbackRecordInput[] {
|
||||
): FeedbackRecordCreateParams[] {
|
||||
const responseData = response.data;
|
||||
if (!responseData) return [];
|
||||
|
||||
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementMap = new Map(elements.map((el) => [el.id, el]));
|
||||
const feedbackRecords: TCreateFeedbackRecordInput[] = [];
|
||||
const feedbackRecords: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
for (const mapping of surveyMappings) {
|
||||
const value = extractResponseValue(responseData, mapping.elementId);
|
||||
@@ -98,7 +98,7 @@ export function transformResponseToFeedbackRecords(
|
||||
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
|
||||
|
||||
const feedbackRecord: TCreateFeedbackRecordInput = {
|
||||
const feedbackRecord: FeedbackRecordCreateParams = {
|
||||
collected_at:
|
||||
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
|
||||
source_type: "formbricks",
|
||||
|
||||
80
apps/web/modules/hub/hub-client.test.ts
Normal file
80
apps/web/modules/hub/hub-client.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
|
||||
vi.mock("@formbricks/hub", () => {
|
||||
const MockFormbricksHub = vi.fn();
|
||||
return { default: MockFormbricksHub };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
HUB_API_KEY: "",
|
||||
HUB_API_URL: "https://hub.test",
|
||||
},
|
||||
}));
|
||||
|
||||
const { env } = await import("@/lib/env");
|
||||
|
||||
const mutableEnv = env as unknown as Record<string, string>;
|
||||
|
||||
const globalForHub = globalThis as unknown as {
|
||||
formbricksHubClient: FormbricksHub | undefined;
|
||||
};
|
||||
|
||||
describe("getHubClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalForHub.formbricksHubClient = undefined;
|
||||
});
|
||||
|
||||
test("returns null when HUB_API_KEY is not set", async () => {
|
||||
mutableEnv.HUB_API_KEY = "";
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const client = getHubClient();
|
||||
|
||||
expect(client).toBeNull();
|
||||
expect(FormbricksHub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates and caches a new client when HUB_API_KEY is set", async () => {
|
||||
mutableEnv.HUB_API_KEY = "test-key";
|
||||
const mockInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
|
||||
vi.mocked(FormbricksHub).mockReturnValue(mockInstance);
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const client = getHubClient();
|
||||
|
||||
expect(FormbricksHub).toHaveBeenCalledWith({ apiKey: "test-key", baseURL: "https://hub.test" });
|
||||
expect(client).toBe(mockInstance);
|
||||
expect(globalForHub.formbricksHubClient).toBe(mockInstance);
|
||||
});
|
||||
|
||||
test("returns cached client on subsequent calls", async () => {
|
||||
const cachedInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
|
||||
globalForHub.formbricksHubClient = cachedInstance;
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const client = getHubClient();
|
||||
|
||||
expect(client).toBe(cachedInstance);
|
||||
expect(FormbricksHub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not cache null result so a later call with the key set can create the client", async () => {
|
||||
mutableEnv.HUB_API_KEY = "";
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
const first = getHubClient();
|
||||
expect(first).toBeNull();
|
||||
expect(globalForHub.formbricksHubClient).toBeUndefined();
|
||||
|
||||
mutableEnv.HUB_API_KEY = "now-set";
|
||||
const mockInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
|
||||
vi.mocked(FormbricksHub).mockReturnValue(mockInstance);
|
||||
|
||||
const second = getHubClient();
|
||||
expect(second).toBe(mockInstance);
|
||||
expect(globalForHub.formbricksHubClient).toBe(mockInstance);
|
||||
});
|
||||
});
|
||||
25
apps/web/modules/hub/hub-client.ts
Normal file
25
apps/web/modules/hub/hub-client.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import "server-only";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const globalForHub = globalThis as unknown as {
|
||||
formbricksHubClient: FormbricksHub | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a shared Formbricks Hub API client when HUB_API_KEY is set.
|
||||
* Uses a global singleton so the same instance is reused across the process
|
||||
* (and across Next.js HMR in development). When the key is not set, returns
|
||||
* null and does not cache that result so a later call with the key set
|
||||
* can create the client.
|
||||
*/
|
||||
export const getHubClient = (): FormbricksHub | null => {
|
||||
if (globalForHub.formbricksHubClient) {
|
||||
return globalForHub.formbricksHubClient;
|
||||
}
|
||||
const apiKey = env.HUB_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const client = new FormbricksHub({ apiKey, baseURL: env.HUB_API_URL });
|
||||
globalForHub.formbricksHubClient = client;
|
||||
return client;
|
||||
};
|
||||
3
apps/web/modules/hub/index.ts
Normal file
3
apps/web/modules/hub/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { getHubClient } from "./hub-client";
|
||||
export { createFeedbackRecord, createFeedbackRecordsBatch, type CreateFeedbackRecordResult } from "./service";
|
||||
export type { FeedbackRecordCreateParams, FeedbackRecordData } from "./types";
|
||||
121
apps/web/modules/hub/service.test.ts
Normal file
121
apps/web/modules/hub/service.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createFeedbackRecord, createFeedbackRecordsBatch } from "./service";
|
||||
import type { FeedbackRecordCreateParams } from "./types";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("./hub-client", () => ({
|
||||
getHubClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getHubClient } = await import("./hub-client");
|
||||
|
||||
const sampleInput: FeedbackRecordCreateParams = {
|
||||
field_id: "el-1",
|
||||
field_type: "rating",
|
||||
source_type: "formbricks",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
value_number: 5,
|
||||
collected_at: "2026-02-24T10:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("hub service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createFeedbackRecord", () => {
|
||||
test("returns error result when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns data when client.create succeeds", async () => {
|
||||
const created = { id: "hub-1", ...sampleInput };
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { create: vi.fn().mockResolvedValue(created) },
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual(created);
|
||||
});
|
||||
|
||||
test("returns error result when client.create throws", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { create: vi.fn().mockRejectedValue(new Error("Network error")) },
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ message: "Network error" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordsBatch", () => {
|
||||
test("returns all errors when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await createFeedbackRecordsBatch([sampleInput, { ...sampleInput, field_id: "el-2" }]);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
result.results.forEach((r) => {
|
||||
expect(r.data).toBeNull();
|
||||
expect(r.error?.message).toContain("HUB_API_KEY is not set");
|
||||
});
|
||||
});
|
||||
|
||||
test("returns results per input when client creates succeed", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: {
|
||||
create: vi
|
||||
.fn()
|
||||
.mockImplementation((input: FeedbackRecordCreateParams) =>
|
||||
Promise.resolve({ id: `hub-${input.field_id}`, ...input })
|
||||
),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
|
||||
const result = await createFeedbackRecordsBatch(inputs);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0].data).toMatchObject({ field_id: "el-1" });
|
||||
expect(result.results[0].error).toBeNull();
|
||||
expect(result.results[1].data).toMatchObject({ field_id: "el-2" });
|
||||
expect(result.results[1].error).toBeNull();
|
||||
});
|
||||
|
||||
test("returns mixed results when some creates fail", async () => {
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "hub-1", ...sampleInput })
|
||||
.mockRejectedValueOnce(new Error("Rate limited"));
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { create },
|
||||
} as any);
|
||||
|
||||
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
|
||||
const result = await createFeedbackRecordsBatch(inputs);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0].data).not.toBeNull();
|
||||
expect(result.results[0].error).toBeNull();
|
||||
expect(result.results[1].data).toBeNull();
|
||||
expect(result.results[1].error).toMatchObject({ message: "Rate limited" });
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/web/modules/hub/service.ts
Normal file
70
apps/web/modules/hub/service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import "server-only";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getHubClient } from "./hub-client";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordData } from "./types";
|
||||
|
||||
export type CreateFeedbackRecordResult = {
|
||||
data: FeedbackRecordData | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
};
|
||||
|
||||
const NO_CONFIG_ERROR = {
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
} as const;
|
||||
|
||||
const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a single feedback record in the Hub.
|
||||
* Returns a result shape with data or error; logs failures.
|
||||
*/
|
||||
export const createFeedbackRecord = async (
|
||||
input: FeedbackRecordCreateParams
|
||||
): Promise<CreateFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create multiple feedback records in the Hub in parallel.
|
||||
* Returns an array of results (data or error) per input; logs failures.
|
||||
*/
|
||||
export const createFeedbackRecordsBatch = async (
|
||||
inputs: FeedbackRecordCreateParams[]
|
||||
): Promise<{ results: CreateFeedbackRecordResult[] }> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return {
|
||||
results: inputs.map(() => ({ data: null, error: { ...NO_CONFIG_ERROR } })),
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
inputs.map(async (input) => {
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null as CreateFeedbackRecordResult["error"] };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
})
|
||||
);
|
||||
return { results };
|
||||
};
|
||||
4
apps/web/modules/hub/types.ts
Normal file
4
apps/web/modules/hub/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type FormbricksHub from "@formbricks/hub";
|
||||
|
||||
export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParams;
|
||||
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;
|
||||
@@ -30,6 +30,7 @@
|
||||
"@formbricks/cache": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/email": "workspace:*",
|
||||
"@formbricks/hub": "0.3.0",
|
||||
"@formbricks/i18n-utils": "workspace:*",
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
"@formbricks/logger": "workspace:*",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -160,6 +160,9 @@ importers:
|
||||
'@formbricks/email':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/email
|
||||
'@formbricks/hub':
|
||||
specifier: 0.3.0
|
||||
version: 0.3.0
|
||||
'@formbricks/i18n-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n-utils
|
||||
@@ -2337,6 +2340,9 @@ packages:
|
||||
'@formatjs/intl-localematcher@0.6.2':
|
||||
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
|
||||
|
||||
'@formbricks/hub@0.3.0':
|
||||
resolution: {integrity: sha512-SfLQghsLILSiN/53mUHgkUbUcCar5l2bGC8DDSV9Y9NWdau+r+zFIYFEoSxhlMzlwhI+uvt/gkbVKShERBeoRQ==}
|
||||
|
||||
'@formkit/auto-animate@0.8.2':
|
||||
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
|
||||
|
||||
@@ -13885,6 +13891,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formbricks/hub@0.3.0': {}
|
||||
|
||||
'@formkit/auto-animate@0.8.2': {}
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
|
||||
Reference in New Issue
Block a user