Merge branch 'epic/connectors' into fix/polish-formbricks-connector

This commit is contained in:
pandeymangg
2026-02-26 14:57:37 +05:30
12 changed files with 543 additions and 323 deletions

View File

@@ -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",
}),
};
}
}

View 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",
});
});
});

View File

@@ -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";

View File

@@ -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",

View 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);
});
});

View 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;
};

View File

@@ -0,0 +1,3 @@
export { getHubClient } from "./hub-client";
export { createFeedbackRecord, createFeedbackRecordsBatch, type CreateFeedbackRecordResult } from "./service";
export type { FeedbackRecordCreateParams, FeedbackRecordData } from "./types";

View 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" });
});
});
});

View 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 };
};

View File

@@ -0,0 +1,4 @@
import type FormbricksHub from "@formbricks/hub";
export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParams;
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;

View File

@@ -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
View File

@@ -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':