Compare commits

..

37 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 7b61e3b9bd feat: add Traefik gateway auth adapter 2026-05-14 00:30:47 +05:30
Anshuman Pandey b2a95d4cee fix: correct matrix/ranking feedback records (#7982) 2026-05-13 11:16:52 +04:00
Bhagya Amarasinghe 64b4e18c5a fix(helm): restore TEI float16 dtype (#7988) 2026-05-13 12:28:44 +05:30
Dhruwang Jariwala ae9c1e499a fix: add missing title in feedback directory (#7983) 2026-05-13 12:04:15 +05:30
Bhagya Amarasinghe daae319c7a fix(helm): restore TEI float16 dtype 2026-05-13 00:04:33 +05:30
Dhruwang Jariwala 5b70c99eb3 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903) (#7981) 2026-05-12 16:55:48 +05:30
Dhruwang 10c09f00a8 refactor(dashboards): address review on removeWidgetFromDashboard
- Drop the prisma.$transaction wrapper; find + delete is two sequential
  steps, doesn't need a transaction.
- Drop the redundant ResourceNotFoundError catch branch; the trailing
  `throw error` already lets it bubble.
- Let action-client infer ctx / parsedInput types.

Tests: cover the two catch branches (Prisma -> DatabaseError, unknown
rethrow) so the new function is fully line-covered.
2026-05-12 16:11:45 +05:30
Javi Aguilar 5f4f133dcb fix: add missing title in feedback directory 2026-05-12 11:07:02 +02:00
Dhruwang Jariwala 037b005d48 fix(charts): pie tooltip spacing + scroll to chart-name on empty save (ENG-914, ENG-916) (#7970) 2026-05-12 13:22:00 +05:30
Dhruwang ddd2d5e983 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903)
ENG-901: clicking Remove on a chart widget no longer enters dashboard edit mode
(which surfaced the rename input) and saving with the last widget removed no longer
surfaces a raw "widgets: Too small" zod error. Out of edit mode, Remove now goes
through a DeleteDialog -> dedicated removeWidgetFromDashboardAction. The batched
update path also allows an empty widgets array now that the lib already supports
deleting all widgets correctly.

ENG-903: add p-1 to AddExistingChartsDialog DialogBody so the focus ring on the
inner search input is no longer clipped by the body's overflow boundary.
2026-05-12 12:51:46 +05:30
Dhruwang Jariwala 6777b284b3 fix(a11y): large selects are not scrollable (#7963) 2026-05-12 12:08:38 +05:30
Anshuman Pandey c6282632e0 fix: fixes read only issues in feedback sources UI (#7974) 2026-05-12 10:20:30 +04:00
Dhruwang Jariwala f84c409bc4 feat: add beta badge to unify feedback navigation section (#7968) 2026-05-12 11:38:09 +05:30
Dhruwang Jariwala 98b475a2a4 fix(charts): time-range crash + sentiment/emotion/response-id cube column mismatches (ENG-907, ENG-906, ENG-915) (#7973) 2026-05-12 10:02:50 +04:00
Dhruwang c48474b943 chore(charts): drop sonar S6478 JSDoc on PieTooltipRow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:31:38 +05:30
Anshuman Pandey 3c0d1e3fd7 fix: fixes connector modal UI errors (#7971) 2026-05-11 16:40:35 +04:00
Bhagya Amarasinghe 1f7a496967 fix(helm): stop forcing TEI float16 dtype (#7967) 2026-05-11 17:24:57 +05:30
Dhruwang 99e378ae2e chore: drop verbose doc comments on ChartDialogFooter props
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:44:15 +05:30
Dhruwang c6e39c3103 refactor(charts): hoist pie tooltip formatter to module scope (sonar S6478)
Same pattern as CartesianChart's ChartTooltipRow — a module-level
component that calls useTranslation internally, plus a thin formatter
function passed to ChartTooltipContent. Resolves the nested-component
warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:41:18 +05:30
Dhruwang 19472ca9d3 chore: inline form onSubmit to avoid deprecated FormEvent type alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:20 +05:30
Dhruwang 43feff0009 fix(charts): inline error styling for empty chart-name on submit
Switch from the native browser tooltip to the in-app inline error pattern:
red Label, isInvalid Input border, and helper text below the field. The
onInvalid handler suppresses the default tooltip and scrolls + focuses the
field via event.currentTarget — no ref needed. Typing clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:36:45 +05:30
Dhruwang 507cec6958 feat(charts): show translated message on empty chart-name validation
Browsers default to "Please fill in this field"; use the existing
workspace.analysis.charts.please_enter_chart_name key so the message
matches the toast we surface elsewhere. Clear custom validity on change
so the form unblocks once the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:52 +05:30
Dhruwang d874b65be9 refactor(charts): use native form validation for chart-name field (ENG-916)
Replace the imperative scrollIntoView + focus on Save click with a tiny
<form> wrapping the required Input. The Save button becomes type=submit
with form="create-chart-form", so the browser handles scroll, focus, and
the "Please fill in this field" tooltip natively when the name is empty.

ChartDialogFooter now accepts either onSaveClick (button mode) or formId
(submit-button mode). All sibling buttons in the footer are explicitly
type="button" so they don't accidentally submit the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:17 +05:30
Dhruwang Jariwala c159af1a26 fix: format toast (ENG-893) (#7966) 2026-05-11 16:22:24 +05:30
Dhruwang 3ce2ef6cf4 fix(charts): scroll/focus chart-name input when save is blocked by empty name (ENG-916)
Clicking Save Chart with the name field empty fired a toast but left the
modal scrolled wherever the user was (e.g. on the chart preview at the
bottom), so they couldn't see what was wrong. Scroll the name field into
view and focus it before delegating to the existing save handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:20:57 +05:30
Dhruwang Jariwala baae6335c9 fix: validate workspace assignment on feedback directory create (ENG-900) (#7965)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-11 14:50:05 +04:00
Dhruwang 8e443db1f6 revert: drop YAxis integer-tick handling
The fractional-tick bar/line/area scenario isn't the bug we're shipping in
this PR. Keep only the pie tooltip spacing/formatting change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:13:40 +05:30
Dhruwang 18cd9afc2c fix(charts): space between value and name in pie tooltip
Returning [value, name] from the tooltip formatter rendered as two adjacent text nodes ("3Nps"). Return a fragment with an explicit space so it reads "3 Nps".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:08:22 +05:30
Dhruwang 680e1e1593 fix(charts): show integer y-axis ticks for COUNT measures and format pie tooltips
ENG-914

When a chart's measures are all whole numbers (e.g. COUNT grouped by a
dimension), Recharts was auto-generating fractional y-axis ticks like
0.5, 1.5, 2 — nonsensical for a discrete count. The pie tooltip also
rendered values via raw String() with no thousand separators.

- Add allValuesAreIntegers() + formatYAxisTick() to chart-utils.
- CartesianChart now passes allowDecimals={false} and a number-aware
  tickFormatter to YAxis when every measure value is an integer.
- ChartRenderer's pie tooltip routes the value through formatCellValue,
  matching the Cartesian tooltip behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:49:57 +05:30
Dhruwang 04bfdeef69 removed stale comment 2026-05-11 15:09:51 +05:30
Dhruwang 8a1db7b6aa reverted errorcode transform change 2026-05-11 15:08:58 +05:30
Dhruwang 1d22fe2da6 fix: surface translated inline form errors and prefer .at() over slice
- FormError ignored its children when an error was set, rendering the raw
  zod message instead of the translated text passed in. Prefer explicit
  children, fall back to the field error message.
- Use .at(-1) in getTranslatedFeedbackDirectoryError per SonarQube S7755.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:03:00 +05:30
Dhruwang d8a119712c revert: drop zero-workspace requirement on feedback directory create
Per Johannes on ENG-893: zero-workspace creation is acceptable — admins
move workspaces between directories and forcing at least one is an
arbitrary limitation. The unformatted-error fix
(getTranslatedFeedbackDirectoryError prefix stripping) is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:54:54 +05:30
Cursor Agent 50089eeca4 feat: add beta badge to unify feedback section
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-11 08:20:06 +00:00
Bhagya Amarasinghe d8fe832f5f fix(helm): stop forcing TEI float16 dtype 2026-05-11 13:24:49 +05:30
Dhruwang 1c904e2243 fix: require at least one workspace when creating a feedback directory
ENG-893

Why: A feedback directory with zero workspaces is useless — it groups
nothing. The create path allowed it, and any validation error that did
fire surfaced as a raw "field: CODE" string in the toast.

How:
- createFeedbackDirectory now rejects empty/missing workspaceIds with
  DIRECTORY_WORKSPACES_REQUIRED before touching the database.
- getTranslatedFeedbackDirectoryError strips the "<field>: " prefix that
  next-safe-action prepends to validation errors, so machine codes always
  map to a translated message.
- Add the new translation key across all 15 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:04:47 +05:30
Javi Aguilar 4dbecc2d58 fix/a11y-select-scroll 2026-05-11 05:52:55 +02:00
62 changed files with 1790 additions and 341 deletions
@@ -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;
+8
View File
@@ -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
+81 -2
View File
@@ -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", {
+16 -1
View File
@@ -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);
}
+291 -32
View File
@@ -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);
});
});
});
+137 -16
View File
@@ -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;
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "フィードバックディレクトリ",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "Директория обратной связи",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "反馈目录",
+7
View File
@@ -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}
@@ -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>
);
@@ -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;
}
};
+7 -21
View File
@@ -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",
});
};
+6 -116
View File
@@ -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();
});
});
+21
View File
@@ -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",
});
};
+54
View File
@@ -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,
+1
View File
@@ -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"` | |
+1
View File
@@ -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
+5
View File
@@ -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.
+4 -4
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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)
+7
View File
@@ -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.
+7
View File
@@ -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: