diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 62adc7da6255..84e3102eec2d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,12 +10,31 @@ Fixes #(issue) Loom Video: https://www.loom.com/ --> -## How should this be tested? +## QA / Test Plan - + + +**How to test** + +- [ ] Step → expected result (include route, error code, and any env flag/var) +- [ ] Edge case → expected result + +**Preconditions / test data** + +- + +**Risks & regressions** + +- + +**Migrations / env / cutover** -- Test A -- Test B +- none ## Checklist @@ -23,7 +42,7 @@ Fixes #(issue) ### Required -- [ ] Filled out the "How to test" section in this PR +- [ ] Filled out the "QA / Test Plan" section in this PR (behavior, edge cases, preconditions, risks, migrations/env) - [ ] Read [How we Code at Formbricks](<[https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md](https://formbricks.com/docs/contributing/how-we-code)>) - [ ] Self-reviewed my own code - [ ] Commented on my code in hard-to-understand bits diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 332f3837d0ef..1fdbf63cbe90 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,10 +12,6 @@ on: # Add other secrets if necessary workflow_dispatch: -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ vars.TURBO_TEAM }} - permissions: contents: read actions: read @@ -56,13 +52,14 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - - - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 @@ -124,9 +121,35 @@ jobs: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \ /tmp/rustfs-init.sh - - name: Build App + # NOTE: no build cache here. We measured caching Next's .next/cache + # incremental compiler cache too — warm build (4m17s) was no faster than + # cold (3m46s), because the Turbo-orchestrated `pnpm build` doesn't reuse + # it in this setup (and there's no Turbo remote cache). Build is ~4min + # regardless; a real reduction needs a Turbo cache backend, not local + # actions/cache. + + # Cache the downloaded browser binaries so re-runs skip the ~100MB + # Chromium download. `--with-deps` below still runs the (uncacheable) apt + # step, but a cache hit avoids the browser fetch itself. + - name: Cache Playwright browsers + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Build App (with Playwright browser install in parallel) run: | + set -euo pipefail + # Install Playwright browsers + OS deps concurrently with the Next.js + # build. Both are largely I/O-bound, so overlapping them shaves the + # browser-install time (~1-2 min) off the critical path. `wait` + # surfaces a non-zero exit from either process. + pnpm exec playwright install --with-deps & + PW_INSTALL_PID=$! pnpm build --filter=@formbricks/web... + wait "$PW_INSTALL_PID" - name: Apply Prisma Migrations run: | @@ -178,9 +201,6 @@ jobs: sleep 10 done - - name: Install Playwright - run: pnpm exec playwright install --with-deps - - name: Determine Playwright execution mode shell: bash env: diff --git a/AGENTS.md b/AGENTS.md index af6b341cc645..3c2da0170ac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,8 @@ Heuristic: Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness. +Every PR must use `.github/pull_request_template.md` — including its `## QA / Test Plan` section, which is the source of truth for release QA (follow the guidance inline in the template; keep its subsection headings). Agents fill it on PR open and re-update it in the same turn on every PR change (new commits, scope or review fixes) so it never drifts from the diff — treat a stale QA section as a bug. + ## Next.js Documentation Do not rely on training data for Next.js behavior in this repo. For any Next.js-related work (routing, layouts, server/client components, caching, next.config, etc.), use the `nextjs-docs` skill, which indexes the version-pinned local docs in `.next-docs/`. diff --git a/apps/web/app/api/v1/client/[workspaceId]/displays/lib/display.test.ts b/apps/web/app/api/v1/client/[workspaceId]/displays/lib/display.test.ts index fc68e1af9236..654578bb8c28 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/displays/lib/display.test.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/displays/lib/display.test.ts @@ -174,7 +174,7 @@ describe("createDisplay", () => { expect(prisma.display.create).not.toHaveBeenCalled(); }); - test("should throw InvalidInputError when survey does not exist (RelatedRecordDoesNotExist)", async () => { + test("should throw InvalidInputError when survey does not exist (RecordNotFound)", async () => { vi.mocked(getContactByUserId).mockResolvedValue(mockContact); vi.mocked(prisma.survey.findUnique).mockResolvedValue(null); diff --git a/apps/web/app/api/v1/management/responses/lib/response.test.ts b/apps/web/app/api/v1/management/responses/lib/response.test.ts index 3bf5a11b4a70..459f8596f41b 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.test.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.test.ts @@ -190,9 +190,9 @@ describe("Response Lib Tests", () => { expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError }); - test("should handle RelatedRecordDoesNotExist error with specific message", async () => { + test("should handle RecordNotFound error with specific message", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", { - code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist + code: "P2025", // PrismaErrorType.RecordNotFound clientVersion: "2.0", }); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(mockOrganization); diff --git a/apps/web/app/api/v1/management/responses/lib/response.ts b/apps/web/app/api/v1/management/responses/lib/response.ts index 8dd85d4e706f..7b00b3e36bb0 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.ts @@ -134,7 +134,7 @@ export const createResponse = async ( return response; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) { + if (error.code === PrismaErrorType.RecordNotFound) { throw new DatabaseError("Display ID does not exist"); } throw new DatabaseError(error.message); diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts index 18f932dc35f9..397f26929b32 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts @@ -22,7 +22,7 @@ export const deleteWebhook = async (id: string): Promise } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Webhook", id); } diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 47db5ded17ef..a6baf63d3325 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -3279,6 +3279,7 @@ checksums: workspace/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6 workspace/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987 workspace/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6 + workspace/surveys/edit/this_will_remove_the_language_from_this_survey: 65cfdf7f8ce806cc4f1a5a34466de080 workspace/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae workspace/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3 workspace/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db diff --git a/apps/web/lib/actionClass/service.test.ts b/apps/web/lib/actionClass/service.test.ts index 2e33cf4a99eb..a0dc7718738a 100644 --- a/apps/web/lib/actionClass/service.test.ts +++ b/apps/web/lib/actionClass/service.test.ts @@ -177,6 +177,17 @@ describe("ActionClass Service", () => { await expect(deleteActionClass("id4")).rejects.toThrow(ResourceNotFoundError); }); + test("should throw DatabaseError for PrismaClientKnownRequestError", async () => { + if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn(); + vi.mocked(prisma.actionClass.delete).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + clientVersion: "test", + }) + ); + await expect(deleteActionClass("id4")).rejects.toThrow(DatabaseError); + }); + test("should rethrow unknown errors", async () => { if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn(); const error = new Error("unknown"); diff --git a/apps/web/lib/feedback-source/service.test.ts b/apps/web/lib/feedback-source/service.test.ts index 7ee7e966ea95..f0a96f7170a4 100644 --- a/apps/web/lib/feedback-source/service.test.ts +++ b/apps/web/lib/feedback-source/service.test.ts @@ -5,6 +5,7 @@ import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbr import { createFeedbackSourceWithMappings, deleteFeedbackSource, + getFeedbackSourceWithMappingsById, getFeedbackSourcesBySurveyId, getFeedbackSourcesWithMappings, updateFeedbackSource, @@ -15,6 +16,7 @@ vi.mock("@formbricks/database", () => ({ prisma: { feedbackSource: { findMany: vi.fn(), + findUnique: vi.fn(), findUniqueOrThrow: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -156,6 +158,59 @@ describe("getFeedbackSourcesWithMappings", () => { await expect(getFeedbackSourcesWithMappings(ENV_ID)).rejects.toThrow(DatabaseError); }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.feedbackSource.findMany).mockRejectedValue(new Error("boom")); + + await expect(getFeedbackSourcesWithMappings(ENV_ID)).rejects.toThrow("boom"); + }); +}); + +describe("getFeedbackSourceWithMappingsById", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns the feedbackSource when found", async () => { + vi.mocked(prisma.feedbackSource.findUnique).mockResolvedValue( + mockFeedbackSourceWithMappingsFromDb as never + ); + + const result = await getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID); + + expect(prisma.feedbackSource.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: FEEDBACK_SOURCE_ID, workspaceId: ENV_ID }, + }) + ); + expect(result).toEqual(mockFeedbackSourceWithMappings); + }); + + test("returns null when not found", async () => { + vi.mocked(prisma.feedbackSource.findUnique).mockResolvedValue(null as never); + + const result = await getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID); + expect(result).toBeNull(); + }); + + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.feedbackSource.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P1001", + clientVersion: "5.0.0", + }) + ); + + await expect(getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow( + DatabaseError + ); + }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.feedbackSource.findUnique).mockRejectedValue(new Error("boom")); + + await expect(getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow("boom"); + }); }); describe("getFeedbackSourcesBySurveyId", () => { @@ -199,6 +254,12 @@ describe("getFeedbackSourcesBySurveyId", () => { await expect(getFeedbackSourcesBySurveyId(SURVEY_ID)).rejects.toThrow(DatabaseError); }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.feedbackSource.findMany).mockRejectedValue(new Error("boom")); + + await expect(getFeedbackSourcesBySurveyId(SURVEY_ID)).rejects.toThrow("boom"); + }); }); describe("updateFeedbackSource", () => { @@ -232,7 +293,7 @@ describe("updateFeedbackSource", () => { test("throws ResourceNotFoundError when feedbackSource does not exist", async () => { vi.mocked(prisma.feedbackSource.update).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Not found", { - code: "P2015", + code: "P2025", clientVersion: "5.0.0", }) ); @@ -285,7 +346,7 @@ describe("deleteFeedbackSource", () => { test("throws ResourceNotFoundError when feedbackSource does not exist", async () => { vi.mocked(prisma.feedbackSource.delete).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Not found", { - code: "P2015", + code: "P2025", clientVersion: "5.0.0", }) ); @@ -303,6 +364,12 @@ describe("deleteFeedbackSource", () => { await expect(deleteFeedbackSource(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow(DatabaseError); }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.feedbackSource.delete).mockRejectedValue(new Error("boom")); + + await expect(deleteFeedbackSource(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow("boom"); + }); }); describe("createFeedbackSourceWithMappings", () => { @@ -491,6 +558,23 @@ describe("createFeedbackSourceWithMappings", () => { ).rejects.toThrow(new InvalidInputError("FEEDBACK_SOURCE_FIELD_MAPPING_DUPLICATE")); }); + test("throws FEEDBACK_SOURCE_NAME_DUPLICATE on a unique violation without target meta", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + clientVersion: "5.0.0", + }) + ); + + await expect( + createFeedbackSourceWithMappings(ENV_ID, { + name: "Dup", + type: "formbricks_survey", + feedbackDirectoryId: FRD_ID, + }) + ).rejects.toThrow(new InvalidInputError("FEEDBACK_SOURCE_NAME_DUPLICATE")); + }); + test("throws FEEDBACK_SOURCE_DIRECTORY_NOT_ASSIGNED_TO_WORKSPACE on composite FK violation", async () => { vi.mocked(prisma.$transaction).mockRejectedValue( makeForeignKeyError("FeedbackSource_feedbackDirectoryId_workspaceId_fkey") @@ -551,6 +635,14 @@ describe("createFeedbackSourceWithMappings", () => { createFeedbackSourceWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackDirectoryId: FRD_ID }) ).rejects.toThrow(DatabaseError); }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue(new Error("boom")); + + await expect( + createFeedbackSourceWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackDirectoryId: FRD_ID }) + ).rejects.toThrow("boom"); + }); }); describe("updateFeedbackSourceWithMappings", () => { @@ -652,7 +744,7 @@ describe("updateFeedbackSourceWithMappings", () => { test("throws ResourceNotFoundError when feedbackSource does not exist", async () => { vi.mocked(prisma.$transaction).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Not found", { - code: "P2015", + code: "P2025", clientVersion: "5.0.0", }) ); @@ -732,4 +824,12 @@ describe("updateFeedbackSourceWithMappings", () => { DatabaseError ); }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.$transaction).mockRejectedValue(new Error("boom")); + + await expect(updateFeedbackSourceWithMappings(FEEDBACK_SOURCE_ID, ENV_ID, { name: "x" })).rejects.toThrow( + "boom" + ); + }); }); diff --git a/apps/web/lib/feedback-source/service.ts b/apps/web/lib/feedback-source/service.ts index 43f65109e848..a92cf81bbf59 100644 --- a/apps/web/lib/feedback-source/service.ts +++ b/apps/web/lib/feedback-source/service.ts @@ -17,6 +17,7 @@ import { ZFeedbackSourceCreateInput, ZFeedbackSourceUpdateInput, } from "@formbricks/types/feedback-source"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { ITEMS_PER_PAGE } from "../constants"; import { getUniqueConstraintFields } from "../utils/prisma-constraint"; import { validateInputs } from "../utils/validate"; @@ -101,7 +102,7 @@ export const getFeedbackSourcesWithMappings = reactCache( return feedbackSources.map(mapFeedbackSourceWithMappings); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -124,7 +125,7 @@ export const getFeedbackSourceWithMappingsById = reactCache( return feedbackSource ? mapFeedbackSourceWithMappings(feedbackSource) : null; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -152,7 +153,7 @@ export const getFeedbackSourcesBySurveyId = reactCache( return feedbackSources.map(mapFeedbackSourceWithMappings); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -183,10 +184,10 @@ export const updateFeedbackSource = async ( return feedbackSource; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.RecordDoesNotExist) { - throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); - } + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { + throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -210,10 +211,10 @@ export const deleteFeedbackSource = async ( return feedbackSource; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.RecordDoesNotExist) { - throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); - } + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { + throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -345,10 +346,10 @@ export const createFeedbackSourceWithMappings = async ( return mapFeedbackSourceWithMappings(result); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw mapUniqueConstraintError(error); - } + if (isUniqueConstraintError(error)) { + throw mapUniqueConstraintError(error); + } + if (isPrismaKnownRequestError(error)) { if (isDirectoryWorkspaceFkViolation(error)) { logger.error( { workspaceId, feedbackDirectoryId: data.feedbackDirectoryId, meta: error.meta }, @@ -428,13 +429,13 @@ export const updateFeedbackSourceWithMappings = async ( return mapFeedbackSourceWithMappings(result); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw mapUniqueConstraintError(error); - } - if (error.code === PrismaErrorType.RecordDoesNotExist) { - throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); - } + if (isUniqueConstraintError(error)) { + throw mapUniqueConstraintError(error); + } + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { + throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; diff --git a/apps/web/lib/organization/service.test.ts b/apps/web/lib/organization/service.test.ts index 10ada71d4e25..d92494a4e9bf 100644 --- a/apps/web/lib/organization/service.test.ts +++ b/apps/web/lib/organization/service.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { updateUser } from "@/lib/user/service"; import { @@ -291,6 +291,30 @@ describe("Organization Service", () => { data: { name: "Updated Org" }, }); }); + + test("should throw ResourceNotFoundError when the update targets a missing organization (P2025)", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record to update not found", { + code: "P2025", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.$transaction).mockImplementation( + async (fn: any) => + await fn({ + organization: { + update: vi.fn().mockRejectedValue(prismaError), + findUnique: vi.fn().mockResolvedValue({ id: "org1" }), + }, + organizationBilling: { + upsert: prisma.organizationBilling.upsert, + }, + }) + ); + + await expect(updateOrganization("org1", { name: "Updated Org" })).rejects.toThrow( + ResourceNotFoundError + ); + }); }); describe("subscribeOrganizationMembersToSurveyResponses", () => { diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts index 01923f910de4..e4a41adb5c67 100644 --- a/apps/web/lib/organization/service.ts +++ b/apps/web/lib/organization/service.ts @@ -260,7 +260,7 @@ export const updateOrganization = async ( } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Organization", organizationId); } diff --git a/apps/web/lib/response/service.test.ts b/apps/web/lib/response/service.test.ts index d955740d0004..788bb0d719e5 100644 --- a/apps/web/lib/response/service.test.ts +++ b/apps/web/lib/response/service.test.ts @@ -361,7 +361,7 @@ describe("updateResponse", () => { vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any); vi.mocked(prisma.response.update).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Record to update not found", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "5.0.0", }) ); @@ -376,7 +376,7 @@ describe("updateResponse", () => { vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any); vi.mocked(prisma.response.update).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Record does not exist", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "5.0.0", }) ); diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts index d489dae813b6..a23105cf3028 100644 --- a/apps/web/lib/response/service.ts +++ b/apps/web/lib/response/service.ts @@ -608,8 +608,8 @@ export const updateResponse = async ( } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RelatedRecordNotFound || + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Response", responseId); } diff --git a/apps/web/lib/tag/service.ts b/apps/web/lib/tag/service.ts index d53c7ca168c3..cc312e724c60 100644 --- a/apps/web/lib/tag/service.ts +++ b/apps/web/lib/tag/service.ts @@ -1,11 +1,10 @@ import "server-only"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; import { Result, err, ok } from "@formbricks/types/error-handlers"; import { TTag } from "@formbricks/types/tags"; +import { isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { TagError } from "../../modules/workspaces/settings/types/tag"; import { ITEMS_PER_PAGE } from "../constants"; import { validateInputs } from "../utils/validate"; @@ -62,13 +61,11 @@ export const createTag = async ( return ok(tag); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - return err({ - code: TagError.TAG_NAME_ALREADY_EXISTS, - message: "Tag with this name already exists", - }); - } + if (isUniqueConstraintError(error)) { + return err({ + code: TagError.TAG_NAME_ALREADY_EXISTS, + message: "Tag with this name already exists", + }); } return err({ code: TagError.UNEXPECTED_ERROR, diff --git a/apps/web/lib/tagOnResponse/service.test.ts b/apps/web/lib/tagOnResponse/service.test.ts index 192fae81cae2..986a6e7b0e9d 100644 --- a/apps/web/lib/tagOnResponse/service.test.ts +++ b/apps/web/lib/tagOnResponse/service.test.ts @@ -212,4 +212,42 @@ describe("TagOnResponse Service", () => { await expect(addTagToRespone("response1", "tag1")).rejects.toThrow(DatabaseError); }); + + test("addTagToRespone should rethrow non-prisma errors", async () => { + vi.mocked(prisma.tagsOnResponses.create).mockRejectedValue(new Error("boom")); + + await expect(addTagToRespone("response1", "tag1")).rejects.toThrow("boom"); + }); + + test("deleteTagOnResponse should throw DatabaseError for prisma errors", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2010", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.tagsOnResponses.delete).mockRejectedValue(prismaError); + + await expect(deleteTagOnResponse("response1", "tag1")).rejects.toThrow(DatabaseError); + }); + + test("deleteTagOnResponse should rethrow non-prisma errors", async () => { + vi.mocked(prisma.tagsOnResponses.delete).mockRejectedValue(new Error("boom")); + + await expect(deleteTagOnResponse("response1", "tag1")).rejects.toThrow("boom"); + }); + + test("getTagsOnResponsesCount should throw DatabaseError for prisma errors", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2010", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.tagsOnResponses.groupBy).mockRejectedValue(prismaError); + + await expect(getTagsOnResponsesCount("env1")).rejects.toThrow(DatabaseError); + }); + + test("getTagsOnResponsesCount should rethrow non-prisma errors", async () => { + vi.mocked(prisma.tagsOnResponses.groupBy).mockRejectedValue(new Error("boom")); + + await expect(getTagsOnResponsesCount("env1")).rejects.toThrow("boom"); + }); }); diff --git a/apps/web/lib/user/service.test.ts b/apps/web/lib/user/service.test.ts index 70dcc7e73c77..92b5deafaeac 100644 --- a/apps/web/lib/user/service.test.ts +++ b/apps/web/lib/user/service.test.ts @@ -186,7 +186,7 @@ describe("User Service", () => { test("should throw ResourceNotFoundError when user not found", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "5.0.0", }); vi.mocked(prisma.user.update).mockRejectedValue(prismaError); diff --git a/apps/web/lib/user/service.ts b/apps/web/lib/user/service.ts index 206b08f86264..9d125bacb9cf 100644 --- a/apps/web/lib/user/service.ts +++ b/apps/web/lib/user/service.ts @@ -75,7 +75,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("User", personId); } diff --git a/apps/web/lib/utils/prisma-error.test.ts b/apps/web/lib/utils/prisma-error.test.ts new file mode 100644 index 000000000000..ddc52938456c --- /dev/null +++ b/apps/web/lib/utils/prisma-error.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; +import { Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "./prisma-error"; + +const knownError = (code: string): Prisma.PrismaClientKnownRequestError => + new Prisma.PrismaClientKnownRequestError("boom", { code, clientVersion: "test" }); + +const uniqueViolation = knownError(PrismaErrorType.UniqueConstraintViolation); +const recordNotFound = knownError(PrismaErrorType.RelatedRecordNotFound); + +describe("isPrismaKnownRequestError", () => { + test("matches any known request error when no code is given", () => { + expect(isPrismaKnownRequestError(uniqueViolation)).toBe(true); + expect(isPrismaKnownRequestError(recordNotFound)).toBe(true); + }); + + test("narrows to a specific code", () => { + expect(isPrismaKnownRequestError(uniqueViolation, PrismaErrorType.UniqueConstraintViolation)).toBe(true); + expect(isPrismaKnownRequestError(recordNotFound, PrismaErrorType.UniqueConstraintViolation)).toBe(false); + }); + + test("is false for non-Prisma errors and look-alikes", () => { + expect(isPrismaKnownRequestError(new Error("plain"))).toBe(false); + expect(isPrismaKnownRequestError({ code: "P2002" })).toBe(false); + expect(isPrismaKnownRequestError(null)).toBe(false); + expect(isPrismaKnownRequestError(undefined)).toBe(false); + }); +}); + +describe("isUniqueConstraintError", () => { + test("is true only for a P2002 unique-constraint violation", () => { + expect(isUniqueConstraintError(uniqueViolation)).toBe(true); + expect(isUniqueConstraintError(recordNotFound)).toBe(false); + expect(isUniqueConstraintError(new Error("plain"))).toBe(false); + }); +}); + +describe("type narrowing", () => { + test("narrows to PrismaClientKnownRequestError so callers can read code/meta", () => { + const error: unknown = new Prisma.PrismaClientKnownRequestError("dup", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "test", + meta: { target: ["email"] }, + }); + + if (isUniqueConstraintError(error)) { + // These accesses must compile (proves the guard yields PrismaClientKnownRequestError, + // not the namespaced type that resolves to `any`) and be correct at runtime. + expect(error.code).toBe("P2002"); + expect(error.meta?.target).toEqual(["email"]); + } else { + throw new Error("expected isUniqueConstraintError to narrow"); + } + }); + + test("the negative branch stays usable (regression guard against never-collapse)", () => { + const error: unknown = new Error("plain"); + + if (isPrismaKnownRequestError(error)) { + throw new Error("unexpected"); + } + + // The guard must return false for a plain Error, leaving `error` usable. The compile-time + // counterpart of this regression is enforced by the refactored source files: if the helper + // predicate used the namespaced `Prisma.PrismaClientKnownRequestError` (which is `any` in + // type position), their post-guard `error.message` accesses would narrow to `never` and fail + // typecheck. + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/apps/web/lib/utils/prisma-error.ts b/apps/web/lib/utils/prisma-error.ts new file mode 100644 index 000000000000..a6d0483fec40 --- /dev/null +++ b/apps/web/lib/utils/prisma-error.ts @@ -0,0 +1,21 @@ +import { Prisma } from "@formbricks/database/prisma"; +import type { PrismaClientKnownRequestError } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; + +/** + * Type guard for Prisma "known request" errors, optionally narrowed to a specific error code. + * Returns a type predicate so callers can read `error.code`/`error.meta` after the check. + * + * Note: the predicate uses the named `PrismaClientKnownRequestError` type (not the namespaced + * `Prisma.PrismaClientKnownRequestError`, which resolves to `any` in type position and would + * collapse the negative branch of the guard to `never`). + */ +export const isPrismaKnownRequestError = ( + error: unknown, + code?: PrismaErrorType +): error is PrismaClientKnownRequestError => + error instanceof Prisma.PrismaClientKnownRequestError && (code === undefined || error.code === code); + +/** Type guard for a Prisma unique-constraint violation (P2002). */ +export const isUniqueConstraintError = (error: unknown): error is PrismaClientKnownRequestError => + isPrismaKnownRequestError(error, PrismaErrorType.UniqueConstraintViolation); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index ec634c0bfd37..fbfc3e4efba9 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -3398,6 +3398,7 @@ "then": "Dann", "this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.", "this_will_remove_the_language_and_all_its_translations": "Dies entfernt diese Sprache und alle zugehörigen Übersetzungen aus dieser Umfrage. Diese Aktion kann nicht rückgängig gemacht werden.", + "this_will_remove_the_language_from_this_survey": "Dadurch wird diese Sprache aus dieser Umfrage entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", "three_points": "3 Punkte", "translated": "Übersetzt", "trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index fcdfd5521130..2e63a1821483 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -3398,6 +3398,7 @@ "then": "Then", "this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.", "this_will_remove_the_language_and_all_its_translations": "This will remove this language and all its translations from this survey. This action cannot be undone.", + "this_will_remove_the_language_from_this_survey": "This will remove this language from this survey. This action cannot be undone.", "three_points": "3 points", "translated": "Translated", "trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired…", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index cf54d31d6c87..2ea5c6e379c0 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -3398,6 +3398,7 @@ "then": "Entonces", "this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.", "this_will_remove_the_language_and_all_its_translations": "Esto eliminará este idioma y todas sus traducciones de esta encuesta. Esta acción no se puede deshacer.", + "this_will_remove_the_language_from_this_survey": "Esto eliminará este idioma de esta encuesta. Esta acción no se puede deshacer.", "three_points": "3 puntos", "translated": "Traducido", "trigger_survey_when_one_of_the_actions_is_fired": "Activar encuesta cuando se dispare una de las acciones...", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 11a711bc11ed..c2025343779e 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -3398,6 +3398,7 @@ "then": "Alors", "this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.", "this_will_remove_the_language_and_all_its_translations": "Cela supprimera cette langue et toutes ses traductions de ce questionnaire. Cette action est irréversible.", + "this_will_remove_the_language_from_this_survey": "Cela supprimera cette langue de ce sondage. Cette action est irréversible.", "three_points": "3 points", "translated": "Traduit", "trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index b3f727b71c8c..08d7983d7b6a 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -3398,6 +3398,7 @@ "then": "Azután", "this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.", "this_will_remove_the_language_and_all_its_translations": "Ez el fogja távolítani ezt a nyelvet és annak összes fordítását ebből a kérdőívből. Ezt a műveletet nem lehet visszavonni.", + "this_will_remove_the_language_from_this_survey": "Ezzel eltávolítja ezt a nyelvet ebből a felmérésből. Ez a művelet nem vonható vissza.", "three_points": "3 pont", "translated": "Lefordítva", "trigger_survey_when_one_of_the_actions_is_fired": "A kérdőív aktiválása, ha a műveletek egyikét elindítják…", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 02db1be83a27..1f1acd204fcc 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -3398,6 +3398,7 @@ "then": "その後", "this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。", "this_will_remove_the_language_and_all_its_translations": "この言語とすべての翻訳がこのアンケートから削除されます。この操作は元に戻せません。", + "this_will_remove_the_language_from_this_survey": "この言語がこのアンケートから削除されます。この操作は元に戻せません。", "three_points": "3点", "translated": "翻訳済み", "trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 7865a9f7c30d..ba1d60df62a8 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -3398,6 +3398,7 @@ "then": "Dan", "this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.", "this_will_remove_the_language_and_all_its_translations": "Dit verwijdert deze taal en alle vertalingen uit deze enquête. Deze actie kan niet ongedaan worden gemaakt.", + "this_will_remove_the_language_from_this_survey": "Hiermee verwijder je deze taal uit deze enquête. Deze actie kan niet ongedaan worden gemaakt.", "three_points": "3 punten", "translated": "Vertaald", "trigger_survey_when_one_of_the_actions_is_fired": "Enquête activeren wanneer een van de acties wordt afgevuurd...", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 4e67ca638f47..5db42e5f30c7 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -3398,6 +3398,7 @@ "then": "Então", "this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.", "this_will_remove_the_language_and_all_its_translations": "Isso removerá este idioma e todas as suas traduções desta pesquisa. Esta ação não pode ser desfeita.", + "this_will_remove_the_language_from_this_survey": "Isso vai remover este idioma desta pesquisa. Esta ação não pode ser desfeita.", "three_points": "3 pontos", "translated": "Traduzido", "trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 67a8b7582632..0571db1dfb23 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -3398,6 +3398,7 @@ "then": "Então", "this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.", "this_will_remove_the_language_and_all_its_translations": "Isto irá remover este idioma e todas as suas traduções deste inquérito. Esta ação não pode ser revertida.", + "this_will_remove_the_language_from_this_survey": "Isto irá remover este idioma deste questionário. Esta ação não pode ser revertida.", "three_points": "3 pontos", "translated": "Traduzido", "trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 18fcfee66756..652adc845792 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -3398,6 +3398,7 @@ "then": "Apoi", "this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.", "this_will_remove_the_language_and_all_its_translations": "Aceasta va elimina această limbă și toate traducerile ei din acest chestionar. Această acțiune nu poate fi anulată.", + "this_will_remove_the_language_from_this_survey": "Aceasta va elimina această limbă din acest sondaj. Această acțiune nu poate fi anulată.", "three_points": "3 puncte", "translated": "Tradus", "trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 16f4116eb0d9..f9bfedabb68a 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -3398,6 +3398,7 @@ "then": "Затем", "this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.", "this_will_remove_the_language_and_all_its_translations": "Это удалит данный язык и все его переводы из этого опроса. Это действие нельзя отменить.", + "this_will_remove_the_language_from_this_survey": "Это удалит данный язык из этого опроса. Это действие нельзя отменить.", "three_points": "3 балла", "translated": "Переведено", "trigger_survey_when_one_of_the_actions_is_fired": "Запустить опрос при выполнении одного из действий...", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 315d8e450466..0b1616b1d201 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -3398,6 +3398,7 @@ "then": "Sedan", "this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.", "this_will_remove_the_language_and_all_its_translations": "Detta tar bort språket och alla dess översättningar från denna enkät. Denna åtgärd kan inte ångras.", + "this_will_remove_the_language_from_this_survey": "Detta tar bort språket från den här undersökningen. Åtgärden kan inte ångras.", "three_points": "3 poäng", "translated": "Översatt", "trigger_survey_when_one_of_the_actions_is_fired": "Utlös enkät när en av åtgärderna aktiveras...", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 1e865fe02cb1..d5143243a3fc 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -3398,6 +3398,7 @@ "then": "Ardından", "this_action_will_remove_all_the_translations_from_this_survey": "Bu işlem bu anketteki tüm çevirileri kaldıracak.", "this_will_remove_the_language_and_all_its_translations": "Bu işlem bu dili ve tüm çevirilerini anketten kaldıracak. Bu işlem geri alınamaz.", + "this_will_remove_the_language_from_this_survey": "Bu dil bu anketten kaldırılacak. Bu işlem geri alınamaz.", "three_points": "3 puan", "translated": "Çevrildi", "trigger_survey_when_one_of_the_actions_is_fired": "İşlemlerden biri gerçekleştiğinde anketi tetikle…", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 0dbfc4ed4bec..e71707b8fa0d 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -3398,6 +3398,7 @@ "then": "然后", "this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。", "this_will_remove_the_language_and_all_its_translations": "这将从此调查问卷中删除该语言及其所有翻译。此操作无法撤销。", + "this_will_remove_the_language_from_this_survey": "这将从此调查中删除该语言。此操作无法撤销。", "three_points": "3 分", "translated": "已翻译", "trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 4fd1708357b1..6c39548feb2f 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -3398,6 +3398,7 @@ "then": "然後", "this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。", "this_will_remove_the_language_and_all_its_translations": "這將會從此問卷中移除該語言及其所有翻譯。此操作無法復原。", + "this_will_remove_the_language_from_this_survey": "這將從此調查中移除該語言。此操作無法復原。", "three_points": "3 分", "translated": "已翻譯", "trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...", diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts index 25df1ce5d7dc..cc1dcda7e91f 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database"; import { ContactAttributeKey, Prisma } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; @@ -65,27 +66,25 @@ export const updateContactAttributeKey = async ( return ok(updatedKey); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist - ) { - return err({ - type: "not_found", - details: [{ field: "contactAttributeKey", issue: "not found" }], - }); - } - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - return err({ - type: "conflict", - details: [ - { - field: "contactAttributeKey", - issue: "Contact attribute key update conflict", - }, - ], - }); - } + if ( + isPrismaKnownRequestError(error, PrismaErrorType.RelatedRecordNotFound) || + isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound) + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + if (isUniqueConstraintError(error)) { + return err({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: "Contact attribute key update conflict", + }, + ], + }); } return err({ type: "internal_server_error", @@ -121,16 +120,14 @@ export const deleteContactAttributeKey = async ( return ok(deletedKey); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist - ) { - return err({ - type: "not_found", - details: [{ field: "contactAttributeKey", issue: "not found" }], - }); - } + if ( + isPrismaKnownRequestError(error, PrismaErrorType.RelatedRecordNotFound) || + isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound) + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); } return err({ type: "internal_server_error", diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts index aaddda80463d..6156befd358d 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts @@ -44,7 +44,7 @@ const mockUpdateInput: TContactAttributeKeyUpdateSchema = { }; const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Mock error message", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "0.0.1", }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts index 2c24f052a59e..5f47a717fb31 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database"; import { ContactAttributeKey, Prisma } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier"; import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils"; import { @@ -75,27 +76,25 @@ export const createContactAttributeKey = async ( return ok(createdContactAttributeKey); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist - ) { - return err({ - type: "not_found", - details: [{ field: "contactAttributeKey", issue: "not found" }], - }); - } - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - return err({ - type: "conflict", - details: [ - { - field: "contactAttributeKey", - issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`, - }, - ], - }); - } + if ( + isPrismaKnownRequestError(error, PrismaErrorType.RelatedRecordNotFound) || + isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound) + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + if (isUniqueConstraintError(error)) { + return err({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`, + }, + ], + }); } return err({ type: "internal_server_error", diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts index aedec55c1006..2fb1dee748c0 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts @@ -152,7 +152,7 @@ describe("createContactAttributeKey", () => { test("returns not found error when related record does not exist", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RelatedRecordNotFound, clientVersion: "0.0.1", }); vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts index dc0e3fb48eba..374bff9c24ce 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts @@ -21,8 +21,8 @@ export const deleteDisplay = async (displayId: string): Promise { test("return a not_found error when the display is not found", async () => { vi.mocked(prisma.display.delete).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Display not found", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", meta: { cause: "Display not found", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts index d8feb0f0f80b..9fbd6bfddd67 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts @@ -359,7 +359,7 @@ describe("Response Lib", () => { test("handle prisma client error code P2025", async () => { vi.mocked(prisma.response.delete).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Response not found", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", meta: { cause: "Response not found", @@ -413,7 +413,7 @@ describe("Response Lib", () => { test("return a not_found error when the response is not found", async () => { vi.mocked(prisma.response.update).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Response not found", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", meta: { cause: "Response not found", @@ -575,7 +575,7 @@ describe("Response Lib", () => { test("propagate error when updateResponse fails", async () => { vi.mocked(mockTx.response.update).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Response not found", { - code: PrismaErrorType.RelatedRecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", meta: { cause: "Response not found", diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts index cef207620fb2..f5b4af07d5c4 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts @@ -15,6 +15,6 @@ export const mockedPrismaWebhookUpdateReturn = { }; export const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Record does not exist", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RelatedRecordNotFound, clientVersion: "PrismaClient 4.0.0", }); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts index c3485609ed93..062b7e5f9ed7 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts @@ -108,8 +108,8 @@ export const updateWebhook = async ( } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RelatedRecordNotFound || + error.code === PrismaErrorType.RecordNotFound ) { return err({ type: "not_found", @@ -143,8 +143,8 @@ export const deleteWebhook = async ( } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RelatedRecordNotFound || + error.code === PrismaErrorType.RecordNotFound ) { return err({ type: "not_found", diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts index 4c82a0e612e8..a5db5f280cea 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts @@ -52,8 +52,8 @@ export const deleteTeam = async ( } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RelatedRecordNotFound || + error.code === PrismaErrorType.RecordNotFound ) { return err({ type: "not_found", @@ -90,8 +90,8 @@ export const updateTeam = async ( } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( - error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RelatedRecordNotFound || + error.code === PrismaErrorType.RecordNotFound ) { return err({ type: "not_found", diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts index c1c56466de8a..4a17160e6b4b 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts @@ -75,7 +75,7 @@ describe("Teams Lib", () => { test("returns not_found error on known prisma error", async () => { (prisma.team.delete as any).mockRejectedValueOnce( new Prisma.PrismaClientKnownRequestError("Not found", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", meta: {}, }) @@ -121,7 +121,7 @@ describe("Teams Lib", () => { test("returns not_found error when update fails due to missing team", async () => { (prisma.team.update as any).mockRejectedValueOnce( new Prisma.PrismaClientKnownRequestError("Not found", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", meta: {}, }) diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts index da09ec2f50a2..97027f0c1e80 100644 --- a/apps/web/modules/auth/lib/user.test.ts +++ b/apps/web/modules/auth/lib/user.test.ts @@ -103,6 +103,34 @@ describe("User Management", () => { }) ).rejects.toThrow(InvalidInputError); }); + + test("throws DatabaseError on a non-unique Prisma error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2010", + clientVersion: "0.0.1", + }); + vi.mocked(prisma.user.create).mockRejectedValueOnce(errToThrow); + + await expect( + createUser({ + email: mockUser.email, + name: mockUser.name, + locale: mockUser.locale, + }) + ).rejects.toMatchObject({ name: "DatabaseError" }); + }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.user.create).mockRejectedValueOnce(new Error("boom")); + + await expect( + createUser({ + email: mockUser.email, + name: mockUser.name, + locale: mockUser.locale, + }) + ).rejects.toThrow("boom"); + }); }); describe("updateUser", () => { @@ -118,13 +146,19 @@ describe("User Management", () => { test("throws ResourceNotFoundError when user doesn't exist", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "0.0.1", }); vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow); await expect(updateUser(mockUser.id, mockUpdateData)).rejects.toThrow(ResourceNotFoundError); }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.user.update).mockRejectedValueOnce(new Error("boom")); + + await expect(updateUser(mockUser.id, mockUpdateData)).rejects.toThrow("boom"); + }); }); describe("updateUserLastLoginAt", () => { @@ -156,6 +190,22 @@ describe("User Management", () => { await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError); }); + + test("throws ResourceNotFoundError on a RecordNotFound Prisma error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.RecordNotFound, + clientVersion: "0.0.1", + }); + vi.mocked(prisma.$transaction).mockRejectedValueOnce(errToThrow); + + await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError); + }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("boom")); + + await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow("boom"); + }); }); describe("getUserByEmail", () => { @@ -182,6 +232,16 @@ describe("User Management", () => { await expect(getUserByEmail(mockEmail)).rejects.toThrow(); }); + + test("throws DatabaseError on a known Prisma request error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2010", + clientVersion: "0.0.1", + }); + vi.mocked(prisma.user.findFirst).mockRejectedValueOnce(errToThrow); + + await expect(getUserByEmail(mockEmail)).rejects.toMatchObject({ name: "DatabaseError" }); + }); }); describe("getUser", () => { @@ -213,5 +273,15 @@ describe("User Management", () => { await expect(getUser(mockUserId)).rejects.toThrow(); }); + + test("throws DatabaseError on a known Prisma request error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2010", + clientVersion: "0.0.1", + }); + vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(errToThrow); + + await expect(getUser(mockUserId)).rejects.toMatchObject({ name: "DatabaseError" }); + }); }); }); diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts index 15915a0af3a4..67a2799729da 100644 --- a/apps/web/modules/auth/lib/user.ts +++ b/apps/web/modules/auth/lib/user.ts @@ -5,6 +5,7 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from "@formbricks/types/user"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; type TUserDbClient = PrismaClient | Prisma.TransactionClient; @@ -30,10 +31,7 @@ export const updateUser = async (id: string, data: TUserUpdateInput, tx?: Prisma return updatedUser; } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist - ) { + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { throw new ResourceNotFoundError("User", id); } throw error; @@ -73,10 +71,7 @@ export const updateUserLastLoginAt = async (email: string) => { throw error; } - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist - ) { + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { throw new ResourceNotFoundError("email", email); } throw error; @@ -103,7 +98,7 @@ export const getUserByEmail = reactCache(async (email: string) => { return user; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } @@ -129,7 +124,7 @@ export const getUser = reactCache(async (id: string) => { } return user; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } @@ -153,14 +148,11 @@ export const createUser = async (data: TUserCreateInput, tx?: Prisma.Transaction return user; } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.UniqueConstraintViolation - ) { + if (isUniqueConstraintError(error)) { throw new InvalidInputError("User with this email already exists"); } - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } diff --git a/apps/web/modules/auth/signup/lib/invite.test.ts b/apps/web/modules/auth/signup/lib/invite.test.ts index a7b0077f168f..4cc7b2adb450 100644 --- a/apps/web/modules/auth/signup/lib/invite.test.ts +++ b/apps/web/modules/auth/signup/lib/invite.test.ts @@ -69,7 +69,7 @@ describe("Invite Management", () => { test("throws DatabaseError when invite doesn't exist", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Record not found", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "0.0.1", }); vi.mocked(prisma.invite.delete).mockRejectedValue(errToThrow); diff --git a/apps/web/modules/ee/analysis/charts/lib/charts.test.ts b/apps/web/modules/ee/analysis/charts/lib/charts.test.ts index 78d5810f737e..7c58b806b7f5 100644 --- a/apps/web/modules/ee/analysis/charts/lib/charts.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/charts.test.ts @@ -167,6 +167,23 @@ describe("Chart Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.chart.create).mockRejectedValue(new Error("boom")); + const { createChart } = await import("./charts"); + + await expect( + createChart({ + workspaceId: mockWorkspaceId, + name: "Test", + type: "bar", + query: {}, + config: {}, + feedbackDirectoryId: mockFeedbackDirectoryId, + createdBy: mockUserId, + }) + ).rejects.toThrow("boom"); + }); }); describe("updateChart", () => { @@ -228,6 +245,26 @@ describe("Chart Service", () => { name: "InvalidInputError", }); }); + + test("throws DatabaseError on other Prisma errors", async () => { + mockTxChart.findFirst.mockResolvedValue(mockChart); + mockTxChart.update.mockRejectedValue(makePrismaError("P2010")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart })); + const { updateChart } = await import("./charts"); + + await expect(updateChart(mockChartId, mockWorkspaceId, { name: "Updated" })).rejects.toMatchObject({ + name: "DatabaseError", + }); + }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxChart.findFirst.mockResolvedValue(mockChart); + mockTxChart.update.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart })); + const { updateChart } = await import("./charts"); + + await expect(updateChart(mockChartId, mockWorkspaceId, { name: "Updated" })).rejects.toThrow("boom"); + }); }); describe("duplicateChart", () => { @@ -332,6 +369,40 @@ describe("Chart Service", () => { expect(prisma.chart.create).not.toHaveBeenCalled(); }); + + test("throws DatabaseError on other Prisma errors", async () => { + vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P2010")); + const { duplicateChart } = await import("./charts"); + + await expect(duplicateChart(mockChartId, mockWorkspaceId, mockUserId)).rejects.toMatchObject({ + name: "DatabaseError", + }); + }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.chart.findFirst).mockRejectedValue(new Error("boom")); + const { duplicateChart } = await import("./charts"); + + await expect(duplicateChart(mockChartId, mockWorkspaceId, mockUserId)).rejects.toThrow("boom"); + }); + + test("throws DatabaseError when looking up copy names hits a Prisma error", async () => { + vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any); + vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P2010")); + const { duplicateChart } = await import("./charts"); + + await expect(duplicateChart(mockChartId, mockWorkspaceId, mockUserId)).rejects.toMatchObject({ + name: "DatabaseError", + }); + }); + + test("rethrows non-Prisma errors raised while looking up copy names", async () => { + vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any); + vi.mocked(prisma.chart.findMany).mockRejectedValue(new Error("boom")); + const { duplicateChart } = await import("./charts"); + + await expect(duplicateChart(mockChartId, mockWorkspaceId, mockUserId)).rejects.toThrow("boom"); + }); }); describe("deleteChart", () => { @@ -371,6 +442,14 @@ describe("Chart Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxChart.findFirst.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart })); + const { deleteChart } = await import("./charts"); + + await expect(deleteChart(mockChartId, mockWorkspaceId)).rejects.toThrow("boom"); + }); }); describe("getChart", () => { @@ -406,6 +485,13 @@ describe("Chart Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.chart.findFirst).mockRejectedValue(new Error("boom")); + const { getChart } = await import("./charts"); + + await expect(getChart(mockChartId, mockWorkspaceId)).rejects.toThrow("boom"); + }); }); describe("getCharts", () => { @@ -457,6 +543,13 @@ describe("Chart Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.chart.findMany).mockRejectedValue(new Error("boom")); + const { getCharts } = await import("./charts"); + + await expect(getCharts(mockWorkspaceId)).rejects.toThrow("boom"); + }); }); describe("getChartsWithCreator", () => { @@ -488,5 +581,12 @@ describe("Chart Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.chart.findMany).mockRejectedValue(new Error("boom")); + const { getChartsWithCreator } = await import("./charts"); + + await expect(getChartsWithCreator(mockWorkspaceId)).rejects.toThrow("boom"); + }); }); }); diff --git a/apps/web/modules/ee/analysis/charts/lib/charts.ts b/apps/web/modules/ee/analysis/charts/lib/charts.ts index 11cc63cac5c1..9ad9d8fbdba3 100644 --- a/apps/web/modules/ee/analysis/charts/lib/charts.ts +++ b/apps/web/modules/ee/analysis/charts/lib/charts.ts @@ -1,10 +1,9 @@ import "server-only"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZChartConfig, ZChartQuery } from "@formbricks/types/analysis"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; import { validateCubeQueryMembers } from "@/modules/ee/analysis/api/lib/cube-query"; import { @@ -46,10 +45,10 @@ export const createChart = async (data: TChartCreateInput): Promise => { select: selectChart, }); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new InvalidInputError("A chart with this name already exists"); - } + if (isUniqueConstraintError(error)) { + throw new InvalidInputError("A chart with this name already exists"); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -94,10 +93,10 @@ export const updateChart = async ( if (error instanceof ResourceNotFoundError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new InvalidInputError("A chart with this name already exists"); - } + if (isUniqueConstraintError(error)) { + throw new InvalidInputError("A chart with this name already exists"); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -129,7 +128,7 @@ const getUniqueCopyName = async (baseName: string, workspaceId: string): Promise } return `${stripped} (copy ${n})`; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -168,7 +167,7 @@ export const duplicateChart = async ( if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -199,7 +198,7 @@ export const deleteChart = async (chartId: string, workspaceId: string): Promise if (error instanceof ResourceNotFoundError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -224,7 +223,7 @@ export const getChart = async (chartId: string, workspaceId: string): Promise { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.dashboard.create).mockRejectedValue(new Error("boom")); + const { createDashboard } = await import("./dashboards"); + + await expect( + createDashboard({ + workspaceId: mockWorkspaceId, + name: "Test", + createdBy: mockUserId, + }) + ).rejects.toThrow("boom"); + }); }); describe("updateDashboard", () => { @@ -205,6 +218,34 @@ describe("Dashboard Service", () => { name: "InvalidInputError", }); }); + + test("throws DatabaseError on other Prisma errors", async () => { + mockTxDashboard.findFirst.mockResolvedValue(mockDashboard); + mockTxDashboard.update.mockRejectedValue(makePrismaError("P2010")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { updateDashboard } = await import("./dashboards"); + + await expect( + updateDashboard(mockDashboardId, mockWorkspaceId, { name: "Updated" }) + ).rejects.toMatchObject({ + name: "DatabaseError", + }); + }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxDashboard.findFirst.mockResolvedValue(mockDashboard); + mockTxDashboard.update.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { updateDashboard } = await import("./dashboards"); + + await expect(updateDashboard(mockDashboardId, mockWorkspaceId, { name: "Updated" })).rejects.toThrow( + "boom" + ); + }); }); describe("deleteDashboard", () => { @@ -246,6 +287,16 @@ describe("Dashboard Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxDashboard.findFirst.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { deleteDashboard } = await import("./dashboards"); + + await expect(deleteDashboard(mockDashboardId, mockWorkspaceId)).rejects.toThrow("boom"); + }); }); describe("duplicateDashboard", () => { @@ -368,6 +419,16 @@ describe("Dashboard Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxDashboard.findFirst.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { duplicateDashboard } = await import("./dashboards"); + + await expect(duplicateDashboard(mockDashboardId, mockWorkspaceId, mockUserId)).rejects.toThrow("boom"); + }); }); describe("getDashboard", () => { @@ -422,6 +483,13 @@ describe("Dashboard Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(new Error("boom")); + const { getDashboard } = await import("./dashboards"); + + await expect(getDashboard(mockDashboardId, mockWorkspaceId)).rejects.toThrow("boom"); + }); }); describe("getDashboards", () => { @@ -466,6 +534,13 @@ describe("Dashboard Service", () => { }); }); + test("rethrows non-Prisma errors unchanged", async () => { + vi.mocked(prisma.dashboard.findMany).mockRejectedValue(new Error("boom")); + const { getDashboards } = await import("./dashboards"); + + await expect(getDashboards(mockWorkspaceId)).rejects.toThrow("boom"); + }); + test("returns containsChart per dashboard when chartId is provided", async () => { const dashboards = [ { @@ -591,6 +666,18 @@ describe("Dashboard Service", () => { name: "DatabaseError", }); }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxDashboard.findFirst.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { updateWidgetLayouts } = await import("./dashboards"); + + await expect(updateWidgetLayouts(mockDashboardId, mockWorkspaceId, widgetUpdates)).rejects.toThrow( + "boom" + ); + }); }); describe("addChartToDashboard", () => { @@ -731,6 +818,50 @@ describe("Dashboard Service", () => { name: "InvalidInputError", }); }); + + test("throws DatabaseError on other Prisma errors", async () => { + mockTxChart.findFirst.mockResolvedValue({ id: mockChartId }); + mockTxDashboard.findFirst.mockResolvedValue(mockDashboard); + mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } }); + mockTxWidget.findMany.mockResolvedValue([]); + mockTxWidget.create.mockRejectedValue(makePrismaError("P2010")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { addChartToDashboard } = await import("./dashboards"); + + await expect( + addChartToDashboard({ + dashboardId: mockDashboardId, + chartId: mockChartId, + workspaceId: mockWorkspaceId, + layout: mockLayout, + }) + ).rejects.toMatchObject({ + name: "DatabaseError", + }); + }); + + test("rethrows non-Prisma errors unchanged", async () => { + mockTxChart.findFirst.mockResolvedValue({ id: mockChartId }); + mockTxDashboard.findFirst.mockResolvedValue(mockDashboard); + mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } }); + mockTxWidget.findMany.mockResolvedValue([]); + mockTxWidget.create.mockRejectedValue(new Error("boom")); + vi.mocked(prisma.$transaction).mockImplementation((cb: any) => + cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget }) + ); + const { addChartToDashboard } = await import("./dashboards"); + + await expect( + addChartToDashboard({ + dashboardId: mockDashboardId, + chartId: mockChartId, + workspaceId: mockWorkspaceId, + layout: mockLayout, + }) + ).rejects.toThrow("boom"); + }); }); describe("removeWidgetFromDashboard", () => { diff --git a/apps/web/modules/ee/analysis/dashboards/lib/dashboards.ts b/apps/web/modules/ee/analysis/dashboards/lib/dashboards.ts index f7832d36e949..894a270a831c 100644 --- a/apps/web/modules/ee/analysis/dashboards/lib/dashboards.ts +++ b/apps/web/modules/ee/analysis/dashboards/lib/dashboards.ts @@ -1,10 +1,9 @@ import "server-only"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; import { TWidgetLayout } from "@formbricks/types/analysis"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; import { selectChart } from "@/modules/ee/analysis/charts/lib/charts"; import { @@ -45,10 +44,10 @@ export const createDashboard = async (data: TDashboardCreateInput): Promise if (error instanceof ResourceNotFoundError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -191,7 +190,7 @@ export const getDashboards = async ( containsChart: (widgets?.length ?? 0) > 0, })); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -253,7 +252,7 @@ export const duplicateDashboard = async ( if (error instanceof ResourceNotFoundError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -313,7 +312,7 @@ export const updateWidgetLayouts = async ( if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -338,7 +337,7 @@ export const removeWidgetFromDashboard = async ( return await prisma.dashboardWidget.delete({ where: { id: widgetId } }); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -417,10 +416,10 @@ export const addChartToDashboard = async (data: TAddWidgetInput) => { if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) { throw error; } - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new InvalidInputError("This chart is already on the dashboard"); - } + if (isUniqueConstraintError(error)) { + throw new InvalidInputError("This chart is already on the dashboard"); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts index d664bf238f2e..e11244f6100a 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts @@ -84,6 +84,13 @@ describe("getContactAttributeKeys", () => { vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(errToThrow); await expect(getContactAttributeKeys(mockWorkspaceIds)).rejects.toThrow(errorMessage); }); + + test("should re-throw non-Prisma errors", async () => { + const mockWorkspaceIds = ["ws1"]; + const errorMessage = "boom"; + vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(new Error(errorMessage)); + await expect(getContactAttributeKeys(mockWorkspaceIds)).rejects.toThrow(errorMessage); + }); }); describe("createContactAttributeKey", () => { @@ -166,6 +173,21 @@ describe("createContactAttributeKey", () => { await expect(createContactAttributeKey(workspaceId, createInput)).rejects.toThrow(errorMessage); }); + test("should throw DatabaseError when attribute key already exists (unique constraint)", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "test", + }) + ); + + await expect(createContactAttributeKey(workspaceId, createInput)).rejects.toThrow(DatabaseError); + await expect(createContactAttributeKey(workspaceId, createInput)).rejects.toThrow( + "Attribute key already exists" + ); + }); + test("should throw generic error if non-Prisma error occurs during create", async () => { vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0); const errorMessage = "Some other create error"; diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts index 3be3df2aac84..e9e71c778400 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts @@ -1,10 +1,9 @@ import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { DatabaseError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors"; import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier"; import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { @@ -21,7 +20,7 @@ export const getContactAttributeKeys = reactCache( return contactAttributeKeys; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -62,11 +61,10 @@ export const createContactAttributeKey = async ( return contactAttributeKey; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new DatabaseError("Attribute key already exists"); - } - + if (isUniqueConstraintError(error)) { + throw new DatabaseError("Attribute key already exists"); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; diff --git a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts index 91c8bff680ed..a057d82429c4 100644 --- a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts +++ b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts @@ -155,7 +155,9 @@ describe("createContactAttributeKey", () => { }); test("rethrows unknown prisma error codes", async () => { - const err = Object.assign(new Error("Some prisma error"), { code: PrismaErrorType.RecordDoesNotExist }); + const err = Object.assign(new Error("Some prisma error"), { + code: "P2016", // a Prisma code the service does not handle → must be rethrown as-is + }); vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(err); try { diff --git a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts index 6ade2dd9edde..1a8365f29271 100644 --- a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts +++ b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts @@ -208,6 +208,13 @@ describe("FeedbackDirectory Service", () => { await expect(getFeedbackDirectoryDetails(mockDirectoryId)).rejects.toThrow(DatabaseError); }); + + test("re-throws unexpected errors", async () => { + const error = new Error("Unexpected error"); + vi.mocked(prisma.feedbackDirectory.findUnique).mockRejectedValueOnce(error); + + await expect(getFeedbackDirectoryDetails(mockDirectoryId)).rejects.toThrow(error); + }); }); describe("getFeedbackDirectoryAuthContext", () => { @@ -256,6 +263,13 @@ describe("FeedbackDirectory Service", () => { await expect(getFeedbackDirectoryAuthContext(mockDirectoryId)).rejects.toThrow(DatabaseError); }); + + test("re-throws unexpected errors", async () => { + const error = new Error("Unexpected error"); + vi.mocked(prisma.feedbackDirectory.findUnique).mockRejectedValueOnce(error); + + await expect(getFeedbackDirectoryAuthContext(mockDirectoryId)).rejects.toThrow(error); + }); }); describe("createFeedbackDirectory", () => { diff --git a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts index 7fa6beffa5d1..61546395be58 100644 --- a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts +++ b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts @@ -7,6 +7,7 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { isDirectoryWorkspaceFkViolation } from "@/lib/feedback-source/service"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; import { TFeedbackDirectory, @@ -62,7 +63,7 @@ export const getFeedbackDirectories = reactCache( feedbackSourceCount: dir._count.feedbackSources, })); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -98,7 +99,7 @@ export const getFeedbackDirectoriesByWorkspaceId = reactCache( }); return rows.map((r) => r.feedbackDirectory); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -135,7 +136,7 @@ export const getFeedbackDirectoryAuthContext = reactCache( isArchived: directory.isArchived, }; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -184,7 +185,7 @@ export const getWorkspaceFeedbackDirectoryAccess = reactCache( return Array.from(accessByWorkspaceId.values()); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -287,7 +288,7 @@ export const getFeedbackDirectoryDetails = reactCache( return mapFeedbackDirectoryDetails(directory); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -340,10 +341,10 @@ export const createFeedbackDirectory = async ( return directory.id; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE"); - } + if (isUniqueConstraintError(error)) { + throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE"); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -589,13 +590,13 @@ export const updateFeedbackDirectory = async ( return true; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE"); - } - if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) { - throw new ResourceNotFoundError("FeedbackDirectory", directoryId); - } + if (isUniqueConstraintError(error)) { + throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE"); + } + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { + throw new ResourceNotFoundError("FeedbackDirectory", directoryId); + } + if (isPrismaKnownRequestError(error)) { // Defense-in-depth: the composite FeedbackSource FK fires on the join-row deletion if the // pre-flight count above was bypassed (e.g. a concurrent source create). Other FK violations // in this transaction (e.g. a concurrently deleted workspace) fall through to DatabaseError. diff --git a/apps/web/modules/ee/quotas/lib/quotas.test.ts b/apps/web/modules/ee/quotas/lib/quotas.test.ts index f967485ed575..fd77f5602c1a 100644 --- a/apps/web/modules/ee/quotas/lib/quotas.test.ts +++ b/apps/web/modules/ee/quotas/lib/quotas.test.ts @@ -212,14 +212,18 @@ describe("Quota Service", () => { expect(arg.data).not.toHaveProperty("surveyId"); }); - test("should throw DatabaseError when quota not found", async () => { + test("should throw ResourceNotFoundError when quota not found", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { - code: "P2015", + code: "P2025", clientVersion: "1.0.0", }); vi.mocked(prisma.surveyQuota.update).mockRejectedValue(prismaError); - await expect(updateQuota(updateInput, mockQuotaId)).rejects.toThrow(ResourceNotFoundError); + await expect(updateQuota(updateInput, mockQuotaId)).rejects.toMatchObject({ + name: "ResourceNotFoundError", + resourceType: "Quota", + resourceId: mockQuotaId, + }); }); test("should throw DatabaseError on other Prisma errors", async () => { @@ -250,14 +254,18 @@ describe("Quota Service", () => { }); }); - test("should throw DatabaseError when quota not found", async () => { + test("should throw ResourceNotFoundError when quota not found", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { - code: "P2015", + code: "P2025", clientVersion: "1.0.0", }); vi.mocked(prisma.surveyQuota.delete).mockRejectedValue(prismaError); - await expect(deleteQuota(mockQuotaId)).rejects.toThrow(ResourceNotFoundError); + await expect(deleteQuota(mockQuotaId)).rejects.toMatchObject({ + name: "ResourceNotFoundError", + resourceType: "Quota", + resourceId: mockQuotaId, + }); }); test("should throw DatabaseError on other Prisma errors", async () => { diff --git a/apps/web/modules/ee/quotas/lib/quotas.ts b/apps/web/modules/ee/quotas/lib/quotas.ts index c987043c3825..833edc931566 100644 --- a/apps/web/modules/ee/quotas/lib/quotas.ts +++ b/apps/web/modules/ee/quotas/lib/quotas.ts @@ -6,6 +6,7 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TSurveyQuota, TSurveyQuotaInput } from "@formbricks/types/quota"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; export const getQuota = reactCache(async (quotaId: string): Promise => { @@ -23,7 +24,7 @@ export const getQuota = reactCache(async (quotaId: string): Promise => { return deletedQuota; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.RecordDoesNotExist) { - throw new ResourceNotFoundError("Quota not found", error.message); - } - + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { + throw new ResourceNotFoundError("Quota", quotaId); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -133,7 +129,7 @@ export const reduceQuotaLimits = async (quotaIds: string[], tx?: Prisma.Transact }, }); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; diff --git a/apps/web/modules/ee/role-management/lib/invite.test.ts b/apps/web/modules/ee/role-management/lib/invite.test.ts index 0c3851a0a29c..797e5d5d8edc 100644 --- a/apps/web/modules/ee/role-management/lib/invite.test.ts +++ b/apps/web/modules/ee/role-management/lib/invite.test.ts @@ -55,7 +55,7 @@ describe("invite.ts", () => { test("should throw ResourceNotFoundError when invite does not exist", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Record does not exist", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", }); diff --git a/apps/web/modules/ee/role-management/lib/invite.ts b/apps/web/modules/ee/role-management/lib/invite.ts index a35f9d86cb70..da669122db3e 100644 --- a/apps/web/modules/ee/role-management/lib/invite.ts +++ b/apps/web/modules/ee/role-management/lib/invite.ts @@ -19,7 +19,7 @@ export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Invite", inviteId); } else { diff --git a/apps/web/modules/ee/role-management/lib/membership.test.ts b/apps/web/modules/ee/role-management/lib/membership.test.ts index deb9620837da..2ecb93718f17 100644 --- a/apps/web/modules/ee/role-management/lib/membership.test.ts +++ b/apps/web/modules/ee/role-management/lib/membership.test.ts @@ -58,7 +58,7 @@ describe("updateMembership", () => { test("should throw ResourceNotFoundError when membership doesn't exist", async () => { const error = new Prisma.PrismaClientKnownRequestError("Record does not exist", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", }); diff --git a/apps/web/modules/ee/role-management/lib/membership.ts b/apps/web/modules/ee/role-management/lib/membership.ts index 9391966e79cb..42b5f71d4de4 100644 --- a/apps/web/modules/ee/role-management/lib/membership.ts +++ b/apps/web/modules/ee/role-management/lib/membership.ts @@ -64,8 +64,7 @@ export const updateMembership = async ( } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - (error.code === PrismaErrorType.RecordDoesNotExist || - error.code === PrismaErrorType.RelatedRecordDoesNotExist) + (error.code === PrismaErrorType.RelatedRecordNotFound || error.code === PrismaErrorType.RecordNotFound) ) { throw new ResourceNotFoundError("Membership", `userId: ${userId}, organizationId: ${organizationId}`); } diff --git a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.test.ts b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.test.ts index 3fe67cc56e0f..b39504a44c08 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.test.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.test.ts @@ -88,6 +88,20 @@ describe("organization", () => { }); expect(prisma.organization.update).not.toHaveBeenCalled(); }); + + test("should throw ResourceNotFoundError when the update targets a missing organization (P2025)", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record to update not found", { + code: "P2025", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.organization.findUnique).mockResolvedValue({ whitelabel: {} } as any); + vi.mocked(prisma.organization.update).mockRejectedValue(prismaError); + + await expect( + updateOrganizationEmailLogoUrl("clg123456789012345678901234", "new-logo.png") + ).rejects.toThrow(ResourceNotFoundError); + }); }); describe("removeOrganizationEmailLogoUrl", () => { @@ -152,6 +166,23 @@ describe("organization", () => { }); expect(prisma.organization.update).not.toHaveBeenCalled(); }); + + test("should throw ResourceNotFoundError when the update targets a missing organization (P2025)", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record to update not found", { + code: "P2025", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.organization.findUnique).mockResolvedValue({ + whitelabel: {}, + workspaces: [], + } as any); + vi.mocked(prisma.organization.update).mockRejectedValue(prismaError); + + await expect(removeOrganizationEmailLogoUrl("clg123456789012345678901234")).rejects.toThrow( + ResourceNotFoundError + ); + }); }); describe("getOrganizationLogoUrl", () => { diff --git a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts index 298e5594cf6c..5691483ef0e5 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts @@ -43,7 +43,7 @@ export const updateOrganizationEmailLogoUrl = async ( } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Organization", organizationId); } @@ -86,7 +86,7 @@ export const removeOrganizationEmailLogoUrl = async (organizationId: string): Pr } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Organization", organizationId); } diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.test.ts b/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.test.ts index c6033f847dbd..72be41c42503 100644 --- a/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.test.ts +++ b/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.test.ts @@ -126,7 +126,7 @@ describe("favicon organization", () => { }; const mockError = new Prisma.PrismaClientKnownRequestError("Record does not exist", { - code: "P2015", // PrismaErrorType.RecordDoesNotExist + code: "P2025", // PrismaErrorType.RecordNotFound clientVersion: "2.0.0", }); diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts b/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts index 50439984d722..c69f4426ec21 100644 --- a/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts +++ b/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts @@ -39,7 +39,7 @@ export const updateOrganizationFaviconUrl = async ( } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Organization", organizationId); } diff --git a/apps/web/modules/integrations/webhooks/lib/webhook.ts b/apps/web/modules/integrations/webhooks/lib/webhook.ts index 44fe558b86b6..b06d8bb67795 100644 --- a/apps/web/modules/integrations/webhooks/lib/webhook.ts +++ b/apps/web/modules/integrations/webhooks/lib/webhook.ts @@ -95,7 +95,7 @@ export const deleteWebhook = async (id: string): Promise => { } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RelatedRecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("Webhook", id); } diff --git a/apps/web/modules/organization/settings/teams/lib/invite.ts b/apps/web/modules/organization/settings/teams/lib/invite.ts index 0aed99b2c6e9..ccb3aec77cc1 100644 --- a/apps/web/modules/organization/settings/teams/lib/invite.ts +++ b/apps/web/modules/organization/settings/teams/lib/invite.ts @@ -2,6 +2,7 @@ import { cache as reactCache } from "react"; import { z } from "zod"; import { prisma } from "@formbricks/database"; import { Invite, Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; import { DatabaseError, InvalidInputError, @@ -25,7 +26,7 @@ export const refreshInviteExpiration = async (inviteId: string): Promise return updatedInvite; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2025") { + if (error.code === PrismaErrorType.RecordNotFound) { throw new ResourceNotFoundError("Invite", inviteId); } throw new DatabaseError(error.message); diff --git a/apps/web/modules/survey/components/template-list/lib/user.test.ts b/apps/web/modules/survey/components/template-list/lib/user.test.ts index d878da9b911a..ed8f4c0c695e 100644 --- a/apps/web/modules/survey/components/template-list/lib/user.test.ts +++ b/apps/web/modules/survey/components/template-list/lib/user.test.ts @@ -66,7 +66,7 @@ describe("updateUser", () => { test("throws ResourceNotFoundError when user does not exist", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "1.0.0", }); diff --git a/apps/web/modules/survey/components/template-list/lib/user.ts b/apps/web/modules/survey/components/template-list/lib/user.ts index cb5951343e6b..36d683fe4595 100644 --- a/apps/web/modules/survey/components/template-list/lib/user.ts +++ b/apps/web/modules/survey/components/template-list/lib/user.ts @@ -32,7 +32,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === PrismaErrorType.RecordDoesNotExist + error.code === PrismaErrorType.RecordNotFound ) { throw new ResourceNotFoundError("User", personId); } diff --git a/apps/web/modules/survey/lib/slug.test.ts b/apps/web/modules/survey/lib/slug.test.ts index 16256e97e148..3fbc359e60c5 100644 --- a/apps/web/modules/survey/lib/slug.test.ts +++ b/apps/web/modules/survey/lib/slug.test.ts @@ -51,6 +51,12 @@ describe("Slug Library Tests", () => { await expect(getSurveyBySlug("error-slug")).rejects.toThrow(DatabaseError); }); + + test("should rethrow non-prisma errors", async () => { + vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(new Error("boom")); + + await expect(getSurveyBySlug("error-slug")).rejects.toThrow("boom"); + }); }); describe("updateSurveySlug", () => { @@ -99,6 +105,12 @@ describe("Slug Library Tests", () => { await expect(updateSurveySlug("survey_123", "new-slug")).rejects.toThrow(DatabaseError); }); + + test("should rethrow non-prisma errors", async () => { + vi.mocked(prisma.survey.update).mockRejectedValueOnce(new Error("boom")); + + await expect(updateSurveySlug("survey_123", "new-slug")).rejects.toThrow("boom"); + }); }); describe("getSurveysWithSlugsByOrganizationId", () => { @@ -136,5 +148,11 @@ describe("Slug Library Tests", () => { await expect(getSurveysWithSlugsByOrganizationId("org_123")).rejects.toThrow(DatabaseError); }); + + test("should rethrow non-prisma errors", async () => { + vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(new Error("boom")); + + await expect(getSurveysWithSlugsByOrganizationId("org_123")).rejects.toThrow("boom"); + }); }); }); diff --git a/apps/web/modules/survey/lib/slug.ts b/apps/web/modules/survey/lib/slug.ts index 589feb003be2..b4e849905c2c 100644 --- a/apps/web/modules/survey/lib/slug.ts +++ b/apps/web/modules/survey/lib/slug.ts @@ -1,9 +1,10 @@ import "server-only"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TSurveyStatus } from "@formbricks/types/surveys/types"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; export interface TSurveyBySlug { id: string; @@ -33,7 +34,7 @@ export const getSurveyBySlug = reactCache(async (slug: string): Promise { }); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2025") { + if (error.code === PrismaErrorType.RecordNotFound) { logger.warn({ surveyId }, "Survey not found during delete"); throw new ResourceNotFoundError("Survey", surveyId); } diff --git a/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx b/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx index f2b34fce7645..54857cd909a3 100644 --- a/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx +++ b/apps/web/modules/survey/multi-language-surveys/components/language-view.tsx @@ -88,6 +88,18 @@ export const LanguageView = ({ const enabledLanguages = getEnabledLanguages(localSurvey.languages); + // Every non-default language to show in the table: the union of workspace languages and languages + // already stored on the survey. Including survey-only languages (e.g. one later removed from the + // workspace) guarantees a language present in the survey object always stays visible here, so it + // can be managed or removed instead of silently lingering in the data (ENG-2001). + const nonDefaultLanguageCodes = useMemo(() => { + const codes = new Set(); + workspaceLanguages.forEach((l) => codes.add(l.code)); + localSurvey.languages.forEach((sl) => codes.add(sl.language.code)); + if (defaultLanguage) codes.delete(defaultLanguage.code); + return [...codes]; + }, [workspaceLanguages, localSurvey.languages, defaultLanguage]); + // Check AI availability once on mount useEffect(() => { let isCurrent = true; @@ -186,8 +198,14 @@ export const LanguageView = ({ newLanguages.push({ enabled: true, default: true, language }); } + // The default language is always keyed as "default" throughout the survey object. Strip any + // stale/empty code key for the promoted language so it can never end up as the default while + // its own code key sits empty next to a populated "default" — the state that makes the response + // API reject valid choice answers (ENG-2001). + const cleanedSurvey = removeLanguageKeysFromSurvey(localSurvey, language.code); + setConfirmationModalInfo((prev) => ({ ...prev, open: false })); - setLocalSurvey({ ...localSurvey, languages: newLanguages }); + setLocalSurvey({ ...cleanedSurvey, languages: newLanguages }); }; const handleToggleLanguage = (code: string) => { @@ -231,13 +249,19 @@ export const LanguageView = ({ const handleRemoveTranslations = (code: string) => { const label = getLanguageLabel(code, locale) ?? code; + const progress = computeTranslationProgress(translatableStrings, code); + const hasTranslations = progress.translated > 0; setConfirmationModalInfo({ open: true, title: `${t("workspace.surveys.edit.remove_translations")}: ${label}`, - body: t("workspace.surveys.edit.this_will_remove_the_language_and_all_its_translations"), + body: hasTranslations + ? t("workspace.surveys.edit.this_will_remove_the_language_and_all_its_translations") + : t("workspace.surveys.edit.this_will_remove_the_language_from_this_survey"), buttonText: t("workspace.surveys.edit.remove_translations"), buttonVariant: "destructive", onConfirm: () => { + // Fully strip the language's keys from the survey object (not just blank the strings) so a + // removed language can never linger and later corrupt the default (ENG-2001). const cleanedSurvey = removeLanguageKeysFromSurvey(localSurvey, code); const updatedLanguages = localSurvey.languages.filter((l) => l.language.code !== code); setLocalSurvey({ ...cleanedSurvey, languages: updatedLanguages }); @@ -254,7 +278,16 @@ export const LanguageView = ({ buttonText: t("workspace.surveys.edit.remove_translations"), buttonVariant: "destructive", onConfirm: () => { - updateSurveyTranslations(localSurvey, []); + // Actually strip every non-default language's keys from the survey object. Using + // updateSurveyTranslations([]) here only re-seeds keys and leaves stale empty code keys + // behind, which can later be promoted into a broken default (ENG-2001). + let cleanedSurvey = localSurvey; + for (const lang of localSurvey.languages) { + if (!lang.default) { + cleanedSurvey = removeLanguageKeysFromSurvey(cleanedSurvey, lang.language.code); + } + } + setLocalSurvey({ ...cleanedSurvey, languages: [] }); setIsMultiLanguageActivated(false); setConfirmationModalInfo((prev) => ({ ...prev, open: false })); }, @@ -385,84 +418,79 @@ export const LanguageView = ({ - {/* Non-default language rows — all workspace languages except default */} - {workspaceLanguages - .filter((pl) => pl.code !== defaultLanguage.code) - .map((pl) => { - const surveyLang = localSurvey.languages.find((sl) => sl.language.code === pl.code); - const inSurvey = !!surveyLang; - const enabled = surveyLang?.enabled ?? false; - const progress = inSurvey - ? computeTranslationProgress(translatableStrings, pl.code) - : null; - - return ( - { - if (inSurvey) { - openTranslationModal(pl.code); - } - }}> - - {getLanguageLabel(pl.code, locale)} - - {pl.code} - - {inSurvey && progress && ( -
-
-
-
- { + const surveyLang = localSurvey.languages.find((sl) => sl.language.code === code); + const inSurvey = !!surveyLang; + const enabled = surveyLang?.enabled ?? false; + const progress = inSurvey ? computeTranslationProgress(translatableStrings, code) : null; + + return ( + { + if (inSurvey) { + openTranslationModal(code); + } + }}> + + {getLanguageLabel(code, locale)} + + {code} + + {inSurvey && progress && ( +
+
+
- {progress.translated}/{progress.total} - -
- )} - - e.stopPropagation()}> - handleToggleLanguage(pl.code)} /> - - e.stopPropagation()}> - {inSurvey && ( - - - - - - openTranslationModal(pl.code)}> - {t("workspace.surveys.edit.manage_translations")} - - {progress && progress.translated > 0 && ( - handleRemoveTranslations(pl.code)}> - {t("workspace.surveys.edit.remove_translations")} - + "h-full rounded-full transition-all", + getProgressColor(progress.percentage) )} - - - )} - - - ); - })} + style={{ width: `${progress.percentage}%` }} + /> +
+ + {progress.translated}/{progress.total} + +
+ )} + + e.stopPropagation()}> + handleToggleLanguage(code)} /> + + e.stopPropagation()}> + {inSurvey && ( + + + + + + openTranslationModal(code)}> + {t("workspace.surveys.edit.manage_translations")} + + handleRemoveTranslations(code)}> + {t("workspace.surveys.edit.remove_translations")} + + + + )} + + + ); + })}
diff --git a/apps/web/modules/workspaces/settings/lib/tag.test.ts b/apps/web/modules/workspaces/settings/lib/tag.test.ts index 6353a812b446..9c96ae06b63b 100644 --- a/apps/web/modules/workspaces/settings/lib/tag.test.ts +++ b/apps/web/modules/workspaces/settings/lib/tag.test.ts @@ -66,7 +66,7 @@ describe("tag lib", () => { }); test("returns tag_not_found on tag not found", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { - code: PrismaErrorType.RecordDoesNotExist, + code: PrismaErrorType.RecordNotFound, clientVersion: "test", }); vi.mocked(prisma.tag.delete).mockRejectedValueOnce(prismaError); diff --git a/apps/web/modules/workspaces/settings/lib/tag.ts b/apps/web/modules/workspaces/settings/lib/tag.ts index 816588332720..5ff09f3ac013 100644 --- a/apps/web/modules/workspaces/settings/lib/tag.ts +++ b/apps/web/modules/workspaces/settings/lib/tag.ts @@ -1,10 +1,10 @@ import "server-only"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId } from "@formbricks/types/common"; import { Result, err, ok } from "@formbricks/types/error-handlers"; import { TTag } from "@formbricks/types/tags"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; import { TagError } from "@/modules/workspaces/settings/types/tag"; @@ -22,13 +22,11 @@ export const deleteTag = async ( return ok(tag); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.RecordDoesNotExist) { - return err({ - code: TagError.TAG_NOT_FOUND, - message: "Tag not found", - }); - } + if (isPrismaKnownRequestError(error, PrismaErrorType.RecordNotFound)) { + return err({ + code: TagError.TAG_NOT_FOUND, + message: "Tag not found", + }); } return err({ @@ -50,13 +48,11 @@ export const updateTagName = async ( return ok(tag); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - return err({ - code: TagError.TAG_NAME_ALREADY_EXISTS, - message: "Tag with this name already exists", - }); - } + if (isUniqueConstraintError(error)) { + return err({ + code: TagError.TAG_NAME_ALREADY_EXISTS, + message: "Tag with this name already exists", + }); } return err({ diff --git a/apps/web/modules/workspaces/settings/lib/workspace.test.ts b/apps/web/modules/workspaces/settings/lib/workspace.test.ts index 2eec03bfc247..e173f742afde 100644 --- a/apps/web/modules/workspaces/settings/lib/workspace.test.ts +++ b/apps/web/modules/workspaces/settings/lib/workspace.test.ts @@ -98,6 +98,20 @@ describe("workspace lib", () => { await expect(updateWorkspace("p1", { name: "Workspace 1" })).rejects.toThrow(); }); + test("throws DatabaseError on PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2010", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.workspace.update).mockRejectedValueOnce(prismaError); + await expect(updateWorkspace("p1", { name: "Workspace 1" })).rejects.toThrow(DatabaseError); + }); + + test("rethrows non-Prisma errors", async () => { + vi.mocked(prisma.workspace.update).mockRejectedValueOnce(new Error("boom")); + await expect(updateWorkspace("p1", { name: "Workspace 1" })).rejects.toThrow("boom"); + }); + test("returns workspace data without Zod validation", async () => { vi.mocked(prisma.workspace.update).mockResolvedValueOnce({ ...baseWorkspace, id: 123 } as any); const result = await updateWorkspace("p1", { name: "Workspace 1" }); diff --git a/apps/web/modules/workspaces/settings/lib/workspace.ts b/apps/web/modules/workspaces/settings/lib/workspace.ts index 515169ed630d..f16dfc8f3430 100644 --- a/apps/web/modules/workspaces/settings/lib/workspace.ts +++ b/apps/web/modules/workspaces/settings/lib/workspace.ts @@ -1,12 +1,12 @@ import "server-only"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; -import { PrismaErrorType } from "@formbricks/database/types/error"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors"; import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formbricks/types/workspace"; import { DEFAULT_LOCALE } from "@/lib/constants"; +import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; import { deleteFilesByWorkspaceId } from "@/modules/storage/service"; @@ -91,7 +91,7 @@ export const updateWorkspace = async ( select: selectWorkspace, }); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -143,10 +143,10 @@ export const createWorkspace = async ( return workspace; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === PrismaErrorType.UniqueConstraintViolation) { - throw new InvalidInputError("A workspace with this name already exists in your organization"); - } + if (isUniqueConstraintError(error)) { + throw new InvalidInputError("A workspace with this name already exists in your organization"); + } + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } throw error; @@ -173,7 +173,7 @@ export const deleteWorkspace = async (workspaceId: string): Promise return workspace; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (isPrismaKnownRequestError(error)) { throw new DatabaseError(error.message); } diff --git a/apps/web/package.json b/apps/web/package.json index f6a5487c13e2..387e0623e694 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -106,7 +106,7 @@ "csv-parse": "6.1.0", "date-fns": "4.1.0", "disposable-email-domains": "1.0.62", - "dompurify": "3.4.11", + "dompurify": "3.4.12", "framer-motion": "12.35.2", "googleapis": "171.4.0", "heic-convert": "2.1.0", diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index d81082db3804..c67c3487dd87 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -10,6 +10,11 @@ import { uploadImageChoicesForPictureSelection, } from "./utils/helper"; +// NOTE: slowMo paces every action in this spec's heavy survey-editor flows. +// It is load-bearing: without it, the question-builder interactions (matrix +// columns, ranking options) hit "element detached from the DOM" races and the +// tests fail. Removing it requires first hardening those waits in +// createSurvey/createSurveyWithLogic (utils/helper.ts). Tracked as a follow-up. test.use({ launchOptions: { slowMo: 150, diff --git a/packages/database/types/error.ts b/packages/database/types/error.ts index 957c62e3608a..51887d2a393f 100644 --- a/packages/database/types/error.ts +++ b/packages/database/types/error.ts @@ -1,6 +1,10 @@ +// Member names follow Prisma's documented error-code semantics. Note the two +// not-found codes are easy to swap: P2015 is the *related* record (a nested/ +// connected record could not be found), while P2025 is the record the operation +// itself targets (the common "row to update/delete not found" case). export enum PrismaErrorType { UniqueConstraintViolation = "P2002", ForeignKeyConstraintViolation = "P2003", - RecordDoesNotExist = "P2015", - RelatedRecordDoesNotExist = "P2025", + RelatedRecordNotFound = "P2015", + RecordNotFound = "P2025", } diff --git a/packages/surveys/src/lib/i18n.test.ts b/packages/surveys/src/lib/i18n.test.ts index e51042567568..39d37268cee1 100644 --- a/packages/surveys/src/lib/i18n.test.ts +++ b/packages/surveys/src/lib/i18n.test.ts @@ -31,5 +31,29 @@ describe("i18n", () => { }; expect(getLocalizedValue(i18nString, "fr")).toBe("French text"); }); + + test("should fall back to default when the requested language key is an empty string", () => { + const i18nString: TI18nString = { + default: "Default text", + "en-GB": "", + }; + expect(getLocalizedValue(i18nString, "en-GB")).toBe("Default text"); + }); + + test("should fall back to default when the requested language key is whitespace-only", () => { + const i18nString: TI18nString = { + default: "Default text", + "en-GB": " ", + }; + expect(getLocalizedValue(i18nString, "en-GB")).toBe("Default text"); + }); + + test("should still return the localized value when it is a non-empty string", () => { + const i18nString: TI18nString = { + default: "Default text", + "en-GB": "British text", + }; + expect(getLocalizedValue(i18nString, "en-GB")).toBe("British text"); + }); }); }); diff --git a/packages/surveys/src/lib/i18n.ts b/packages/surveys/src/lib/i18n.ts index b9f7be8cda31..69d50f917798 100644 --- a/packages/surveys/src/lib/i18n.ts +++ b/packages/surveys/src/lib/i18n.ts @@ -24,8 +24,14 @@ export const getLocalizedValue = ( let result = ""; if (isI18nObject(value)) { - if (typeof value[languageId] === "string") { - result = value[languageId]; + // An empty (or whitespace-only) localized value means the string was never translated for this + // language, so fall back to `default`. This is standard i18n behavior and, critically, keeps + // choice-membership validation correct: a survey whose default language is keyed by its real + // code (e.g. "en-GB": "") with content still under `default` must resolve to the default label + // rather than an empty string, otherwise valid responses are rejected (see ENG-2001). + const localized = value[languageId]; + if (typeof localized === "string" && localized.trim() !== "") { + result = localized; } else { result = value.default; } diff --git a/packages/surveys/src/lib/validation/evaluator.test.ts b/packages/surveys/src/lib/validation/evaluator.test.ts index edda8fbe2100..4ccfc067787e 100644 --- a/packages/surveys/src/lib/validation/evaluator.test.ts +++ b/packages/surveys/src/lib/validation/evaluator.test.ts @@ -17,17 +17,13 @@ const mockT = vi.fn((key: string) => { return key; }) as unknown as TFunction; -// Mock getLocalizedValue and getTranslations -vi.mock("@/lib/i18n", () => { +// Mock only getTranslations; keep the real getLocalizedValue so these tests exercise the actual +// localization fallback the evaluator relies on (a hand-rolled mirror masked ENG-2001). +vi.mock("@/lib/i18n", async (importOriginal) => { + const actual = await importOriginal(); const mockTFn = vi.fn((key: string) => key) as unknown as TFunction; return { - getLocalizedValue: ( - localizedString: Record | undefined, - languageCode: string - ): string => { - if (!localizedString) return ""; - return localizedString[languageCode] || localizedString.default || ""; - }, + ...actual, getTranslations: () => mockTFn, }; }); @@ -256,6 +252,43 @@ describe("validateElementResponse", () => { expect(result.errors[0].ruleId).toBe("invalidOption"); }); + test("should accept a choice by its default label when the response language code's label is empty (ENG-2001)", () => { + // Reproduces the broken-survey state: the default language is keyed by its real code + // ("en-GB") but that key is empty, while the actual label text lives under "default". + // The submitted value is the default label; membership must still resolve via the fallback. + const element = { + id: "single1", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + headline: { default: "Pick", "en-GB": "" }, + required: true, + choices: [ + { id: "c1", label: { default: "AdJUST", "en-GB": "" } }, + { id: "c2", label: { default: "Keep", "en-GB": "" } }, + ], + } as unknown as TSurveyElement; + + // Response recorded under the default language's real code (see getDefaultLanguageCode). + expect(validateElementResponse(element, "AdJUST", "en-GB").valid).toBe(true); + expect(validateElementResponse(element, "c2", "en-GB").valid).toBe(true); // still matches by id + }); + + test("should still reject a genuinely invalid value when labels are empty for the language (ENG-2001)", () => { + const element = { + id: "single1", + type: TSurveyElementTypeEnum.MultipleChoiceSingle, + headline: { default: "Pick", "en-GB": "" }, + required: true, + choices: [ + { id: "c1", label: { default: "AdJUST", "en-GB": "" } }, + { id: "c2", label: { default: "Keep", "en-GB": "" } }, + ], + } as unknown as TSurveyElement; + + const result = validateElementResponse(element, "INJECTED", "en-GB"); + expect(result.valid).toBe(false); + expect(result.errors[0].ruleId).toBe("invalidOption"); + }); + test("should accept arbitrary free text for a single-select with an Other option", () => { const element = { id: "single1", diff --git a/playwright.config.ts b/playwright.config.ts index 6adf3456576d..404a43054afd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,8 +19,11 @@ export default defineConfig({ timeout: 120000, /* Fail the test run after the first failure */ maxFailures: process.env.CI ? undefined : 1, // Allow more failures in CI to avoid cascading shutdowns - /* Opt out of parallel tests on CI. */ - // workers: os.cpus().length, + /* Pin worker count on CI. The GitHub runner has ~4 vCPUs; Playwright's default + is only ~50% of cores (≈2 workers). Running 4 in parallel roughly halves the + test-execution portion of the run. Tune down if CPU contention with the app + server starts causing timeout-driven flakiness. */ + workers: process.env.CI ? 4 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [["html", { outputFolder: "playwright-report", open: "never" }]], /* Shared settings for all the workspaces below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a558b3c19ce5..a07571553dbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,10 +6,10 @@ settings: overrides: '@grpc/grpc-js': 1.14.4 - '@hono/node-server': 1.19.13 - hono: 4.12.25 - protobufjs@7: 7.6.3 - protobufjs@8: 8.6.3 + '@hono/node-server': 2.0.10 + hono: 4.12.27 + protobufjs@7: 7.6.5 + protobufjs@8: 8.6.6 '@tootallnate/once': 2.0.1 '@xmldom/xmldom': 0.9.10 axios: 1.18.0 @@ -19,7 +19,8 @@ overrides: minimatch@10: 10.2.5 postcss: 8.5.14 fast-xml-parser: 5.7.0 - typeorm: 0.3.29 + body-parser: 2.3.0 + typeorm: 0.3.31 js-cookie: 3.0.7 uuid@8: 11.1.1 uuid@9: 11.1.1 @@ -28,7 +29,7 @@ overrides: ws@8: 8.21.0 '@babel/core': 7.29.6 '@opentelemetry/core': 2.8.0 - dompurify: 3.4.11 + dompurify: 3.4.12 esbuild: 0.28.1 undici@7: 7.28.0 form-data@4: 4.0.6 @@ -403,8 +404,8 @@ importers: specifier: 1.0.62 version: 1.0.62 dompurify: - specifier: 3.4.11 - version: 3.4.11 + specifier: 3.4.12 + version: 3.4.12 framer-motion: specifier: 12.35.2 version: 12.35.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2505,11 +2506,11 @@ packages: engines: {node: '>=6'} hasBin: true - '@hono/node-server@1.19.13': - resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} peerDependencies: - hono: 4.12.25 + hono: 4.12.27 '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} @@ -4013,9 +4014,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -6890,8 +6888,8 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} any-promise@1.3.0: @@ -7169,8 +7167,8 @@ packages: bl@6.1.6: resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boolbase@1.0.0: @@ -7661,6 +7659,9 @@ packages: dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -7860,8 +7861,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.11: - resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -8757,8 +8758,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} hosted-git-info@2.8.9: @@ -10598,12 +10599,12 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - protobufjs@7.6.3: - resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} - protobufjs@8.6.3: - resolution: {integrity: sha512-alQyzT0j401LGBtwsqu6uprjR6pfNH1UJf9N6GBFMjIcd+HzTe0/HrjAbFCqun+zvnfLarrxAtMM2xvZ+kFZ5A==} + protobufjs@8.6.6: + resolution: {integrity: sha512-RkLIhE+Tdc2Kpq1F4Uw6OnpAXPca7B+GSDov/GqGCqNBpVyDskQ9yReZfgM+C7xw9AAix873iQZXbBWXj6NzYQ==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -11956,8 +11957,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typeorm@0.3.29: - resolution: {integrity: sha512-wwPEX/df4l72gCmOsrs0otJZYLGA9lLQkUZCkukbsymEycV4zXv2KM7wU7v2r8L01TaCgY9ApSSqHQWBOUhEoQ==} + typeorm@0.3.31: + resolution: {integrity: sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==} engines: {node: '>=16.13.0'} hasBin: true peerDependencies: @@ -11968,13 +11969,13 @@ packages: mongodb: ^5.8.0 || ^6.0.0 mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^6.3.0 + oracledb: ^6.3.0 || ^7.0.0 pg: ^8.5.1 pg-native: ^3.0.0 pg-query-stream: ^4.0.0 redis: ^3.1.1 || ^4.0.0 || ^5.0.14 sql.js: ^1.4.0 - sqlite3: ^5.0.3 + sqlite3: ^5.0.3 || ^6.0.0 ts-node: ^10.7.0 typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 peerDependenciesMeta: @@ -12573,6 +12574,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yargs@18.0.0: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -14191,7 +14196,7 @@ snapshots: redis: 4.7.1 reflect-metadata: 0.2.2 ripemd160: 2.0.3 - typeorm: 0.3.29(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.20.0(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)) + typeorm: 0.3.31(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.20.0(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)) transitivePeerDependencies: - '@google-cloud/spanner' - '@mongodb-js/zstd' @@ -14563,12 +14568,12 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 yargs: 17.7.2 - '@hono/node-server@1.19.13(hono@4.12.25)': + '@hono/node-server@2.0.10(hono@4.12.27)': dependencies: - hono: 4.12.25 + hono: 4.12.27 '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.6))': dependencies: @@ -14990,7 +14995,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.25) + '@hono/node-server': 2.0.10(hono@4.12.27) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -15000,7 +15005,7 @@ snapshots: eventsource-parser: 3.0.8 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.25 + hono: 4.12.27 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -16011,7 +16016,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - protobufjs: 7.6.3 + protobufjs: 7.6.5 '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.0)': dependencies: @@ -16022,7 +16027,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - protobufjs: 8.6.3 + protobufjs: 8.6.6 '@opentelemetry/otlp-transformer@0.217.0(@opentelemetry/api@1.9.0)': dependencies: @@ -16033,7 +16038,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) - protobufjs: 8.6.3 + protobufjs: 8.6.6 '@opentelemetry/otlp-transformer@0.53.0(@opentelemetry/api@1.9.0)': dependencies: @@ -16044,7 +16049,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 1.26.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) - protobufjs: 7.6.3 + protobufjs: 7.6.5 '@opentelemetry/propagator-b3@1.26.0(@opentelemetry/api@1.9.0)': dependencies: @@ -16385,13 +16390,13 @@ snapshots: '@electric-sql/pglite': 0.4.1 '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.13(hono@4.12.25) + '@hono/node-server': 2.0.10(hono@4.12.27) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.2 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.12.25 + hono: 4.12.27 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -16486,8 +16491,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -19691,7 +19694,7 @@ snapshots: ansi-styles@6.2.3: {} - ansis@4.2.0: {} + ansis@4.3.1: {} any-promise@1.3.0: {} @@ -19971,10 +19974,10 @@ snapshots: inherits: 2.0.4 readable-stream: 4.7.0 - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 @@ -20492,6 +20495,8 @@ snapshots: dayjs@1.11.20: {} + dayjs@1.11.21: {} + de-indent@1.0.2: {} debounce-fn@6.0.0: @@ -20649,7 +20654,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.11: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -21327,7 +21332,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -21812,7 +21817,7 @@ snapshots: help-me@5.0.0: {} - hono@4.12.25: {} + hono@4.12.27: {} hosted-git-info@2.8.9: {} @@ -22188,7 +22193,7 @@ snapshots: isomorphic-dompurify@3.9.0(@noble/hashes@2.0.1): dependencies: - dompurify: 3.4.11 + dompurify: 3.4.12 jsdom: 29.1.1(@noble/hashes@2.0.1) transitivePeerDependencies: - '@noble/hashes' @@ -23534,7 +23539,7 @@ snapshots: '@posthog/core': 1.25.3 '@posthog/types': 1.369.5 core-js: 3.48.0 - dompurify: 3.4.11 + dompurify: 3.4.12 fflate: 0.4.8 preact: 10.29.1 query-selector-shadow-dom: 1.0.1 @@ -23662,7 +23667,7 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - protobufjs@7.6.3: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -23670,14 +23675,13 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 '@types/node': 25.4.0 long: 5.3.2 - protobufjs@8.6.3: + protobufjs@8.6.6: dependencies: long: 5.3.2 @@ -24082,7 +24086,7 @@ snapshots: classnames: 2.5.1 core-js: 3.48.0 decko: 1.2.0 - dompurify: 3.4.11 + dompurify: 3.4.12 eventemitter3: 5.0.1 json-pointer: 0.6.2 lunr: 2.3.9 @@ -25284,13 +25288,13 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.29(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.20.0(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)): + typeorm@0.3.31(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.20.0(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 - ansis: 4.2.0 + ansis: 4.3.1 app-root-path: 3.1.0 buffer: 6.0.3 - dayjs: 1.11.20 + dayjs: 1.11.21 debug: 4.4.3 dedent: 1.7.2 dotenv: 16.6.1 @@ -25300,7 +25304,7 @@ snapshots: sql-highlight: 6.1.0 tslib: 2.8.1 uuid: 11.1.1 - yargs: 17.7.2 + yargs: 17.7.3 optionalDependencies: better-sqlite3: 12.8.0 ioredis: 5.8.1 @@ -25996,6 +26000,16 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yargs@18.0.0: dependencies: cliui: 9.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 510e257226fb..65b81bdcdaeb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,11 +24,21 @@ overrides: # @grpc/grpc-js is patched in 1.14.4 against malformed-request and malformed-compressed-message crashes. "@grpc/grpc-js": 1.14.4 # Prisma dev tooling and @modelcontextprotocol/sdk chains. - "@hono/node-server": 1.19.13 - hono: 4.12.25 + # ENG-1998 / GHSA-frvp-7c67-39w9: @hono/node-server <2.0.5 serve-static path traversal on Windows via + # encoded backslash; patched in 2.0.5 (major bump, only peer is hono ^4; node >=20 is already required). + # GHSA-9mqv-5hh9-4cgg: 2.0.0–2.0.9 unauthenticated memory-leak DoS via aborted WebSocket handshake; + # patched in 2.0.10. Pinned to 2.0.10 to close both. + "@hono/node-server": 2.0.10 + # ENG-1995 / GHSA-hvrm-45r6-mjfj (CVE-2026-59896), ENG-1996 / GHSA-w62v-xxxg-mg59 (CVE-2026-59895), + # ENG-1997 / GHSA-xgm2-5f3f-mvvc (CVE-2026-59897): hono <4.12.27 jsx cross-request context disclosure, + # cx() server-side XSS, and API Gateway v1 repeated-header de-dup; all patched in 4.12.27. + hono: 4.12.27 # OpenTelemetry / protobuf chains. - protobufjs@7: 7.6.3 - protobufjs@8: 8.6.3 + # ENG-1962 / GHSA-j3f2-48v5-ccww (CVE-2026-59877): protobufjs .proto option-parsing infinite-loop DoS, + # patched in 7.6.5 / 8.6.6. ENG-1963 / GHSA-jfj6-75fj-8934 (CVE-2026-59876): text-format map prototype + # mutation, patched in 8.6.5. Each line pinned to its lowest release covering both advisories. + protobufjs@7: 7.6.5 + protobufjs@8: 8.6.6 # sqlite3 / node-gyp chain: tar advisory pins are safe here because sqlite3 is pulled in # transitively by typeorm but the project uses postgres; sqlite3 is never built at runtime. # Therefore node-gyp@8 / http-proxy-agent@4 compatibility is not a concern. @@ -47,8 +57,13 @@ overrides: postcss: 8.5.14 # SDK and runtime transitive security pins. fast-xml-parser: 5.7.0 # load-bearing: <5.7.0 vulnerable (GHSA-gh4j-gqv2-49f6 + GHSA-jp2q-39xq-3w4g) + # ENG-1942 / GHSA-v422-hmwv-36x6 (CVE-2026-12590): body-parser 2.0.0–2.2.x silently skips the request + # size check when given an invalid `limit`, disabling DoS protection; patched in 2.3.0. + body-parser: 2.3.0 # GHSA-9ggv-8w38-r7pm: @boxyhq/saml-jackson still resolves typeorm 0.3.28; patched in 0.3.29. - typeorm: 0.3.29 + # ENG-1990 / GHSA-2rp8-mm9q-fp49: typeorm <0.3.31 migration:generate template-literal code injection; + # patched in 0.3.31. + typeorm: 0.3.31 # js-cookie / uuid: load-bearing security pins that are also MAJOR coercions on transitive consumers # (react-use@17 js-cookie v2->v3; @azure/msal-node + next-auth uuid v8/9->v11). Natural versions are # vulnerable (GHSA-qjx8-664m-686j / GHSA-w5hq-g745-h8pq), so the pins stay; runtime verified in build+tests. @@ -65,7 +80,8 @@ overrides: # Dependabot security batch (transitive-only; direct deps vite/ua-parser-js bumped in package.json). "@babel/core": 7.29.6 # ENG-1376 / GHSA-4x5r-pxfx-6jf8 "@opentelemetry/core": 2.8.0 # ENG-1355 / GHSA-8988-4f7v-96qf - dompurify: 3.4.11 # ENG-1359 + GHSA-vxr8-fq34-vvx9 / GHSA-gvmj-g25r-r7wr (audit: <3.4.9); ENG-1527-batch / GHSA-cmwh-pvxp-8882 raises the pin to >=3.4.11 (3.4.9/3.4.10 vulnerable to clobbered-root / cross-realm / hook-pollution XSS) + # ENG-1991 / GHSA-c2j3-45gr-mqc4: dompurify <=3.4.11 CUSTOM_ELEMENT_HANDLING bypasses afterSanitizeElements for allowed custom elements; patched in 3.4.12. + dompurify: 3.4.12 # ENG-1359 + GHSA-vxr8-fq34-vvx9 / GHSA-gvmj-g25r-r7wr (audit: <3.4.9); ENG-1527-batch / GHSA-cmwh-pvxp-8882 raised the pin to >=3.4.11 (3.4.9/3.4.10 vulnerable to clobbered-root / cross-realm / hook-pollution XSS) esbuild: 0.28.1 # ENG-1327 / GHSA-g7r4-m6w7-qqqr # ENG-1509 / ENG-1505 / GHSA-vmh5-mc38-953g: undici 7.x TLS certificate # validation bypass, patched in 7.28.0 (covers the jsdom transitive 7.25.0). diff --git a/prisma.config.mjs b/prisma.config.mjs index dd3224138b44..e4339caaed5b 100644 --- a/prisma.config.mjs +++ b/prisma.config.mjs @@ -16,6 +16,14 @@ import "dotenv/config"; export default { schema: "packages/database/schema.prisma", migrations: { + // This points at the GENERATED, git-ignored scratch dir — NOT the checked-in + // source of truth `packages/database/migration` (singular). That directory is + // *mixed*: schema migrations (`migration.sql`) and custom data migrations + // (`migration.ts`) interleaved by timestamp. The migration runner copies only + // the schema migrations here so `migrate deploy` sees a pure-SQL directory. + // Do NOT repoint this at `packages/database/migration` — Prisma would treat the + // data-migration dirs (tracked separately in the DataMigration table, not + // `_prisma_migrations`) as pending SQL migrations and break. Decided in ENG-1145. path: "packages/database/migrations", seed: "tsx packages/database/src/seed.ts", },