mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 19:38:37 -05:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b61e3b9bd | |||
| b2a95d4cee | |||
| 64b4e18c5a | |||
| ae9c1e499a | |||
| daae319c7a | |||
| 5b70c99eb3 | |||
| 10c09f00a8 | |||
| 5f4f133dcb | |||
| 037b005d48 | |||
| ddd2d5e983 | |||
| 6777b284b3 | |||
| c6282632e0 | |||
| f84c409bc4 | |||
| 98b475a2a4 | |||
| c48474b943 | |||
| 3c0d1e3fd7 | |||
| 1f7a496967 | |||
| 99e378ae2e | |||
| c6e39c3103 | |||
| 19472ca9d3 | |||
| 43feff0009 | |||
| 507cec6958 | |||
| d874b65be9 | |||
| c159af1a26 | |||
| 3ce2ef6cf4 | |||
| baae6335c9 | |||
| 8e443db1f6 | |||
| 18cd9afc2c | |||
| 680e1e1593 | |||
| 04bfdeef69 | |||
| 8a1db7b6aa | |||
| 1d22fe2da6 | |||
| d8a119712c | |||
| 50089eeca4 | |||
| d8fe832f5f | |||
| 1c904e2243 | |||
| 4dbecc2d58 |
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -151,7 +152,17 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
name: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{t("workspace.unify.unify_feedback")}</span>
|
||||
<Badge
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="normal-case text-[10px] font-semibold tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
@@ -1743,6 +1743,7 @@ checksums:
|
||||
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
|
||||
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
|
||||
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
|
||||
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
|
||||
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
|
||||
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
|
||||
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
|
||||
@@ -3529,6 +3530,12 @@ checksums:
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
|
||||
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
|
||||
workspace/unify/error_connector_name_duplicate: 56a1a05212e87dff21261d1db90fdb11
|
||||
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
|
||||
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
|
||||
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
|
||||
@@ -3576,6 +3583,7 @@ checksums:
|
||||
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
|
||||
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
|
||||
@@ -386,11 +386,12 @@ describe("createConnectorWithMappings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "name"] },
|
||||
})
|
||||
);
|
||||
|
||||
@@ -400,7 +401,43 @@ describe("createConnectorWithMappings", () => {
|
||||
type: "formbricks_survey",
|
||||
feedbackDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
).rejects.toThrow(new InvalidInputError("CONNECTOR_NAME_DUPLICATE"));
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup mapping",
|
||||
type: "formbricks_survey",
|
||||
feedbackDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE"));
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup field mapping",
|
||||
type: "csv",
|
||||
feedbackDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE"));
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
@@ -526,6 +563,48 @@ describe("updateConnectorWithMappings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "name"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Dup" })).rejects.toThrow(
|
||||
new InvalidInputError("CONNECTOR_NAME_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on formbricks mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
|
||||
new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
|
||||
new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
|
||||
@@ -212,6 +212,18 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
return new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE");
|
||||
}
|
||||
if (targetFields.includes("sourceFieldId") || targetFields.includes("targetFieldId")) {
|
||||
return new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE");
|
||||
}
|
||||
return new InvalidInputError("CONNECTOR_NAME_DUPLICATE");
|
||||
};
|
||||
|
||||
export type TFormbricksMappingsInput = {
|
||||
type: "formbricks_survey";
|
||||
mappings: TConnectorFormbricksMappingCreateInput[];
|
||||
@@ -284,7 +296,7 @@ export const createConnectorWithMappings = async (
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
|
||||
throw mapUniqueConstraintError(error);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
@@ -359,6 +371,9 @@ export const updateConnectorWithMappings = async (
|
||||
return mapConnectorWithMappings(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw mapUniqueConstraintError(error);
|
||||
}
|
||||
if (error.code === PrismaErrorType.RecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("Connector", connectorId);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ const mockSurvey = {
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockTenantId = "cmp2f6428000504la7iyh87h1";
|
||||
|
||||
const mockResponse = {
|
||||
id: "resp-1",
|
||||
createdAt: NOW,
|
||||
@@ -84,13 +86,18 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
|
||||
test("returns empty array when response has no data", () => {
|
||||
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
|
||||
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
|
||||
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings, mockTenantId);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array when no mappings match the survey", () => {
|
||||
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
|
||||
const result = transformResponseToFeedbackRecords(
|
||||
mockResponse,
|
||||
mockSurvey,
|
||||
otherSurveyMappings,
|
||||
mockTenantId
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -100,7 +107,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-text": "" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -113,14 +120,14 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text" }),
|
||||
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-nps");
|
||||
});
|
||||
|
||||
test("transforms text field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
source_type: "formbricks_survey",
|
||||
@@ -137,7 +144,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
|
||||
test("transforms nps field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_number).toBe(9);
|
||||
expect(result[0].field_type).toBe("nps");
|
||||
@@ -145,28 +152,28 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
|
||||
test("transforms rating field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_number).toBe(4);
|
||||
});
|
||||
|
||||
test("transforms date field to ISO string", () => {
|
||||
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
|
||||
});
|
||||
|
||||
test("transforms boolean field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
test("transforms categorical (multi-select) field to comma-separated text", () => {
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_text).toBe("feat-a, feat-b");
|
||||
});
|
||||
@@ -175,13 +182,13 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
const mappings = [
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].field_label).toBe("Custom Label");
|
||||
});
|
||||
|
||||
test("sets collected_at from response createdAt", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].collected_at).toBe(NOW.toISOString());
|
||||
});
|
||||
|
||||
@@ -189,7 +196,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
|
||||
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].collected_at).toBe(updatedAt.toISOString());
|
||||
});
|
||||
|
||||
@@ -199,7 +206,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
createdAt: "2026-02-26T10:00:00.000Z",
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
|
||||
});
|
||||
|
||||
@@ -209,28 +216,22 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
expect(result[0].tenant_id).toBe("tenant-abc");
|
||||
});
|
||||
|
||||
test("omits tenant_id when not provided", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result[0].tenant_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("omits language when response language is 'default'", () => {
|
||||
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].language).toBeUndefined();
|
||||
});
|
||||
|
||||
test("omits user_id when contact has no userId", () => {
|
||||
const response = { ...mockResponse, contact: null } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].user_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("transforms all mappings in a single call", () => {
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings, mockTenantId);
|
||||
expect(result).toHaveLength(6);
|
||||
const fieldIds = result.map((r) => r.field_id);
|
||||
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
|
||||
@@ -246,7 +247,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-bare": "some text" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, survey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId);
|
||||
expect(result[0].field_label).toBe("Untitled");
|
||||
});
|
||||
|
||||
@@ -257,7 +258,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-nps": "7" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_number).toBe(7);
|
||||
});
|
||||
|
||||
@@ -267,7 +268,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-nps": "not-a-number" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -277,7 +278,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-text": { nested: "value" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
|
||||
});
|
||||
|
||||
@@ -287,7 +288,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-date": "not-a-date" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_date).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -297,7 +298,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-bool": "1" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
@@ -307,7 +308,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-bool": "false" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_boolean).toBe(false);
|
||||
});
|
||||
|
||||
@@ -317,7 +318,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-text": ["a", "b", "c"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe("a, b, c");
|
||||
});
|
||||
|
||||
@@ -327,8 +328,266 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-multi": "single-choice" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe("single-choice");
|
||||
});
|
||||
|
||||
test("JSON-stringifies object value for categorical field (matrix/ranking responses)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": { row1: "col1", row2: "col2" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1", row2: "col2" }));
|
||||
expect(result[0].value_text).not.toBe("[object Object]");
|
||||
});
|
||||
|
||||
test("creates a record for a ranking response (string array)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": ["LabelA", "LabelB", "LabelC"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-multi");
|
||||
expect(result[0].value_text).toBe("LabelA, LabelB, LabelC");
|
||||
});
|
||||
|
||||
test("creates a record for an empty ranking response (empty array)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": [] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_text).toBe("");
|
||||
});
|
||||
|
||||
test("JSON-stringifies object value for unknown hubFieldType (default branch)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": { row1: "col1" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [
|
||||
createMapping({
|
||||
elementId: "el-multi",
|
||||
hubFieldType: "unknown-type" as TConnectorFormbricksMapping["hubFieldType"],
|
||||
}),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1" }));
|
||||
expect(result[0].value_text).not.toBe("[object Object]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix expansion", () => {
|
||||
const matrixSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Matrix Survey",
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-matrix",
|
||||
type: "matrix",
|
||||
headline: { default: "Rate each feature" },
|
||||
rows: [
|
||||
{ id: "row-1", label: { default: "Speed" } },
|
||||
{ id: "row-2", label: { default: "Quality" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: { default: "Good" } },
|
||||
{ id: "col-2", label: { default: "Bad" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
test("emits one record per answered row with shared field_group_id", () => {
|
||||
const response = {
|
||||
id: "resp-matrix",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { Speed: "Good", Quality: "Bad" } },
|
||||
language: "default",
|
||||
contact: { userId: "user-42" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.field_group_id === "el-matrix")).toBe(true);
|
||||
expect(result.every((r) => r.field_group_label === "Rate each feature")).toBe(true);
|
||||
expect(result.every((r) => r.submission_id === "resp-matrix")).toBe(true);
|
||||
expect(result.every((r) => r.metadata?.question_type === "matrix")).toBe(true);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
field_id: "el-matrix__row-1",
|
||||
field_label: "Speed",
|
||||
field_type: "categorical",
|
||||
value_text: "Good",
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
field_id: "el-matrix__row-2",
|
||||
field_label: "Quality",
|
||||
value_text: "Bad",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips matrix rows with empty cell value", () => {
|
||||
const response = {
|
||||
id: "resp-matrix-partial",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { Speed: "Good", Quality: "" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-matrix__row-1");
|
||||
});
|
||||
|
||||
test("skips matrix rows whose label does not match any row choice", () => {
|
||||
const response = {
|
||||
id: "resp-matrix-stale",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { "Old Row Label": "Good", Quality: "Bad" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-matrix__row-2");
|
||||
});
|
||||
|
||||
test("JSON-stringifies non-string matrix cell value (regression for ENG-891)", () => {
|
||||
const cellObject = { a: 1 };
|
||||
const response = {
|
||||
id: "resp-matrix-obj",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { Speed: cellObject } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
field_id: "el-matrix__row-1",
|
||||
field_label: "Speed",
|
||||
field_group_id: "el-matrix",
|
||||
field_group_label: "Rate each feature",
|
||||
metadata: { question_type: "matrix" },
|
||||
value_text: JSON.stringify(cellObject),
|
||||
});
|
||||
expect(result[0].value_text).not.toBe("[object Object]");
|
||||
});
|
||||
|
||||
test("emits no records for empty matrix response", () => {
|
||||
const response = {
|
||||
id: "resp-empty",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": {} },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ranking expansion", () => {
|
||||
const rankingSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Ranking Survey",
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-ranking",
|
||||
type: "ranking",
|
||||
headline: { default: "Rank these features" },
|
||||
choices: [
|
||||
{ id: "ch-1", label: { default: "Reports" } },
|
||||
{ id: "ch-2", label: { default: "Dashboards" } },
|
||||
{ id: "ch-3", label: { default: "Alerts" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
test("emits one record per ranked item with rank as value_number", () => {
|
||||
const response = {
|
||||
id: "resp-ranking",
|
||||
createdAt: NOW,
|
||||
data: { "el-ranking": ["Dashboards", "Reports", "Alerts"] },
|
||||
language: "default",
|
||||
contact: { userId: "user-42" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.every((r) => r.field_group_id === "el-ranking")).toBe(true);
|
||||
expect(result.every((r) => r.field_group_label === "Rank these features")).toBe(true);
|
||||
expect(result.every((r) => r.field_type === "number")).toBe(true);
|
||||
expect(result.every((r) => r.metadata?.question_type === "ranking")).toBe(true);
|
||||
expect(result.every((r) => r.metadata?.total_items === 3)).toBe(true);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
field_id: "el-ranking__ch-2",
|
||||
field_label: "Dashboards",
|
||||
value_number: 1,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
field_id: "el-ranking__ch-1",
|
||||
field_label: "Reports",
|
||||
value_number: 2,
|
||||
});
|
||||
expect(result[2]).toMatchObject({
|
||||
field_id: "el-ranking__ch-3",
|
||||
field_label: "Alerts",
|
||||
value_number: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits no records for empty ranking response", () => {
|
||||
const response = {
|
||||
id: "resp-empty",
|
||||
createdAt: NOW,
|
||||
data: { "el-ranking": [] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips ranking items whose label does not match any choice", () => {
|
||||
const response = {
|
||||
id: "resp-ranking-stale",
|
||||
createdAt: NOW,
|
||||
data: { "el-ranking": ["Reports", "Removed Option"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-ranking__ch-1");
|
||||
expect(result[0].value_number).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
|
||||
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import type {
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyMatrixElement,
|
||||
TSurveyMatrixElementChoice,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import type { 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";
|
||||
@@ -14,6 +21,18 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
|
||||
return getTextContent(raw) || "Untitled";
|
||||
};
|
||||
|
||||
const getChoiceLabel = (choice: { label: TSurveyElementChoice["label"] }, language: string): string => {
|
||||
return getTextContent(getLocalizedValue(choice.label, language));
|
||||
};
|
||||
|
||||
const findChoiceByLabel = <T extends { id: string; label: TSurveyElementChoice["label"] }>(
|
||||
choices: T[],
|
||||
label: string,
|
||||
language: string
|
||||
): T | undefined => {
|
||||
return choices.find((choice) => getChoiceLabel(choice, language) === label);
|
||||
};
|
||||
|
||||
const toIsoTimestamp = (value: unknown): string | null => {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
@@ -83,16 +102,114 @@ const convertValueToHubFields = (
|
||||
case "categorical":
|
||||
if (typeof value === "string") return { value_text: value };
|
||||
if (Array.isArray(value)) return { value_text: value.join(", ") };
|
||||
if (typeof value === "object") return { value_text: JSON.stringify(value) };
|
||||
return { value_text: String(value) };
|
||||
|
||||
default:
|
||||
return { value_text: typeof value === "string" ? value : String(value) };
|
||||
if (typeof value === "string") return { value_text: value };
|
||||
if (Array.isArray(value)) return { value_text: value.join(", ") };
|
||||
if (typeof value === "object") return { value_text: JSON.stringify(value) };
|
||||
return { value_text: String(value) };
|
||||
}
|
||||
};
|
||||
|
||||
type BaseRecordFields = Pick<
|
||||
FeedbackRecordCreateParams,
|
||||
"collected_at" | "source_type" | "submission_id" | "tenant_id" | "source_id" | "source_name"
|
||||
> & {
|
||||
language?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
const buildBaseFields = (
|
||||
response: TResponse,
|
||||
survey: Pick<TSurvey, "id" | "name">,
|
||||
tenantId: string
|
||||
): BaseRecordFields => ({
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
source_id: survey.id,
|
||||
source_name: survey.name,
|
||||
...(response.language && response.language !== "default" ? { language: response.language } : {}),
|
||||
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
|
||||
});
|
||||
|
||||
const expandMatrixToRecords = (
|
||||
element: TSurveyMatrixElement,
|
||||
mapping: TConnectorFormbricksMapping,
|
||||
value: TResponseDataValue,
|
||||
baseFields: BaseRecordFields
|
||||
): FeedbackRecordCreateParams[] => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
|
||||
const language = baseFields.language ?? "default";
|
||||
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
for (const [rowLabel, columnLabel] of Object.entries(value)) {
|
||||
if (columnLabel === undefined || columnLabel === null || columnLabel === "") continue;
|
||||
|
||||
const row = findChoiceByLabel<TSurveyMatrixElementChoice>(element.rows, rowLabel, language);
|
||||
if (!row) continue;
|
||||
|
||||
const valueFields = convertValueToHubFields(columnLabel as TResponseDataValue, mapping.hubFieldType);
|
||||
|
||||
records.push({
|
||||
...baseFields,
|
||||
field_id: `${element.id}__${row.id}`,
|
||||
field_type: mapping.hubFieldType,
|
||||
field_label: getChoiceLabel(row, "default"),
|
||||
field_group_id: element.id,
|
||||
field_group_label: groupLabel,
|
||||
metadata: { question_type: "matrix" },
|
||||
...valueFields,
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
const expandRankingToRecords = (
|
||||
element: TSurveyRankingElement,
|
||||
mapping: TConnectorFormbricksMapping,
|
||||
value: TResponseDataValue,
|
||||
baseFields: BaseRecordFields
|
||||
): FeedbackRecordCreateParams[] => {
|
||||
if (!Array.isArray(value) || value.length === 0) return [];
|
||||
|
||||
const language = baseFields.language ?? "default";
|
||||
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
value.forEach((itemLabel, index) => {
|
||||
if (typeof itemLabel !== "string" || itemLabel === "") return;
|
||||
|
||||
const choice = findChoiceByLabel<TSurveyElementChoice>(element.choices, itemLabel, language);
|
||||
if (!choice) return;
|
||||
|
||||
records.push({
|
||||
...baseFields,
|
||||
field_id: `${element.id}__${choice.id}`,
|
||||
field_type: "number",
|
||||
field_label: getChoiceLabel(choice, "default"),
|
||||
field_group_id: element.id,
|
||||
field_group_label: groupLabel,
|
||||
metadata: { question_type: "ranking", total_items: value.length },
|
||||
value_number: index + 1,
|
||||
});
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a Formbricks survey response into FeedbackRecord payloads.
|
||||
* Called from the pipeline handler when a response is created/finished.
|
||||
*
|
||||
* Matrix and ranking questions expand into one record per row/item, sharing a
|
||||
* field_group_id so Hub analytics can aggregate across them.
|
||||
*/
|
||||
export function transformResponseToFeedbackRecords(
|
||||
response: TResponse,
|
||||
@@ -106,31 +223,35 @@ export function transformResponseToFeedbackRecords(
|
||||
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 baseFields = buildBaseFields(response, survey, tenantId);
|
||||
const feedbackRecords: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
for (const mapping of surveyMappings) {
|
||||
const value = extractResponseValue(responseData, mapping.elementId);
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
|
||||
const element = elementMap.get(mapping.elementId);
|
||||
|
||||
if (element?.type === TSurveyElementTypeEnum.Matrix) {
|
||||
feedbackRecords.push(...expandMatrixToRecords(element, mapping, value, baseFields));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element?.type === TSurveyElementTypeEnum.Ranking) {
|
||||
feedbackRecords.push(...expandRankingToRecords(element, mapping, value, baseFields));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
|
||||
|
||||
const feedbackRecord = {
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
feedbackRecords.push({
|
||||
...baseFields,
|
||||
field_id: mapping.elementId,
|
||||
field_type: mapping.hubFieldType,
|
||||
source_id: survey.id,
|
||||
source_name: survey.name,
|
||||
field_label: fieldLabel,
|
||||
...(response.language && response.language !== "default" ? { language: response.language } : {}),
|
||||
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
|
||||
...valueFields,
|
||||
};
|
||||
|
||||
feedbackRecords.push(feedbackRecord as FeedbackRecordCreateParams);
|
||||
});
|
||||
}
|
||||
|
||||
return feedbackRecords;
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} Diagramm(e) hinzufügen",
|
||||
"chart_removed": "Diagramm vom Dashboard entfernt",
|
||||
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
|
||||
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
|
||||
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
|
||||
"enter_value": "Wert eingeben...",
|
||||
"enum": "Aufzählung",
|
||||
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
|
||||
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
|
||||
"error_connector_name_duplicate": "Eine Quelle mit diesem Namen existiert bereits",
|
||||
"error_connector_name_required": "Quellenname ist erforderlich",
|
||||
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
|
||||
"error_connector_survey_required": "Wähle eine Umfrage aus",
|
||||
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
|
||||
"feedback_date": "Aktuelles Datum",
|
||||
"feedback_directory": "Feedback-Verzeichnis",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
"chart_removed": "Chart removed from dashboard",
|
||||
"charts_add_failed": "Failed to add charts to dashboard",
|
||||
"charts_add_partial_failure": "Failed to add {count} chart(s)",
|
||||
"charts_added_to_dashboard": "Charts added to dashboard",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"enter_value": "Enter value...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
|
||||
"error_connector_name_duplicate": "A source with this name already exists",
|
||||
"error_connector_name_required": "Source name is required",
|
||||
"error_connector_questions_required": "Select at least one question",
|
||||
"error_connector_survey_required": "Select a survey",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_directory": "Feedback Directory",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Añadir {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico eliminado del panel",
|
||||
"charts_add_failed": "Error al añadir gráficos al panel",
|
||||
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos añadidos al panel",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"enter_value": "Introduce un valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
|
||||
"error_connector_name_duplicate": "Ya existe una fuente con este nombre",
|
||||
"error_connector_name_required": "El nombre de origen es obligatorio",
|
||||
"error_connector_questions_required": "Selecciona al menos una pregunta",
|
||||
"error_connector_survey_required": "Selecciona una encuesta",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_directory": "Directorio de feedback",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Ajouter {count} graphique(s)",
|
||||
"chart_removed": "Graphique retiré du tableau de bord",
|
||||
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
|
||||
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
|
||||
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"enter_value": "Saisir une valeur...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
|
||||
"error_connector_name_duplicate": "Une source avec ce nom existe déjà",
|
||||
"error_connector_name_required": "Le nom de la source est requis",
|
||||
"error_connector_questions_required": "Sélectionnez au moins une question",
|
||||
"error_connector_survey_required": "Sélectionnez une enquête",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_directory": "Répertoire de retours",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} diagram hozzáadása",
|
||||
"chart_removed": "A diagram eltávolítva a műszerfalról",
|
||||
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
|
||||
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
|
||||
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"enter_value": "Érték megadása...",
|
||||
"enum": "felsorolás",
|
||||
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
|
||||
"error_connector_name_duplicate": "Már létezik forrás ezzel a névvel",
|
||||
"error_connector_name_required": "A forrás neve kötelező",
|
||||
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
|
||||
"error_connector_survey_required": "Válasszon ki egy felmérést",
|
||||
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
|
||||
"feedback_date": "Aktuális dátum",
|
||||
"feedback_directory": "Visszajelzési könyvtár",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count}個のグラフを追加",
|
||||
"chart_removed": "チャートがダッシュボードから削除されました",
|
||||
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
|
||||
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
|
||||
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"enter_value": "値を入力...",
|
||||
"enum": "列挙型",
|
||||
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
|
||||
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
|
||||
"error_connector_name_duplicate": "この名前のソースは既に存在します",
|
||||
"error_connector_name_required": "ソース名は必須です",
|
||||
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
|
||||
"error_connector_survey_required": "アンケートを選択してください",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_directory": "フィードバックディレクトリ",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafiek(en) toevoegen",
|
||||
"chart_removed": "Grafiek verwijderd van dashboard",
|
||||
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
|
||||
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
|
||||
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"enter_value": "Voer waarde in...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
|
||||
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
|
||||
"error_connector_name_duplicate": "Er bestaat al een bron met deze naam",
|
||||
"error_connector_name_required": "Bronnaam is verplicht",
|
||||
"error_connector_questions_required": "Selecteer minimaal één vraag",
|
||||
"error_connector_survey_required": "Selecteer een enquête",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_directory": "Feedbackmap",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico removido do painel",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"enter_value": "Digite o valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
"error_connector_name_duplicate": "Uma fonte com este nome já existe",
|
||||
"error_connector_name_required": "O nome da fonte é obrigatório",
|
||||
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Selecione uma pesquisa",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_directory": "Diretório de Feedback",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico removido do painel",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"enter_value": "Introduzir valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
"error_connector_name_duplicate": "Já existe uma origem com este nome",
|
||||
"error_connector_name_required": "O nome da origem é obrigatório",
|
||||
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Seleciona um inquérito",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_directory": "Diretório de Feedback",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adaugă {count} grafic(e)",
|
||||
"chart_removed": "Graficul a fost eliminat din tabloul de bord",
|
||||
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
|
||||
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
|
||||
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"enter_value": "Introdu valoarea...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
|
||||
"error_connector_name_duplicate": "Există deja o sursă cu acest nume",
|
||||
"error_connector_name_required": "Numele sursei este obligatoriu",
|
||||
"error_connector_questions_required": "Selectează cel puțin o întrebare",
|
||||
"error_connector_survey_required": "Selectează un sondaj",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_directory": "Director de Feedback",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Добавить {count} график(ов)",
|
||||
"chart_removed": "График удалён с панели",
|
||||
"charts_add_failed": "Не удалось добавить графики на дашборд",
|
||||
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
|
||||
"charts_added_to_dashboard": "Графики добавлены на дашборд",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"enter_value": "Введите значение...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
|
||||
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
|
||||
"error_connector_name_duplicate": "Источник с таким именем уже существует",
|
||||
"error_connector_name_required": "Необходимо указать название источника",
|
||||
"error_connector_questions_required": "Выберите хотя бы один вопрос",
|
||||
"error_connector_survey_required": "Выберите опрос",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_directory": "Директория обратной связи",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Lägg till {count} diagram",
|
||||
"chart_removed": "Diagram borttaget från instrumentpanelen",
|
||||
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
|
||||
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
|
||||
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"enter_value": "Ange värde...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
|
||||
"error_connector_name_duplicate": "En källa med det här namnet finns redan",
|
||||
"error_connector_name_required": "Källnamn krävs",
|
||||
"error_connector_questions_required": "Välj minst en fråga",
|
||||
"error_connector_survey_required": "Välj en undersökning",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_directory": "Feedback-katalog",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafik ekle",
|
||||
"chart_removed": "Grafik gösterge panosundan kaldırıldı",
|
||||
"charts_add_failed": "Grafikler panoya eklenemedi",
|
||||
"charts_add_partial_failure": "{count} grafik eklenemedi",
|
||||
"charts_added_to_dashboard": "Grafikler panoya eklendi",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "Bu kaynak için bir ad girin",
|
||||
"enter_value": "Değer girin...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
|
||||
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
|
||||
"error_connector_name_duplicate": "Bu isimde bir kaynak zaten mevcut",
|
||||
"error_connector_name_required": "Kaynak adı gereklidir",
|
||||
"error_connector_questions_required": "En az bir soru seçin",
|
||||
"error_connector_survey_required": "Bir anket seçin",
|
||||
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
|
||||
"feedback_date": "Geçerli tarih",
|
||||
"feedback_directory": "Geri Bildirim Dizini",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "添加 {count} 个图表",
|
||||
"chart_removed": "图表已从仪表板中移除",
|
||||
"charts_add_failed": "添加图表到仪表板失败",
|
||||
"charts_add_partial_failure": "添加 {count} 个图表失败",
|
||||
"charts_added_to_dashboard": "图表已添加到仪表板",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"enter_value": "请输入值...",
|
||||
"enum": "枚举",
|
||||
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
|
||||
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
|
||||
"error_connector_name_duplicate": "该名称的数据源已存在",
|
||||
"error_connector_name_required": "数据源名称为必填项",
|
||||
"error_connector_questions_required": "请至少选择一个问题",
|
||||
"error_connector_survey_required": "请选择一个调查问卷",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_directory": "反馈目录",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "新增 {count} 個圖表",
|
||||
"chart_removed": "圖表已從儀表板移除",
|
||||
"charts_add_failed": "無法將圖表新增至儀表板",
|
||||
"charts_add_partial_failure": "無法新增 {count} 個圖表",
|
||||
"charts_added_to_dashboard": "圖表已新增至儀表板",
|
||||
@@ -3689,6 +3690,12 @@
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"enter_value": "請輸入值……",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
|
||||
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
|
||||
"error_connector_name_duplicate": "已存在使用此名稱的來源",
|
||||
"error_connector_name_required": "來源名稱為必填項目",
|
||||
"error_connector_questions_required": "請至少選擇一個問題",
|
||||
"error_connector_survey_required": "請選擇一個調查問卷",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_directory": "意見回饋目錄",
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onSaveClick?: () => void;
|
||||
formId?: string;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
@@ -15,6 +16,7 @@ interface ChartDialogFooterProps {
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
formId,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
@@ -24,12 +26,16 @@ export function ChartDialogFooter({
|
||||
return (
|
||||
<DialogFooter>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<Button variant="outline" type="button" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<Button
|
||||
type={formId ? "submit" : "button"}
|
||||
form={formId}
|
||||
onClick={formId ? undefined : onSaveClick}
|
||||
disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesia
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_MEASURE_COLORS,
|
||||
formatCellValue,
|
||||
formatXAxisTick,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
@@ -20,6 +21,19 @@ const formatPieLabel = ({ name, percent }: { name: string; percent?: number }):
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
const PieTooltipRow = ({ value, name }: Readonly<{ value: unknown; name: string }>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{formatCellValue(value)} {formatCubeColumnHeader(name, t)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const pieTooltipFormatter = (value: unknown, name: string | number) => (
|
||||
<PieTooltipRow value={value} name={String(name)} />
|
||||
);
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
@@ -165,13 +179,7 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent formatter={pieTooltipFormatter} />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
@@ -79,6 +80,8 @@ export function CreateChartView({
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const CREATE_CHART_FORM_ID = "create-chart-form";
|
||||
const [chartNameError, setChartNameError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData) {
|
||||
@@ -136,17 +139,38 @@ export function CreateChartView({
|
||||
<div className="grid gap-4">
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<form
|
||||
id={CREATE_CHART_FORM_ID}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setChartNameError(null);
|
||||
return handleSaveChart();
|
||||
}}
|
||||
className="space-y-2">
|
||||
<Label htmlFor="create-chart-name" className={cn(chartNameError && "text-red-500")}>
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
if (chartNameError) setChartNameError(null);
|
||||
setChartName(event.target.value);
|
||||
}}
|
||||
onInvalid={(event) => {
|
||||
// Suppress the browser tooltip and render our inline message instead.
|
||||
event.preventDefault();
|
||||
event.currentTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
event.currentTarget.focus();
|
||||
setChartNameError(t("workspace.analysis.charts.please_enter_chart_name"));
|
||||
}}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
isInvalid={!!chartNameError}
|
||||
/>
|
||||
</div>
|
||||
{chartNameError && <p className="text-sm text-red-500">{chartNameError}</p>}
|
||||
</form>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
@@ -212,7 +236,7 @@ export function CreateChartView({
|
||||
|
||||
{chartData && (
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
formId={CREATE_CHART_FORM_ID}
|
||||
isSaving={isSaving}
|
||||
showAddToDashboard={false}
|
||||
saveLabel={
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
duplicateDashboard,
|
||||
getDashboard,
|
||||
getDashboards,
|
||||
removeWidgetFromDashboard,
|
||||
updateDashboard,
|
||||
updateWidgetLayouts,
|
||||
} from "./lib/dashboards";
|
||||
@@ -111,15 +112,13 @@ export const updateDashboardAction = authenticatedActionClient.inputSchema(ZUpda
|
||||
const ZUpdateWidgetLayoutsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
widgets: z.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const updateWidgetLayoutsAction = authenticatedActionClient
|
||||
@@ -325,3 +324,34 @@ export const addChartToDashboardAction = authenticatedActionClient
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZRemoveWidgetFromDashboardAction = z.object({
|
||||
workspaceId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgetId: ZId,
|
||||
});
|
||||
|
||||
export const removeWidgetFromDashboardAction = authenticatedActionClient
|
||||
.inputSchema(ZRemoveWidgetFromDashboardAction)
|
||||
.action(
|
||||
withAuditLogging("deleted", "dashboardWidget", async ({ ctx, parsedInput }) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
|
||||
const widget = await removeWidgetFromDashboard(
|
||||
parsedInput.dashboardId,
|
||||
workspaceId,
|
||||
parsedInput.widgetId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
ctx.auditLoggingCtx.oldObject = widget;
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function AddExistingChartsDialog({
|
||||
<DialogTitle>{t("common.add_charts")}</DialogTitle>
|
||||
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<DialogBody className="p-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center rounded-md border px-3 py-2">
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
|
||||
@@ -17,10 +17,15 @@ import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/das
|
||||
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
|
||||
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
|
||||
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
|
||||
import {
|
||||
removeWidgetFromDashboardAction,
|
||||
updateDashboardAction,
|
||||
updateWidgetLayoutsAction,
|
||||
} from "../actions";
|
||||
import type { TDashboardWidgetError } from "../lib/widget-errors";
|
||||
|
||||
const ROW_HEIGHT = 80;
|
||||
@@ -163,6 +168,8 @@ export function DashboardDetailClient({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
const [widgetIdToRemove, setWidgetIdToRemove] = useState<string | null>(null);
|
||||
const [isRemovingWidget, setIsRemovingWidget] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
@@ -207,17 +214,36 @@ export function DashboardDetailClient({
|
||||
|
||||
const handleRemoveWidgetFromMenu = useCallback(
|
||||
(widgetId: string) => {
|
||||
if (!isEditing) {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
setIsEditing(true);
|
||||
if (isEditing) {
|
||||
handleRemoveWidget(widgetId);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveWidget(widgetId);
|
||||
setWidgetIdToRemove(widgetId);
|
||||
},
|
||||
[dashboard.widgets, handleRemoveWidget, isEditing]
|
||||
[handleRemoveWidget, isEditing]
|
||||
);
|
||||
|
||||
const handleConfirmRemoveWidget = useCallback(async () => {
|
||||
if (!widgetIdToRemove) return;
|
||||
setIsRemovingWidget(true);
|
||||
try {
|
||||
const result = await removeWidgetFromDashboardAction({
|
||||
workspaceId,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId: widgetIdToRemove,
|
||||
});
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.analysis.dashboards.chart_removed"));
|
||||
setWidgetIdToRemove(null);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setIsRemovingWidget(false);
|
||||
}
|
||||
}, [widgetIdToRemove, workspaceId, dashboard.id, router, t, startTransition]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
@@ -373,6 +399,17 @@ export function DashboardDetailClient({
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
/>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<DeleteDialog
|
||||
open={widgetIdToRemove !== null}
|
||||
setOpen={(open) => {
|
||||
if (!open) setWidgetIdToRemove(null);
|
||||
}}
|
||||
deleteWhat={t("common.chart")}
|
||||
onDelete={handleConfirmRemoveWidget}
|
||||
isDeleting={isRemovingWidget}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
|
||||
var mockTxWidget: {
|
||||
// NOSONAR / test code
|
||||
aggregate: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -29,9 +31,11 @@ vi.mock("@formbricks/database", () => {
|
||||
const txChart = { findFirst: vi.fn() };
|
||||
const txWidget = {
|
||||
aggregate: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
mockTxDashboard = txDash;
|
||||
@@ -44,6 +48,7 @@ vi.mock("@formbricks/database", () => {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
dashboardWidget: txWidget,
|
||||
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
|
||||
},
|
||||
};
|
||||
@@ -672,4 +677,52 @@ describe("Dashboard Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeWidgetFromDashboard", () => {
|
||||
const mockWidgetId = "widget-abc-123";
|
||||
|
||||
test("deletes a widget that belongs to the dashboard", async () => {
|
||||
const mockWidget = { id: mockWidgetId, dashboardId: mockDashboardId, chartId: mockChartId };
|
||||
mockTxWidget.findFirst.mockResolvedValue(mockWidget);
|
||||
mockTxWidget.delete.mockResolvedValue(mockWidget);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId);
|
||||
|
||||
expect(result).toEqual(mockWidget);
|
||||
expect(mockTxWidget.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockWidgetId, dashboard: { id: mockDashboardId, workspaceId: mockWorkspaceId } },
|
||||
});
|
||||
expect(mockTxWidget.delete).toHaveBeenCalledWith({ where: { id: mockWidgetId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when the widget is not on the dashboard", async () => {
|
||||
mockTxWidget.findFirst.mockResolvedValue(null);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
|
||||
).rejects.toMatchObject({ name: "ResourceNotFoundError", resourceType: "DashboardWidget" });
|
||||
expect(mockTxWidget.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("wraps Prisma errors in DatabaseError", async () => {
|
||||
mockTxWidget.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
|
||||
).rejects.toMatchObject({ name: "DatabaseError" });
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const error = new Error("boom");
|
||||
mockTxWidget.findFirst.mockRejectedValue(error);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)).rejects.toBe(
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +301,31 @@ export const updateWidgetLayouts = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const removeWidgetFromDashboard = async (
|
||||
dashboardId: string,
|
||||
workspaceId: string,
|
||||
widgetId: string
|
||||
) => {
|
||||
validateInputs([dashboardId, ZId], [workspaceId, ZId], [widgetId, ZId]);
|
||||
|
||||
try {
|
||||
const widget = await prisma.dashboardWidget.findFirst({
|
||||
where: { id: widgetId, dashboard: { id: dashboardId, workspaceId } },
|
||||
});
|
||||
|
||||
if (!widget) {
|
||||
throw new ResourceNotFoundError("DashboardWidget", widgetId);
|
||||
}
|
||||
|
||||
return await prisma.dashboardWidget.delete({ where: { id: widgetId } });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
validateInputs([data, ZAddWidgetInput]);
|
||||
|
||||
|
||||
@@ -64,10 +64,13 @@ export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
|
||||
timeDim.dateRange = config.timeDimension.dateRange;
|
||||
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const formatDate = (date: Date | string) => {
|
||||
// dateRange round-trips through JSON (saved chart → parseQueryToState), so the array
|
||||
// elements may already be ISO strings — coerce before formatting.
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||
@@ -137,7 +140,12 @@ export function parseQueryToState(query: TChartQuery): Partial<ChartBuilderState
|
||||
config.granularity = timeDim.granularity;
|
||||
}
|
||||
if (timeDim.dateRange) {
|
||||
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
|
||||
if (typeof timeDim.dateRange === "string") {
|
||||
config.dateRange = timeDim.dateRange;
|
||||
} else if (Array.isArray(timeDim.dateRange) && timeDim.dateRange.length === 2) {
|
||||
// Stored as [isoString, isoString]; lift back into Date objects for the date-picker UI.
|
||||
config.dateRange = [new Date(timeDim.dateRange[0]), new Date(timeDim.dateRange[1])];
|
||||
}
|
||||
}
|
||||
state.timeDimension = config;
|
||||
}
|
||||
|
||||
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId1,
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
|
||||
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows creation when workspace is only assigned to archived directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
|
||||
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
|
||||
if (count !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
|
||||
}
|
||||
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
|
||||
}
|
||||
|
||||
const directory = await prisma.feedbackDirectory.create({
|
||||
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
|
||||
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
|
||||
* assigning a workspace to multiple active directories, but the server must also
|
||||
* reject such payloads to keep this guarantee under direct API access.
|
||||
*
|
||||
* Pass `directoryId` when updating an existing directory to exclude it from the
|
||||
* conflict check. Omit it on create — every active directory is a conflict.
|
||||
*/
|
||||
const assertWorkspacesNotAssignedElsewhere = async (
|
||||
directoryId: string,
|
||||
directoryId: string | undefined,
|
||||
workspaceIds: string[]
|
||||
): Promise<void> => {
|
||||
if (workspaceIds.length === 0) return;
|
||||
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
|
||||
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
|
||||
where: {
|
||||
workspaceId: { in: workspaceIds },
|
||||
feedbackDirectoryId: { not: directoryId },
|
||||
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackDirectoryView } from "@/modules/ee/feedback-directory/components/feedback-directory-view";
|
||||
import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
@@ -16,9 +17,12 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
|
||||
|
||||
const isFeedbackDirectoriesAllowed = await getIsFeedbackDirectoriesEnabled(organization.id);
|
||||
const pageTitle = t("workspace.settings.feedback_directories.title");
|
||||
|
||||
if (!isFeedbackDirectoriesAllowed) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<div className="flex items-center justify-center">
|
||||
<UpgradePrompt
|
||||
title={t("workspace.settings.feedback_directories.upgrade_prompt_title")}
|
||||
@@ -47,6 +51,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
if (!isOwner && !isManager) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<p className="text-sm text-slate-500">{t("workspace.settings.feedback_directories.no_access")}</p>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
@@ -54,6 +59,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<FeedbackDirectoryView organizationId={organization.id} membershipRole={currentUserMembership.role} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
import { CsvImportModal } from "./csv-import-modal";
|
||||
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
@@ -35,6 +36,7 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
isReadOnly,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -78,7 +80,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ export function ConnectorsSection({
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
}): Promise<boolean> => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
@@ -111,19 +113,20 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +158,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,11 +173,15 @@ export function ConnectorsSection({
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
buttonInfo={
|
||||
isReadOnly
|
||||
? undefined
|
||||
: {
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}
|
||||
}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -183,15 +190,18 @@ export function ConnectorsSection({
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
{!isReadOnly && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
@@ -208,6 +218,7 @@ export function ConnectorsSection({
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
isReadOnly={isReadOnly}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
|
||||
+15
-11
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
|
||||
title={t(getConnectorTypeLabelKey(connector.type))}>
|
||||
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="col-span-4 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<div className="col-span-2 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
|
||||
<span className="truncate">{connector.creatorName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end pr-2">
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorsTableRowsContainer = ({
|
||||
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}: ConnectorsTableRowsContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
|
||||
onDuplicate={() => onDuplicate(connector)}
|
||||
onToggleStatus={() => onToggleStatus(connector)}
|
||||
onDelete={() => onDelete(connector.id)}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsTable({
|
||||
@@ -23,6 +24,7 @@ export function ConnectorsTable({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
isReadOnly = false,
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -30,8 +32,8 @@ export function ConnectorsTable({
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-4">{t("common.name")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
@@ -48,6 +50,7 @@ export function ConnectorsTable({
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
@@ -339,7 +340,12 @@ export const CreateConnectorModal = ({
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
if (connectorId && values.importHistorical) {
|
||||
if (!connectorId) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.importHistorical) {
|
||||
await handleHistoricalImport(connectorId, values.surveyId);
|
||||
}
|
||||
|
||||
@@ -368,7 +374,12 @@ export const CreateConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
if (!connectorId) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
@@ -426,11 +437,13 @@ export const CreateConnectorModal = ({
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -440,7 +453,9 @@ export const CreateConnectorModal = ({
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -450,7 +465,7 @@ export const CreateConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -467,7 +482,9 @@ export const CreateConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -475,7 +492,7 @@ export const CreateConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -487,7 +504,9 @@ export const CreateConnectorModal = ({
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
}) => Promise<boolean>;
|
||||
surveys: TUnifySurvey[];
|
||||
onOpenCsvImport?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
onOpenCsvImport,
|
||||
isReadOnly = false,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
|
||||
<div className="space-y-4 py-4">
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<fieldset
|
||||
disabled={isReadOnly}
|
||||
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
|
||||
isReadOnly ? "opacity-70" : ""
|
||||
}`}>
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
{isReadOnly ? (
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
const {
|
||||
isOwner,
|
||||
isManager,
|
||||
hasReadAccess,
|
||||
hasReadWriteAccess,
|
||||
hasManageAccess,
|
||||
isReadOnly,
|
||||
session,
|
||||
organization,
|
||||
} = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
|
||||
export const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
|
||||
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
|
||||
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
|
||||
switch (errorCode) {
|
||||
case "CONNECTOR_NAME_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_name_duplicate");
|
||||
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
|
||||
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_field_mapping_duplicate");
|
||||
case "CONNECTOR_NAME_REQUIRED":
|
||||
return t("workspace.unify.error_connector_name_required");
|
||||
case "CONNECTOR_SURVEY_REQUIRED":
|
||||
return t("workspace.unify.error_connector_survey_required");
|
||||
case "CONNECTOR_QUESTIONS_REQUIRED":
|
||||
return t("workspace.unify.error_connector_questions_required");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import {
|
||||
TEnvoyRequestAuthorizer,
|
||||
authenticateEnvoyRequest,
|
||||
buildStatusResponse,
|
||||
parseEnvoyRequestMetadata,
|
||||
} from "./shared";
|
||||
|
||||
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseEnvoyRequestMetadata(request);
|
||||
@@ -16,20 +10,12 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildStatusResponse(400, "Unsupported Envoy auth route");
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
return await authorizer.authorize({
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? "unknown",
|
||||
buildAllowResponse: buildEnvoyAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Envoy auth route",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,51 +1,11 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
|
||||
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
|
||||
|
||||
export type TEnvoyOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TEnvoyGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TEnvoyOriginalRequest;
|
||||
principal: TEnvoyAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<Response>;
|
||||
};
|
||||
|
||||
export const buildAllowResponse = (): Response =>
|
||||
export const buildEnvoyAllowResponse = (): Response =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -53,20 +13,12 @@ export const buildAllowResponse = (): Response =>
|
||||
},
|
||||
});
|
||||
|
||||
export const buildStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const parseEnvoyRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +29,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
|
||||
if (originalPathSegments.length === 0) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Missing original request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,69 +45,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid original request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateEnvoyRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler
|
||||
): Promise<TEnvoyAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import "server-only";
|
||||
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import { TGatewayRequestAuthorizer } from "./request";
|
||||
|
||||
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
|
||||
@@ -0,0 +1,152 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
export type TGatewayOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
|
||||
|
||||
export type TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
|
||||
gatewayToken?: TGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
principal: TGatewayAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<TGatewayAuthorizationDecision>;
|
||||
};
|
||||
|
||||
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
|
||||
|
||||
export const authenticateGatewayRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TGatewayTokenHandler
|
||||
): Promise<TGatewayAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authorizeGatewayRequest = async ({
|
||||
request,
|
||||
originalRequest,
|
||||
authorizers,
|
||||
requestId,
|
||||
buildAllowResponse,
|
||||
unsupportedRouteMessage,
|
||||
}: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
authorizers: TGatewayRequestAuthorizer[];
|
||||
requestId: string;
|
||||
buildAllowResponse: () => Response;
|
||||
unsupportedRouteMessage: string;
|
||||
}): Promise<Response> => {
|
||||
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildGatewayStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const authorizationDecision = await authorizer.authorize({
|
||||
request,
|
||||
originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
|
||||
};
|
||||
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
TEnvoyAuthenticatedPrincipal,
|
||||
TEnvoyRequestAuthorizer,
|
||||
buildAllowResponse,
|
||||
buildStatusResponse,
|
||||
} from "@/modules/envoy-auth/shared";
|
||||
TGatewayAuthenticatedPrincipal,
|
||||
TGatewayRequestAuthorizer,
|
||||
allowGatewayRequest,
|
||||
buildGatewayStatusResponse,
|
||||
} from "@/modules/gateway-auth/lib/request";
|
||||
import { getFeedbackRecordTenant } from "@/modules/hub/service";
|
||||
|
||||
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
|
||||
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
|
||||
return null;
|
||||
};
|
||||
|
||||
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
|
||||
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
|
||||
|
||||
const parseTenantId = (tenantId: string | null): string | null => {
|
||||
if (!tenantId) {
|
||||
@@ -200,7 +200,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned not found"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(403, "Forbidden"),
|
||||
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup failed"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +247,14 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned invalid tenant"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
return { tenantId };
|
||||
};
|
||||
|
||||
const authorizeGatewayRequest = async (
|
||||
const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
principal: TAuthenticatedGatewayPrincipal,
|
||||
feedbackDirectoryId: string,
|
||||
requiredPermission: TFeedbackRecordsGatewayPermission
|
||||
@@ -308,7 +308,7 @@ const authorizeGatewayRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
|
||||
gatewayToken: {
|
||||
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
|
||||
@@ -317,15 +317,21 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
authorize: async ({ request, originalRequest, principal, requestId }) => {
|
||||
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
|
||||
if (!route) {
|
||||
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
|
||||
};
|
||||
}
|
||||
|
||||
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
|
||||
if ("errorResponse" in tenantResolution) {
|
||||
return tenantResolution.errorResponse;
|
||||
return {
|
||||
status: "deny",
|
||||
response: tenantResolution.errorResponse,
|
||||
};
|
||||
}
|
||||
|
||||
const authorizationResult = await authorizeGatewayRequest(
|
||||
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
|
||||
principal,
|
||||
tenantResolution.tenantId,
|
||||
route.requiredPermission
|
||||
@@ -341,7 +347,10 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
},
|
||||
"Feedback records gateway authorization denied"
|
||||
);
|
||||
return buildStatusResponse(403, "Forbidden");
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -355,6 +364,6 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
"Feedback records gateway authorization allowed"
|
||||
);
|
||||
|
||||
return buildAllowResponse();
|
||||
return allowGatewayRequest();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authorizeTraefikRequest } from "./service";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetBearerTokenFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockVerifyFeedbackRecordsGatewayToken,
|
||||
mockGetFeedbackDirectoryAuthContext,
|
||||
mockGetFeedbackRecordTenant,
|
||||
mockCheckAuthorizationUpdated,
|
||||
mockUserFindUnique,
|
||||
mockGetIsUnifyFeedbackEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetBearerTokenFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
|
||||
mockGetFeedbackDirectoryAuthContext: vi.fn(),
|
||||
mockGetFeedbackRecordTenant: vi.fn(),
|
||||
mockCheckAuthorizationUpdated: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockGetIsUnifyFeedbackEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/jwt")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
|
||||
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const feedbackDirectoryId = "clxx1234567890123456789012";
|
||||
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
|
||||
|
||||
const createRequest = ({
|
||||
method = "GET",
|
||||
forwardedMethod = "GET",
|
||||
forwardedUri,
|
||||
headers = {},
|
||||
body,
|
||||
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
|
||||
}: {
|
||||
method?: string;
|
||||
forwardedMethod?: string;
|
||||
forwardedUri?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
adapterUrl?: string;
|
||||
} = {}) =>
|
||||
new NextRequest(adapterUrl, {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-method": forwardedMethod,
|
||||
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
|
||||
"x-forwarded-host": "app.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
describe("authorizeTraefikRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: ["workspace_1"],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: { tenantId: feedbackDirectoryId },
|
||||
error: null,
|
||||
});
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(true);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("allows requests using Traefik forwarded method and URI metadata", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
method: "POST",
|
||||
forwardedMethod: "POST",
|
||||
forwardedUri: "/api/v3/feedbackRecords",
|
||||
headers: {
|
||||
authorization: "Bearer fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
|
||||
});
|
||||
|
||||
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 400 when Traefik forwarded metadata is missing", async () => {
|
||||
const response = await authorizeTraefikRequest(
|
||||
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedMethod: "PATCH",
|
||||
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: "workspace_1",
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockGetProxySession.mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
cookie: "next-auth.session-token=still-present",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockGetProxySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseTraefikRequestMetadata(request);
|
||||
if ("errorResponse" in requestMetadata) {
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
|
||||
buildAllowResponse: buildTraefikAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Traefik auth route",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
|
||||
|
||||
const isTraefikAuthPath = (pathname: string): boolean =>
|
||||
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
|
||||
|
||||
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
|
||||
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
|
||||
return new URL(forwardedUri);
|
||||
}
|
||||
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https";
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
|
||||
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
|
||||
|
||||
return new URL(normalizedUri, `${proto}://${host}`);
|
||||
};
|
||||
|
||||
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
|
||||
|
||||
export const parseTraefikRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
|
||||
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
|
||||
|
||||
if (!forwardedMethod || !forwardedUri) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
originalRequest: {
|
||||
method: forwardedMethod.toUpperCase(),
|
||||
url: buildForwardedRequestUrl(request, forwardedUri),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const errorMessage = error?.message || error?.root?.message;
|
||||
const body = error ? String(errorMessage) : children;
|
||||
// Explicit children win — they're typically a translated/formatted version of the raw error.
|
||||
const body = children ?? (error ? String(errorMessage) : null);
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}>
|
||||
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
@@ -98,6 +122,8 @@ export {
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
|
||||
@@ -186,6 +186,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
|
||||
| hub.embeddings.enabled | bool | `false` | |
|
||||
| hub.embeddings.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
|
||||
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
|
||||
| hub.embeddings.huggingFace.token | string | `""` | |
|
||||
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
|
||||
|
||||
@@ -782,6 +782,7 @@ hub:
|
||||
# When empty, the chart renders TEI args from model, servedModelName, port,
|
||||
# revision, and persistence.mountPath. Set this to fully override args.
|
||||
args: []
|
||||
# Keeps the default GTE CPU runtime within the default memory budget.
|
||||
extraArgs:
|
||||
- --dtype
|
||||
- float16
|
||||
|
||||
@@ -37,3 +37,8 @@ The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses the same local Postgres database and `HUB_API_KEY` defaults to `dev-api-key`. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub` and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
|
||||
|
||||
In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled.
|
||||
|
||||
The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3
|
||||
path to Hub's `/v1/feedback-records`, injects `Authorization: Bearer ${HUB_API_KEY}` for Hub, and strips client
|
||||
API key/cookie headers before the Hub hop.
|
||||
|
||||
@@ -61,7 +61,7 @@ cube(`FeedbackRecords`, {
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
sql: `${CUBE}.metadata->>'sentiment'`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
@@ -97,9 +97,9 @@ cube(`FeedbackRecords`, {
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
sql: `submission_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
@@ -109,7 +109,7 @@ cube(`FeedbackRecords`, {
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
sql: `${CUBE}.metadata->>'emotion'`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
+65
-2
@@ -397,6 +397,12 @@ EOF
|
||||
print " - \"traefik.http.routers.formbricks.tls=true\""
|
||||
print " - \"traefik.http.routers.formbricks.tls.certresolver=default\""
|
||||
print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.priority=200\""
|
||||
if (hsts_enabled == "y") {
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsSeconds=31536000\""
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.forceSTSHeader=true\""
|
||||
@@ -405,6 +411,10 @@ EOF
|
||||
} else {
|
||||
print " - \"traefik.http.routers.formbricks_http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.formbricks_http.rule=Host(`" domain_name "`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.priority=200\""
|
||||
}
|
||||
print $0
|
||||
} else {
|
||||
@@ -413,6 +423,57 @@ EOF
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 1b: Add FeedbackRecords gateway labels to the Hub service.
|
||||
awk -v domain_name="$domain_name" -v hsts_enabled="$hsts_enabled" '
|
||||
BEGIN { in_hub = 0; inserted = 0 }
|
||||
/^ hub:/ { in_hub = 1 }
|
||||
in_hub && /^ [A-Za-z0-9_-]+:/ && !/^ hub:/ { in_hub = 0 }
|
||||
{
|
||||
if (in_hub && !inserted && $0 ~ /^ environment:/) {
|
||||
print " labels:"
|
||||
print " - \"traefik.enable=true\""
|
||||
print " - \"traefik.http.services.feedback-records-hub.loadbalancer.server.port=8080\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
if (hsts_enabled != "y") {
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
}
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.address=http://formbricks:3000/api/traefik-auth/feedback-records\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.forwardbody=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.maxbodysize=1048576\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.preserverequestmethod=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.regex=^/api/v3/feedbackRecords(.*)\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.replacement=/v1/feedback-records$${1}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Authorization=Bearer ${HUB_API_KEY}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.X-API-Key=\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Cookie=\""
|
||||
inserted = 1
|
||||
}
|
||||
print
|
||||
}
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
|
||||
@@ -511,11 +572,12 @@ EOF
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v2.11.31"
|
||||
image: "traefik:v3.6.4"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
- minio
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -540,11 +602,12 @@ EOF
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.11.31"
|
||||
image: "traefik:v3.6.4"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "API Gateway"
|
||||
description: "Gateway auth architecture for proxied service APIs"
|
||||
icon: "route"
|
||||
---
|
||||
|
||||
### Gateway Model
|
||||
|
||||
Formbricks gateway auth is split into three layers:
|
||||
|
||||
- A shared gateway-auth core authenticates the caller, normalizes the original request, and dispatches to a
|
||||
service authorizer.
|
||||
- Provider adapters translate ingress-specific auth requests into the shared shape. Envoy uses
|
||||
`/api/envoy-auth/[...path]`; Traefik uses `/api/traefik-auth/[...path]`.
|
||||
- Service authorizers own service-specific authorization. FeedbackRecords is the first registered service authorizer.
|
||||
|
||||
### Tokens
|
||||
|
||||
Session-authenticated browser callers should use `/api/v3/gateway/token` with
|
||||
`{ "service": "feedbackRecords" }`. The token identifies the user for the gateway only; every proxied request
|
||||
still runs through gateway authorization. `/api/v3/feedbackRecords/token` remains a compatibility alias.
|
||||
|
||||
### Provider Adapters
|
||||
|
||||
Envoy and Traefik do not send auth subrequests in the same format, so they stay as thin adapters. Envoy derives the
|
||||
original path from the auth request path. Traefik derives it from `X-Forwarded-Method` and `X-Forwarded-Uri`.
|
||||
Both adapters reuse the same gateway-auth core and FeedbackRecords authorizer.
|
||||
@@ -309,6 +309,7 @@
|
||||
"group": "Technical Handbook",
|
||||
"pages": [
|
||||
"development/technical-handbook/overview",
|
||||
"development/technical-handbook/api-gateway",
|
||||
"development/technical-handbook/background-job-processing",
|
||||
"development/technical-handbook/cube-tenant-isolation",
|
||||
"development/technical-handbook/database-model",
|
||||
|
||||
@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
<<: *environment
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.11.31"
|
||||
image: "traefik:v3.6.4"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
@@ -146,4 +146,4 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
This setup ensures that Formbricks securely communicates using your own SSL certificate. 🚀
|
||||
|
||||
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
|
||||
@@ -151,6 +151,13 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
If you use the one-click Traefik setup, FeedbackRecords are available on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Custom Docker reverse proxies need equivalent wiring:
|
||||
run gateway auth against the Formbricks app, rewrite `/api/v3/feedbackRecords` to Hub's
|
||||
`/v1/feedback-records`, and inject `Authorization: Bearer <HUB_API_KEY>` only on the Hub-bound hop.
|
||||
</Info>
|
||||
|
||||
## Update
|
||||
|
||||
Please take a look at our [migration guide](/self-hosting/advanced/migration) for version specific steps to update Formbricks.
|
||||
|
||||
@@ -40,6 +40,13 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker
|
||||
finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`.
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
The v5 one-click Traefik setup also exposes Hub-backed FeedbackRecords through Formbricks at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik calls the internal Formbricks gateway auth
|
||||
endpoint first, then forwards allowed requests to Hub with the generated `HUB_API_KEY`. Browser callers should
|
||||
request short-lived gateway tokens from `/api/v3/gateway/token` with `{ "service": "feedbackRecords" }`.
|
||||
</Info>
|
||||
|
||||
### Script Prompts
|
||||
|
||||
During installation, the script will prompt you to provide some details:
|
||||
|
||||
Reference in New Issue
Block a user