Compare commits

..

6 Commits

Author SHA1 Message Date
Dhruwang
9d8fa1878a chore: switch internal reads from environmentId to projectId
Migrate all internal (non-API) read queries from WHERE environmentId
to WHERE projectId across surveys, contacts, action classes, tags,
webhooks, segments, integrations, and contact attribute keys.

Service functions renamed:
- getTagsByEnvironmentId -> getTagsByProjectId
- getActionClassByEnvironmentIdAndName -> getActionClassByProjectIdAndName
- getWebhookCountBySource(environmentId) -> getWebhookCountBySource(projectId)
- getPublishedLinkSurveys(environmentId) -> getPublishedLinkSurveys(projectId)

All page components resolve projectId from environment.projectId
early and pass it downstream. Tests updated to match.
2026-03-27 12:50:10 +05:30
Dhruwang
d202b9263f chore: dual-write projectId in all create/upsert paths
Add projectId alongside environmentId in all resource creation and
upsert code paths. This is Phase 3 of the environment deprecation plan.

For 15 call sites, replaced verbose getEnvironment() + null check
boilerplate with the existing getProjectIdFromEnvironmentId() helper,
which encapsulates the same logic in a single call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 12:50:10 +05:30
Dhruwang Jariwala
71cca557fc chore(db): add nullable projectId to environment-owned models (#7588) 2026-03-27 12:33:44 +05:30
Dhruwang Jariwala
1500b6f7f3 docs: deprecate environments migration plan (#7586)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:34:40 +04:00
Dhruwang
2c9fbf83e4 chore: merge epic/v5 into chore/deprecate-environments 2026-03-26 15:10:31 +05:30
Matti Nannt
81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
141 changed files with 11043 additions and 18614 deletions

View File

@@ -38,6 +38,15 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -185,11 +194,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

View File

@@ -6,7 +6,7 @@ import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTagsByProjectId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -23,10 +23,12 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getTagsByProjectId(projectId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
]);
@@ -43,7 +45,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
throw new ResourceNotFoundError(t("common.organization"), null);
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
const segments = isContactsEnabled ? await getSegments(projectId) : [];
const publicDomain = getPublicDomain();

View File

@@ -24,6 +24,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const surveyId = params.surveyId;
if (!surveyId) {
@@ -44,7 +46,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const segments = isContactsEnabled ? await getSegments(projectId) : [];
if (!organizationId) {
throw new ResourceNotFoundError(t("common.organization"), null);

View File

@@ -6,7 +6,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTagsByProjectId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
@@ -84,8 +84,10 @@ export const getSurveyFilterDataAction = authenticatedActionClient
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const projectId = survey.projectId!;
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
getTagsByProjectId(projectId),
getResponseFilteringValues(parsedInput.surveyId),
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
]);

View File

@@ -20,9 +20,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getSurveys(projectId),
getIntegrations(projectId),
getUserLocale(session.user.id),
]);

View File

@@ -19,6 +19,8 @@ const ZValidateGoogleSheetsConnectionAction = z.object({
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.inputSchema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
@@ -29,13 +31,13 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
projectId,
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
const integration = await getIntegrationByType(projectId, "googleSheets");
if (!integration) {
return { data: false };
}

View File

@@ -24,9 +24,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getSurveys(projectId),
getIntegrations(projectId),
getUserLocale(session.user.id),
]);

View File

@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getWebhookCountBySource = async (
environmentId: string,
projectId: string,
source?: Webhook["source"]
): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
validateInputs([projectId, ZId], [source, z.string().optional()]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
projectId,
source,
},
});

View File

@@ -31,9 +31,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [surveys, notionIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getSurveys(projectId),
getIntegrationByType(projectId, "notion"),
getUserLocale(session.user.id),
]);

View File

@@ -33,6 +33,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [
integrations,
userWebhookCount,
@@ -41,12 +43,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
n8nwebhookCount,
activePiecesWebhookCount,
] = await Promise.all([
getIntegrations(params.environmentId),
getWebhookCountBySource(params.environmentId, "user"),
getWebhookCountBySource(params.environmentId, "zapier"),
getWebhookCountBySource(params.environmentId, "make"),
getWebhookCountBySource(params.environmentId, "n8n"),
getWebhookCountBySource(params.environmentId, "activepieces"),
getIntegrations(projectId),
getWebhookCountBySource(projectId, "user"),
getWebhookCountBySource(projectId, "zapier"),
getWebhookCountBySource(projectId, "make"),
getWebhookCountBySource(projectId, "n8n"),
getWebhookCountBySource(projectId, "activepieces"),
]);
const isIntegrationConnected = (type: TIntegrationType) =>

View File

@@ -19,9 +19,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [surveys, slackIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getSurveys(projectId),
getIntegrationByType(projectId, "slack"),
getUserLocale(session.user.id),
]);

View File

@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
@@ -152,8 +153,9 @@ export const POST = async (request: Request) => {
if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const [integrations, responseCount] = await Promise.all([
getIntegrations(environmentId),
getIntegrations(projectId),
getResponseCountBySurveyId(surveyId),
]);

View File

@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});

View File

@@ -6,26 +6,10 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
const authMethod = getAuthMethod(account);
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
}
}
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
status,
userType: "user" as const,
newObject: {
...user,
authMethod: getAuthMethod(account),
authMethod,
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
...(error ? { errorMessage: error.message } : {}),
},
});
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};

View File

@@ -10,6 +10,7 @@ import {
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -67,7 +68,8 @@ export const GET = async (req: Request) => {
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const existingIntegration = await getIntegrationByType(projectId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {

View File

@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
@@ -15,9 +16,11 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
if (userId) {
contact = await getContactByUserId(environmentId, userId);
if (!contact) {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
contact = await prisma.contact.create({
data: {
environment: { connect: { id: environmentId } },
project: { connect: { id: projectId } },
attributes: {
create: {
attributeKey: {

View File

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

View File

@@ -5,6 +5,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -36,7 +37,8 @@ export const GET = withV1ApiWrapper({
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const integration = (await getIntegrationByType(projectId, "airtable")) as TIntegrationAirtable;
if (!integration) {
return {

View File

@@ -11,6 +11,7 @@ import {
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -88,7 +89,8 @@ export const GET = withV1ApiWrapper({
},
};
const existingIntegration = await getIntegrationByType(environmentId, "notion");
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const existingIntegration = await getIntegrationByType(projectId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}

View File

@@ -8,6 +8,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -88,7 +89,8 @@ export const GET = withV1ApiWrapper({
team: data.team,
};
const slackIntegration = await getIntegrationByType(environmentId, "slack");
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const slackIntegration = await getIntegrationByType(projectId, "slack");
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],

View File

@@ -19,6 +19,7 @@ const selectActionClass = {
key: true,
noCodeConfig: true,
environmentId: true,
projectId: 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,
projectId: true,
},
},
},

View File

@@ -5,6 +5,7 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
@@ -12,6 +13,8 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
validateInputs([webhookInput, ZWebhookInput]);
await validateWebhookUrl(webhookInput.url);
const projectId = await getProjectIdFromEnvironmentId(webhookInput.environmentId);
try {
const secret = generateWebhookSecret();
@@ -23,11 +26,8 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
surveyIds: webhookInput.surveyIds || [],
triggers: webhookInput.triggers || [],
secret,
environment: {
connect: {
id: webhookInput.environmentId,
},
},
environmentId: webhookInput.environmentId,
projectId,
},
});

View File

@@ -44,16 +44,16 @@ export const GET = withV3ApiWrapper({
return authResult;
}
const { environmentId } = authResult;
const { projectId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
getSurveyListPage(projectId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
getSurveyCount(projectId, parsed.filterCriteria),
]);
return successListResponse(

View File

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

View File

@@ -1,97 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});

View File

@@ -20,36 +20,3 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -5,7 +5,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClassByProjectIdAndName,
getActionClasses,
} from "./service";
@@ -49,7 +49,7 @@ describe("ActionClass Service", () => {
const result = await getActionClasses("env1");
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { environmentId: "env1" },
where: { projectId: "env1" },
select: expect.any(Object),
take: undefined,
skip: undefined,
@@ -63,7 +63,7 @@ describe("ActionClass Service", () => {
});
});
describe("getActionClassByEnvironmentIdAndName", () => {
describe("getActionClassByProjectIdAndName", () => {
test("should return action class when found", async () => {
const mockActionClass: TActionClass = {
id: "id2",
@@ -83,10 +83,10 @@ describe("ActionClass Service", () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
const result = await getActionClassByProjectIdAndName("env2", "Action 2");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
where: { name: "Action 2", environmentId: "env2" },
where: { name: "Action 2", projectId: "env2" },
select: expect.any(Object),
});
});
@@ -94,14 +94,14 @@ describe("ActionClass Service", () => {
test("should return null when not found", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
const result = await getActionClassByProjectIdAndName("env2", "Action 2");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
await expect(getActionClassByProjectIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
});
});

View File

@@ -9,6 +9,7 @@ import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { getProjectIdFromEnvironmentId } from "../utils/helper";
import { validateInputs } from "../utils/validate";
const selectActionClass = {
@@ -21,16 +22,17 @@ const selectActionClass = {
key: true,
noCodeConfig: true,
environmentId: true,
projectId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(
async (environmentId: string, page?: number): Promise<TActionClass[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
async (projectId: string, page?: number): Promise<TActionClass[]> => {
validateInputs([projectId, ZId], [page, ZOptionalNumber]);
try {
return await prisma.actionClass.findMany({
where: {
environmentId: environmentId,
projectId,
},
select: selectActionClass,
take: page ? ITEMS_PER_PAGE : undefined,
@@ -40,21 +42,21 @@ export const getActionClasses = reactCache(
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
throw new DatabaseError(`Database error when fetching actions for project ${projectId}`);
}
}
);
// This function is used to get an action by its name and environmentId(it can return private actions as well)
export const getActionClassByEnvironmentIdAndName = reactCache(
async (environmentId: string, name: string): Promise<TActionClass | null> => {
validateInputs([environmentId, ZId], [name, ZString]);
// This function is used to get an action by its name and projectId(it can return private actions as well)
export const getActionClassByProjectIdAndName = reactCache(
async (projectId: string, name: string): Promise<TActionClass | null> => {
validateInputs([projectId, ZId], [name, ZString]);
try {
const actionClass = await prisma.actionClass.findFirst({
where: {
name,
environmentId,
projectId,
},
select: selectActionClass,
});
@@ -113,10 +115,13 @@ export const createActionClass = async (
const { environmentId: _, ...actionClassInput } = actionClass;
try {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const actionClassPrisma = await prisma.actionClass.create({
data: {
...actionClassInput,
environment: { connect: { id: environmentId } },
environmentId,
projectId,
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig:
actionClassInput.type === "noCode"

View File

@@ -14,6 +14,7 @@ import {
} from "@formbricks/types/integration/airtable";
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
import { getProjectIdFromEnvironmentId } from "../utils/helper";
import { delay } from "../utils/promises";
import { truncateText } from "../utils/strings";
@@ -78,10 +79,8 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const airtableIntegration = (await getIntegrationByType(projectId, "airtable")) as TIntegrationAirtable;
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key

View File

@@ -26,7 +26,6 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
@@ -41,6 +40,8 @@ export const GITHUB_ID = env.GITHUB_ID;
export const GITHUB_SECRET = env.GITHUB_SECRET;
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
export const HUB_API_URL = env.HUB_API_URL;
export const HUB_API_KEY = env.HUB_API_KEY;
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;

View File

@@ -15,7 +15,6 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -34,6 +33,8 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(),
HUB_API_URL: z.url(),
HUB_API_KEY: z.string().optional(),
IMPRINT_URL: z
.url()
.optional()
@@ -142,7 +143,6 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
@@ -161,6 +161,8 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
HUB_API_URL: process.env.HUB_API_URL,
HUB_API_KEY: process.env.HUB_API_KEY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,

View File

@@ -7,6 +7,7 @@ import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { getProjectIdFromEnvironmentId } from "../utils/helper";
import { validateInputs } from "../utils/validate";
const transformIntegration = (integration: TIntegration): TIntegration => {
@@ -28,6 +29,8 @@ export const createOrUpdateIntegration = async (
): Promise<TIntegration> => {
validateInputs([environmentId, ZId]);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
try {
const integration = await prisma.integration.upsert({
where: {
@@ -38,11 +41,13 @@ export const createOrUpdateIntegration = async (
},
update: {
...integrationData,
environment: { connect: { id: environmentId } },
environmentId,
projectId,
},
create: {
...integrationData,
environment: { connect: { id: environmentId } },
environmentId,
projectId,
},
});
return integration;
@@ -56,13 +61,13 @@ export const createOrUpdateIntegration = async (
};
export const getIntegrations = reactCache(
async (environmentId: string, page?: number): Promise<TIntegration[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
async (projectId: string, page?: number): Promise<TIntegration[]> => {
validateInputs([projectId, ZId], [page, ZOptionalNumber]);
try {
const integrations = await prisma.integration.findMany({
where: {
environmentId,
projectId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
@@ -94,16 +99,14 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
async (projectId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
validateInputs([projectId, ZId], [type, ZIntegrationType]);
try {
const integration = await prisma.integration.findUnique({
const integration = await prisma.integration.findFirst({
where: {
type_environmentId: {
environmentId,
type,
},
projectId,
type,
},
});
return integration ? transformIntegration(integration) : null;

View File

@@ -6,6 +6,7 @@ import {
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
import { getProjectIdFromEnvironmentId } from "../utils/helper";
const fetchPages = async (config: TIntegrationNotionConfig) => {
try {
@@ -29,7 +30,8 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const notionIntegration = (await getIntegrationByType(projectId, "notion")) as TIntegrationNotion;
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}

View File

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

View File

@@ -4,6 +4,7 @@ import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { getProjectIdFromEnvironmentId } from "../utils/helper";
import { truncateText } from "../utils/strings";
export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIntegrationItem[]> => {
@@ -58,7 +59,8 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const slackIntegration = (await getIntegrationByType(projectId, "slack")) as TIntegrationSlack;
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}

View File

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

View File

@@ -14,6 +14,7 @@ import {
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { getProjectIdFromEnvironmentId } from "../utils/helper";
import { validateInputs } from "../utils/validate";
import {
checkForInvalidImagesInQuestions,
@@ -30,6 +31,7 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
projectId: true,
createdBy: true,
status: true,
welcomeCard: true,
@@ -84,6 +86,7 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
name: true,
description: true,
type: true,
@@ -243,13 +246,13 @@ export const getSurveysByActionClassId = reactCache(
);
export const getSurveys = reactCache(
async (environmentId: string, limit?: number, offset?: number): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
async (projectId: string, limit?: number, offset?: number): Promise<TSurvey[]> => {
validateInputs([projectId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
projectId,
},
select: selectSurvey,
orderBy: {
@@ -270,12 +273,12 @@ export const getSurveys = reactCache(
}
);
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
validateInputs([environmentId, ZId]);
export const getSurveyCount = reactCache(async (projectId: string): Promise<number> => {
validateInputs([projectId, ZId]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId: environmentId,
projectId,
},
});
@@ -471,6 +474,11 @@ export const updateSurveyInternal = async (
id: environmentId,
},
},
project: {
connect: {
id: currentSurvey.projectId!,
},
},
},
},
},
@@ -624,7 +632,10 @@ export const createSurvey = async (
};
}
const organization = await getOrganizationByEnvironmentId(parsedEnvironmentId);
const [organization, projectId] = await Promise.all([
getOrganizationByEnvironmentId(parsedEnvironmentId),
getProjectIdFromEnvironmentId(parsedEnvironmentId),
]);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
@@ -659,6 +670,11 @@ export const createSurvey = async (
id: parsedEnvironmentId,
},
},
project: {
connect: {
id: projectId,
},
},
},
select: selectSurvey,
});
@@ -670,11 +686,8 @@ export const createSurvey = async (
title: survey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: parsedEnvironmentId,
},
},
environmentId: parsedEnvironmentId,
projectId,
},
});

View File

@@ -4,7 +4,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TTag } from "@formbricks/types/tags";
import { TagError } from "@/modules/projects/settings/types/tag";
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
import { createTag, getTag, getTagsByProjectId } from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -21,8 +21,8 @@ describe("Tag Service", () => {
vi.clearAllMocks();
});
describe("getTagsByEnvironmentId", () => {
test("should return tags for a given environment ID", async () => {
describe("getTagsByProjectId", () => {
test("should return tags for a given project ID", async () => {
const mockTags: TTag[] = [
{
id: "tag1",
@@ -35,11 +35,11 @@ describe("Tag Service", () => {
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
const result = await getTagsByEnvironmentId("env1");
const result = await getTagsByProjectId("env1");
expect(result).toEqual(mockTags);
expect(prisma.tag.findMany).toHaveBeenCalledWith({
where: {
environmentId: "env1",
projectId: "env1",
},
take: undefined,
skip: undefined,
@@ -59,11 +59,11 @@ describe("Tag Service", () => {
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
const result = await getTagsByEnvironmentId("env1", 1);
const result = await getTagsByProjectId("env1", 1);
expect(result).toEqual(mockTags);
expect(prisma.tag.findMany).toHaveBeenCalledWith({
where: {
environmentId: "env1",
projectId: "env1",
},
take: 30,
skip: 0,

View File

@@ -6,29 +6,28 @@ 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 { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
export const getTagsByEnvironmentId = reactCache(
async (environmentId: string, page?: number): Promise<TTag[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
export const getTagsByProjectId = reactCache(async (projectId: string, page?: number): Promise<TTag[]> => {
validateInputs([projectId, ZId], [page, ZOptionalNumber]);
try {
const tags = await prisma.tag.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
try {
const tags = await prisma.tag.findMany({
where: {
projectId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return tags;
} catch (error) {
throw error;
}
return tags;
} catch (error) {
throw error;
}
);
});
export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
validateInputs([id, ZId]);
@@ -52,11 +51,14 @@ export const createTag = async (
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
validateInputs([environmentId, ZId], [name, ZString]);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
try {
const tag = await prisma.tag.create({
data: {
name,
environmentId,
projectId,
},
});

View File

@@ -9,10 +9,6 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -298,78 +294,4 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});

View File

@@ -1,7 +1,6 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};

View File

@@ -3,6 +3,7 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { getEnvironment } from "@/lib/environment/service";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils";
import {
@@ -45,6 +46,14 @@ export const createContactAttributeKey = async (
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key, dataType } = contactAttributeKey;
const environment = await getEnvironment(environmentId);
if (!environment) {
return err({
type: "not_found",
details: [{ field: "environment", issue: "not found" }],
});
}
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
environment: {
@@ -52,6 +61,11 @@ export const createContactAttributeKey = async (
id: environmentId,
},
},
project: {
connect: {
id: environment.projectId,
},
},
name: name ?? formatSnakeCaseToTitleCase(key),
description,
key,

View File

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

View File

@@ -184,6 +184,7 @@ describe("Response Lib", () => {
updatedAt: true,
name: true,
environmentId: true,
projectId: true,
},
},
},

View File

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

View File

@@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { getEnvironment } from "@/lib/environment/service";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
@@ -68,6 +69,14 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
});
}
const environment = await getEnvironment(environmentId);
if (!environment) {
return err({
type: "not_found",
details: [{ field: "environment", issue: "not_found" }],
});
}
try {
const secret = generateWebhookSecret();
@@ -77,6 +86,11 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
id: environmentId,
},
},
project: {
connect: {
id: environment.projectId,
},
},
name,
url,
source,

View File

@@ -10,25 +10,6 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -319,20 +300,51 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("session callback", () => {
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
});
});

View File

@@ -1,4 +1,3 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -14,7 +13,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -32,7 +31,6 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -312,17 +310,30 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {

View File

@@ -1,115 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});

View File

@@ -1,54 +0,0 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};

View File

@@ -106,7 +106,10 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -125,7 +128,10 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -139,7 +145,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -159,7 +165,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});

View File

@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});

View File

@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,

View File

@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();

View File

@@ -458,21 +458,18 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
subscriptionCount: number
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
);
};
@@ -1267,7 +1264,8 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string
organizationId: string,
idempotencySuffix = "reconcile"
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1344,14 +1342,12 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
status: "active",
limit: 1,
});
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
}
}
};
@@ -1359,6 +1355,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};

View File

@@ -21,16 +21,21 @@ interface ActivitySectionProps {
}
export const ActivitySection = async ({ environment, contactId, environmentTags }: ActivitySectionProps) => {
const [responses, displays] = await Promise.all([
const [responses, displays, project] = await Promise.all([
getResponsesByContactId(contactId),
getDisplaysByContactId(contactId),
getProjectByEnvironmentId(environment.id),
]);
if (!project) {
throw new ResourceNotFoundError("Project", null);
}
const allSurveyIds = [
...new Set([...(responses?.map((r) => r.surveyId) || []), ...displays.map((d) => d.surveyId)]),
];
const surveys: TSurvey[] = allSurveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
const surveys: TSurvey[] = allSurveyIds.length === 0 ? [] : ((await getSurveys(project.id)) ?? []);
const session = await getServerSession(authOptions);
const t = await getTranslate();
@@ -48,11 +53,6 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
throw new Error(t("environments.contacts.no_responses_found"));
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const locale = user.locale ?? DEFAULT_LOCALE;

View File

@@ -1,5 +1,6 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTagsByProjectId } from "@/lib/tag/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
@@ -22,13 +23,15 @@ export const SingleContactPage = async (props: {
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const [environmentTags, contact, publishedLinkSurveys, attributesWithKeyInfo, allAttributeKeys] =
await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getTagsByProjectId(projectId),
getContact(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getPublishedLinkSurveys(projectId),
getContactAttributesWithKeyInfo(params.contactId),
getContactAttributeKeys(params.environmentId),
getContactAttributeKeys(projectId),
]);
if (!contact) {

View File

@@ -30,6 +30,8 @@ const ZGetContactsAction = z.object({
export const getContactsAction = authenticatedActionClient
.inputSchema(ZGetContactsAction)
.action(async ({ ctx, parsedInput }) => {
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
@@ -41,12 +43,12 @@ export const getContactsAction = authenticatedActionClient
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
projectId,
},
],
});
return getContacts(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue);
return getContacts(projectId, parsedInput.offset, parsedInput.searchValue);
});
const ZContactDeleteAction = z.object({

View File

@@ -15,7 +15,7 @@ const getEnvironment = async (environmentId: string) =>
async () => {
return prisma.environment.findUnique({
where: { id: environmentId },
select: { id: true, type: true },
select: { id: true, type: true, projectId: true },
});
},
createCacheKey.environment.config(environmentId),
@@ -63,12 +63,15 @@ const getContactWithFullData = async (environmentId: string, userId: string) =>
/**
* Creates contact with comprehensive data structure
*/
const createContact = async (environmentId: string, userId: string) => {
const createContact = async (environmentId: string, projectId: string, userId: string) => {
return prisma.contact.create({
data: {
environment: {
connect: { id: environmentId },
},
project: {
connect: { id: projectId },
},
attributes: {
create: [
{
@@ -164,7 +167,7 @@ export const updateUser = async (
// Create contact if doesn't exist
if (!contactData) {
contactData = await createContact(environmentId, userId);
contactData = await createContact(environmentId, environment.projectId, userId);
}
// Process contact attributes efficiently (single pass)

View File

@@ -5,6 +5,7 @@ import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
@@ -29,6 +30,8 @@ export const createContactAttributeKey = async (
environmentId: string,
data: TContactAttributeKeyCreateInput
): Promise<TContactAttributeKey | null> => {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
where: {
environmentId,
@@ -54,6 +57,11 @@ export const createContactAttributeKey = async (
id: environmentId,
},
},
project: {
connect: {
id: projectId,
},
},
},
});

View File

@@ -4,6 +4,7 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { getEnvironment } from "@/lib/environment/service";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
@@ -406,6 +407,7 @@ const upsertAttributeKeysInBatches = async (
tx: Prisma.TransactionClient,
keysToUpsert: Map<string, { key: string; name: string; dataType: TContactAttributeDataType }>,
environmentId: string,
projectId: string,
attributeKeyMap: Record<string, string>
): Promise<void> => {
const keysArray = Array.from(keysToUpsert.values());
@@ -414,17 +416,18 @@ const upsertAttributeKeysInBatches = async (
const batch = keysArray.slice(i, i + BATCH_SIZE);
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "dataType", "created_at", "updated_at")
SELECT
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "projectId", "dataType", "created_at", "updated_at")
SELECT
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
${environmentId},
${projectId},
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.dataType)}]`}::text[]::"ContactAttributeDataType"[]),
NOW(),
NOW()
ON CONFLICT ("key", "environmentId")
DO UPDATE SET
ON CONFLICT ("key", "environmentId")
DO UPDATE SET
"name" = EXCLUDED."name",
"updated_at" = NOW()
RETURNING "id", "key"
@@ -490,6 +493,16 @@ export const upsertBulkContacts = async (
>
> => {
const contactIdxWithConflictingUserIds: number[] = [];
const environment = await getEnvironment(environmentId);
if (!environment) {
return err({
type: "not_found",
details: [{ field: "environment", issue: "not found" }],
});
}
const { projectId } = environment;
const { userIdsInContacts, attributeKeys } = extractContactMetadata(contacts);
const [existingUserIds, existingContactsByEmail, existingAttributeKeys] = await Promise.all([
@@ -624,11 +637,11 @@ export const upsertBulkContacts = async (
// Upsert attribute keys in batches
if (keysToUpsert.size > 0) {
await upsertAttributeKeysInBatches(tx, keysToUpsert, environmentId, attributeKeyMap);
await upsertAttributeKeysInBatches(tx, keysToUpsert, environmentId, projectId, attributeKeyMap);
}
// Create new contacts
const newContacts = contactsToCreate.map(() => ({ id: createId(), environmentId }));
const newContacts = contactsToCreate.map(() => ({ id: createId(), environmentId, projectId }));
if (newContacts.length > 0) {
await tx.contact.createMany({ data: newContacts });

View File

@@ -1,5 +1,6 @@
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { getEnvironment } from "@/lib/environment/service";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { readAttributeValue } from "@/modules/ee/contacts/lib/attribute-storage";
import { TContactCreateRequest, TContactResponse } from "@/modules/ee/contacts/types/contact";
@@ -18,6 +19,14 @@ export const createContact = async (
});
}
const environment = await getEnvironment(environmentId);
if (!environment) {
return err({
type: "not_found",
details: [{ field: "environment", issue: "not found" }],
});
}
// Extract userId if present
const userId = attributes.userId;
@@ -98,6 +107,7 @@ export const createContact = async (
const result = await prisma.contact.create({
data: {
environmentId,
projectId: environment.projectId,
attributes: {
createMany: {
data: attributeData,

View File

@@ -1,3 +1,4 @@
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "@/lingodotdev/server";
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
@@ -15,9 +16,10 @@ export const AttributesPage = async ({
const params = await paramsProps;
const locale = await getLocale();
const t = await getTranslate();
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const [{ isReadOnly, organization }, contactAttributeKeys] = await Promise.all([
getEnvironmentAuth(params.environmentId),
getContactAttributeKeys(params.environmentId),
getContactAttributeKeys(projectId),
]);
const isContactsEnabled = await getIsContactsEnabled(organization.id);

View File

@@ -4,6 +4,7 @@ import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributesInput, ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import { prepareNewSDKAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
@@ -145,14 +146,20 @@ export const updateAttributes = async (
? null
: String(contactAttributesParam.userId);
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
emailValue ? hasEmailAttribute(emailValue, environmentId, contactId) : Promise.resolve(null),
userIdValue ? hasUserIdAttribute(userIdValue, environmentId, contactId) : Promise.resolve(null),
]);
// Fetch current attributes, contact attribute keys, environment, and email/userId checks in parallel
const [
currentAttributes,
contactAttributeKeys,
projectId,
existingEmailAttribute,
existingUserIdAttribute,
] = await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
getProjectIdFromEnvironmentId(environmentId),
emailValue ? hasEmailAttribute(emailValue, environmentId, contactId) : Promise.resolve(null),
userIdValue ? hasUserIdAttribute(userIdValue, environmentId, contactId) : Promise.resolve(null),
]);
// Process email and userId existence early
const emailExists = !!existingEmailAttribute;
@@ -360,6 +367,7 @@ export const updateAttributes = async (
type: "custom",
dataType,
environment: { connect: { id: environmentId } },
project: { connect: { id: projectId } },
attributes: {
create: {
contactId,

View File

@@ -3,12 +3,13 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
export const getContactAttributeKeys = reactCache(
async (environmentId: string): Promise<TContactAttributeKey[]> => {
async (projectId: string): Promise<TContactAttributeKey[]> => {
return await prisma.contactAttributeKey.findMany({
where: { environmentId },
where: { projectId },
});
}
);
@@ -31,6 +32,8 @@ export const createContactAttributeKey = async (data: {
description?: string;
dataType?: TContactAttributeDataType;
}): Promise<TContactAttributeKey> => {
const projectId = await getProjectIdFromEnvironmentId(data.environmentId);
try {
const contactAttributeKey = await prisma.contactAttributeKey.create({
data: {
@@ -38,6 +41,7 @@ export const createContactAttributeKey = async (data: {
name: data.name ?? formatSnakeCaseToTitleCase(data.key),
description: data.description ?? null,
environmentId: data.environmentId,
projectId,
type: "custom",
...(data.dataType && { dataType: data.dataType }),
},

View File

@@ -7,6 +7,7 @@ import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common"
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
@@ -98,6 +99,7 @@ const selectContact = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
attributes: {
select: {
value: true,
@@ -114,8 +116,8 @@ const selectContact = {
},
} satisfies Prisma.ContactSelect;
export const buildContactWhereClause = (environmentId: string, search?: string): Prisma.ContactWhereInput => {
const whereClause: Prisma.ContactWhereInput = { environmentId };
export const buildContactWhereClause = (projectId: string, search?: string): Prisma.ContactWhereInput => {
const whereClause: Prisma.ContactWhereInput = { projectId };
if (search) {
whereClause.OR = [
@@ -142,12 +144,12 @@ export const buildContactWhereClause = (environmentId: string, search?: string):
};
export const getContacts = reactCache(
async (environmentId: string, offset?: number, searchValue?: string): Promise<TContactWithAttributes[]> => {
validateInputs([environmentId, ZId], [offset, ZOptionalNumber], [searchValue, ZOptionalString]);
async (projectId: string, offset?: number, searchValue?: string): Promise<TContactWithAttributes[]> => {
validateInputs([projectId, ZId], [offset, ZOptionalNumber], [searchValue, ZOptionalString]);
try {
const contacts = await prisma.contact.findMany({
where: buildContactWhereClause(environmentId, searchValue),
where: buildContactWhereClause(projectId, searchValue),
select: selectContact,
take: ITEMS_PER_PAGE,
skip: offset,
@@ -398,7 +400,8 @@ const createMissingAttributeKeys = async (
lowercaseToActualKeyMap: Map<string, string>,
attributeKeyMap: Map<string, string>,
attributeTypeMap: Map<string, TAttributeTypeInfo>,
environmentId: string
environmentId: string,
projectId: string
): Promise<void> => {
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
@@ -427,6 +430,7 @@ const createMissingAttributeKeys = async (
name: formatSnakeCaseToTitleCase(key),
dataType: attributeTypeMap.get(key)?.dataType ?? "string",
environmentId,
projectId,
})),
skipDuplicates: true,
});
@@ -461,6 +465,7 @@ type TCsvProcessingContext = {
attributeTypeMap: Map<string, TAttributeTypeInfo>;
duplicateContactsAction: "skip" | "update" | "overwrite";
environmentId: string;
projectId: string;
};
/**
@@ -478,6 +483,7 @@ const processCsvRecord = async (
attributeTypeMap,
duplicateContactsAction,
environmentId,
projectId,
} = ctx;
// Map CSV keys to actual DB keys (case-insensitive matching)
const mappedRecord: Record<string, string> = {};
@@ -500,6 +506,7 @@ const processCsvRecord = async (
return prisma.contact.create({
data: {
environmentId,
projectId,
attributes: {
create: createAttributeConnections(mappedRecord, environmentId, attributeTypeMap),
},
@@ -610,14 +617,17 @@ export const createContactsFromCSV = async (
);
try {
// Step 1: Extract metadata from CSV data
// Step 1: Resolve projectId from environment
const projectId = await getProjectIdFromEnvironmentId(environmentId);
// Step 2: Extract metadata from CSV data
const { csvEmails, csvUserIds, csvKeys, attributeValuesByKey } = extractCsvMetadata(csvData);
// Step 2: Fetch existing data from database
// Step 3: Fetch existing data from database
const [existingContactsByEmail, existingUserIds, existingAttributeKeys] = await Promise.all([
prisma.contact.findMany({
where: {
environmentId,
projectId,
attributes: { some: { attributeKey: { key: "email" }, value: { in: csvEmails } } },
},
select: {
@@ -626,11 +636,11 @@ export const createContactsFromCSV = async (
},
}),
prisma.contactAttribute.findMany({
where: { attributeKey: { key: "userId", environmentId }, value: { in: csvUserIds } },
where: { attributeKey: { key: "userId", projectId }, value: { in: csvUserIds } },
select: { value: true, contactId: true },
}),
prisma.contactAttributeKey.findMany({
where: { environmentId },
where: { projectId },
select: { key: true, id: true, dataType: true },
}),
]);
@@ -668,7 +678,8 @@ export const createContactsFromCSV = async (
lowercaseToActualKeyMap,
attributeKeyMap,
attributeTypeMap,
environmentId
environmentId,
projectId
);
// Step 6: Process each CSV record
@@ -680,6 +691,7 @@ export const createContactsFromCSV = async (
attributeTypeMap,
duplicateContactsAction,
environmentId,
projectId,
};
const CHUNK_SIZE = 50;

View File

@@ -10,10 +10,10 @@ export interface PublishedLinkSurvey {
}
export const getPublishedLinkSurveys = reactCache(
async (environmentId: string): Promise<PublishedLinkSurvey[]> => {
async (projectId: string): Promise<PublishedLinkSurvey[]> => {
try {
const surveys = await prisma.survey.findMany({
where: { environmentId, status: "inProgress", type: "link" },
where: { projectId, status: "inProgress", type: "link" },
select: {
id: true,
name: true,

View File

@@ -1,4 +1,5 @@
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
import { UploadContactsCSVButton } from "@/modules/ee/contacts/components/upload-contacts-button";
@@ -19,12 +20,14 @@ export const ContactsPage = async ({
const t = await getTranslate();
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organization.id);
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const contactAttributeKeys = await getContactAttributeKeys(params.environmentId);
const initialContacts = await getContacts(params.environmentId, 0);
const contactAttributeKeys = await getContactAttributeKeys(projectId);
const initialContacts = await getContacts(projectId, 0);
const AddContactsButton = (
<UploadContactsCSVButton environmentId={environment.id} contactAttributeKeys={contactAttributeKeys} />

View File

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

View File

@@ -32,6 +32,7 @@ import {
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
import { isSameDay, subtractTimeUnit } from "./date-utils";
@@ -55,6 +56,7 @@ export const selectSegment = {
title: true,
description: true,
environmentId: true,
projectId: true,
filters: true,
isPrivate: true,
surveys: {
@@ -107,12 +109,12 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
}
});
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyRefs[]> => {
validateInputs([environmentId, ZId]);
export const getSegments = reactCache(async (projectId: string): Promise<TSegmentWithSurveyRefs[]> => {
validateInputs([projectId, ZId]);
try {
const segments = await prisma.segment.findMany({
where: {
environmentId,
projectId,
},
select: selectSegment,
});
@@ -138,6 +140,8 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
const projectId = await getProjectIdFromEnvironmentId(environmentId);
try {
// Private segments use upsert because auto-save may have already created a
// default (empty-filter) segment via connectOrCreate before the user publishes.
@@ -156,11 +160,13 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
description,
isPrivate,
filters,
projectId,
...surveyConnect,
},
update: {
description,
filters,
projectId,
...surveyConnect,
},
select: selectSegment,
@@ -176,6 +182,7 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
description,
isPrivate,
filters,
projectId,
...surveyConnect,
},
select: selectSegment,
@@ -233,6 +240,7 @@ export const cloneSegment = async (segmentId: string, surveyId: string): Promise
isPrivate: segment.isPrivate,
environmentId: segment.environmentId,
filters: segment.filters,
projectId: segment.projectId,
surveys: {
connect: {
id: surveyId,
@@ -327,7 +335,8 @@ export const resetSegmentInSurvey = async (surveyId: string): Promise<TSegment>
isPrivate: true,
filters: [],
surveys: { connect: { id: surveyId } },
environment: { connect: { id: survey?.environmentId } },
environmentId: survey.environmentId,
projectId: survey.projectId,
},
select: selectSegment,
});
@@ -385,13 +394,13 @@ export const updateSegment = async (segmentId: string, data: TSegmentUpdateInput
}
};
export const getSegmentsByAttributeKey = reactCache(async (environmentId: string, attributeKey: string) => {
validateInputs([environmentId, ZId], [attributeKey, ZString]);
export const getSegmentsByAttributeKey = reactCache(async (projectId: string, attributeKey: string) => {
validateInputs([projectId, ZId], [attributeKey, ZString]);
try {
const segments = await prisma.segment.findMany({
where: {
environmentId,
projectId,
},
select: selectSegment,
});

View File

@@ -1,3 +1,4 @@
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
@@ -17,9 +18,11 @@ export const SegmentsPage = async ({
const { isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const [segments, contactAttributeKeys] = await Promise.all([
getSegments(params.environmentId),
getContactAttributeKeys(params.environmentId),
getSegments(projectId),
getContactAttributeKeys(projectId),
]);
const isContactsEnabled = await getIsContactsEnabled(organization.id);

View File

@@ -3,7 +3,7 @@ import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
@@ -23,21 +23,6 @@ import {
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
const syncSsoAccount = async (userId: string, account: Account) => {
await upsertAccount({
userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
});
};
export const handleSsoCallback = async ({
user,
account,
@@ -123,7 +108,6 @@ export const handleSsoCallback = async ({
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
await syncSsoAccount(existingUserWithAccount.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
@@ -149,7 +133,6 @@ export const handleSsoCallback = async ({
);
await updateUser(existingUserWithAccount.id, { email: user.email });
await syncSsoAccount(existingUserWithAccount.id, account);
return true;
}
@@ -171,7 +154,6 @@ export const handleSsoCallback = async ({
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
await syncSsoAccount(existingUserWithEmail.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
@@ -360,7 +342,6 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
await syncSsoAccount(userProfile.id, account);
if (isMultiOrgEnabled) {
contextLogger.debug(
@@ -377,6 +358,10 @@ export const handleSsoCallback = async ({
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -63,7 +62,7 @@ vi.mock("@/modules/ee/sso/lib/team", () => ({
}));
vi.mock("@/lib/account/service", () => ({
upsertAccount: vi.fn(),
createAccount: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
@@ -204,36 +203,6 @@ describe("handleSsoCallback", () => {
});
});
test("should not overwrite stored tokens when the provider omits them", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: {
...mockAccount,
access_token: undefined,
refresh_token: undefined,
expires_at: undefined,
scope: undefined,
token_type: undefined,
id_token: undefined,
},
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith({
userId: mockUser.id,
type: mockAccount.type,
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
});
});
test("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,

View File

@@ -11,10 +11,9 @@ import { AddWebhookModal } from "./add-webhook-modal";
interface AddWebhookButtonProps {
environment: TEnvironment;
surveys: TSurvey[];
allowInternalUrls: boolean;
}
export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: AddWebhookButtonProps) => {
export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps) => {
const { t } = useTranslation();
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
return (
@@ -32,7 +31,6 @@ export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: Ad
surveys={surveys}
open={isAddWebhookModalOpen}
setOpen={setAddWebhookModalOpen}
allowInternalUrls={allowInternalUrls}
/>
</>
);

View File

@@ -34,16 +34,9 @@ interface AddWebhookModalProps {
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
allowInternalUrls: boolean;
}
export const AddWebhookModal = ({
environmentId,
surveys,
open,
setOpen,
allowInternalUrls,
}: AddWebhookModalProps) => {
export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) => {
const router = useRouter();
const {
handleSubmit,
@@ -66,7 +59,7 @@ export const AddWebhookModal = ({
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return { success: false };

View File

@@ -23,17 +23,9 @@ interface WebhookModalProps {
webhook: Webhook;
surveys: TSurvey[];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookModal = ({
open,
setOpen,
webhook,
surveys,
isReadOnly,
allowInternalUrls,
}: WebhookModalProps) => {
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
@@ -46,13 +38,7 @@ export const WebhookModal = ({
{
title: t("common.settings"),
children: (
<WebhookSettingsTab
webhook={webhook}
surveys={surveys}
setOpen={setOpen}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
<WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} isReadOnly={isReadOnly} />
),
},
];

View File

@@ -26,16 +26,9 @@ interface WebhookSettingsTabProps {
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookSettingsTab = ({
webhook,
surveys,
setOpen,
isReadOnly,
allowInternalUrls,
}: WebhookSettingsTabProps) => {
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -67,7 +60,7 @@ export const WebhookSettingsTab = ({
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return false;

View File

@@ -14,7 +14,6 @@ interface WebhookTableProps {
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookTable = ({
@@ -23,12 +22,12 @@ export const WebhookTable = ({
surveys,
children: [TableHeading, webhookRows],
isReadOnly,
allowInternalUrls,
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const { t } = useTranslation();
const [activeWebhook, setActiveWebhook] = useState<Webhook>({
environmentId: environment.id,
projectId: null,
id: "",
name: "",
url: "",
@@ -73,7 +72,6 @@ export const WebhookTable = ({
webhook={activeWebhook}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
</>
);

View File

@@ -1,4 +1,4 @@
export const validWebHookURL = (urlInput: string, allowInternalUrls = false) => {
export const validWebHookURL = (urlInput: string) => {
const trimmedInput = urlInput.trim();
if (!trimmedInput) {
return { valid: false, error: "Please enter a URL" };
@@ -7,13 +7,6 @@ export const validWebHookURL = (urlInput: string, allowInternalUrls = false) =>
try {
const url = new URL(trimmedInput);
if (allowInternalUrls) {
if (url.protocol !== "https:" && url.protocol !== "http:") {
return { valid: false, error: "URL must start with https:// or http://" };
}
return { valid: true };
}
if (url.protocol !== "https:") {
return { valid: false, error: "URL must start with https://" };
}

View File

@@ -10,6 +10,7 @@ import {
UnknownError,
} from "@formbricks/types/errors";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
@@ -105,6 +106,8 @@ export const createWebhook = async (
): Promise<Webhook> => {
await validateWebhookUrl(webhookInput.url);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
@@ -117,11 +120,8 @@ export const createWebhook = async (
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
secret: signingSecret,
environment: {
connect: {
id: environmentId,
},
},
environmentId,
projectId,
},
});
@@ -139,13 +139,13 @@ export const createWebhook = async (
}
};
export const getWebhooks = async (environmentId: string): Promise<Webhook[]> => {
validateInputs([environmentId, ZId]);
export const getWebhooks = async (projectId: string): Promise<Webhook[]> => {
validateInputs([projectId, ZId]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
projectId,
},
orderBy: {
createdAt: "desc",

View File

@@ -1,4 +1,3 @@
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -17,29 +16,20 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const projectId = environment.projectId;
const [webhooks, surveys] = await Promise.all([
getWebhooks(params.environmentId),
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
getWebhooks(projectId),
getSurveys(projectId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
]);
const renderAddWebhookButton = () => (
<AddWebhookButton
environment={environment}
surveys={surveys}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}
/>
);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable
environment={environment}
webhooks={webhooks}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}>
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />

View File

@@ -1,6 +1,7 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTagsByProjectId } from "@/lib/tag/service";
import { getTagsOnResponsesCount } from "@/lib/tagOnResponse/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
@@ -14,8 +15,10 @@ export const TagsPage = async (props: { params: Promise<{ environmentId: string
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const [tags, environmentTagsCount] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getTagsByProjectId(projectId),
getTagsOnResponsesCount(params.environmentId),
]);

View File

@@ -8,6 +8,7 @@ import {
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
@@ -24,7 +25,12 @@ export const createSurvey = async (
delete restSurveyBody.languages;
}
const actionClasses = await getActionClasses(environmentId);
const [organization, projectId] = await Promise.all([
getOrganizationByEnvironmentId(environmentId),
getProjectIdFromEnvironmentId(environmentId),
]);
const actionClasses = await getActionClasses(projectId);
// @ts-expect-error
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
@@ -43,8 +49,6 @@ export const createSurvey = async (
},
};
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
@@ -75,6 +79,11 @@ export const createSurvey = async (
id: environmentId,
},
},
project: {
connect: {
id: projectId,
},
},
},
select: selectSurvey,
});
@@ -86,11 +95,8 @@ export const createSurvey = async (
title: survey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: environmentId,
},
},
environmentId,
projectId,
},
});

View File

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

View File

@@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const createActionClass = async (
environmentId: string,
@@ -11,10 +12,13 @@ export const createActionClass = async (
const { environmentId: _, ...actionClassInput } = actionClass;
try {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
const actionClassPrisma = await prisma.actionClass.create({
data: {
...actionClassInput,
environment: { connect: { id: environmentId } },
environmentId,
projectId,
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig:
actionClassInput.type === "noCode"

View File

@@ -6,6 +6,7 @@ import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { updateSurveyInternal } from "@/lib/survey/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
@@ -21,8 +22,12 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const surveyId = updatedSurvey.id;
let data: any = {};
const actionClasses = await getActionClasses(updatedSurvey.environmentId);
const currentSurvey = await getSurvey(surveyId);
const [actionClasses, currentSurvey] = await Promise.all([
getProjectIdFromEnvironmentId(updatedSurvey.environmentId).then((projectId) =>
getActionClasses(projectId)
),
getSurvey(surveyId),
]);
if (!currentSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
@@ -161,6 +166,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
}
} else if (type === "app") {
if (!currentSurvey.segment) {
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await prisma.survey.update({
where: {
id: surveyId,
@@ -183,6 +190,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
id: environmentId,
},
},
project: {
connect: {
id: projectId,
},
},
},
},
},

View File

@@ -8,6 +8,7 @@ import {
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -50,14 +51,16 @@ export const SurveyEditorPage = async (props: {
await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const [survey, projectWithTeamIds, actionClasses, contactAttributeKeys, responseCount, segments] =
await Promise.all([
getSurvey(params.surveyId),
getProjectWithTeamIdsByEnvironmentId(params.environmentId),
getActionClasses(params.environmentId),
getContactAttributeKeys(params.environmentId),
getActionClasses(projectId),
getContactAttributeKeys(projectId),
getResponseCountBySurveyId(params.surveyId),
getSegments(params.environmentId),
getSegments(projectId),
]);
if (!projectWithTeamIds) {

View File

@@ -5,19 +5,19 @@ import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getActionClasses = reactCache(async (environmentId: string): Promise<ActionClass[]> => {
validateInputs([environmentId, z.cuid2()]);
export const getActionClasses = reactCache(async (projectId: string): Promise<ActionClass[]> => {
validateInputs([projectId, z.cuid2()]);
try {
return await prisma.actionClass.findMany({
where: {
environmentId: environmentId,
projectId,
},
orderBy: {
createdAt: "asc",
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
throw new DatabaseError(`Database error when fetching actions for project ${projectId}`);
}
});

View File

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

View File

@@ -148,12 +148,12 @@ function buildStandardCursorWhere(
}
function buildBaseWhere(
environmentId: string,
projectId: string,
filterCriteria?: TSurveyFilterCriteria,
extraWhere?: Prisma.SurveyWhereInput
): Prisma.SurveyWhereInput {
return {
environmentId,
projectId,
...buildWhereClause(filterCriteria),
...extraWhere,
};
@@ -197,7 +197,7 @@ function getRelevanceNextCursor(survey: TSurveyRow, bucket: TRelevanceBucket): T
}
async function findSurveyRows(
environmentId: string,
projectId: string,
limit: number,
sortBy: TStandardSurveyListSort,
filterCriteria?: TSurveyFilterCriteria,
@@ -207,7 +207,7 @@ async function findSurveyRows(
const cursorWhere = cursor ? buildStandardCursorWhere(sortBy, cursor) : undefined;
return prisma.survey.findMany({
where: buildBaseWhere(environmentId, filterCriteria, {
where: buildBaseWhere(projectId, filterCriteria, {
...extraWhere,
...cursorWhere,
}),
@@ -237,11 +237,11 @@ function buildSurveyListPage(rows: TSurveyRow[], cursor: TSurveyListPageCursor |
}
async function getStandardSurveyListPage(
environmentId: string,
projectId: string,
options: TGetSurveyListPageOptions & { sortBy: TStandardSurveyListSort }
): Promise<TSurveyListPage> {
const surveyRows = await findSurveyRows(
environmentId,
projectId,
options.limit,
options.sortBy,
options.filterCriteria,
@@ -258,7 +258,7 @@ async function getStandardSurveyListPage(
}
async function findRelevanceRows(
environmentId: string,
projectId: string,
limit: number,
filterCriteria: TSurveyFilterCriteria | undefined,
bucket: TRelevanceBucket,
@@ -271,7 +271,7 @@ async function findRelevanceRows(
: undefined;
return prisma.survey.findMany({
where: buildBaseWhere(environmentId, filterCriteria, {
where: buildBaseWhere(projectId, filterCriteria, {
...statusWhere,
...cursorWhere,
}),
@@ -282,10 +282,10 @@ async function findRelevanceRows(
}
async function hasMoreRelevanceRowsInOtherBucket(
environmentId: string,
projectId: string,
filterCriteria?: TSurveyFilterCriteria
): Promise<boolean> {
const otherRows = await findRelevanceRows(environmentId, 1, filterCriteria, OTHER_BUCKET, null);
const otherRows = await findRelevanceRows(projectId, 1, filterCriteria, OTHER_BUCKET, null);
return otherRows.length > 0;
}
@@ -315,13 +315,13 @@ function buildRelevancePage(rows: TSurveyRow[], bucket: TRelevanceBucket | null)
}
async function getInProgressRelevanceStep(
environmentId: string,
projectId: string,
limit: number,
filterCriteria: TSurveyFilterCriteria | undefined,
cursor: TRelevanceSurveyListCursor | null
): Promise<{ pageRows: TSurveyRow[]; remaining: number; response: TSurveyListPage | null }> {
const inProgressRows = await findRelevanceRows(
environmentId,
projectId,
limit,
filterCriteria,
IN_PROGRESS_BUCKET,
@@ -337,7 +337,7 @@ async function getInProgressRelevanceStep(
}
async function buildInProgressOnlyRelevancePage(
environmentId: string,
projectId: string,
rows: TSurveyRow[],
filterCriteria: TSurveyFilterCriteria | undefined,
cursor: TRelevanceSurveyListCursor | null
@@ -345,13 +345,13 @@ async function buildInProgressOnlyRelevancePage(
const hasOtherRows =
rows.length > 0 &&
shouldReadInProgressBucket(cursor) &&
(await hasMoreRelevanceRowsInOtherBucket(environmentId, filterCriteria));
(await hasMoreRelevanceRowsInOtherBucket(projectId, filterCriteria));
return buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
}
async function getRelevanceSurveyListPage(
environmentId: string,
projectId: string,
options: TGetSurveyListPageOptions & { sortBy: "relevance" }
): Promise<TSurveyListPage> {
const relevanceCursor = getRelevanceCursor(options.cursor);
@@ -360,7 +360,7 @@ async function getRelevanceSurveyListPage(
if (shouldReadInProgressBucket(relevanceCursor)) {
const inProgressStep = await getInProgressRelevanceStep(
environmentId,
projectId,
remaining,
options.filterCriteria,
relevanceCursor
@@ -376,7 +376,7 @@ async function getRelevanceSurveyListPage(
if (remaining <= 0) {
return await buildInProgressOnlyRelevancePage(
environmentId,
projectId,
pageRows,
options.filterCriteria,
relevanceCursor
@@ -384,7 +384,7 @@ async function getRelevanceSurveyListPage(
}
const otherRows = await findRelevanceRows(
environmentId,
projectId,
remaining,
options.filterCriteria,
OTHER_BUCKET,
@@ -397,18 +397,18 @@ async function getRelevanceSurveyListPage(
}
export async function getSurveyListPage(
environmentId: string,
projectId: string,
options: TGetSurveyListPageOptions
): Promise<TSurveyListPage> {
try {
if (options.sortBy === "relevance") {
return await getRelevanceSurveyListPage(environmentId, {
return await getRelevanceSurveyListPage(projectId, {
...options,
sortBy: "relevance",
});
}
return await getStandardSurveyListPage(environmentId, {
return await getStandardSurveyListPage(projectId, {
...options,
sortBy: options.sortBy,
});

View File

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

View File

@@ -21,7 +21,7 @@ import { mapSurveyRowToSurvey, mapSurveyRowsToSurveys, surveySelect } from "./su
export const getSurveys = reactCache(
async (
environmentId: string,
projectId: string,
limit?: number,
offset?: number,
filterCriteria?: TSurveyFilterCriteria
@@ -29,13 +29,13 @@ export const getSurveys = reactCache(
try {
if (filterCriteria?.sortBy === "relevance") {
// Call the sortByRelevance function
return await getSurveysSortedByRelevance(environmentId, limit, offset ?? 0, filterCriteria);
return await getSurveysSortedByRelevance(projectId, limit, offset ?? 0, filterCriteria);
}
// Fetch surveys normally with pagination and include response count
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
projectId,
...buildWhereClause(filterCriteria),
},
select: surveySelect,
@@ -57,7 +57,7 @@ export const getSurveys = reactCache(
export const getSurveysSortedByRelevance = reactCache(
async (
environmentId: string,
projectId: string,
limit?: number,
offset?: number,
filterCriteria?: TSurveyFilterCriteria
@@ -67,7 +67,7 @@ export const getSurveysSortedByRelevance = reactCache(
const inProgressSurveyCount = await prisma.survey.count({
where: {
environmentId,
projectId,
status: "inProgress",
...buildWhereClause(filterCriteria),
},
@@ -79,7 +79,7 @@ export const getSurveysSortedByRelevance = reactCache(
? []
: await prisma.survey.findMany({
where: {
environmentId,
projectId,
status: "inProgress",
...buildWhereClause(filterCriteria),
},
@@ -97,7 +97,7 @@ export const getSurveysSortedByRelevance = reactCache(
const newOffset = Math.max(0, offset - inProgressSurveyCount);
const additionalSurveys = await prisma.survey.findMany({
where: {
environmentId,
projectId,
status: { not: "inProgress" },
...buildWhereClause(filterCriteria),
},
@@ -288,10 +288,10 @@ export const copySurveyToOtherEnvironment = async (
if (!targetProject) throw new ResourceNotFoundError("Project", targetEnvironmentId);
}
// Fetch existing action classes in target environment for name conflict checks
// Fetch existing action classes in target project for name conflict checks
const existingActionClasses = !isSameEnvironment
? await prisma.actionClass.findMany({
where: { environmentId: targetEnvironmentId },
where: { projectId: targetProject.id },
select: { name: true, type: true, key: true, noCodeConfig: true, id: true },
})
: [];
@@ -380,6 +380,7 @@ export const copySurveyToOtherEnvironment = async (
const baseActionClassData = {
name: modifiedName,
environment: { connect: { id: targetEnvironmentId } },
project: { connect: { id: targetProject.id } },
description: trigger.actionClass.description,
type: trigger.actionClass.type,
};
@@ -444,6 +445,11 @@ export const copySurveyToOtherEnvironment = async (
id: targetEnvironmentId,
},
},
project: {
connect: {
id: targetProject.id,
},
},
creator: {
connect: {
id: userId,
@@ -493,6 +499,7 @@ export const copySurveyToOtherEnvironment = async (
isPrivate: true,
filters: existingSurvey.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
project: { connect: { id: targetProject.id } },
},
};
} else if (isSameEnvironment) {
@@ -502,7 +509,7 @@ export const copySurveyToOtherEnvironment = async (
where: {
title: existingSurvey.segment.title,
isPrivate: false,
environmentId: targetEnvironmentId,
projectId: targetProject.id,
},
});
@@ -514,6 +521,7 @@ export const copySurveyToOtherEnvironment = async (
isPrivate: false,
filters: existingSurvey.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
project: { connect: { id: targetProject.id } },
},
};
}
@@ -569,14 +577,14 @@ export const copySurveyToOtherEnvironment = async (
}
};
/** Count surveys in an environment, optionally with the same filter as getSurveys (so total matches list). */
/** Count surveys in a project, optionally with the same filter as getSurveys (so total matches list). */
export const getSurveyCount = reactCache(
async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
validateInputs([environmentId, z.cuid2()]);
async (projectId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
validateInputs([projectId, z.cuid2()]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId,
projectId,
...buildWhereClause(filterCriteria),
},
});

View File

@@ -6,6 +6,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { DEFAULT_LOCALE, SURVEYS_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserLocale } from "@/lib/user/service";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
@@ -43,7 +44,8 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const surveyCount = await getSurveyCount(params.environmentId);
const projectId = await getProjectIdFromEnvironmentId(params.environmentId);
const surveyCount = await getSurveyCount(projectId);
const currentProjectChannel = project.config.channel ?? null;
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;

View File

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

View File

@@ -42,7 +42,6 @@
"@lexical/react": "0.41.0",
"@lexical/rich-text": "0.41.0",
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.213.0",
@@ -100,7 +99,7 @@
"next-auth": "4.24.13",
"next-safe-action": "8.1.8",
"node-fetch": "3.3.2",
"nodemailer": "8.0.4",
"nodemailer": "8.0.2",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",

View File

@@ -485,55 +485,5 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
test("should invalidate a copied session cookie after logout", async ({ page, browser, users }) => {
const user = await users.create();
await user.login();
const sessionCookie = (await page.context().cookies()).find((cookie) =>
cookie.name.includes("next-auth.session-token")
);
expect(sessionCookie).toBeDefined();
const preLogoutContext = await browser.newContext();
try {
await preLogoutContext.addCookies([sessionCookie!]);
const preLogoutPage = await preLogoutContext.newPage();
await preLogoutPage.goto("http://localhost:3000/environments");
await expect(preLogoutPage).not.toHaveURL(/\/auth\/login/);
} finally {
await preLogoutContext.close();
}
const signOutCsrfToken = await page
.context()
.request.get("/api/auth/csrf")
.then((response) => response.json())
.then((json) => json.csrfToken);
const signOutResponse = await page.context().request.post("/api/auth/signout", {
form: {
callbackUrl: "/auth/login",
csrfToken: signOutCsrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
expect(signOutResponse.status()).not.toBe(500);
const replayContext = await browser.newContext();
try {
await replayContext.addCookies([sessionCookie!]);
const replayPage = await replayContext.newPage();
await replayPage.goto("http://localhost:3000/environments");
await expect(replayPage).toHaveURL(/\/auth\/login/);
} finally {
await replayContext.close();
}
});
});
});

View File

@@ -1,85 +0,0 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { proxy } from "./proxy";
const { mockGetProxySession, mockIsPublicDomainConfigured, mockIsRequestFromPublicDomain } = vi.hoisted(
() => ({
mockGetProxySession: vi.fn(),
mockIsPublicDomainConfigured: vi.fn(),
mockIsRequestFromPublicDomain: vi.fn(),
})
);
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/app/middleware/domain-utils", () => ({
isPublicDomainConfigured: mockIsPublicDomainConfigured,
isRequestFromPublicDomain: mockIsRequestFromPublicDomain,
}));
vi.mock("@/app/middleware/endpoint-validator", () => ({
isAuthProtectedRoute: (url: string) => url.startsWith("/environments"),
isRouteAllowedForDomain: vi.fn(() => true),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/utils/url", () => ({
isValidCallbackUrl: (url: string) => url.startsWith("http://localhost:3000"),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPublicDomainConfigured.mockReturnValue(false);
mockIsRequestFromPublicDomain.mockReturnValue(false);
});
test("redirects unauthenticated protected routes to login with callbackUrl", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(new NextRequest("http://localhost:3000/environments/test"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
);
});
test("rejects invalid callback URLs", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(
new NextRequest("http://localhost:3000/auth/login?callbackUrl=https%3A%2F%2Fevil.example")
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid callback URL" });
});
test("redirects authenticated callback requests to the callback URL", async () => {
mockGetProxySession.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
});
const response = await proxy(
new NextRequest(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
)
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/environments/test");
});
});

View File

@@ -1,3 +1,4 @@
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
@@ -5,12 +6,11 @@ import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middl
import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator";
import { WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const session = await getProxySession(request);
const token = await getToken({ req: request as any });
if (isAuthProtectedRoute(request.nextUrl.pathname) && !session) {
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
@@ -21,7 +21,7 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (session && callbackUrl) {
if (token && callbackUrl) {
return NextResponse.redirect(callbackUrl);
}

View File

@@ -15,7 +15,7 @@ export default defineConfig({
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts", "proxy.ts"],
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts"],
exclude: [
// Build and configuration files
"**/.next/**", // Next.js build output

Some files were not shown because too many files have changed in this diff Show More