Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,11 @@ AZUREAD_TENANT_ID=
# Accepted values for AI_PROVIDER: aws, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
# AI_PROVIDER=google
# AI_MODEL=gemini-2.5-flash
# AI_MODEL=gemini-3.5-flash

# Google Cloud settings for Gemini models
# For gemini-3.5-flash, use global, us, or eu. Regional locations such as europe-west3 or me-central2
# only work for models that Google lists as supported there, such as gemini-2.5-flash.
# Credentials are optional when Application Default Credentials are available.
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const WorkspaceBreadcrumb = ({
});
};

const LimitModalButtons = (): [ModalButton, ModalButton] => {
const getLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
Expand Down Expand Up @@ -247,7 +247,7 @@ export const WorkspaceBreadcrumb = ({
<WorkspaceLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
buttons={getLimitModalButtons()}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
Expand Down
52 changes: 52 additions & 0 deletions apps/web/app/api/client/[workspaceId]/responses/lib/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import type { TContactAttributes } from "@formbricks/types/contact-attribute";
import type { TResponse } from "@formbricks/types/responses";
import type { TTag } from "@formbricks/types/tags";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";

type TQuotaEvaluationResponseInput = {
surveyId: string;
data: TResponse["data"];
variables?: TResponse["variables"];
language?: string;
};

export const buildClientResponse = (
responsePrisma: Omit<TResponse, "contact" | "tags"> & { tags: { tag: TTag }[] },
contact: { id: string; attributes: TContactAttributes } | null
): TResponse => ({
...responsePrisma,
contact: contact
? {
id: contact.id,
userId: contact.attributes.userId,
}
: null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});

export const createResponseWithQuotaEvaluation = async <TInput extends TQuotaEvaluationResponseInput>(
responseInput: TInput,
createResponse: (responseInput: TInput, tx: Prisma.TransactionClient) => Promise<TResponse>
) => {
return await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const response = await createResponse(responseInput, tx);

const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language,
responseFinished: response.finished,
tx,
});

return {
...response,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});
};
40 changes: 6 additions & 34 deletions apps/web/app/api/v1/client/[workspaceId]/responses/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
buildClientResponse,
createResponseWithQuotaEvaluation as createClientResponseWithQuotaEvaluation,
} from "@/app/api/client/[workspaceId]/responses/lib/response";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
Expand All @@ -21,7 +24,6 @@ import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact";

export const responseSelection = {
Expand Down Expand Up @@ -65,26 +67,7 @@ export const responseSelection = {
export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInput
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx) => {
const response = await createResponse(responseInput, tx);

const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language,
responseFinished: response.finished,
tx,
});

return {
...response,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});

return txResponse;
return await createClientResponseWithQuotaEvaluation(responseInput, createResponse);
};

export const createResponse = async (
Expand Down Expand Up @@ -133,18 +116,7 @@ export const createResponse = async (
select: responseSelection,
});

const response = {
...responsePrisma,
contact: contact
? {
id: contact.id,
userId: contact.attributes.userId,
}
: null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};

return response;
return buildClientResponse(responsePrisma, contact);
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
Expand Down
40 changes: 6 additions & 34 deletions apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
buildClientResponse,
createResponseWithQuotaEvaluation as createClientResponseWithQuotaEvaluation,
} from "@/app/api/client/[workspaceId]/responses/lib/response";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
Expand All @@ -22,32 +25,12 @@ import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";

export const createResponseWithQuotaEvaluation = async (
responseInput: TResponseInputV2
): Promise<TResponseWithQuotaFull> => {
const txResponse = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const response = await createResponse(responseInput, tx);

const quotaResult = await evaluateResponseQuotas({
surveyId: responseInput.surveyId,
responseId: response.id,
data: responseInput.data,
variables: responseInput.variables,
language: responseInput.language,
responseFinished: response.finished,
tx,
});

return {
...response,
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
};
});

return txResponse;
return await createClientResponseWithQuotaEvaluation(responseInput, createResponse);
};

const buildPrismaResponseData = (
Expand Down Expand Up @@ -124,18 +107,7 @@ export const createResponse = async (
select: responseSelection,
});

const response: TResponse = {
...responsePrisma,
contact: contact
? {
id: contact.id,
userId: contact.attributes.userId,
}
: null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};

return response;
return buildClientResponse(responsePrisma, contact);
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
Expand Down
36 changes: 36 additions & 0 deletions apps/web/app/api/v3/lib/api-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,42 @@ describe("withV3ApiWrapper", () => {
expect(body.code).toBe("too_many_requests");
});

test("applies rate limiting before parsing request bodies", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));

const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});

const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);

expect(response.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});

test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
Expand Down
22 changes: 11 additions & 11 deletions apps/web/app/api/v3/lib/api-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,17 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return authResult.response;
}

const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}

const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn(
Expand All @@ -423,17 +434,6 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return parsedInputResult.response;
}

const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}

auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);

const response = await handler({
Expand Down
Loading
Loading