mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-28 09:31:13 -05:00
Compare commits
6 Commits
codex/depe
...
chore/depr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8fa1878a | ||
|
|
d202b9263f | ||
|
|
71cca557fc | ||
|
|
1500b6f7f3 | ||
|
|
2c9fbf83e4 | ||
|
|
81272b96e1 |
14
.env.example
14
.env.example
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) : [],
|
||||
]);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -45,6 +45,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[]) ?? [],
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,6 +58,7 @@ export const getResponseForPipeline = async (
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -184,6 +184,7 @@ describe("Response Lib", () => {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
secret: true,
|
||||
}).meta({
|
||||
id: "webhookUpdate",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -45,6 +45,7 @@ export function CreateSegmentModal({
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId,
|
||||
projectId: null,
|
||||
id: "",
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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://" };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export const surveySelect = {
|
||||
status: true,
|
||||
singleUse: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user