feat(connector): use @formbricks/hub SDK instead of custom hub-client

- Remove apps/web/lib/connector/hub-client.ts
- Add @formbricks/hub dependency to apps/web
- pipeline-handler: create Hub client from env (HUB_API_KEY, HUB_API_URL), call SDK create in batch with same results shape
- transform: use FormbricksHub.FeedbackRecordCreateParams from SDK directly

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tiago Farto
2026-02-25 12:20:46 +00:00
parent 75e71e39bc
commit e665227437
5 changed files with 65 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

@@ -1,11 +1,58 @@
import "server-only";
import FormbricksHub from "@formbricks/hub";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "./hub-client";
import { env } from "@/lib/env";
import { getConnectorsBySurveyId, updateConnector } from "./service";
import { transformResponseToFeedbackRecords } from "./transform";
type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParams;
type FeedbackRecordData = FormbricksHub.FeedbackRecordData;
function getHubClient(): FormbricksHub | null {
const apiKey = env.HUB_API_KEY;
if (!apiKey) return null;
return new FormbricksHub({
apiKey,
baseURL: env.HUB_API_URL ?? undefined,
});
}
async function createFeedbackRecordsBatch(inputs: FeedbackRecordCreateParams[]): Promise<{
results: Array<{
data: FeedbackRecordData | null;
error: { status: number; message: string; detail: string } | null;
}>;
}> {
const client = getHubClient();
const errorNoConfig = {
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
};
if (!client) {
return {
results: inputs.map(() => ({ data: null, error: errorNoConfig })),
};
}
const results = await Promise.all(
inputs.map(async (input) => {
try {
const data = await client.feedbackRecords.create(input);
return { data, error: null as { status: number; message: string; detail: string } | null };
} catch (err) {
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 } };
}
})
);
return { results };
}
/**
* Handle connector pipeline for a survey response
*

View File

@@ -1,11 +1,11 @@
import "server-only";
import type FormbricksHub from "@formbricks/hub";
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
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";
type TResponseValue = string | number | string[] | Record<string, string> | undefined;
@@ -26,7 +26,10 @@ const convertValueToHubFields = (
value: TResponseValue,
hubFieldType: THubFieldType
): Partial<
Pick<TCreateFeedbackRecordInput, "value_text" | "value_number" | "value_boolean" | "value_date">
Pick<
FormbricksHub.FeedbackRecordCreateParams,
"value_text" | "value_number" | "value_boolean" | "value_date"
>
> => {
if (value === undefined || value === null) {
return {};
@@ -85,14 +88,14 @@ export function transformResponseToFeedbackRecords(
survey: TSurvey,
mappings: TConnectorFormbricksMapping[],
tenantId?: string
): TCreateFeedbackRecordInput[] {
): FormbricksHub.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: FormbricksHub.FeedbackRecordCreateParams[] = [];
for (const mapping of surveyMappings) {
const value = extractResponseValue(responseData, mapping.elementId);
@@ -101,7 +104,7 @@ export function transformResponseToFeedbackRecords(
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord: TCreateFeedbackRecordInput = {
const feedbackRecord: FormbricksHub.FeedbackRecordCreateParams = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",

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