mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 10:19:31 -06:00
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:
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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