feat: wire workspace settings to feedback record directories#7910
Conversation
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations. Made-with: Cursor
Made-with: Cursor
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience. Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
…fixes - Move TSX components into components/ subfolder - Extract types/schemas into lib/types.ts and utils into lib/utils.ts - Remove `as unknown as` double-casts in actions.ts with explicit field mapping - Fix IDOR: use generic "not found" error instead of AuthorizationError for directory mismatch, parallelize auth + directory checks in retrieve/update - Replace `as never` casts with proper isPresetSourceType type guard and explicit updatePayload typing - Remove unused directoryName interpolation param from showing_count_loaded - Deduplicate formatSourceType across table and drawer Co-Authored-By: Claude Opus 4.6 <[email protected]>
…e/update - Add utils.test.ts with 32 tests covering all exported functions: getValueFieldByType, toLocalDateTimeInput, toISOOrUndefined, getCreateDefaults, mapRecordToValues, getReadOnlyMetadataEntries, parseNumberValue, isPresetSourceType, formatSourceType - Add 6 tests to hub service.test.ts for retrieveFeedbackRecord and updateFeedbackRecord (null client, success, error cases) Co-Authored-By: Claude Opus 4.6 <[email protected]>
…cords Replace .sort() with .toSorted() to avoid mutating arrays in-place during chaining, and remove the redundant CreateFeedbackRecordResult type alias in favor of HubFeedbackRecordResult. Co-Authored-By: Claude Opus 4.6 <[email protected]>
🚨 PR Size WarningThis PR has approximately 3021 lines of changes (2508 additions, 513 deletions across 34 files). Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs. 💡 Suggestions:
📊 What was counted:
📚 Guidelines:
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split. |
WalkthroughThis pull request introduces a comprehensive feedback records management system. It adds server actions for creating, retrieving, and updating feedback records with authorization validation. A new 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/modules/workspaces/settings/lib/workspace.ts (1)
78-123:⚠️ Potential issue | 🟠 MajorMake the selected-directory path atomic.
The FRD existence check,
workspace.create, optionalworkspaceTeam.createMany, andfeedbackRecordDirectoryWorkspace.createall run as separate statements. If the directory changes or the join insert fails after Line 93, this throws after the workspace has already been created, leaving a partially provisioned workspace without the requested directory link.Wrap this branch in a single
prisma.$transaction(...)or create the join via a nested write offworkspace.create(...).Possible direction
- if (feedbackRecordDirectoryId) { - const feedbackDirectory = await prisma.feedbackRecordDirectory.findFirst({ - where: { - id: feedbackRecordDirectoryId, - organizationId, - isArchived: false, - }, - select: { id: true }, - }); - - if (!feedbackDirectory) { - throw new InvalidInputError("FEEDBACK_RECORD_DIRECTORY_NOT_FOUND"); - } - } - - const workspace = await prisma.workspace.create({ - data: { - config: { - channel: null, - industry: null, - }, - ...data, - name: workspaceInput.name, - organizationId, - }, - select: selectWorkspace, - }); - - if (teamIds) { - await prisma.workspaceTeam.createMany({ - data: teamIds.map((teamId) => ({ - workspaceId: workspace.id, - teamId, - })), - }); - } - - if (feedbackRecordDirectoryId) { - await prisma.feedbackRecordDirectoryWorkspace.create({ - data: { - feedbackRecordDirectoryId, - workspaceId: workspace.id, - }, - }); - - return workspace; - } + const workspace = await prisma.$transaction(async (tx) => { + if (feedbackRecordDirectoryId) { + const feedbackDirectory = await tx.feedbackRecordDirectory.findFirst({ + where: { + id: feedbackRecordDirectoryId, + organizationId, + isArchived: false, + }, + select: { id: true }, + }); + + if (!feedbackDirectory) { + throw new InvalidInputError("FEEDBACK_RECORD_DIRECTORY_NOT_FOUND"); + } + } + + const workspace = await tx.workspace.create({ + data: { + config: { + channel: null, + industry: null, + }, + ...data, + name: workspaceInput.name, + organizationId, + }, + select: selectWorkspace, + }); + + if (teamIds) { + await tx.workspaceTeam.createMany({ + data: teamIds.map((teamId) => ({ + workspaceId: workspace.id, + teamId, + })), + }); + } + + if (feedbackRecordDirectoryId) { + await tx.feedbackRecordDirectoryWorkspace.create({ + data: { + feedbackRecordDirectoryId, + workspaceId: workspace.id, + }, + }); + } + + return workspace; + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/modules/workspaces/settings/lib/workspace.ts` around lines 78 - 123, The FRD existence check, workspace creation (prisma.workspace.create), optional workspaceTeam.createMany, and feedbackRecordDirectoryWorkspace.create must be made atomic to avoid partial state; wrap the sequence in a single prisma.$transaction that first verifies feedbackRecordDirectory (using feedbackRecordDirectoryId and prisma.feedbackRecordDirectory.findFirst) and then creates the workspace plus any workspaceTeam entries and the feedbackRecordDirectoryWorkspace join inside the same transaction, or alternatively perform the join as a nested write within prisma.workspace.create so the workspace and feedbackRecordDirectoryWorkspace are created together atomically.apps/web/modules/hub/service.ts (1)
26-30:⚠️ Potential issue | 🟠 MajorDo not expose raw Hub error text in public results.
This copies the SDK error message straight into the result object, and the new feedback-record actions/UI surface that text with
getFormattedErrorMessage(...). That leaks upstream/internal Hub responses directly to end users. Please map these failures to a small set of safe public messages and keep the raw detail only in logs. Based on learnings: "The Formbricks API changes error messages before returning them to clients to avoid leaking internal details, even though the raw error.message appears in the internal function implementation."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/modules/hub/service.ts` around lines 26 - 30, createResultFromError currently copies raw error.message into the public result; change it to map internal errors to a small set of safe public messages and log the raw detail only. Specifically, in createResultFromError (and where it checks FormbricksHub.APIError), derive a publicMessage based on err.status (e.g., 401 -> "unauthorized", 404 -> "not_found", 400 -> "invalid_request", 500+ -> "server_error", default -> "unknown_error"), set the returned error.message to that publicMessage and set error.detail to an empty string or the same publicMessage, and send the raw err/message to the server log via the project logger (or console.error) so only logs contain internal details; keep the returned status numeric as before and use HubFeedbackRecordResult as the return type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/app/`(app)/workspaces/[workspaceId]/components/MainNavigation.tsx:
- Around line 152-154: Replace the hardcoded label in the navigation entry (the
object with id: "ask" and name: "Ask") so it uses the i18n translator instead of
a string literal; call the file's translation function (t) for the name (e.g.,
name: t('Ask') or the appropriate key used by surrounding entries) and ensure
the component uses useTranslation()/t where other labels do—update the object
with id "ask" to reference t(...) rather than "Ask".
In
`@apps/web/app/`(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-record-form-drawer.tsx:
- Around line 470-487: The select currently renders raw IDs from
SOURCE_TYPE_PRESET_OPTIONS and the custom option; change it to display
translated labels by mapping each option through a shared display helper (e.g.,
translateSourceType or getSourceTypeLabel) that calls t() with the appropriate
translation key, then pass the translated string as the SelectItem children
while keeping the option value as the stable ID; apply the same change for the
other select instance referenced (the block around the later SelectItems) and
ensure SOURCE_TYPE_CUSTOM_VALUE still renders its translated label via
t("workspace.unify.custom_source_type") while preserving form values and
setCustomSourceType behavior.
- Around line 130-145: Before awaiting retrieveFeedbackRecordAction in
loadRecord, clear the previous state by calling setRecord(null), reset the form
to neutral values via form.reset(defaultValues), and set source-type state back
to defaults (setSourceTypeMode(SOURCE_TYPE_CUSTOM_VALUE) and
setCustomSourceType("")), so stale data cannot render while loading; if the
fetch fails, keep the drawer closed or in an error state by invoking the
component's close handler (e.g., onClose() or setIsOpen(false)) and ensure
setIsLoadingRecord(false) is still called after handling the error.
In
`@apps/web/app/`(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx:
- Around line 313-315: The row is only clickable via the <tr>'s onClick,
excluding keyboard users; update feedback-records-table.tsx to make the edit
action keyboard-accessible by either (preferred) adding a real <button> in a
cell that calls the same edit/open-drawer handler (use the existing onClick
handler or the open/edit function and give the button a clear accessible name or
aria-label), or (minimal) make the <tr> focusable (tabIndex={0}) and add an
onKeyDown that activates the same handler on Enter/Space; ensure the handler
referenced (onClick passed to the <tr> or the component method that opens the
drawer) is reused so behavior stays consistent and preserve visual styles
(cursor/hover).
- Around line 101-128: The code currently treats an empty aggregated row set as
a failure; change the success check to detect failed requests instead: after
awaiting Promise.all(listFeedbackRecordsAction...) compute results and
successfulRecords as before, then if (results.some((r) => !r?.data)) { const
firstErrorResult = results.find((r) => !r?.data); const errorMessage =
firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
toast.error(errorMessage ??
t("workspace.unify.failed_to_load_feedback_records"), { id: toastId });
setIsRefreshing(false); return; } — this ensures you only treat the overall
refresh as failed when any request errored (using listFeedbackRecordsAction
results), while allowing successfulRecords to be empty (no rows) and still
proceed to setRecords(mergedRecords), setIsRefreshing(false) and toast.success
as before; keep using mergedRecords variable and the existing
getFormattedErrorMessage, toast, setIsRefreshing, setRecords calls.
In
`@apps/web/app/`(app)/workspaces/[workspaceId]/unify/feedback-records/lib/types.ts:
- Around line 28-31: ZMetadataEntry currently rejects empty keys too early;
relax its key rule to allow empty strings during editing (e.g., change key:
z.string().trim().min(1) to z.string().trim() or z.string().trim().min(0)) and
make the final/submit validation enforce non-empty keys by adding a refinement
or a separate submit-time schema that ensures key.length > 0 for each metadata
entry; apply the same relaxation to the other metadata-entry schema referenced
in the file (the one noted at lines 54-55) so the drawer can add blank rows
without triggering validation until submit.
In
`@apps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal.tsx`:
- Around line 173-197: The client-side pause-confirmation logic in
handleSubmitForm relies on stale local state (initialWorkspaceIds and
directory.connectors); instead, call the server to compute affected connectors
before deciding to show the confirmation UI. Change handleSubmitForm to POST a
preview request (e.g., new API endpoint or existing submitDirectory preview
mode) with the proposed data to get current affected connector count, then—based
on the server response—either setPendingSubmitData / setConnectorsToPauseCount /
setConfirmPauseDialogOpen and return, or proceed to call submitDirectory(data,
false) if no confirmation is needed; keep identifiers handleSubmitForm,
initialWorkspaceIds, directory.connectors, submitDirectory,
setPendingSubmitData, setConnectorsToPauseCount, and setConfirmPauseDialogOpen
to locate and update the code.
In
`@apps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.ts`:
- Around line 427-438: Before composing payload in
updateFeedbackRecordDirectoryAction, validate the single-active-directory
invariant by querying existing non-archived directories for the workspaces being
assigned/unarchived and rejecting the update if any workspace is already linked
to a different active FRD; use the same identifiers passed into
getArchiveUpdate(directoryId, isArchived) and
getWorkspaceAssignmentUpdate(directoryId, organizationId, workspaceIds) to find
conflicts (via getWorkspaceFeedbackRecordDirectoryAccess or a direct lookup),
and throw a clear error when a conflict exists so clients cannot bypass the
client-side preflight in feedback-record-directory-table.tsx.
- Around line 440-447: The update to prisma.feedbackRecordDirectory
(prisma.feedbackRecordDirectory.update) is committed before
pauseConnectorsInWorkspaces runs, risking a half-applied state if
connector.updateMany inside pauseConnectorsInWorkspaces fails; wrap both the
directory update and the call to pauseConnectorsInWorkspaces (or the
connector.updateMany it invokes) in a single transaction (e.g., use
prisma.$transaction) so both succeed or both roll back, or alternatively
implement compensating rollback logic that reverses the directory assignment
change if pauseConnectorsInWorkspaces fails; locate the update in
feedback-record-directory.ts and ensure pauseConnectorsInWorkspaces (or the
internal connector.updateMany) is executed within that same transactional
context.
In `@apps/web/modules/workspaces/components/create-workspace-modal/index.tsx`:
- Around line 83-115: The effect that loads teams/directories (useEffect with
inner fetchModalData) currently omits the modal open state so the auto-select
logic for feedbackRecordDirectoryId (uses getValues and setValue) doesn't run
when the modal is reopened; modify the effect to also depend on the modal open
flag (add open to the dependency array) or only call fetchModalData when open is
true so that after form.reset() the directoriesResponse.data handling will
re-run and setValue("feedbackRecordDirectoryId", ...) as needed.
---
Outside diff comments:
In `@apps/web/modules/hub/service.ts`:
- Around line 26-30: createResultFromError currently copies raw error.message
into the public result; change it to map internal errors to a small set of safe
public messages and log the raw detail only. Specifically, in
createResultFromError (and where it checks FormbricksHub.APIError), derive a
publicMessage based on err.status (e.g., 401 -> "unauthorized", 404 ->
"not_found", 400 -> "invalid_request", 500+ -> "server_error", default ->
"unknown_error"), set the returned error.message to that publicMessage and set
error.detail to an empty string or the same publicMessage, and send the raw
err/message to the server log via the project logger (or console.error) so only
logs contain internal details; keep the returned status numeric as before and
use HubFeedbackRecordResult as the return type.
In `@apps/web/modules/workspaces/settings/lib/workspace.ts`:
- Around line 78-123: The FRD existence check, workspace creation
(prisma.workspace.create), optional workspaceTeam.createMany, and
feedbackRecordDirectoryWorkspace.create must be made atomic to avoid partial
state; wrap the sequence in a single prisma.$transaction that first verifies
feedbackRecordDirectory (using feedbackRecordDirectoryId and
prisma.feedbackRecordDirectory.findFirst) and then creates the workspace plus
any workspaceTeam entries and the feedbackRecordDirectoryWorkspace join inside
the same transaction, or alternatively perform the join as a nested write within
prisma.workspace.create so the workspace and feedbackRecordDirectoryWorkspace
are created together atomically.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 93b3b6a7-27d8-45ea-b0f4-41ae5a5f13dd
⛔ Files ignored due to path filters (16)
apps/web/i18n.lockis excluded by!**/*.lockapps/web/locales/de-DE.jsonis excluded by!apps/web/locales/**apps/web/locales/en-US.jsonis excluded by!apps/web/locales/**apps/web/locales/es-ES.jsonis excluded by!apps/web/locales/**apps/web/locales/fr-FR.jsonis excluded by!apps/web/locales/**apps/web/locales/hu-HU.jsonis excluded by!apps/web/locales/**apps/web/locales/ja-JP.jsonis excluded by!apps/web/locales/**apps/web/locales/nl-NL.jsonis excluded by!apps/web/locales/**apps/web/locales/pt-BR.jsonis excluded by!apps/web/locales/**apps/web/locales/pt-PT.jsonis excluded by!apps/web/locales/**apps/web/locales/ro-RO.jsonis excluded by!apps/web/locales/**apps/web/locales/ru-RU.jsonis excluded by!apps/web/locales/**apps/web/locales/sv-SE.jsonis excluded by!apps/web/locales/**apps/web/locales/tr-TR.jsonis excluded by!apps/web/locales/**apps/web/locales/zh-Hans-CN.jsonis excluded by!apps/web/locales/**apps/web/locales/zh-Hant-TW.jsonis excluded by!apps/web/locales/**
📒 Files selected for processing (33)
apps/web/app/(app)/workspaces/[workspaceId]/actions.tsapps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsxapps/web/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb.tsxapps/web/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/components/UnifyConfigNavigation.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.tsapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-record-form-drawer.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-page-client.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/types.tsapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.test.tsapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.tsapps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsxapps/web/app/(app)/workspaces/[workspaceId]/unify/page.tsxapps/web/modules/ee/feedback-record-directory/actions.tsapps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal.tsxapps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-table.tsxapps/web/modules/ee/feedback-record-directory/components/feedback-record-directory-view.tsxapps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.test.tsapps/web/modules/ee/feedback-record-directory/lib/feedback-record-directory.tsapps/web/modules/ee/feedback-record-directory/types/feedback-record-directory.tsapps/web/modules/hub/index.tsapps/web/modules/hub/service.test.tsapps/web/modules/hub/service.tsapps/web/modules/hub/types.tsapps/web/modules/ui/components/multi-select/index.tsxapps/web/modules/ui/components/secondary-navigation/index.tsxapps/web/modules/workspaces/components/create-workspace-modal/index.tsxapps/web/modules/workspaces/settings/actions.tsapps/web/modules/workspaces/settings/components/workspace-config-navigation.tsxapps/web/modules/workspaces/settings/lib/workspace.test.tsapps/web/modules/workspaces/settings/lib/workspace.ts
💤 Files with no reviewable changes (1)
- apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx
|



Summary
Replaces #7827. Rebased onto current
epic/v5so the diff shows only PR3 + PR4 work — PR1 (#7825) and PR2 (#7826) are already onepic/v5via squash merge.PR4 (#7828) commits are folded in here; that PR can be closed when this lands.
What changed vs #7827
epic/v5via squash)b1a4277ca) + PR4 chain (bf0ad4569→963f89c52)Validation
pnpm i18n✅ all keys validlib/connector,unify,workspaces/settings/lib,feedback-record-directory,hub)tsc --noEmit: 61 errors, identical toepic/v5baseline (zero new)Test plan
pnpm --filter @formbricks/web testpnpm i18nContributes to ENG-777