Compare commits

..

3 Commits

Author SHA1 Message Date
Dhruwang
5239dfd9b1 chore(db): add nullable workspaceId to environment-owned models
Phase 1 of environment deprecation: adds optional workspaceId column
with FK, index, and cascade delete to all 9 environment-owned models
(Survey, Contact, ActionClass, ContactAttributeKey, Webhook, Tag,
Segment, Integration, ApiKeyEnvironment) and reverse relations on
Workspace.

Replaces the previous projectId approach (reverted in the prior commit)
to align with the Project → Workspace rename from epic/v5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:57:59 +05:30
Dhruwang
d45cbefcff Merge remote-tracking branch 'origin/epic/v5' into revert/remove-projectid-from-env-models
# Conflicts:
#	apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx
#	apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx
#	apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/page.tsx
#	apps/web/app/(app)/environments/[environmentId]/actions.ts
#	apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
#	apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx
#	apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx
#	apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx
#	apps/web/modules/ee/contacts/[contactId]/components/activity-section.tsx
#	apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx
#	apps/web/modules/ee/contacts/layout.tsx
#	apps/web/modules/ee/whitelabel/remove-branding/actions.ts
#	apps/web/modules/environments/lib/utils.test.ts
#	apps/web/modules/environments/lib/utils.ts
#	apps/web/modules/projects/settings/general/components/delete-project.tsx
#	apps/web/modules/survey/editor/page.tsx
#	apps/web/modules/survey/list/page.tsx
#	apps/web/modules/survey/templates/page.tsx
#	apps/web/modules/workspaces/settings/actions.ts
#	apps/web/modules/workspaces/settings/look/page.tsx
2026-04-01 10:47:48 +05:30
Dhruwang
f1c6180ae2 Revert "chore(db): add nullable projectId to environment-owned models (#7588)"
This reverts commit 71cca557fc.
2026-04-01 10:27:41 +05:30
59 changed files with 175 additions and 231 deletions

View File

@@ -26,7 +26,7 @@ const Page = async (props: ConnectPageProps) => {
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const channel = workspace.config.channel || null;

View File

@@ -39,7 +39,7 @@ const Page = async (props: XMTemplatePageProps) => {
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const workspaces = await getUserWorkspaces(session.user.id, organizationId);

View File

@@ -43,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that workspace permission exists for members
if (isMember && !workspacePermission) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_permission_not_found"));
}
return (

View File

@@ -25,7 +25,7 @@ const AccountSettingsLayout = async (props: {
}
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!session) {

View File

@@ -22,7 +22,7 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
}
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!session) {

View File

@@ -14,7 +14,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error("Workspace not found");
}
const styling = getStyling(workspace, survey);

View File

@@ -5,7 +5,6 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -22,20 +21,10 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", params.environmentId),
};
}
const { environmentId } = resolved;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
@@ -49,7 +38,7 @@ export const POST = withV1ApiWrapper({
}
if (inputValidation.data.userId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {

View File

@@ -4,7 +4,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
@@ -30,10 +29,15 @@ export const GET = withV1ApiWrapper({
};
}
const idParam = params.environmentId.trim();
const environmentId = params.environmentId.trim();
// Validate CUID format
const cuidValidation = ZEnvironmentId.safeParse(idParam);
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
@@ -41,23 +45,13 @@ export const GET = withV1ApiWrapper({
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID format detected"
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(idParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", idParam),
};
}
const { environmentId } = resolved;
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(environmentId);
const { data } = environmentState;

View File

@@ -45,6 +45,7 @@ export const responseSelection = {
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
},

View File

@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -12,7 +13,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -66,16 +66,19 @@ export const POST = withV1ApiWrapper({
};
}
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return {
response: responses.notFoundResponse("Environment", params.environmentId),
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
}
const { environmentId } = resolved;
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!responseInputValidation.success) {
return {

View File

@@ -6,7 +6,6 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
@@ -30,16 +29,7 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", params.environmentId),
};
}
const { environmentId } = resolved;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
try {

View File

@@ -19,6 +19,7 @@ const selectActionClass = {
key: true,
noCodeConfig: true,
environmentId: true,
workspaceId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {

View File

@@ -50,6 +50,7 @@ export const responseSelection = {
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
},

View File

@@ -4,7 +4,6 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displ
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -26,18 +25,10 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return responses.notFoundResponse("Environment", params.environmentId);
}
const { environmentId } = resolved;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
@@ -49,7 +40,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
if (inputValidation.data.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);

View File

@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
@@ -11,7 +12,6 @@ import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -49,15 +49,18 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(params.environmentId);
if (!resolved) {
return responses.notFoundResponse("Environment", params.environmentId);
}
const { environmentId } = resolved;
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",

View File

@@ -4823,6 +4823,7 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
name: t("templates.preview_survey_name"),
type: "link" as const,
environmentId: "cltwumfcz0009echxg02fh7oa",
workspaceId: null,
createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress" as const,
welcomeCard: {

View File

@@ -21,6 +21,7 @@ const selectActionClass = {
key: true,
noCodeConfig: true,
environmentId: true,
workspaceId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(

View File

@@ -75,6 +75,7 @@ export const responseSelection = {
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
},

View File

@@ -19,6 +19,7 @@ const selectContact = {
createdAt: true,
updatedAt: true,
environmentId: true,
workspaceId: true,
attributes: {
select: {
value: true,
@@ -41,6 +42,7 @@ const commonMockProperties = {
createdAt: currentDate,
updatedAt: currentDate,
environmentId: mockId,
workspaceId: null,
};
type SurveyMock = Prisma.SurveyGetPayload<{

View File

@@ -30,6 +30,7 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
workspaceId: true,
createdBy: true,
status: true,
welcomeCard: true,
@@ -84,6 +85,7 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
workspaceId: true,
name: true,
description: true,
type: true,

View File

@@ -1,88 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { resolveClientApiIds } from "./resolve-client-id";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
workspace: {
findUnique: vi.fn(),
},
},
}));
describe("resolveClientApiIds", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves an environmentId to environmentId + workspaceId", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
id: "env-123",
workspaceId: "ws-456",
} as any);
const result = await resolveClientApiIds("env-123");
expect(result).toEqual({
environmentId: "env-123",
workspaceId: "ws-456",
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: "env-123" },
select: { id: true, workspaceId: true },
});
expect(prisma.workspace.findUnique).not.toHaveBeenCalled();
});
it("resolves a workspaceId to workspaceId + production environmentId", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
id: "ws-456",
environments: [{ id: "env-prod-789" }],
} as any);
const result = await resolveClientApiIds("ws-456");
expect(result).toEqual({
environmentId: "env-prod-789",
workspaceId: "ws-456",
});
expect(prisma.workspace.findUnique).toHaveBeenCalledWith({
where: { id: "ws-456" },
select: {
id: true,
environments: {
where: { type: "production" },
select: { id: true },
take: 1,
},
},
});
});
it("returns null when neither environment nor workspace is found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(prisma.workspace.findUnique).mockResolvedValue(null);
const result = await resolveClientApiIds("unknown-id");
expect(result).toBeNull();
});
it("returns null when workspace exists but has no production environment", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
id: "ws-456",
environments: [],
} as any);
const result = await resolveClientApiIds("ws-456");
expect(result).toBeNull();
});
});

View File

@@ -1,45 +0,0 @@
import "server-only";
import { prisma } from "@formbricks/database";
export type TResolvedClientIds = {
workspaceId: string;
environmentId: string;
};
/**
* Resolves a URL parameter that may be an environmentId (old SDK) or workspaceId (new SDK).
*
* - If the id matches an Environment, returns the environment's id and its parent workspaceId.
* - If not, checks the Workspace table and returns the workspace's production environment id.
* - Returns null if neither lookup succeeds.
*/
export const resolveClientApiIds = async (id: string): Promise<TResolvedClientIds | null> => {
// Try as environmentId first (existing SDKs)
const environment = await prisma.environment.findUnique({
where: { id },
select: { id: true, workspaceId: true },
});
if (environment) {
return { workspaceId: environment.workspaceId, environmentId: environment.id };
}
// Try as workspaceId (new SDKs sending workspaceId)
const workspace = await prisma.workspace.findUnique({
where: { id },
select: {
id: true,
environments: {
where: { type: "production" },
select: { id: true },
take: 1,
},
},
});
if (workspace && workspace.environments[0]) {
return { workspaceId: workspace.id, environmentId: workspace.environments[0].id };
}
return null;
};

View File

@@ -58,6 +58,7 @@ export const getResponseForPipeline = async (
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
},

View File

@@ -140,6 +140,7 @@ describe("Response Lib", () => {
updatedAt: new Date(),
name: "important",
environmentId: "env123",
workspaceId: null,
},
},
],
@@ -163,6 +164,7 @@ describe("Response Lib", () => {
updatedAt: mockPrismaResponse.tags[0].tag.updatedAt,
name: "important",
environmentId: "env123",
workspaceId: null,
},
],
});
@@ -184,6 +186,7 @@ describe("Response Lib", () => {
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
},

View File

@@ -17,6 +17,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
createdAt: true,
updatedAt: true,
environmentId: true,
workspaceId: true,
secret: true,
}).meta({
id: "webhookUpdate",

View File

@@ -50,7 +50,7 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const workspacePermission = await getWorkspacePermissionByUserId(session.user.id, workspace.id);

View File

@@ -7,7 +7,6 @@ import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
@@ -46,10 +45,15 @@ export const POST = withV1ApiWrapper({
};
}
const idParam = params.environmentId.trim();
const environmentId = params.environmentId.trim();
// Validate CUID format
const cuidValidation = ZEnvironmentId.safeParse(idParam);
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
@@ -57,22 +61,13 @@ export const POST = withV1ApiWrapper({
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID format detected"
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
const resolved = await resolveClientApiIds(idParam);
if (!resolved) {
return {
response: responses.notFoundResponse("Environment", idParam),
};
}
const { environmentId } = resolved;
const jsonInput = await req.json();
// Basic input validation without Zod overhead

View File

@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TWorkspace } from "@formbricks/types/workspace";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -21,7 +20,7 @@ export const ContactsSecondaryNavigation = async ({
workspace = await getWorkspaceByEnvironmentId(environmentId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
}

View File

@@ -45,7 +45,7 @@ const ConfigLayout = async (props: {
const workspace = await getWorkspaceByEnvironmentId(params.environmentId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
return children;

View File

@@ -98,6 +98,7 @@ const selectContact = {
createdAt: true,
updatedAt: true,
environmentId: true,
workspaceId: true,
attributes: {
select: {
value: true,

View File

@@ -45,6 +45,7 @@ export function CreateSegmentModal({
isPrivate: false,
filters: [],
environmentId,
workspaceId: null,
id: "",
surveys: [],
createdAt: new Date(),

View File

@@ -55,6 +55,7 @@ export const selectSegment = {
title: true,
description: true,
environmentId: true,
workspaceId: true,
filters: true,
isPrivate: true,
surveys: {

View File

@@ -47,7 +47,7 @@ export const updateWorkspaceBrandingAction = authenticatedActionClient
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
throw new Error("Organization not found");
}
const canRemoveBranding = await getRemoveBrandingPermission(organizationId);

View File

@@ -168,7 +168,7 @@ describe("utils.ts", () => {
test("throws error if workspace not found", async () => {
vi.mocked(getWorkspaceByEnvironmentId).mockResolvedValueOnce(null);
await expect(getEnvironmentAuth("env123")).rejects.toThrow(ResourceNotFoundError);
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.workspace_not_found");
});
test("throws error if environment not found", async () => {

View File

@@ -48,7 +48,7 @@ export const getEnvironmentAuth = reactCache(async (environmentId: string): Prom
]);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!environment) {

View File

@@ -27,6 +27,7 @@ export const WebhookTable = ({
const { t } = useTranslation();
const [activeWebhook, setActiveWebhook] = useState<Webhook>({
environmentId: environment.id,
workspaceId: null,
id: "",
name: "",
url: "",

View File

@@ -49,6 +49,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
isPrivate: true,
title: localSurvey.id,
environmentId: environment.id,
workspaceId: null,
surveys: [localSurvey.id],
filters: [],
createdAt: new Date(),

View File

@@ -61,7 +61,7 @@ export const SurveyEditorPage = async (props: {
]);
if (!workspaceWithTeamIds) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const organizationBilling = await getOrganizationBilling(workspaceWithTeamIds.organizationId);

View File

@@ -14,6 +14,7 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
workspaceId: true,
createdBy: true,
status: true,
welcomeCard: true,
@@ -69,6 +70,7 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
workspaceId: true,
name: true,
description: true,
type: true,
@@ -84,6 +86,7 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
workspaceId: true,
title: true,
description: true,
isPrivate: true,

View File

@@ -15,6 +15,7 @@ export const surveySelect = {
status: true,
singleUse: true,
environmentId: true,
workspaceId: true,
_count: {
select: { responses: true },
},

View File

@@ -34,7 +34,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
const workspace = await getWorkspaceWithTeamIdsByEnvironmentId(params.environmentId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);

View File

@@ -9,6 +9,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
name: "Minimal Survey",
type: "app",
environmentId: "someEnvId1",
workspaceId: null,
createdBy: null,
status: "draft",
displayOption: "displayOnce",

View File

@@ -22,7 +22,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
const workspace = await getWorkspaceWithTeamIdsByEnvironmentId(environmentId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (isReadOnly) {

View File

@@ -25,7 +25,7 @@ export const WorkspaceLookSettingsPage = async (props: { params: Promise<{ envir
const workspace = await getWorkspaceByEnvironmentId(params.environmentId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error("Workspace not found");
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.id);

View File

@@ -47,7 +47,12 @@ export const xmSegmentMigration: MigrationScript = {
id: "s644oyyqccstfdeejc4fluye",
name: "20241209110456_xm_segment_migration",
run: async ({ tx }) => {
const allSegments = await tx.segment.findMany();
const allSegments = await tx.segment.findMany({
select: {
id: true,
filters: true,
},
});
const updationPromises = [];
for (const segment of allSegments) {
updationPromises.push(
@@ -56,6 +61,7 @@ export const xmSegmentMigration: MigrationScript = {
data: {
filters: findAndReplace(segment.filters),
},
select: { id: true },
})
);
}

View File

@@ -0,0 +1,32 @@
-- AlterTable: Add nullable workspaceId to all environment-owned models
ALTER TABLE "Webhook" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "ContactAttributeKey" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "Contact" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "Tag" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "Survey" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "ActionClass" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "Integration" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "ApiKeyEnvironment" ADD COLUMN "workspaceId" TEXT;
ALTER TABLE "Segment" ADD COLUMN "workspaceId" TEXT;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ContactAttributeKey" ADD CONSTRAINT "ContactAttributeKey_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Contact" ADD CONSTRAINT "Contact_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ActionClass" ADD CONSTRAINT "ActionClass_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ApiKeyEnvironment" ADD CONSTRAINT "ApiKeyEnvironment_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Segment" ADD CONSTRAINT "Segment_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "Webhook_workspaceId_idx" ON "Webhook"("workspaceId");
CREATE INDEX "ContactAttributeKey_workspaceId_created_at_idx" ON "ContactAttributeKey"("workspaceId", "created_at");
CREATE INDEX "Contact_workspaceId_idx" ON "Contact"("workspaceId");
CREATE INDEX "Tag_workspaceId_idx" ON "Tag"("workspaceId");
CREATE INDEX "Survey_workspaceId_updated_at_idx" ON "Survey"("workspaceId", "updated_at");
CREATE INDEX "ActionClass_workspaceId_created_at_idx" ON "ActionClass"("workspaceId", "created_at");
CREATE INDEX "Integration_workspaceId_idx" ON "Integration"("workspaceId");
CREATE INDEX "ApiKeyEnvironment_workspaceId_idx" ON "ApiKeyEnvironment"("workspaceId");
CREATE INDEX "Segment_workspaceId_idx" ON "Segment"("workspaceId");

View File

@@ -49,11 +49,14 @@ model Webhook {
source WebhookSource @default(user)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
triggers PipelineTriggers[]
surveyIds String[]
secret String?
@@index([environmentId])
@@index([workspaceId])
}
/// Represents an attribute value associated with a contact.
@@ -116,11 +119,14 @@ model ContactAttributeKey {
dataType ContactAttributeDataType @default(string)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
attributes ContactAttribute[]
attributeFilters SurveyAttributeFilter[]
@@unique([key, environmentId])
@@index([environmentId, createdAt])
@@index([workspaceId, createdAt])
}
/// Represents a person or user who can receive and respond to surveys.
@@ -137,11 +143,14 @@ model Contact {
updatedAt DateTime @updatedAt @map(name: "updated_at")
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
responses Response[]
attributes ContactAttribute[]
displays Display[]
@@index([environmentId])
@@index([workspaceId])
}
/// Stores a user's response to a survey, including their answers and metadata.
@@ -204,8 +213,11 @@ model Tag {
responses TagsOnResponses[]
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
@@unique([environmentId, name])
@@index([workspaceId])
}
/// Junction table linking tags to responses.
@@ -350,6 +362,8 @@ model Survey {
type SurveyType @default(app)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
creator User? @relation(fields: [createdBy], references: [id])
createdBy String?
status SurveyStatus @default(draft)
@@ -413,6 +427,7 @@ model Survey {
@@index([environmentId, updatedAt])
@@index([segmentId])
@@index([workspaceId, updatedAt])
}
/// Represents a quota configuration for a survey.
@@ -507,11 +522,14 @@ model ActionClass {
noCodeConfig Json?
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
surveyTriggers SurveyTrigger[]
@@unique([key, environmentId])
@@unique([name, environmentId])
@@index([environmentId, createdAt])
@@index([workspaceId, createdAt])
}
enum EnvironmentType {
@@ -540,9 +558,12 @@ model Integration {
/// [IntegrationConfig]
config Json
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
@@unique([type, environmentId])
@@index([environmentId])
@@index([workspaceId])
}
enum DataMigrationStatus {
@@ -649,6 +670,17 @@ model Workspace {
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
feedbackRecordDirectoryWorkspaces FeedbackRecordDirectoryWorkspace[]
// Direct resource relations (for environment deprecation migration)
surveys Survey[]
contacts Contact[]
actionClasses ActionClass[]
contactAttributeKeys ContactAttributeKey[]
webhooks Webhook[]
tags Tag[]
segments Segment[]
integrations Integration[]
apiKeyEnvironments ApiKeyEnvironment[]
@@unique([organizationId, name])
}
@@ -809,10 +841,13 @@ model ApiKeyEnvironment {
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
permission ApiKeyPermission
@@unique([apiKeyId, environmentId])
@@index([environmentId])
@@index([workspaceId])
}
enum IdentityProvider {
@@ -912,9 +947,12 @@ model Segment {
filters Json @default("[]")
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String?
surveys Survey[]
@@unique([environmentId, title])
@@index([workspaceId])
}
/// Represents a supported language in the system.

View File

@@ -54,6 +54,7 @@ export const ZContactAttributeKey = z.object({
})
.describe("The data type of the attribute (string, number, date)"),
environmentId: z.cuid2().describe("The ID of the environment this attribute belongs to"),
workspaceId: z.string().nullable(),
}) satisfies z.ZodType<ContactAttributeKey>;
ZContactAttributeKey.meta({

View File

@@ -17,6 +17,7 @@ export const ZContact = z.object({
})
.describe("When the contact was last updated"),
environmentId: z.string().describe("The environment this contact belongs to"),
workspaceId: z.string().nullable(),
}) satisfies z.ZodType<Contact>;
ZContact.meta({

View File

@@ -72,6 +72,7 @@ const ZSurveyBase = z.object({
pin: z.string().nullable().describe("The pin of the survey"),
createdBy: z.string().nullable().describe("The user who created the survey"),
environmentId: z.cuid2().describe("The environment ID of the survey"),
workspaceId: z.string().nullable(),
questions: z.array(ZSurveyQuestion).describe("The questions of the survey"),
blocks: ZSurveyBlocks.prefault([]).describe("The blocks of the survey"),
endings: z.array(ZSurveyEnding).prefault([]).describe("The endings of the survey"),

View File

@@ -19,6 +19,7 @@ export const ZWebhook = z.object({
url: z.url().describe("The URL of the webhook"),
source: z.enum(["user", "zapier", "make", "n8n"]).describe("The source of the webhook"),
environmentId: z.cuid2().describe("The ID of the environment"),
workspaceId: z.string().nullable(),
triggers: z
.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"]))
.describe("The triggers of the webhook")

View File

@@ -62,6 +62,7 @@ export const mockSurvey: TEnvironmentStateSurvey = {
createdAt: new Date("2025-01-01T10:00:00Z"),
updatedAt: new Date("2025-01-01T10:00:00Z"),
environmentId: mockEnvironmentId,
workspaceId: null,
description: "Manual Trigger",
noCodeConfig: {
elementSelector: { cssSelector: ".btn", innerHtml: "Click me" },

View File

@@ -135,6 +135,7 @@ export const ZActionClass = z.object({
key: z.string().trim().min(1).nullable(),
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
environmentId: z.string(),
workspaceId: z.string().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});

View File

@@ -19,6 +19,7 @@ export const ZContactAttributeKey = z.object({
type: ZContactAttributeKeyType,
dataType: ZContactAttributeDataType.prefault("string"),
environmentId: z.string(),
workspaceId: z.string().nullable(),
});
export type TContactAttributeKey = z.infer<typeof ZContactAttributeKey>;

View File

@@ -19,6 +19,7 @@ export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
export const ZIntegrationBase = z.object({
id: z.string(),
environmentId: z.string(),
workspaceId: z.string().nullable(),
});
export const ZIntegration = ZIntegrationBase.extend({

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
export const ZIntegrationBase = z.object({
id: z.string(),
environmentId: z.string(),
workspaceId: z.string().nullable(),
});
export const ZIntegrationBaseSurveyData = z.object({

View File

@@ -344,6 +344,7 @@ export const ZSegment = z.object({
isPrivate: z.boolean().prefault(true),
filters: ZSegmentFilters,
environmentId: z.string(),
workspaceId: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
surveys: z.array(z.string()),

View File

@@ -826,6 +826,7 @@ export const ZSurveyBase = z.object({
name: z.string(),
type: ZSurveyType,
environmentId: z.string(),
workspaceId: z.string().nullable(),
createdBy: z.string().nullable(),
status: ZSurveyStatus,
displayOption: ZSurveyDisplayOption,

View File

@@ -6,6 +6,7 @@ export const ZTag = z.object({
updatedAt: z.date(),
name: z.string(),
environmentId: z.string(),
workspaceId: z.string().nullable(),
});
export type TTag = z.infer<typeof ZTag>;